Webhooks
A webhook endpoint is a URL Synex POSTs events to as they happen, so your integration reacts to changes instead of polling for them. The body of every delivery is exactly the event object GET /v1/events/{event} returns — same envelope, same snapshot — so you can build and test against the feed first and turn on delivery afterwards without changing your parser.
Neither ability is in a key's default set, and webhook_endpoints:write is
sensitive — an endpoint is a destination your team's data flows to. Grant
both deliberately; see authentication.
The webhook endpoint model
- Name
id- Type
- string
- Description
Unique identifier, prefixed
we_.
- Name
object- Type
- string
- Description
Always
webhook_endpoint.
- Name
url- Type
- string
- Description
Where deliveries are POSTed. See URL requirements.
- Name
secret- Type
- string
- Description
The signing secret,
whsec_followed by 48 hex characters. Present only on create and roll — every other response omits the key entirely rather than returning null. Encrypted at rest and never rendered again.
- Name
enabled_events- Type
- array
- Description
The event types this endpoint receives. Either concrete types from the catalog, or the single-element list
["*"]. The two forms cannot be mixed.
- Name
status- Type
- string
- Description
enabledordisabled.
- Name
description- Type
- string
- Description
Free-text label for your own use, at most 255 characters. Nullable.
- Name
metadata- Type
- object
- Description
Caller-owned metadata bag.
- Name
last_success_at- Type
- string
- Description
When this endpoint last answered a delivery with 2xx. Null if it never has.
- Name
first_failed_at- Type
- string
- Description
When the current unbroken run of failures began — the clock auto-disable measures. Reset to null by any successful delivery, and by re-enabling the endpoint.
- Name
disabled_at- Type
- string
- Description
When the endpoint was disabled, whether by you or automatically. Null while
statusisenabled.
- Name
created_at- Type
- string
- Description
ISO 8601 timestamp of creation.
- Name
updated_at- Type
- string
- Description
ISO 8601 timestamp of the last update.
A team may hold 16 endpoints; the seventeenth create is 400 webhook_endpoint_limit_reached. Delete or disable one to make room.
URL requirements
Your endpoint URL is validated before it is stored, and again at delivery time. These are requirements, not preferences:
httpsonly. Plaintext delivery would put signed customer data on the wire in clear.- A publicly routable host. A URL that points at — or whose hostname resolves into — a private, loopback, link-local, carrier-NAT or cloud metadata range is refused.
- No userinfo.
https://user:password@host/pathis rejected: credentials in a URL are sent on every delivery and logged by every proxy in between. - At most 2048 characters.
Every rejection is 400 webhook_url_invalid with param naming url. The message names the problem class but never the address a hostname resolved to.
Because the check runs again at delivery time, an endpoint whose DNS is later re-pointed into a blocked range starts failing deliveries — and those failures consume attempts and count toward auto-disable exactly like any other.
Choosing enabled_events
Either a list of concrete types from the event catalog, or the single-element list ["*"]. "*" means every type including ones added in future, which is the right choice for a firehose consumer and the wrong one for a narrow integration that would rather opt in explicitly.
The two forms cannot be mixed. ["*", "passport.updated"] reads like a narrowing but is not one, so rather than silently subscribing you to everything it is rejected with 400 enabled_events_invalid — as is any type not in the catalog. Duplicates are collapsed silently.
Changing the subscription later is a PATCH, and it replaces the list rather than merging into it.
List webhook endpoints
Lists the team's endpoints, newest first. Requires webhook_endpoints:read. Returns a paginated list.
Secrets are omitted from every row.
Optional attributes
- Name
limit- Type
- integer
- Description
Page size, 1–100 (default 10).
- Name
cursor- Type
- string
- Description
A pagination cursor from a previous response's
next_cursor.
Request
curl https://api.synexcloud.com/v1/webhook_endpoints \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "list",
"data": [
{
"id": "we_8f3a2c1e9d7b4065",
"object": "webhook_endpoint",
"url": "https://hooks.kestrel-energy.example/synex",
"enabled_events": ["passport.created", "passport.updated"],
"status": "enabled",
"description": "Production passport sync",
"metadata": {},
"last_success_at": "2026-07-28T09:12:44Z",
"first_failed_at": null,
"disabled_at": null,
"created_at": "2026-07-14T11:03:19Z",
"updated_at": "2026-07-28T09:12:44Z"
}
],
"has_more": false,
"next_cursor": null,
"url": "/v1/webhook_endpoints"
}
Register an endpoint
Registers a destination and returns its signing secret. Requires webhook_endpoints:write.
Capture secret from this response. It is shown here and in the roll response, and nowhere else.
Required attributes
- Name
url- Type
- string
- Description
Where deliveries are POSTed. Must satisfy the URL requirements.
- Name
enabled_events- Type
- array
- Description
Concrete catalog types, or
["*"]. The two cannot be mixed.
Optional attributes
- Name
description- Type
- string
- Description
Free-text label, at most 255 characters.
- Name
metadata- Type
- object
- Description
Caller-owned metadata bag.
Request
curl https://api.synexcloud.com/v1/webhook_endpoints \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.kestrel-energy.example/synex",
"enabled_events": ["passport.created", "passport.updated"],
"description": "Production passport sync"
}'
Response
{
"id": "we_8f3a2c1e9d7b4065",
"object": "webhook_endpoint",
"url": "https://hooks.kestrel-energy.example/synex",
"secret": "whsec_5c1f8a3e7b0d296441fa7c8e2b5d0937e46a1c8f3b7d0e25",
"enabled_events": ["passport.created", "passport.updated"],
"status": "enabled",
"description": "Production passport sync",
"metadata": {},
"last_success_at": null,
"first_failed_at": null,
"disabled_at": null,
"created_at": "2026-07-28T09:12:44Z",
"updated_at": "2026-07-28T09:12:44Z"
}
Retrieve an endpoint
Reads one endpoint. Requires webhook_endpoints:read.
The secret key is absent from this response — not null.
Read the three health timestamps together to know where the endpoint stands:
| Reading | Meaning |
|---|---|
last_success_at set, first_failed_at null | Healthy |
first_failed_at set and ageing | Failing now; auto-disable at five days |
disabled_at set, status: disabled | Disabled, by you or automatically |
Request
curl https://api.synexcloud.com/v1/webhook_endpoints/we_8f3a2c1e9d7b4065 \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"id": "we_8f3a2c1e9d7b4065",
"object": "webhook_endpoint",
"url": "https://hooks.kestrel-energy.example/synex",
"enabled_events": ["passport.created", "passport.updated"],
"status": "enabled",
"description": "Production passport sync",
"metadata": {},
"last_success_at": "2026-07-28T09:12:44Z",
"first_failed_at": null,
"disabled_at": null,
"created_at": "2026-07-14T11:03:19Z",
"updated_at": "2026-07-28T09:12:44Z"
}
Update an endpoint
Changes the URL, subscription, description, metadata or status. Requires webhook_endpoints:write. Fields you omit are left alone.
enabled_events replaces the list rather than merging into it.
Setting status to enabled on a disabled endpoint clears disabled_at and first_failed_at — the failure clock is reset, not paused. Without that, an endpoint disabled after five failing days would be re-disabled by the next sweep before your fix had a chance.
To stop deliveries temporarily without losing the configuration, secret or history, PATCH to status: disabled rather than deleting.
Optional attributes
- Name
url- Type
- string
- Description
Replacement destination, revalidated against the URL requirements.
- Name
enabled_events- Type
- array
- Description
Replacement subscription.
- Name
description- Type
- string
- Description
Replacement label.
- Name
status- Type
- string
- Description
enabledordisabled.
- Name
metadata- Type
- object
- Description
Merged per key; a null value deletes that key.
Request
curl -X PATCH https://api.synexcloud.com/v1/webhook_endpoints/we_8f3a2c1e9d7b4065 \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "enabled"}'
Response
{
"id": "we_8f3a2c1e9d7b4065",
"object": "webhook_endpoint",
"url": "https://hooks.kestrel-energy.example/synex",
"enabled_events": ["passport.created", "passport.updated"],
"status": "enabled",
"description": "Production passport sync",
"metadata": {},
"last_success_at": "2026-07-28T09:12:44Z",
"first_failed_at": null,
"disabled_at": null,
"created_at": "2026-07-14T11:03:19Z",
"updated_at": "2026-07-28T11:41:02Z"
}
Delete an endpoint
Removes the endpoint. Requires webhook_endpoints:write.
Deleting removes its delivery records with it. Events themselves belong to the team's feed and are untouched — if you only want to pause delivery, PATCH to status: disabled instead.
Request
curl -X DELETE https://api.synexcloud.com/v1/webhook_endpoints/we_8f3a2c1e9d7b4065 \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "webhook_endpoint",
"id": "we_8f3a2c1e9d7b4065",
"deleted": true
}
Roll the signing secret
Issues a new signing secret and returns it in full. Requires webhook_endpoints:write.
Rolling is designed never to drop a delivery: for 24 hours the previous secret keeps signing alongside the new one, and every delivery in that window carries two v1= entries in its signature header — one computed with each secret. A consumer that has not yet deployed the new secret still finds a match; one that has, also does.
So the safe rotation is: roll, deploy the new secret at your leisure within 24 hours, done. No coordinated cutover, no window in which neither secret verifies.
There is exactly one previous-secret slot. Rolling again while a window is still open replaces the previous secret rather than stacking a third, so the older of the two stops verifying immediately and the 24 hours restart. An unbounded chain of retired secrets would mean one leaked three rolls ago still verified today.
Roll immediately if a secret leaks: anyone holding it can forge a delivery your consumer will accept as genuine.
Request
curl -X POST https://api.synexcloud.com/v1/webhook_endpoints/we_8f3a2c1e9d7b4065/roll_secret \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"id": "we_8f3a2c1e9d7b4065",
"object": "webhook_endpoint",
"url": "https://hooks.kestrel-energy.example/synex",
"secret": "whsec_1a7d0c4f9e2b6538d0af7c3e1b9d5024f8c6a3e70b14d29f",
"enabled_events": ["passport.created", "passport.updated"],
"status": "enabled",
"description": "Production passport sync",
"metadata": {},
"last_success_at": "2026-07-28T09:12:44Z",
"first_failed_at": null,
"disabled_at": null,
"created_at": "2026-07-14T11:03:19Z",
"updated_at": "2026-07-28T12:00:00Z"
}
List deliveries
The attempt history for one endpoint. Requires webhook_endpoints:read. Returns a paginated list.
When an integration is not receiving what it expects, this is the endpoint that tells you which half of the problem you have. No row for an event you expected means fan-out never matched it — check enabled_events and whether the endpoint was enabled at the time. A row in failed or exhausted means Synex tried and your server did not accept it, and last_http_status and last_error say how. A null last_http_status with a populated last_error means nothing answered at all: DNS failure, connection refused, or a timeout.
There is one row per (endpoint, event) pair rather than one per attempt — attempt_count carries the history.
status | Meaning |
|---|---|
pending | Queued or in flight; no attempt has completed |
succeeded | Answered 2xx. delivered_at is set |
failed | Last attempt failed, another is due at next_retry_at |
exhausted | All attempts spent, or the event aged out of retention |
Request
curl https://api.synexcloud.com/v1/webhook_endpoints/we_8f3a2c1e9d7b4065/deliveries \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "list",
"data": [
{
"id": "wd_7a0c3e5b1d8f4926",
"object": "webhook_delivery",
"event": "evt_9b3e5d7c0a2f1846",
"status": "failed",
"attempt_count": 3,
"last_http_status": 502,
"last_error": "HTTP 502: <html><head><title>502 Bad Gateway</title>",
"last_attempted_at": "2026-07-22T14:04:37Z",
"next_retry_at": "2026-07-22T14:14:37Z",
"delivered_at": null
}
],
"has_more": false,
"next_cursor": null,
"url": "/v1/webhook_endpoints/we_8f3a2c1e9d7b4065/deliveries"
}
The delivery contract
Synex sends a POST with a JSON body that is the event object, and these headers:
| Header | Value |
|---|---|
Synex-Signature | t=<unix>,v1=<hex> — one or two v1= entries |
Synex-Webhook-Id | The delivery id (wd_…), stable across retries of the same delivery |
Content-Type | application/json |
User-Agent | Synex-Webhooks/1.0 |
Rules of the exchange:
- Only a 2xx counts as success. Anything else is a failure and will be retried.
- Redirects are never followed. A 3xx is recorded as a failure. Following one would hand the destination to the HTTP client after the URL checks had already approved a different host, which is the whole bug class those checks exist to prevent. Register the final URL.
- Timeouts are 5 seconds to connect and 10 seconds in total. Acknowledge fast and do your work asynchronously — a slow consumer converts into a failed delivery. Return 2xx as soon as you have durably queued the event.
Verifying the signature
The signature is HMAC-SHA256 over the string "{t}.{raw_body}", keyed with your endpoint secret, where t is the unix timestamp from the header.
Two properties of that construction carry the whole design. The timestamp is inside the signed string, not merely alongside it, so it cannot be rewritten without invalidating the signature — which is what makes a freshness check meaningful. And the body is signed as the raw bytes on the wire, never a re-serialization: decode the JSON and re-encode it before verifying and key order, unicode escaping and float formatting will all differ, so every signature will appear invalid. Capture the raw body before your framework parses it.
Verification
function synex_verify(string $rawBody, string $header, string $secret): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) {
continue;
}
[$key, $value] = $pair;
if ($key === 't' && preg_match('/^\d+$/', $value) === 1) {
$timestamp = (int) $value;
} elseif ($key === 'v1' && $value !== '') {
// There may be TWO v1 entries during a secret roll.
$signatures[] = $value;
}
}
if ($timestamp === null || $signatures === []) {
return false;
}
// Reject stale and future-dated deliveries alike: 5 minutes either way.
if (abs(time() - $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
$matched = false;
foreach ($signatures as $candidate) {
// hash_equals is constant-time. The loop deliberately does not break
// early — returning on the first match leaks, through timing, which
// of two signatures in a rolled header was the live one.
$matched = hash_equals($expected, $candidate) || $matched;
}
return $matched;
}
In any language, the same five steps:
1. Read the raw request body as bytes, before any JSON parsing.
2. Parse the Synex-Signature header into t and a LIST of v1 values.
3. Reject if |now - t| > 300 seconds.
4. expected = HMAC_SHA256(key = secret, message = t + "." + rawBody), hex.
5. Return true if expected equals ANY v1 value, compared in CONSTANT TIME.
Four details are easy to get wrong and each one breaks verification silently:
- Scan every
v1=entry. During a 24-hour roll overlap there are two. Code that reads the first and stops will reject half the deliveries it should accept — and only during a rotation, which is exactly when you are not looking. - Compare in constant time.
hash_equals,crypto.timingSafeEqual,hmac.compare_digest. A plain==on a signature leaks it a byte at a time. - Tolerance is 300 seconds, checked in both directions. A signature from the future is as much a sign of a forged or misconfigured sender as one from last week.
- Sign the raw body, as above.
Unknown keys in the header are safe to ignore — the format is designed to grow, and the v1 tag is a version marker so a future scheme can be added alongside it rather than replacing it.
Retries and delivery semantics
A failed delivery is retried up to 8 attempts total, with jittered backoff at roughly:
| Attempt | Delay after the previous one |
|---|---|
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 4 hours |
| 7 | 12 hours |
| 8 | 24 hours |
That is about three days of trying. When the last attempt fails the delivery is marked exhausted and nothing further is sent for that event.
Delivery is at-least-once, with no ordering guarantee. Both halves matter:
- Deduplicate on
event.id. The same event can legitimately arrive more than once — a retry after your server accepted the request but failed to answer in time, for instance. Record processed ids and discard repeats.Synex-Webhook-Ididentifies the delivery and is stable across retries of the same one, which makes it a useful log correlator;event.idis the correct idempotency key for your business logic. - Never assume sequence. Two events about the same passport can arrive out of order, because a retried delivery can land after a later event's first attempt. Do not reconstruct state by replaying events in arrival order. When order matters, treat the event as a notification that something changed and re-fetch the object for current truth — the snapshot in
data.objectis the state at emission, not necessarily the state now.
Two edge cases are worth knowing about. An attempt is charged only when the request was actually made, so infrastructure trouble on our side never counts against your endpoint's health. And if an event is pruned by the 30-day retention while deliveries to it are still backing off, that delivery closes as exhausted with an explanatory error, and your endpoint's health is left untouched.
Auto-disable, and getting back
An endpoint that fails continuously for five days — no successful delivery since first_failed_at — is automatically disabled. status becomes disabled, disabled_at is set, and a webhook_endpoint.disabled event is emitted. That event is delivered to your other healthy endpoints and never to the one being disabled, which would be pointless.
A disabled endpoint is skipped during fan-out entirely: it accrues no deliveries at all, and events that occur while it is disabled are not queued up for later.
Re-enable with a PATCH to status: enabled once you have fixed the cause. That resumes future deliveries; it does not resurrect deliveries that already exhausted their attempts — catch up on those through the events feed.
A checklist for a new consumer
- Serve
https, on a publicly resolvable host. - Capture the raw request body before parsing.
- Verify the signature: every
v1=entry, constant-time compare, 300-second tolerance. - Return 2xx as soon as the event is durably queued — within 10 seconds.
- Deduplicate on
event.id. - Do not depend on ordering; re-fetch the object when current truth matters.
- Handle
data.truncatedby re-fetching. - Tolerate event types you do not recognise.
- Monitor
first_failed_aton your endpoints, or subscribe something towebhook_endpoint.disabled.
Errors
| Code | Status | Meaning |
|---|---|---|
webhook_url_invalid | 400 | The URL is not https, carries userinfo, exceeds 2048 characters, or resolves into a blocked range. param is url |
enabled_events_invalid | 400 | An unknown type, or "*" mixed with concrete types. param is enabled_events |
webhook_endpoint_limit_reached | 400 | The team already holds 16 endpoints |
metadata_invalid | 400 | The merged metadata bag broke its limits |
resource_missing | 404 | No such endpoint on your team |
insufficient_permissions | 403 | The key lacks webhook_endpoints:read or webhook_endpoints:write |
See errors for the shared envelope.