Everything you need to subscribe to Incomaker events and receive them reliably: how to create a
subscription, verify that a call really came from us, what happens when your receiver is down, and
what you can and can't configure yet. Read this before your first POST /v4/webhooks — the endpoint
reference in this folder covers shape, this page covers behavior.
1. Quickstart#
That's the whole loop. Everything below explains what happens between steps 1 and 3, and how to make
your receiver trustworthy and correct.On <JWT> above. This API accepts Authorization: Bearer <token> with either a JWT issued by
the Incomaker token service, or a bare API key (commons-api exchanges it for a token on your
behalf on every request). A session header also works for browser/UI callers. JWT is the
preferred form — it skips the exchange round-trip and is what any existing commons-api
integration should already be sending — so the examples in this guide use it throughout.
2. Topics#
Call GET /v4/webhooks/topics for the live, current catalogue — don't hardcode a list, it grows over
time. As of this writing, nine topics exist, all describing something commons-api itself writes
(contacts and orders):| topic | fires when |
|---|
contact.created | a new contact is created |
contact.updated | an existing contact is updated |
contact.deleted | a contact is deleted |
contact.unsubscribed | a contact is removed from every subscription list (the "fully off" case — see below) |
contact.list_added | a contact is added to any one list |
contact.list_removed | a contact is removed from any one list |
order.created | a new order is created |
order.updated | an existing order is updated |
order.cancelled | an order's state changes specifically to cancelled (no other state transition fires this) |
A subscription can list any subset. There's no wildcard syntax yet — list every topic you want
explicitly.Two topics you might expect that don't exist, on purpose:contact.subscribed. There's no single write in commons-api that unambiguously means "this
contact opted into marketing" as opposed to "this contact was added to list #7." Rather than guess,
we only ship the unambiguous half of the pair: contact.list_added for "added to a list" (generic,
correct for any list) and contact.unsubscribed for "removed from everything" (the one case that
really is unambiguous). If you need to detect opt-in specifically, watch contact.list_added and
filter on the list you care about client-side for now.
cart.abandoned. Detecting an abandoned cart means detecting the absence of a checkout after
some time window — that needs a scheduled sweep, not a webhook-style write-to-hook. Not available
yet.
3. The payload envelope#
Every delivery is a JSON POST (or PUT/PATCH, if you configured that). You can configure the shape of the payload - see 3b - or use a default payload format:{
"id": "whd_75b14900-1b9d-4cf1-910b-1ee7f014ed60",
"type": "contact.unsubscribed",
"timestamp": "2026-08-25T23:41:54.000Z",
"payloadVersion": "amos",
"websiteId": "3f2a9c4e-8b1d-4a6f-9c2e-7d5f1a8b3e6c",
"data": {
"contact": {
"contactId": "0f3e2b1a-...",
"clientContactId": "1042",
"email": "jan@example.cz"
},
"lists": [{ "id": 7, "name": "Newsletter" }]
},
"links": {}
}
| field | meaning |
|---|
id | Stable identifier for this delivery, whd_-prefixed. Use it as your idempotency key — see §5. Also sent as the webhook-id header. |
type | The topic name, exactly as in the catalogue. |
timestamp | When the underlying event actually happened — not when this specific delivery attempt was sent. Stable across retries. |
payloadVersion | See §7 — always "amos" right now. |
websiteId | The website this event belongs to, as a uuid — the same identifier your own auth token carries. Only relevant if one endpoint somehow serves multiple websites; otherwise a constant you can ignore. |
data | Topic-specific. See GET /v4/webhooks/topics's exampleData for every topic's shape — it's not identical across topics (e.g. order.* nests under order, not contact). |
links | Reserved; currently empty. |
data.contact.clientContactId and data.order.clientOrderId are always present when you set them at
creation — prefer matching on these (your own identifiers) rather than the internal contactId/
orderId UUIDs when correlating a delivery back to your own records.
3b. Customizing the payload#
Don't want the envelope above? Set body (and optionally bodyFormat, currently only "json" is
supported) on the endpoint and we send that instead — rendered fresh for every event.{
"topics": ["contact.created"],
"url": "https://shop.example.cz/api/webhooks/incomaker",
"bodyFormat": "json",
"body": {
"contact": {
"id": "{{ data.contact.contactId }}",
"email": "{{ data.contact.email }}"
}
}
}
When contact.created fires, that delivers exactly:{"contact":{"id":"e6308cf8-c972-11e9-ac23-ac1f6b0076ec","email":"vach@incomaker.com"}}
body is a JSON object (stringified automatically) or a raw string — either form works.
{{path.to.field}} placeholders are resolved against the same envelope described in §3 —
id, type, timestamp, websiteId, and data.* (topic-specific; check
GET /v4/webhooks/topics's exampleData for the exact path names per topic, since they differ —
data.order.* for order topics, data.contact.* for contact ones).
This is plain string substitution, not a full templating language — no conditionals, no loops,
just "replace this placeholder with this value."
Rendered fresh on every delivery attempt, including retries — never frozen at creation time.
The signature covers the rendered body, not the envelope. If you set a custom body,
webhook-signature is computed over exactly what you configured, post-rendering — verify against
the bytes you actually receive, same as always (§4).
A template referencing a path that doesn't exist is a delivery failure, logged on the attempt
(GET /v4/webhooks/deliveries/{deliveryId}) — never a silently empty or broken body sent to you.
Omit body entirely to keep getting the canonical envelope from §3 — nothing changes for existing
endpoints that don't set it.
4. Verifying a delivery is genuine#
We sign every request per the Standard Webhooks convention, so
any off-the-shelf Standard Webhooks verification library works unmodified. Three headers are sent
with every request:webhook-id: whd_75b14900-1b9d-4cf1-910b-1ee7f014ed60
webhook-timestamp: 1787696517
webhook-signature: v1,K5oZfzN95Z4WsjTz+9nOJ9tHfE...
These are always present and cannot be disabled or overridden by anything you configure — signing
isn't optional.What's signed: {webhook-id}.{webhook-timestamp}.{raw request body}, HMAC-SHA256, keyed by your
endpoint's secret (the whsec_... value you got back exactly once at creation), base64-encoded,
prefixed v1,.Verify it yourself (Node.js, no library):Two rules that matter for correctness, not just security:1.
Verify against the raw, unparsed body bytes. If your framework's body parser has already
deserialized and re-serialized the JSON by the time your verification code runs, the bytes won't
match what we signed even though the content is logically identical (key ordering, whitespace).
Hook verification in before any body-parsing middleware, or use your framework's raw-body option.
2.
Check the timestamp, don't just check the signature. webhook-timestamp is a Unix-seconds
value; reject anything outside a tolerance window you're comfortable with (a few minutes is
typical) to protect against a captured request being replayed later. The signature alone doesn't
protect against replay — the timestamp check does.
5. Idempotency#
Delivery is at-least-once, not exactly-once. A retried delivery (see §6) reuses the same
webhook-id/id across every attempt — that's the whole point of it being stable — so use
webhook-id as your deduplication key on your side. Store the ids you've already processed (even a
short-TTL cache is enough) and skip anything you've seen before you apply its side effects.
6. Delivery, retries, and failure#
Success is any 2xx status code from your receiver. Anything else is a failure, and failures
split into two very different buckets:Retryable: 408, 429, any 5xx, or the request not completing at all (timeout, connection
refused, DNS failure). These get retried automatically.
Terminal: any other 4xx (400, 401, 403, 404, 422, ...). These are not retried —
if your receiver rejects the payload as malformed or unauthorized, retrying the identical request
won't help, so we don't hammer you with it. Fix the underlying issue and the next real event will
succeed; the failed one stays dead.
Retry ladder (retryable failures only), roughly: immediately, then 15s, 1m, 5m, 30m, 2h, 6h, 12h
later — 8 attempts spread across about 21 hours before a delivery is marked dead and abandoned.
Exact timings are an operational default and may be tuned; treat "roughly a day, front-loaded toward
fast retries" as the guarantee, not the specific numbers.Timeout: we wait 15 seconds for your receiver to respond before treating the attempt as failed
(and retryable, per the bucket above).Ordering: deliveries are not guaranteed to arrive in the order their underlying events
occurred. If your integration needs strict ordering (e.g. contact.updated must never be processed
before an earlier contact.created for the same contact), use data.contact.contactId +
timestamp to reorder on your side rather than assuming arrival order.If your endpoint keeps failing, it will eventually be auto-disabled after enough consecutive
failures across separate deliveries, to stop us calling a URL that's clearly not going to work. Check
GET /v4/webhooks/{id} for "status": "disabled" if deliveries seem to have silently stopped, and
PATCH it back to "enabled" once you've fixed the receiver — nothing is lost, matching events
during the disabled window simply weren't delivered and won't be automatically replayed (see
"What's not here yet" below).Debugging a specific failure: GET /v4/webhooks/{id}/deliveries?status=dead (or failed) to
find it, then GET /v4/webhooks/deliveries/{deliveryId} for the full per-attempt log — every
attempt's status code, timing, and your receiver's actual response body. Authorization and
webhook-signature are redacted in the logged request headers even though they were genuinely sent
— the log is for diagnosing your receiver's behavior, not for replaying a call with live
credentials.
7. Payload versioning#
payloadVersion is a plain codename — "amos" right now — not a date, and not "v1". Two
deliberate choices behind that:Not a REST version. The /v4/ in every path here is a different axis entirely: it versions
this API's request/response shapes for managing subscriptions. payloadVersion versions the
event envelope shape sent to your receiver. They change independently — a future /v5/ wouldn't
necessarily touch payload shape, and a payload change wouldn't require a new REST version.
Not a date. The envelope already carries a real timestamp field; a date-shaped version string
sitting right next to it would be genuinely confusing (which date is which?). A codename can't be
mistaken for either.
An endpoint is pinned to whatever payloadVersion was current when it was created and keeps
receiving that shape indefinitely — a version doesn't change under you. There is currently exactly
one version, so this only matters as forward-looking context for when a second one ships.
8. Securing the target URL#
Every URL is checked, both when you create/update the endpoint and again on every actual send
(since DNS can change after creation):HTTPS required outside local development environments. Plain http:// is only accepted where
the platform is explicitly configured for it (e.g. testing against a local tunnel).
Private, loopback, link-local, carrier-grade-NAT, and cloud-metadata addresses are rejected —
including 169.254.169.254 specifically — along with localhost and any *.internal /
*.local / *.svc.cluster.local / *.cluster.local hostname.
Redirects are never followed. If your URL responds with a 3xx, that's treated as a non-2xx
response (see §6), not as "go here instead."
A URL that fails validation at creation time returns 400 with a specific reason immediately — you
will never end up with an endpoint that silently never delivers because its target was unsafe from
the start.
9. Authentication to your receiver#
Beyond the mandatory signature (§4), you can optionally add:A bearer token — "auth": {"type": "bearer", "token": "..."} at creation, sent as
Authorization: Bearer <token> on every delivery. Stored encrypted; never returned by any GET, the
same guarantee as the signing secret.
Static extra headers — "headers": [{"key": "X-Tenant", "value": "cz"}], sent verbatim on
every delivery, in addition to the signature headers. You cannot use this to override
webhook-id/webhook-timestamp/webhook-signature/Content-Type — those are always ours.
Basic auth, API-key auth, and OAuth2 aren't available yet — bearer or nothing are the only choices
today.
10. Limits (today)#
One event, one call. Batching multiple events into a single delivery isn't available —
batch.max must be 1 (the default); sending anything else is rejected at creation.
Website-scoped only. An endpoint belongs to exactly one website. Subscribing once across every
website in an account isn't available yet ("scope": "account" is rejected).
filter is accepted but not enforced. You can send a filter object at creation/update and it
will be stored, but nothing currently evaluates it — every event matching your topics is
delivered regardless of what's in filter. Don't rely on it to narrow volume yet.
11. What's not here yet#
Worth knowing about so you don't go looking for it:Custom events (your own event names) and email/campaign events (opens, clicks, bounces,
sends) aren't subscribable topics yet — only the nine contact/order topics in §2 exist.
No manual replay. A dead delivery cannot currently be resubmitted through the API; you'd need
to wait for the next real occurrence of that event.
No secret rotation without deletion. If you lose your signing secret, the only recovery today
is deleting the endpoint and creating a new one (a fresh secret each time).
No form-encoded or XML body, no URL templating. JSON body customization exists (§3b), but
form-encoded/XML bodies and interpolating values into the URL itself are not built.
None of this affects the reliability guarantees above (signing, retries, delivery logging) — it's
scope, not stability, that's still growing.
12. Quick troubleshooting checklist#
Nothing arrives at all → check GET /v4/webhooks/{id} — is status still "enabled"? An
endpoint that failed enough times gets auto-disabled (§6).
Signature verification fails on your side → almost always a raw-body issue (§4, rule 1) — check
whether your framework already parsed the body before your verification code runs.
Some events arrive, others don't → check whether the missing ones are actually a topic you
subscribed to (GET /v4/webhooks/{id}'s topics array) — a common mistake is subscribing to
contact.list_added while expecting general "subscribed" events (§2).
A specific delivery never succeeded → GET /v4/webhooks/deliveries/{deliveryId} for the exact
status code and body your receiver returned on each attempt (§6).
Your receiver gets a body that doesn't match your body template, or an error instead → check
the attempt log for a render error (§3b) — usually a {{path}} that doesn't exist for that topic;
compare against GET /v4/webhooks/topics's exampleData for the exact field names.
Modified at 2026-08-27 16:56:05