DEVELOPER · WEBHOOKS

Fire any workflow off a Fotowall event.

Subscribe to photo, event, milestone, and guest events with signed, retried HTTPS callbacks. Every payload is HMAC-SHA256 signed. Every delivery retries with exponential backoff. If your stack speaks HTTPS, it speaks Fotowall.

  • HMAC-SHA256 signed
  • 3 retries, exponential backoff
  • < 5s per delivery
  • Send test events from admin
OVERVIEW

What Fotowall webhooks do and how they work.

What fires a webhook

Anytime a photo, event, milestone, or guest reaches a state worth knowing about, we write a record to your event's webhookEvents subcollection. A Cloud Function picks it up and POSTs to every URL configured on the parent event.

Per-event configuration

Webhook URLs live on the event document, not the tenant. Each event can have multiple URLs and a single shared webhook secret used to sign every delivery. Secrets are visible only to tenant superadmins.

Delivery semantics

At-least-once. We retry non-2xx and timeout responses 3 times with exponential backoff (1s, 5s, 30s), then mark the delivery failed. Use the Fotowall-Delivery-Id header for idempotency.

Security

Every payload is signed with HMAC-SHA256 over the raw request body. Verify on every request — never trust the body without checking the Fotowall-Signature header. Replay protection via the timestamp.

QUICKSTART

Sixty seconds to a working webhook.

  1. 01

    Set up an endpoint

    Anywhere that accepts POST + JSON. Sites that publish webhook URLs (Zapier Catch Hook, Make Custom Webhook, Slack incoming webhook) all work as-is.

  2. 02

    Add the URL to your event

    In your Fotowall admin: event settings → Integrations → Webhooks. Paste the URL and pick which events fire it. Copy the generated webhook secret somewhere safe.

  3. 03

    Send a test event

    Hit "Send test" in the admin. A signed photo.uploaded payload lands at your URL within a couple of seconds. Verify the signature, log the body, ship it.

EVENT TYPES

Seven events. Pick which ones fire per webhook URL.

Subscribe to all events or filter to just the ones you care about. Below is the canonical payload for each event type. All timestamps are ISO 8601 UTC.

photo.uploaded

A guest uploads a photo. Fires even when moderation is on and the photo is still pending.

{
  "event": "photo.uploaded",
  "eventId": "spring-gala-2026",
  "photo": {
    "id": "ph_8a92",
    "status": "pending",
    "url": "https://storage.googleapis.com/fotowall/.../ph_8a92.jpg",
    "uploaderName": "Maya",
    "uploaderEmail": "maya@example.com",
    "createdAt": "2026-05-17T03:14:00Z"
  }
}
photo.approved

A moderator approves a pending photo, OR a photo is uploaded under auto-approve.

{
  "event": "photo.approved",
  "eventId": "spring-gala-2026",
  "photo": {
    "id": "ph_8a92",
    "status": "approved",
    "url": "https://storage.googleapis.com/fotowall/.../ph_8a92.jpg",
    "uploaderName": "Maya",
    "approvedAt": "2026-05-17T03:14:42Z",
    "approvedBy": "admin@yourorg.com"
  }
}
photo.rejected

A moderator rejects a pending photo.

{
  "event": "photo.rejected",
  "eventId": "spring-gala-2026",
  "photo": {
    "id": "ph_8a92",
    "status": "rejected",
    "uploaderName": "Maya",
    "rejectedAt": "2026-05-17T03:14:18Z",
    "rejectedBy": "admin@yourorg.com",
    "reason": "duplicate"
  }
}
event.published

Your event goes live — photos can be uploaded and the wall is accepting traffic.

{
  "event": "event.published",
  "eventId": "spring-gala-2026",
  "eventName": "Spring Gala 2026",
  "template": "gala",
  "publishedAt": "2026-05-17T18:00:00Z",
  "galleryUrl": "https://app.fotowall.io/gallery.html?event=spring-gala-2026"
}
event.archived

You archive the event (no more uploads, gallery becomes read-only).

{
  "event": "event.archived",
  "eventId": "spring-gala-2026",
  "archivedAt": "2026-05-18T08:00:00Z",
  "photoCount": 742
}
milestone.reached

The event hits a photo-count threshold (100, 500, 1000, 5000, 10000).

{
  "event": "milestone.reached",
  "eventId": "spring-gala-2026",
  "milestone": 500,
  "reachedAt": "2026-05-17T21:34:11Z",
  "latestPhoto": {
    "id": "ph_500x",
    "uploaderName": "Marcus"
  }
}
guest.joined

A new uploader posts for the first time — useful for greeting flows and audience growth.

{
  "event": "guest.joined",
  "eventId": "spring-gala-2026",
  "uploaderName": "Marcus",
  "uploaderEmail": "marcus@example.com",
  "firstPhotoId": "ph_500x",
  "joinedAt": "2026-05-17T21:34:11Z"
}
REQUEST HEADERS

Every delivery ships with these headers.

Header What it carries
Fotowall-Signature HMAC-SHA256 of the raw request body keyed with your event's webhook secret. Format: t=<unix>,v1=<hex>.
Fotowall-Event-Type The event name (e.g. photo.approved). Same as `event` in the JSON body.
Fotowall-Event-Id Your Fotowall event ID. Same as `eventId` in the body. Useful for routing in shared endpoints.
Fotowall-Delivery-Id Unique ID for this delivery attempt. Identical across retries — use it for idempotency.
User-Agent Fotowall-Webhooks/1.0 — pin this in your firewall allowlist if you want.
SIGNATURE VERIFICATION

