Integrations

Receive signed webhooks at your own endpoint

Register an HTTPS receiver through the API, verify each delivery, and handle retries and duplicates correctly.

On this page

Register an HTTPS address to receive events from Snapbar. Follow the setup steps through a signed synthetic test, then confirm a real guest delivery in Activity. The downloadable Node 24/SQLite example is for an approved-photo display index; adapt its durable receiver pattern for other workflows.

Build a receiver

  1. The receiver must be reachable on the public internet over HTTPS on port 443, with no username or password in the address. Registration rejects plain HTTP, credentials, a non-default port, and a private or loopback IP address written as a literal. A hostname is not resolved at registration: https://localhost/hook or a name that points at a private network passes registration and then fails at delivery as a permanent Action needed row. Redirects are never followed.

  2. Read the raw request body, verify the signature, durably store the Idempotency-Key together with the work or a queued job, and return a 2xx response within 10 seconds; the connection is cut at exactly 10 seconds and the attempt counts as a timeout. Do heavier work after responding. 408, 425, 429, 5xx, timeouts, and most 3xx responses are retried; 301 and 308 are permanent because the address itself moved, and any other 4xx is permanent.

  3. During development, expose a local server through one HTTPS tunnel. Use either Cloudflare or ngrok below. Register its public URL in the next section, then install the returned secret before sending a test. Keep signature verification enabled.

    # Cloudflare: prints a https://<random>.trycloudflare.com address
    cloudflared tunnel --url http://localhost:3000
    
    # ngrok: prints a https://<random>.ngrok-free.app address
    ngrok http 3000

Register the endpoint

  1. Create a live API key with Manage notification endpoints and Read submissions using Create API keys. Call GET /activations as shown there and copy your experience’s UUID id.

  2. Replace the example URL and UUID below with your receiver and experience. This example sends finished-photo events for one experience. Choose submission.created for guest details before processing, submission.completed for finished photos without a review requirement, or submission.approved for approved photos. Use the consent policy you chose above. Omit experience_ids only when you intend to receive the whole account.

    curl --fail-with-body \
      --request POST \
      --header "Authorization: Bearer ${SNAPBAR_API_KEY}" \
      --header 'Content-Type: application/json' \
      --data '{
        "url": "https://example.com/snapbar",
        "event_types": ["submission.completed"],
        "consent_policy": "marketing_consented",
        "experience_ids": ["2f5c1a8e-7d3b-4e1f-9a6c-0b8d4e2f1a37"]
      }' \
      https://integrations.snapbar.com/v1/webhook-endpoints
  3. The 201 response includes signing_secret once, prefixed whsec_. Store it as a credential next to the endpoint id and install it in your receiver. Registration activates the endpoint immediately; use a rehearsal experience until verification passes. If the response is lost, list endpoints with GET /webhook-endpoints before registering again, then delete the orphan and re-register to get a new secret.

    {
      "id": "9c2e4b6a-1d3f-4a5b-8c7d-2e1f0a9b8c7d",
      "url": "https://example.com/snapbar",
      "source": "custom",
      "event_types": ["submission.completed"],
      "experience_ids": ["2f5c1a8e-7d3b-4e1f-9a6c-0b8d4e2f1a37"],
      "consent_policy": "marketing_consented",
      "status": "active",
      "created_at": "2026-07-18T19:20:11.512+00:00",
      "signing_secret": "whsec_…",
      "warning": "Store this signing secret securely. Snapbar will not return it again."
    }

