Case study · Events & social
How JustNetwork powers live event photo galleries with FileAway
JustNetwork lets event organizers run live photo galleries that anyone can contribute to — including photographers and guests who don't have an account. They built that flow on FileAway and fileaway-sdk, and never touch the upload bytes or the storage bucket themselves.
At a glance
- External photographers upload with zero account and zero credentials — just a link.
- JustNetwork is never in the byte path and never holds bucket credentials.
- Photos appear in the live gallery in real time (webhook → Pusher).
- A single module is the only code that talks to FileAway.
- Missed webhooks self-heal via a backfill/reconcile pass.
- The whole integration runs on seven SDK calls.
The challenge
A JustNetwork event has a photo gallery. Event managers can upload from inside the app, but the most valuable photos come from the people on the ground — hired photographers and guests who will never sign up for an account. JustNetwork needed a way for those external contributors to add photos to a specific event's gallery without building an upload pipeline, without becoming a middleman for the file bytes, and without handing storage credentials to anyone.
They also wanted the gallery to feel alive: a photo dropped by a photographer should appear in every open browser within seconds, and a dropped webhook should never leave a hole in the gallery.
Two upload paths, one gallery
Photos reach a gallery two ways. Both land as rows in the same EventGallery table and render in the same UI; they differ only in how the display URL is resolved. Only the first uses FileAway.
| Path | Who uploads | Where bytes land | FileAway? |
|---|---|---|---|
| Photographer link | External photographers / guests (no account) | FileAway's integration bucket | ✅ yes |
| Direct in-app upload | Event managers via the app UI | JustNetwork's own S3 bucket | ❌ no |
Everything below concerns the photographer-link path.
The end-to-end lifecycle
ORGANIZER JUSTNETWORK BACKEND FILEAWAY PHOTOGRAPHER
│ │ │ │
│ POST /fileaway-link │ │ │
├───────────────────────────▶│ links.create(...) │ │
│ ├─────────────────────────▶│ mints link │
│ │ {id, slug, uploadUrl} │ │
│ uploadUrl ◀───────────────┤ store on Event │ │
│ │ │ │
│ ── shares uploadUrl ───────────────────────────────────────────────────────▶│
│ │ │ hosted upload page │
│ │ │◀── PUT bytes ────────┤
│ │ POST /webhook │ (direct to bucket) │
│ │◀─ upload.created ─────────┤ │
│ │ verify HMAC → 200 │ │
│ │ persist row, dedupe │ │
│ │ Pusher PHOTO_NEW ──────▶ (browsers update live) │a · Link creation
When an organizer opens an event's upload settings, JustNetwork's createLinkForEvent calls links.create with the integration id, a label, expiresAt, maxUploads, maxFileSizeBytes, the allowed image types (jpeg / png / heic / webp), uploadMode: 'multiple', requireUploaderInfo: true, branding, a webhook config, and the key that ties it all together — metadata: { eventId }.
const link = await fileaway.links.create({
integrationId: process.env.FILEAWAY_INTEGRATION_ID,
label: "Event " + eventId + " - gallery",
allowedMimeTypes: ["image/jpeg", "image/png", "image/heic", "image/webp"],
uploadMode: "multiple",
requireUploaderInfo: true,
maxUploads: FILEAWAY_MAX_UPLOADS,
maxFileSizeBytes: FILEAWAY_MAX_FILE_SIZE,
expiresAt,
metadata: { eventId }, // the correlation key for every webhook
webhook: { url: SERVER_URL + "/just-network/integrations/fileaway/webhook",
secret: process.env.FILEAWAY_WEBHOOK_SECRET },
branding,
});
// store link.id + link.uploadUrl on the eventThe returned id and uploadUrl are stored on the event as fileaway_link_id / fileaway_upload_url. Because links.create itself is not idempotent, the controller checks those columns first — so re-requesting the link never mints a duplicate.
b · Upload
The organizer shares uploadUrl. Photographers upload through FileAway's hosted page straight into the bucket. JustNetwork is not in this path at all — no bytes, no credentials, no proxy.
c · Webhook → database
FileAway POSTs upload.createdto JustNetwork's webhook route. The controller reads the X-Webhook-* headers, verifies the HMAC over the raw body with webhooks.verify (which throws on a bad signature or clock skew), acks 200 immediately, then processes asynchronously — writing an EventGallery row, deduping first on webhook_delivery_id, then on fileaway_upload_id, with a unique-constraint catch as a backstop.
.type (not .event), already parsed — so the handler uses the verified object directly, with no JSON.parse and no manual crypto.d · Realtime
After persisting, broadcastPhotoNew fires a PHOTO_NEW event on the Pusher channel EVENT_GALLERY_{eventId}, carrying an already-resolved display URL and its expiry — so every open browser prepends the new photo instantly.
e · Display
On gallery read, each row is resolved by resolveGalleryUrl. FileAway-origin rows (they have a fileaway_upload_id) go through uploads.getDownloadUrl, which returns a short-lived proxy downloadUrl plus expiresAt — cached in-process per upload, never past expiry. Direct-S3 rows use their stored link with no expiry.
On the web, a RefreshableImage renders the URL as a plain <img> and, just before expiresAt (or on a load error), mints a fresh one — so an expired token never shows a broken image.
f · Backfill / reconcile
Webhooks can be missed — an endpoint down through all retries, a deploy mid-delivery. backfillFromFileaway pages through links.uploads (a Page<Upload>: rows on page.data, walked via page.next()) and upserts anything missing, using the exact same idempotent write as the live path. Upload items come back raw (_id, not the normalized id).
Trust & security boundaries
API key stays server-side
FILEAWAY_API_KEY lives only on the backend, never shipped to web or mobile. The SDK validates its format and builds the client lazily, so a bad key fails at call time, not import time.
Uploaders need no credentials
External photographers only ever receive the link's uploadUrl.
Webhook authenticity via HMAC
A shared secret signs HMAC-SHA256 over "{timestamp}.{rawBody}" — which is exactly why the webhook route bypasses express.json() to preserve the raw bytes.
Downloads are proxy URLs
Token-bearing, fetched with no auth header, streamed through FileAway (bucket/region/object key never exposed), short-lived, and never persisted — JustNetwork stores the uploadId and mints a URL on demand.
The SDK in practice
Everything FileAway-facing is funneled through one module — fileawayService.js — using the fileaway-sdk client, with no raw fetch remaining. SDK errors are normalized into a FileawayError (carrying .status / .code) so the controllers' 404-self-heal and status-passthrough keep working. Seven calls cover the whole integration:
| SDK call | Triggered by | Frequency |
|---|---|---|
| uploads.getDownloadUrl(id, { expiresIn }) | Every gallery render (cached) | Hot path |
| webhooks.verify({ payload, signature, timestamp, secret }) | Each inbound webhook | Per upload |
| links.create(input) | Organizer mints a link | Rare |
| links.get(id) | Viewing link management | Occasional |
| links.delete(id) | Closing a link | Rare |
| links.update(id, patch) | Webhook-URL self-heal | Rare |
| links.uploads(id, { page, limit }) | Manual backfill/reconcile | Rare |
Methods they deliberately skip: uploads.download (they never need server-side bytes), links.uploadsAll (backfill uses manual paging to fit the existing dedup loop), and createUploadTicket / completeUploadTicket(uploaders use the hosted page, so brokered in-app uploads aren't needed).
What made it go smoothly
The integration is small and boring in the best way. A handful of FileAway specifics, learned once, kept it clean:
- Mount the webhook route with
express.rawbeforeexpress.json()— HMAC verification needs the exact bytes. - Read the verified event's kind from
.type, not.event; it's already parsed. - Upload records from
links.uploadsexpose_id, not the normalizedid— key dedup/refresh on that. - Pass
FILEAWAY_BASE_URLverbatim; never strip/v1. The SDK appends paths directly. links.createis not idempotent — dedupe by storing the link id on the event and checking it first.- Persist the
uploadId, never the download URL; mint a fresh proxy URL on demand (noRangesupport, so it's display, not streaming).
“Our users drop photos into a live gallery from a link, and they land in our own storage — we never run an upload server or touch a byte. The hard parts were already solved.”
Build your own upload flow
Connect your S3 or R2 bucket, mint a branded link, and receive files with a few lines of fileaway-sdk.