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.
Choose the consent policy
marketing_consented(default) delivers only guests who gave marketing consent. Guests without consent appear in Activity as skipped.media_onlysetsleadtonulland omits contact fields and consent records. Photos, media links, and activation/submission identifiers still leave Snapbar and may identify a person. Use it for a photo display with your own access, storage, retention, and deletion controls; it is not anonymous data.all_leadscannot be set through the API. It requires a confirmation by an owner or admin in the dashboard and is currently available for managed destinations only.
Build a receiver
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/hookor 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.Read the raw request body, verify the signature, durably store the
Idempotency-Keytogether with the work or a queued job, and return a2xxresponse 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 most3xxresponses are retried;301and308are permanent because the address itself moved, and any other4xxis permanent.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
Create a live API key with Manage notification endpoints and Read submissions using Create API keys. Call
GET /activationsas shown there and copy your experience’s UUIDid.Replace the example URL and UUID below with your receiver and experience. This example sends finished-photo events for one experience. Choose
submission.createdfor guest details before processing,submission.completedfor finished photos without a review requirement, orsubmission.approvedfor approved photos. Use the consent policy you chose above. Omitexperience_idsonly 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-endpointsThe
201response includessigning_secretonce, prefixedwhsec_. 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 withGET /webhook-endpointsbefore 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
Each delivery is a
POSTwithContent-Type: application/json,User-Agent: Snapbar-Integrations/1,X-Snapbar-Event-Id,Idempotency-Key, andX-Snapbar-Signaturein the formt=<unix seconds>,v1=<hex HMAC>. The header may carry more than onev1entry; accept the delivery when any of them matches.Compute HMAC-SHA256 with your signing secret over the timestamp, a period, and the exact raw body bytes. Accept the request when any
v1value 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'))); }Delivery is at least once. Insert the
Idempotency-Keyinto 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 return2xxwithout 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. UseX-Snapbar-Event-Idfor 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
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: truefor 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/deliveriesWith a live manage-webhooks key, call
POST /test/deliverieswith{"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 reportsendpoint_id,outcome(succeededorfailed), andresponse_status(an HTTP status ornull). 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" }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
testkey.{ "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/deliveriesorGET /hooks/samplecarries"test": true, fixed identifiers, an emptymediaarray, and a lead named Test Lead at test@snapbar.com. Gate ontest === trueso 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-Typerather than assuming a file extension.
Maintenance: rotate a signing secret
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, andexperience_idsas the current one (A), and store itssigning_secret.Deploy the receiver with both secrets and confirm B using a live manage-webhooks key with
POST /test/deliveriesand B’sendpoint_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 byIdempotency-Keyand make the business operation idempotent bysubmission.idplusevent.type. Delete A only after B passes; removal does not transfer A’s queued work.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
- 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
- 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
- The photo is cleared for public display: complete and approved, whichever happened last.
- Fires once per submission and never for a held or rejected photo. See Send approved photos to your own display.
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.