Verify before you trust.

The Fotowall-Signature header looks like t=1715000000,v1=ab12.... Concatenate the timestamp + "." + raw body, HMAC-SHA256 with your event's webhook secret, compare in constant time, and reject if the timestamp is older than 5 minutes.

Node.js (Express)

import crypto from 'node:crypto';

const SECRET = process.env.FOTOWALL_WEBHOOK_SECRET!;

app.post('/webhooks/fotowall', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.header('Fotowall-Signature') ?? '';
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));

  const t  = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return res.status(400).send('missing signature');

  // Replay defense — reject anything older than 5 minutes.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) {
    return res.status(400).send('stale');
  }

  const signed   = `${t}.${req.body.toString('utf8')}`;
  const expected = crypto.createHmac('sha256', SECRET).update(signed).digest('hex');

  // Constant-time comparison.
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(v1, 'hex');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('bad signature');
  }

  const payload = JSON.parse(req.body.toString('utf8'));
  // ... process payload, return 200 fast, do work async ...
  res.status(200).end();
});

Python (Flask)

import hmac, hashlib, time, os
from flask import request, abort

SECRET = os.environ['FOTOWALL_WEBHOOK_SECRET'].encode()

@app.post('/webhooks/fotowall')
def fotowall():
    header = request.headers.get('Fotowall-Signature', '')
    parts  = dict(p.split('=', 1) for p in header.split(','))
    t, v1  = parts.get('t'), parts.get('v1')
    if not t or not v1:
        abort(400)

    if abs(time.time() - int(t)) > 300:
        abort(400)

    signed   = f"{t}.{request.get_data(as_text=True)}".encode()
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):
        abort(401)

    payload = request.get_json(force=True)
    # ... handle, return 200, queue work async ...
    return '', 200
RETRY POLICY

Three retries, exponential backoff.

Return 2xx and we stop. Return anything else — including timeouts — and we retry. All four attempts share the same Fotowall-Delivery-Id, so you can dedupe with it.

Attempt Delay before attempt Notes
#1 immediate First delivery attempt. ~5s timeout per request.
#2 1s Retried if attempt 1 returns non-2xx or times out.
#3 5s Retried if attempt 2 still fails.
#4 30s Final attempt. After this we mark the delivery as `failed` and stop.
TESTING

Roll your own dry-run with curl.

The admin "Send test" button is the easy path. For full-control replays — and for unit-testing your endpoint — generate a signed request yourself.

Generate a signature with openssl + curl

SECRET="whsec_your_event_secret"
BODY='{"event":"photo.uploaded","eventId":"spring-gala-2026","photo":{"id":"ph_test"}}'
T=$(date +%s)
SIG=$(printf "%s.%s" "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -X POST https://your.app/webhooks/fotowall \
  -H "Content-Type: application/json" \
  -H "Fotowall-Event-Type: photo.uploaded" \
  -H "Fotowall-Event-Id: spring-gala-2026" \
  -H "Fotowall-Delivery-Id: del_$(uuidgen)" \
  -H "Fotowall-Signature: t=$T,v1=$SIG" \
  -H "User-Agent: Fotowall-Webhooks/1.0" \
  --data "$BODY"
SECURITY BEST PRACTICES

The five things to actually do.

  1. 1 · Verify every signature

    Use constant-time comparison (Node's crypto.timingSafeEqual, Python's hmac.compare_digest). Naive === leaks timing.

  2. 2 · Reject stale timestamps

    The signature includes a Unix timestamp. Reject anything older than 5 minutes to defeat captured-request replays.

  3. 3 · Pin the user-agent (optional)

    We always send User-Agent: Fotowall-Webhooks/1.0. Pin this in WAF rules to reduce spam if your endpoint is public.

  4. 4 · IP allowlist if your security posture requires it

    Webhooks egress from Google Cloud's us-central1 ranges. Talk to support for a static-IP path on Enterprise.

  5. 5 · Dedupe by delivery ID

    Retries reuse the Fotowall-Delivery-Id. Cache it for ~1 hour and short-circuit duplicates server-side.

FAQ

Questions we get every week.

How do I verify the Fotowall-Signature?

Concatenate the timestamp (t=) and the raw body with a "." separator, then HMAC-SHA256 it with your event's webhook secret. Compare against the v1= hex. Reject if the timestamp is older than 5 minutes (replay defense).

Can I receive webhooks for multiple events at one endpoint?

Yes — that's the recommended pattern. Each event uses its own webhook secret, and the Fotowall-Event-Id header tells you which event fired. Route on that header.

What HTTP status codes should I return?

2xx means success — we will not retry. Anything else (including timeouts) triggers the retry policy. If you need to acknowledge receipt quickly, return 200 immediately and queue the work async.

Are deliveries ordered?

Within a single event, deliveries are best-effort ordered. Retries can re-order events though, so use the timestamps in the payload + the Fotowall-Delivery-Id header for true idempotency.

Can I see what deliveries succeeded or failed?

A delivery log viewer is shipping with the next admin update — every attempt with its response code and body will appear in the event settings page. Today, errors are logged server-side and a delivery summary surfaces in the daily ops digest.

START_BUILDING

Open admin, paste a URL, watch the events fly.

Or wire to one of our pre-built integrations if you'd rather not roll your own.

Open admin See pre-built integrations