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.

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

    enabled or disabled.

  • 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 status is enabled.

  • 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:

  • https only. 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/path is 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.


GET/v1/webhook_endpoints

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

GET
/v1/webhook_endpoints
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"
}

POST/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

POST
/v1/webhook_endpoints
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"
}

GET/v1/webhook_endpoints/:id

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:

ReadingMeaning
last_success_at set, first_failed_at nullHealthy
first_failed_at set and ageingFailing now; auto-disable at five days
disabled_at set, status: disabledDisabled, by you or automatically

Request

GET
/v1/webhook_endpoints/we_8f3a2c1e9d7b4065
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"
}

PATCH/v1/webhook_endpoints/:id

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

    enabled or disabled.

  • Name
    metadata
    Type
    object
    Description

    Merged per key; a null value deletes that key.

Request

PATCH
/v1/webhook_endpoints/we_8f3a2c1e9d7b4065
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/v1/webhook_endpoints/:id

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

DELETE
/v1/webhook_endpoints/we_8f3a2c1e9d7b4065
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
}

POST/v1/webhook_endpoints/:id/roll_secret

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

POST
/v1/webhook_endpoints/we_8f3a2c1e9d7b4065/roll_secret
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"
}

GET/v1/webhook_endpoints/:id/deliveries

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.

statusMeaning
pendingQueued or in flight; no attempt has completed
succeededAnswered 2xx. delivered_at is set
failedLast attempt failed, another is due at next_retry_at
exhaustedAll attempts spent, or the event aged out of retention

Request

GET
/v1/webhook_endpoints/we_8f3a2c1e9d7b4065/deliveries
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:

HeaderValue
Synex-Signaturet=<unix>,v1=<hex> — one or two v1= entries
Synex-Webhook-IdThe delivery id (wd_…), stable across retries of the same delivery
Content-Typeapplication/json
User-AgentSynex-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:

AttemptDelay after the previous one
230 seconds
32 minutes
410 minutes
51 hour
64 hours
712 hours
824 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-Id identifies the delivery and is stable across retries of the same one, which makes it a useful log correlator; event.id is 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.object is 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

  1. Serve https, on a publicly resolvable host.
  2. Capture the raw request body before parsing.
  3. Verify the signature: every v1= entry, constant-time compare, 300-second tolerance.
  4. Return 2xx as soon as the event is durably queued — within 10 seconds.
  5. Deduplicate on event.id.
  6. Do not depend on ordering; re-fetch the object when current truth matters.
  7. Handle data.truncated by re-fetching.
  8. Tolerate event types you do not recognise.
  9. Monitor first_failed_at on your endpoints, or subscribe something to webhook_endpoint.disabled.

Errors

CodeStatusMeaning
webhook_url_invalid400The URL is not https, carries userinfo, exceeds 2048 characters, or resolves into a blocked range. param is url
enabled_events_invalid400An unknown type, or "*" mixed with concrete types. param is enabled_events
webhook_endpoint_limit_reached400The team already holds 16 endpoints
metadata_invalid400The merged metadata bag broke its limits
resource_missing404No such endpoint on your team
insufficient_permissions403The key lacks webhook_endpoints:read or webhook_endpoints:write

See errors for the shared envelope.

Was this page helpful?