Skip to content

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.

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.

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.

Every delivery includes:

  • content-type: application/json
  • mosaic-event-id — the stable Event UUID
  • mosaic-delivery-id — the stable request UUID reused across retries
  • mosaic-timestamp — Unix time in seconds
  • mosaic-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.1
content-type: application/json
mosaic-event-id: 55555555-5555-4555-8555-555555555555
mosaic-delivery-id: 77777777-7777-4777-8777-777777777777
mosaic-timestamp: 1774094400
mosaic-signature: v1=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
user-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"}}
  1. Read the raw request body before JSON parsing.
  2. Parse the bounded body and require data.id to equal the mosaic-event-id header (and deliveryId to equal mosaic-delivery-id). Reject a mismatch before signature acceptance or deduplication.
  3. Reject a timestamp outside the replay window your receiver chooses.
  4. Build ${mosaic-timestamp}.${raw-request-body} exactly.
  5. 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.
  6. Compare the expected and supplied signatures in constant time, then check version and validate the payload: data against the OpenAPI EventResponse.data schema.
  7. Deduplicate durable processing by the already-compared mosaic-event-id; retain mosaic-delivery-id for request-level diagnostics.
  8. Return a 2xx only 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.

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.

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.

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:

Terminal window
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"}}'