Webhooks
Mosaic sends only newly created live Events to configured Destinations. Baseline Events
are queryable but silent by default. A delivery is a POST of one versioned JSON payload, with no
redirect following. The production Worker transport aborts each webhook POST after exactly 10
seconds; a timeout is a failed attempt subject to the normal queue retry policy.
Destination safety
Section titled “Destination safety”Webhook destinations must use public HTTPS hostnames without userinfo. Mosaic rejects every literal IPv6 host, including public literals, plus loopback and private IPv4 hosts. The same policy is rechecked when a persisted delivery is claimed; malformed, unsafe, legacy, or tampered rows are terminalized without a network request.
Mosaic revalidates the URL at create, test, claim, and delivery time. The production Cloudflare Worker also uses strict-public global fetch enforcement at DNS resolution and connect time, so a hostname that rebinds to a private address is rejected. Redirects are never followed, which prevents a public endpoint from redirecting a request into a private network.
Payload
Section titled “Payload”Every signed body is one webhook_payload envelope. Its data is the public Event exactly as
GET /v1/events/{eventId} returns it (the same EventResponse.data schema in OpenAPI), never an
internal record:
{ "object": "webhook_payload", "version": "2026-09-28", "type": "youtube.description_match.observed", "deliveryId": "77777777-7777-4777-8777-777777777777", "data": { "id": "55555555-5555-4555-8555-555555555555", "object": "event", "type": "youtube.description_match.observed", "source": "youtube", "classification": "live", "brand": { "id": "12121212-1212-4121-8121-121212121212", "name": "Acme" }, "trackedBrand": { "id": "34343434-3434-4343-8343-343434343434" }, "…": "the remaining public Event fields" }}| Field | Meaning |
|---|---|
version |
Payload version, currently 2026-09-28. A breaking body change ships a new version. |
type |
The Event type, or mosaic.webhook.test for a test delivery. |
deliveryId |
Same value as mosaic-delivery-id. |
data |
The public Event; data.id equals mosaic-event-id. For a test, { id, object: "webhook_test", ... }. |
Delivery does not compute complete-history novelty, so a YouTube Event’s novelty grains are
unlabeled; read GET /v1/events/{eventId} for first-versus-repeat labels. Slack messages are
rendered from the same public Event. Payload version 2026-09-28 replaced the earlier body, which
was the internal Event with legacy flat fields and no envelope.
Headers and signed bytes
Section titled “Headers and signed bytes”Every delivery includes:
content-type: application/jsonmosaic-event-id— the stable Event UUIDmosaic-delivery-id— the stable request UUID reused across retriesmosaic-timestamp— Unix time in secondsmosaic-signature—v1=<lowercase-hex-HMAC-SHA256>user-agent: Mosaic-Webhooks/1.0 (+https://mosaic.dev)
Compute the signature over the exact bytes, without reparsing or reserializing the body:
${mosaic-timestamp}.${raw-request-body}Use the Destination-specific signingSecret returned once by Destination creation or rotation. Do not use the
server master key, log the secret, or put it in client-side code.
For header inspection only, this request shows the signed-header names. Its compact body is not a
valid delivery; use a complete public Event from GET /v1/events/{eventId} as data when testing
a receiver:
POST /webhooks/mosaic HTTP/1.1content-type: application/jsonmosaic-event-id: 55555555-5555-4555-8555-555555555555mosaic-delivery-id: 77777777-7777-4777-8777-777777777777mosaic-timestamp: 1774094400mosaic-signature: v1=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdefuser-agent: Mosaic-Webhooks/1.0 (+https://mosaic.dev)
{"object":"webhook_payload","version":"2026-09-28","type":"youtube.description_match.observed","deliveryId":"77777777-7777-4777-8777-777777777777","data":{"id":"55555555-5555-4555-8555-555555555555"}}Verification procedure
Section titled “Verification procedure”- Read the raw request body before JSON parsing.
- Parse the bounded body and require
data.idto equal themosaic-event-idheader (anddeliveryIdto equalmosaic-delivery-id). Reject a mismatch before signature acceptance or deduplication. - Reject a timestamp outside the replay window your receiver chooses.
- Build
${mosaic-timestamp}.${raw-request-body}exactly. - Compute HMAC-SHA256 using the UTF-8 bytes of the complete
whsec_…string as the key. Do not strip the prefix or base64-decode it; Mosaic signatures are not Stripe-compatible. - Compare the expected and supplied signatures in constant time, then check
versionand validate the payload:dataagainst the OpenAPIEventResponse.dataschema. - Deduplicate durable processing by the already-compared
mosaic-event-id; retainmosaic-delivery-idfor request-level diagnostics. - Return a
2xxonly after durable processing succeeds.
The timestamp is part of replay defense; the stable Event ID is the idempotency key for your consumer. Mosaic does not promise exactly-once network delivery.
Tests and secret rotation
Section titled “Tests and secret rotation”POST /v1/destinations/{destinationId}/test sends the same envelope with
type: "mosaic.webhook.test" and
data: { "id": <delivery UUID>, "object": "webhook_test", "destinationId", "createdAt" }, not an
Event. Its mosaic-event-id is the delivery UUID. Verify its signature over the same
timestamp-plus-raw-body bytes.
Rotation takes effect immediately and has no dual-secret overlap. The old secret stops verifying
new requests as soon as rotation commits. An idempotent replay with the same rotation key returns
the same new secret. Replaying the original create key after a later rotation returns 409; it does
not recover either the old or current secret.
Retries, redirects, and delivery state
Section titled “Retries, redirects, and delivery state”Mosaic records a durable outbox intent per (Event, webhook Destination). Baseline Events do not
create intents. Repeated collection jobs, duplicate notifications, enqueue loss, and delivery
retries reuse that intent rather than creating another one.
The Cloudflare Queue consumer is configured with maxRetries: 5 (initial attempt plus five
retries). Ordinary retry delay is 60 seconds. Network failures and non-2xx responses mark the
attempt failed and are retried; after the terminal attempt the durable row is marked failed for
operator inspection. A missing delivery is ACKed as already gone. Mosaic does not follow webhook
redirects: a redirect response is terminal for that delivery, and the Destination must be updated
explicitly.
Your receiver should treat repeated Event IDs as a successful duplicate after checking the stored payload/signature policy. Do not create a second business record for a retry.
An Event that reveals a suppressed or erased subject (ADR 0018) is never routed, and a pending webhook or Slack delivery for it is settled as failed without sending. Reversing a suppression does not replay withheld deliveries.
Secret-safe curl inspection
Section titled “Secret-safe curl inspection”Do not print the secret or send a real event body to an untrusted endpoint. To inspect headers in a local receiver, use a placeholder URL and a test payload only:
curl --request POST "https://example.com/webhooks/mosaic" \ --header "Content-Type: application/json" \ --header "mosaic-event-id: 55555555-5555-4555-8555-555555555555" \ --header "mosaic-delivery-id: 77777777-7777-4777-8777-777777777777" \ --header "mosaic-timestamp: 1774094400" \ --header "mosaic-signature: v1=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" \ --data '{"object":"webhook_payload","version":"2026-09-28","type":"mosaic.webhook.test","deliveryId":"77777777-7777-4777-8777-777777777777","data":{"id":"55555555-5555-4555-8555-555555555555","object":"webhook_test"}}'