Verify every delivery

  1. Each delivery is a POST with Content-Type: application/json, User-Agent: Snapbar-Integrations/1, X-Snapbar-Event-Id, Idempotency-Key, and X-Snapbar-Signature in the form t=<unix seconds>,v1=<hex HMAC>. The header may carry more than one v1 entry; accept the delivery when any of them matches.

  2. Compute HMAC-SHA256 with your signing secret over the timestamp, a period, and the exact raw body bytes. Accept the request when any v1 value matches and the timestamp is within 300 seconds of your clock. Verify before parsing: re-serializing the JSON changes the bytes.

    import { createHmac, timingSafeEqual } from 'node:crypto';
    
    export function verifySnapbarWebhook(rawBody, signatureHeader, secret) {
      if (typeof signatureHeader !== 'string' || signatureHeader.length > 4096) return false;
      const entries = signatureHeader.split(',').map((entry) => entry.trim().split('=', 2));
      const timestamps = entries.filter(([name]) => name === 't');
      if (timestamps.length !== 1) return false;
      const timestamp = timestamps[0][1];
      const signatures = entries.filter(([name]) => name === 'v1').map(([, value]) => value);
    
      if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
      if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) return false;
    
      const expected = createHmac('sha256', secret)
        .update(Buffer.from(`${timestamp}.`, 'utf8'))
        .update(rawBody)
        .digest();
    
      return signatures.some((candidate) => /^[0-9a-f]{64}$/i.test(candidate) && timingSafeEqual(expected, Buffer.from(candidate, 'hex')));
    }
  3. Delivery is at least once. Insert the Idempotency-Key into a table with a unique constraint and apply the side effect in the same transaction, or store the key together with a durable job you process afterwards, then return 2xx without repeating the work when the key already exists. Recording the key without committing the work turns a crash into a false success, because the retry is acknowledged and never redelivered. Use X-Snapbar-Event-Id for correlation only; it identifies the delivery, so different endpoints receive different event ids and idempotency keys for the same submission.

Send a test and confirm success

  1. Start your receiver with the registered signing secret installed. Replace the endpoint ID below, then run this request with your live key. It sends synthetic data, so your receiver should ignore test: true for business processing.

    curl --fail-with-body \
      --request POST \
      --header "Authorization: Bearer ${SNAPBAR_API_KEY}" \
      --header 'Content-Type: application/json' \
      --data '{"endpoint_id": "YOUR_REGISTERED_ENDPOINT_ID"}' \
      https://integrations.snapbar.com/v1/test/deliveries
  2. With a live manage-webhooks key, call POST /test/deliveries with {"endpoint_id":"..."} after installing that endpoint’s secret in the receiver. Snapbar signs the synthetic payload with the existing secret; no new secret is returned. The response reports endpoint_id, outcome (succeeded or failed), and response_status (an HTTP status or null). The send creates no Activity record, does not expose guest data, and is limited to 10 per key per minute.

    {
      "outcome": "succeeded",
      "response_status": 200,
      "endpoint_id": "9c2e4b6a-1d3f-4a5b-8c7d-2e1f0a9b8c7d"
    }
  3. Complete one real guest-flow rehearsal with the selected consent policy and event. Confirm the expected result in your app and a Delivered row in Integrations > Activity. Synthetic tests do not appear in Activity. For a moderated display, also verify that pending and rejected photos stay off the screen.

Read the envelope

  • Every delivery carries Lead Envelope version 1. The schema only ever adds fields, so ignore keys you do not recognize and never treat an unfamiliar enum value as a failure. A real delivery has no test key.

    {
      "schema_version": 1,
      "event": { "id": "…", "occurred_at": "2026-07-18T19:24:35.000Z", "type": "submission.completed" },
      "account": { "id": "…", "slug": "example-team" },
      "activation": { "id": "…", "short_id": "exp_Abc123Def456", "name": "Summer Launch", "type": "ai_photo_booth", "starts_at": "…", "ends_at": "…", "timezone": "America/Los_Angeles" },
      "submission": { "id": "…", "short_id": "sub_Sub123Def456", "state": "complete", "created_at": "…", "completed_at": "…" },
      "lead": { "id": "…", "email": "guest@example.com", "first_name": "Alex", "last_name": "Rivera", "phone": null, "custom_fields": {}, "attendee": null, "consent": { "marketing": true, "terms": true, "captured_at": "…", "marketing_consented_at": "…", "terms_consented_at": "…", "source": "lead_form", "text_version": "2026-07" } },
      "media": [ { "id": "…", "category": "image_output", "url": "https://…", "thumbnail_url": "https://…", "width": 1080, "height": 1350, "duration_seconds": null } ],
      "links": { "activation": null, "submission": null }
    }
  • A synthetic delivery from POST /test/deliveries or GET /hooks/sample carries "test": true, fixed identifiers, an empty media array, and a lead named Test Lead at test@snapbar.com. Gate on test === true so a test never reaches production tables or a live wall.

    {
      "schema_version": 1,
      "test": true,
      "event": { "id": "00000000-0000-4000-8000-000000000101", "occurred_at": "…", "type": "submission.completed" },
      "activation": { "id": "00000000-0000-4000-8000-000000000102", "short_id": "exp_TestEnvelope1", "name": "Snapbar Test Activation", "type": "ai_photo_booth", "timezone": "UTC", "starts_at": "…", "ends_at": null },
      "submission": { "id": "00000000-0000-4000-8000-000000000104", "short_id": "sub_TestEnvelope1", "state": "complete", "created_at": "…", "completed_at": "…" },
      "lead": { "id": "00000000-0000-4000-8000-000000000105", "email": "test@snapbar.com", "first_name": "Test", "last_name": "Lead", "phone": null, "custom_fields": {}, "attendee": null, "consent": { "marketing": true, "terms": true, "source": "partner_api_test", "text_version": "test-v1", "captured_at": "…", "marketing_consented_at": "…", "terms_consented_at": "…" } },
      "media": [],
      "links": { "activation": null, "submission": null }
    }
  • Media addresses are public CDN links with no authentication and no fixed expiry. Download the file when the delivery arrives and serve it from your own storage: a link stops working when a guest asks for erasure or the activation media is removed, and the served format is negotiated per request, so read the response Content-Type rather than assuming a file extension.

Maintenance: rotate a signing secret

  1. A secret cannot be rotated in place. Register a second endpoint (B) at a new path on the same host with the same event_types, consent_policy, and experience_ids as the current one (A), and store its signing_secret.

  2. Deploy the receiver with both secrets and confirm B using a live manage-webhooks key with POST /test/deliveries and B’s endpoint_id, then with one real rehearsal event. Keep verification enabled. Both A and B may receive the same real event during overlap: dedupe transport retries by Idempotency-Key and make the business operation idempotent by submission.id plus event.type. Delete A only after B passes; removal does not transfer A’s queued work.

  3. Delete A with DELETE /webhook-endpoints/{id}. Queued work closes as Skipped with reason, but a request already sent can still arrive and cannot be recalled. Keep accepting and deduplicating in-flight requests during the cutover; retire the old path and secret after outstanding requests have settled.

Event types

submission.created

No media
  • The guest submitted. Carries the lead when the policy allows it.
  • Use it for fast lead capture that does not need the photo.

submission.completed

Media included
  • Processing finished. Carries the finished media links.
  • Fires whether or not the host has reviewed the photo, so a receiver that must never show unreviewed photos should not use it alone.

submission.approved

Media included

What to know

  • Retries and endpoint health.Failed sends follow the retry ladder in Partner API errors and limits, with at most three in flight per endpoint. Five consecutive failures mark Failing, after which each new photo gets one attempt. A later failure more than seven days after the streak began disables the endpoint; one success resets the streak. The primary account owner receives an auto-disable email, and Snapbar receives internal transition alerts. Fix the receiver, register and verify a replacement, then delete the old endpoint. Old deliveries stay attached to the old endpoint and replay cannot redirect them. Use the approved-photo reconciliation procedure below for displays; contact Snapbar with endpoint and activation IDs for other catch-up needs.
  • Deleting or losing an endpoint closes its queue.Deleting or automatically disabling an endpoint closes its undelivered rows as Skipped with reason. A replacement receives future events only; replay does not move old deliveries to it. Preserve the old endpoint ID and delivery IDs for support before deletion. For display recovery, use GET /activations/{id}/submissions?approved_only=true, drain all pages without a completion watermark, and reconcile the current approved/visible set. This is current-state recovery, not a historical event replay.
  • Hiding media does not recall a delivery.If a host hides or deletes a photo after it was delivered, the receiver keeps what it received. Later API reads omit hidden media, but no retraction event is sent.
  • Limits.An account can hold 50 active endpoints, each subscribed to up to three event types and scoped to up to 100 experiences. Each delivery appears once in Integrations > Activity, with its current status and attempt count, where a teammate who can edit experiences can replay failed rows. Retention of stored payloads is described in Partner API reference.
  • Duplicate registrations.Endpoint registration is not idempotent. Two identical requests create two endpoints that both receive every event, so check the list before retrying a request whose response was lost.

Tips

  • Commit the idempotency key together with the completed work or a durable job before returning 2xx, well inside the 10 second window. Process durable jobs afterwards; a slow receiver looks like a timeout and gets retried.
  • Keep the tunnel receiver and the production receiver on separate endpoints so a laptop going to sleep never drives the production endpoint into Failing.

Related articles

Create API keys for the partner APISend approved photos to your own displayFix failed deliveriesPartner API errors and limits