> ## Documentation Index
> Fetch the complete documentation index at: https://docs.signa.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Set up an endpoint, verify signatures, handle retries, rotate secrets

A webhook endpoint is a URL you control that receives signed `POST` requests from Signa whenever a subscribed event fires.

## Set up an endpoint

<Steps>
  <Step title="Build the receiver">
    Accept `POST application/json` and verify the [Standard Webhooks](https://www.standardwebhooks.com/) headers, see [Verify signatures](#verify-signatures) below.
  </Step>

  <Step title="Register it">
    [`POST /v1/webhooks`](/api-reference/monitoring/webhooks/create) registers your endpoint. The signing `secret` is returned once in the response, store it before the response is discarded.

    ```ts theme={null}
    const wh = await signa.webhooks.create({
      url: 'https://alerts.example.com/signa',
      description: 'Production alert webhook',
      enabled_events: ['alert.created'],
    });
    console.log('Store this secret:', wh.secret);
    ```
  </Step>

  <Step title="Verify deliveries">
    Use the SDK helper or any [Standard Webhooks](https://www.standardwebhooks.com/)-compatible library to verify the HMAC-SHA256 signature on every delivery, before you parse the body.
  </Step>
</Steps>

## Event types

| Event type      | When it fires                                        |
| --------------- | ---------------------------------------------------- |
| `alert.created` | A watch matched a trademark. One delivery per alert. |

`alert.created` is the only event you can subscribe to today via `enabled_events` on [`POST /v1/webhooks`](/api-reference/monitoring/webhooks/create) or [`PATCH /v1/webhooks/{id}`](/api-reference/monitoring/webhooks/update). Any other slug returns `400`.

### `webhook.test` is not subscribable

`webhook.test` is delivered only when you call [`POST /v1/webhooks/{id}/test`](/api-reference/monitoring/webhooks/test). The envelope matches `alert.created` but `data` is a fixed `{ "type": "ping" }` payload. Test deliveries are never retried and never count toward auto-disable, so probing a dead endpoint with `/test` is safe.

## Receiving `trademark.*` events

There is no direct `trademark.*` webhook subscription — putting a `trademark.*` slug in `enabled_events` returns `400`. Trademark change events are delivered **through watches**: create a [watch](/guides/monitoring/watches) scoped to the marks you care about, and every matching change arrives as an `alert.created` delivery.

The envelope `type` is always `alert.created`. The **derived trademark event type** is inside the payload, twice:

* `data.event_type` — flat field, **always present**.
* `data.event.type` — inside the rich `event` object, best-effort (feature-detect it — see [Payload shape](#payload-shape)).

Route on `data.event_type`, not on the envelope `type`.

### The five trademark event types

| `data.event_type`          | Derived when                                                                                                   | Delivered by default?                                                              |
| -------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `trademark.created`        | A matching mark is seen for the first time.                                                                    | **Yes**                                                                            |
| `trademark.updated`        | Tracked fields changed, none of them status fields.                                                            | **Yes**                                                                            |
| `trademark.status_changed` | Any of the four status fields changed (`status_stage`, `status_primary`, `status_reason`, `challenge_states`). | **Yes**                                                                            |
| `trademark.retracted`      | The upstream office feed pulled the record — a **pure** `is_retracted` false → true flip.                      | **Opt-in** via [`query.trigger_events`](/guides/monitoring/watches#trigger_events) |
| `trademark.corrected`      | A previously-retracted record reappeared — a **pure** `is_retracted` true → false flip.                        | **Opt-in** via [`query.trigger_events`](/guides/monitoring/watches#trigger_events) |

A watch that omits `trigger_events` receives the first three. To receive `retracted` / `corrected`, list them explicitly:

```ts theme={null}
await signa.watches.create({
  name: 'Portfolio incl. source retractions',
  watch_type: 'portfolio',
  query: {
    version: 'v1',
    filters: { trademarkIds: ['tm_018f9b2e-9b6c-7c9c-b4f1-1234567890ab'] },
    trigger_events: [
      'trademark.created',
      'trademark.updated',
      'trademark.status_changed',
      'trademark.retracted',
      'trademark.corrected',
    ],
  },
});
```

<Note>
  **Pure-flip rule for `retracted` / `corrected` (deliberate).** These two are derived only when the `is_retracted` flip is the **sole** tracked change on that record update. When the flip co-occurs with any other field change, the event is derived as `trademark.updated` or `trademark.status_changed` instead — otherwise a default watch (which isn't subscribed to the opt-in events) would silently lose the alert it normally gets. In practice: a record that reappears *with* content changes surfaces as `updated` / `status_changed`, not `corrected`.
</Note>

### Scope of coverage

This path is watch-scoped, not a firehose:

* You only receive events for marks that **match the watch's query** (its filters, offices, and — for similarity watches — score threshold).
* Events are evaluated only for **watch-eligible sync runs**. Bulk backfills and historical re-ingestion runs are suppressed and never produce alerts.

To widen coverage, widen the watch (or create more watches).

<Note>
  `GET /v1/events` is a **reserved surface and is not yet populated** — it returns an empty list today. For trademark change events, use `GET /v1/alerts` (pull) or this watch + webhook path (push).
</Note>

## Payload shape

Every delivery uses the same envelope:

```json theme={null}
{
  "type": "alert.created",
  "id": "alt_8kLm2nPq",
  "timestamp": "2026-05-08T14:32:11.428Z",
  "data": { /* the alert object, see below */ }
}
```

| Field       | Notes                                                                                                                                                          |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`      | The event slug. Matches what you subscribed via `enabled_events`.                                                                                              |
| `id`        | Prefixed event ID. For `alert.created` this is the alert's prefixed ID (`alt_*`) and equals the `webhook-id` header.                                           |
| `timestamp` | ISO 8601 UTC instant captured at signing. Fresh on every retry.                                                                                                |
| `data`      | The alert object, identical to what [`GET /v1/alerts/{id}`](/api-reference/monitoring/alerts/retrieve) returns (minus the REST-only `evaluation_epoch` field). |

The SDK type is `AlertCreatedEvent` (`import type { AlertCreatedEvent } from '@signa-so/sdk'`). All IDs are prefixed and can be passed directly to the matching REST resources, no conversion needed. The body is self-contained: the snapshot, diff, watch name, deadline, and `customer_reference` are all inline, so you don't need to call back to the REST API to render an alert.

### `data` fields

| Field                | Type                                                  | Description                                                                                                                                                                                                                                                                                        |
| -------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                 | `string` (`alt_*`)                                    | Prefixed alert ID.                                                                                                                                                                                                                                                                                 |
| `object`             | `'alert'`                                             | Resource type discriminator.                                                                                                                                                                                                                                                                       |
| `schema_version`     | `string`                                              | Alert wire schema version (e.g. `'2026-06-01'`).                                                                                                                                                                                                                                                   |
| `watch`              | `{ id, name, type }`                                  | The watch that fired: `id` (`wat_*`), human-readable `name`, and `type` (`mark` / `portfolio` / `owner` / `class` / `similarity`).                                                                                                                                                                 |
| `customer_reference` | `string \| null`                                      | The watch's customer passthrough label, frozen at emit time. `null` when unset. See [Watches: `customer_reference`](/guides/monitoring/watches#customer_reference-your-own-label).                                                                                                                 |
| `event`              | `{ type, summary, diff[], diff_truncated? }`          | Why the alert fired. `type` is the event slug; `summary` is a short human one-liner (e.g. `"Status primary changed: pending → registered"`); `diff` is an array of changed fields; `diff_truncated` is `true` (and otherwise absent) when the diff was clipped to stay under the wire byte budget. |
| `match`              | `object \| null`                                      | Match metadata (`reason`, `score`, `score_basis`) for similarity watches; `null` for pure-filter alerts.                                                                                                                                                                                           |
| `trademark`          | `object`                                              | Inline snapshot of the matched mark: `id` (`tm_*`), `mark_text`, `mark_feature_type`, `office_code`, `status`, `filing_date`, `registration_date`, `nice_classes`, `owner_name`, `as_of`, and `links.self`.                                                                                        |
| `deadline`           | `{ severity, opposition_window_status, must_act_by }` | Severity ranking, opposition-window state, and ISO 8601 action deadline (fields are `null` when none apply).                                                                                                                                                                                       |
| `timestamps`         | `{ occurred_at, ingested_at?, created_at }`           | When the source change occurred, when it was ingested (omitted when there's no linked change), and when the alert was created.                                                                                                                                                                     |
| `links`              | `{ trademark, watch }`                                | Relative REST paths to the matched trademark and the watch.                                                                                                                                                                                                                                        |

The payload deliberately omits any tenant identifier. Each endpoint URL belongs to one organization, so the tenant is implicit in which endpoint received the delivery.

## Signing

Every delivery is signed using HMAC-SHA256 per the [Standard Webhooks](https://www.standardwebhooks.com/) spec. Three signed headers, plus one unsigned attempt counter:

| Header              | Meaning                                                                                                                                                                                                                                      |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `webhook-id`        | Stable event identifier. Same value across retries and redeliveries. For `alert.created` this is the alert's prefixed ID (`alt_*`). Use this as your application-level idempotency key so retries don't double-process the underlying event. |
| `webhook-timestamp` | Unix seconds at delivery attempt time. Fresh on every retry. Reject deliveries older than 5 minutes (the SDK helper does this for you).                                                                                                      |
| `webhook-signature` | `v1,<base64-HMAC>`. During rotation, two space-separated entries: `v1,<curr> v1,<prev>`.                                                                                                                                                     |
| `webhook-attempt`   | Delivery attempt number (1 = first delivery, 2 = first retry, up to 7). Not signed, see [Idempotency](#idempotency) for safe usage.                                                                                                          |

<Warning>
  The body is canonicalized JSON (sorted keys, UTF-8, no trailing newline) before signing. Verify against the raw request bytes, not against a re-serialized version. If your framework parses, re-stringifies, or alters whitespace before your verifier sees the body, signature verification will fail.
</Warning>

## Verify signatures

### TypeScript / Node, SDK helper

The `@signa-so/sdk` package exports a thin wrapper over [`standardwebhooks`](https://www.npmjs.com/package/standardwebhooks):

```ts theme={null}
import express from 'express';
import { verifyWebhookSignature } from '@signa-so/sdk';

const app = express();
const SECRET = process.env.SIGNA_WEBHOOK_SECRET!;

app.post(
  '/signa-webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const ok = verifyWebhookSignature(
      {
        'webhook-id': req.header('webhook-id')!,
        'webhook-timestamp': req.header('webhook-timestamp')!,
        'webhook-signature': req.header('webhook-signature')!,
      },
      req.body.toString('utf-8'),
      SECRET,
    );
    if (!ok) return res.sendStatus(401);

    const event = JSON.parse(req.body.toString('utf-8'));
    console.log('alert received:', event.data.id);
    res.sendStatus(200);
  },
);
```

Returns `true` for a valid signature against `SECRET`, `false` on stale timestamps (more than 5 minutes of skew, enforced by the reference library), and accepts the rotation overlap (`v1,<curr> v1,<prev>`), verifying if either entry passes. The body MUST be the raw request bytes, no `JSON.parse` round trip first. Mismatched whitespace breaks the HMAC.

### TypeScript / Node, without the SDK

```ts theme={null}
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.SIGNA_WEBHOOK_SECRET!);
try {
  wh.verify(rawBody, {
    'webhook-id': req.header('webhook-id')!,
    'webhook-timestamp': req.header('webhook-timestamp')!,
    'webhook-signature': req.header('webhook-signature')!,
  });
} catch (err) {
  return res.sendStatus(401);
}
```

### Idempotency

Use `webhook-id` (the value, not the body) as your application-level idempotency key. The same alert delivered twice (retry, redeliver, duplicate dispatch) carries the same `webhook-id`, so your business logic (creating tickets, sending notifications, writing to your own DB) just needs to check "have I processed this id?"

Do not dedup blindly on `webhook-id` alone at the infrastructure layer. `webhook-id` is reused across retries on purpose, that's how application-level idempotency works, but an infrastructure-layer dedup keyed only on `webhook-id` will swallow a retry your handler actually wanted to see (for example, the first attempt timed out before your handler committed). If you need infrastructure-layer dedup (retry-storm protection, queue fan-out, observability counters), key on the tuple `(webhook-id, webhook-attempt)` instead:

```typescript theme={null}
// Infra-layer dedup that doesn't swallow retries
const dedupKey = `${headers['webhook-id']}:${headers['webhook-attempt']}`;
if (seenDeliveries.has(dedupKey)) return; // exact same attempt arrived twice
seenDeliveries.add(dedupKey);

// Application-layer idempotency, webhook-id is correct here
const alertId = headers['webhook-id'];
if (await alertsProcessed.has(alertId)) return; // already handled this alert
await processAlert(body);
await alertsProcessed.insert(alertId);
```

> **Security note:** `webhook-attempt` is not part of the signed envelope. Per the [Standard Webhooks spec](https://www.standardwebhooks.com/), only `webhook-id`, `webhook-timestamp`, and the body are signed. An attacker who replays a captured request can set any `webhook-attempt` value they like. Use it only for dedup-counting and observability, never as input to a security decision.

### Common pitfalls

* **Verify first, parse second.** Always validate the signature against the raw bytes before calling `JSON.parse`. Reject `401` on a failed verification and never touch the body.
* **Re-serializing the body.** Verify against the bytes you received, not against `JSON.stringify(JSON.parse(body))`. Whitespace matters.
* **Comma vs space in `webhook-signature`.** During rotation the header contains two entries separated by a single space. Some libraries split on commas, make sure yours follows the spec.
* **Forgetting timestamp freshness.** A leaked secret plus a stale signature is replayable. The SDK helper enforces 5-minute skew automatically; if you roll your own, do the same.

## Retry policy

Failed deliveries are retried with exponential backoff, 7 attempts total:

| Attempt | Delay           |
| ------- | --------------- |
| 1       | immediate       |
| 2       | +5s             |
| 3       | +25s            |
| 4       | +2 min          |
| 5       | +15 min         |
| 6       | +1 h            |
| 7       | +6 h (terminal) |

Each delay carries plus or minus 20% jitter to spread retry bursts. A delivery is "failed" if the receiver returns 4xx/5xx, times out (5s connect, 10s read), or refuses TLS. After attempt 7 the delivery row's `status` is set to `exhausted` and Signa gives up.

Delivery status values:

| Status      | Meaning                                                                                                                                          |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `pending`   | Queued or scheduled for retry. Not yet a final outcome.                                                                                          |
| `delivered` | Receiver returned 2xx.                                                                                                                           |
| `failed`    | Last attempt failed but more retries remain.                                                                                                     |
| `exhausted` | All 7 attempts failed. Replay manually with [`POST /v1/webhooks/{id}/deliveries/{did}/redeliver`](/api-reference/monitoring/webhooks/redeliver). |

## Auto-disable

An endpoint is disabled when either of two triggers fires:

* **Consecutive failures.** When `consecutive_failures` reaches 100, the endpoint is disabled.
* **Rolling failure rate.** Over the last 50 attempts, if the failure rate exceeds 50%, the endpoint is disabled. The rolling check only activates after 50 attempts, so a single failure on a brand-new endpoint will not disable it.

A long-tail flaky endpoint can hit the rate trigger without ever hitting the consecutive count; a short hard-down outage can hit the consecutive count first.

When an endpoint is disabled you'll see `status='disabled'` and a `disabled_reason` of `auto_consecutive_100`, `auto_failure_rate_50_over_50`, or `manual`.

### Re-enabling a disabled endpoint

Re-enable a disabled endpoint with [`PATCH /v1/webhooks/{id}`](/api-reference/monitoring/webhooks/update) and `{"status": "active"}`. This is self-serve, you don't need to contact support.

The same call also resets the auto-disable counters: `consecutive_failures` goes back to `0` and the rolling failure-rate window is cleared, and `disabled_at` / `disabled_reason` are wiped. A re-enabled endpoint therefore starts from a clean slate rather than re-disabling on its very next failed delivery.

Before re-enabling, verify the receiver is healthy with [`POST /v1/webhooks/{id}/test`](/api-reference/monitoring/webhooks/test), test deliveries are free, are never retried, and never count toward auto-disable, so you can confirm the endpoint is back up without risking an immediate re-disable.

```bash theme={null}
# 1. Confirm the receiver is healthy (test pings don't count toward auto-disable)
curl -X POST "https://api.signa.so/v1/webhooks/whk_01HK.../test" \
  -H "Authorization: Bearer $SIGNA_API_KEY"

# 2. Re-enable, resets consecutive_failures + the failure-rate window
curl -X PATCH "https://api.signa.so/v1/webhooks/whk_01HK..." \
  -H "Authorization: Bearer $SIGNA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "active"}'
```

## Changing the URL

To migrate an endpoint to a new URL (domain rename, infrastructure move), `PATCH` the endpoint with the new value:

```bash theme={null}
curl -X PATCH "https://api.signa.so/v1/webhooks/whk_01HK..." \
  -H "Authorization: Bearer $SIGNA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: migrate-whk-url-2026-06-12" \
  -d '{"url": "https://new.example.com/signa"}'
```

The signing secret is preserved. Future delivery attempts (including retries already scheduled) go to the new URL. Test the new URL with [`POST /v1/webhooks/{id}/test`](/api-reference/monitoring/webhooks/test) before relying on it, test deliveries are free and do not affect auto-disable counters.

## Rotation

Call [`POST /v1/webhooks/{id}/rotate-secret`](/api-reference/monitoring/webhooks/rotate-secret) to roll the signing secret. For 24 hours both secrets are valid, Signa signs every delivery with both:

```
webhook-signature: v1,<new-signature> v1,<old-signature>
```

The reference Standard Webhooks library accepts either, so your verifier needs no changes during the overlap. Update your receiver to the new secret any time in the window.

Calling `rotate-secret` again while the previous-secret window is still active returns `409`, so a second rotation can't silently invalidate the overlap window an in-flight receiver update depends on.

### Emergency force rotation

For a suspected secret leak mid-overlap, pass `force=true` (in the request body or as `?force=true` on the URL):

```bash theme={null}
curl -X POST "https://api.signa.so/v1/webhooks/whk_01HK.../rotate-secret?force=true" \
  -H "Authorization: Bearer $SIGNA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: force-rotate-whk-2026-06-12" \
  -d '{"reason": "Suspected secret leak, incident IR-2026-04-12"}'
```

Force rotation:

* Skips the 24h overlap window (no `409`).
* Immediately invalidates the previous secret. Any receiver still using it will fail signature verification on the next delivery.
* Writes a `webhook.secret.force_rotated` audit log entry with the optional `reason`.

Use force rotation only when the previous secret is known or suspected to be compromised. For routine rotations, wait for the overlap window to close.

## Redelivery

If your receiver is down for a stretch and deliveries land in `status: "exhausted"`, replay them manually. The delivery ID is the `id` field from [`GET /v1/webhooks/{id}/deliveries`](/api-reference/monitoring/webhooks/list-deliveries), a raw UUID, not a prefixed ID:

```bash theme={null}
curl -X POST "https://api.signa.so/v1/webhooks/whk_01HK.../deliveries/01890a91-7c2e-7f3a-b9d4-3e5f6a7b8c9d/redeliver" \
  -H "Authorization: Bearer $SIGNA_API_KEY" \
  -H "Idempotency-Key: redeliver-01890a91-2026-06-12"
```

Redelivery carries a fresh `webhook-timestamp` (so it passes freshness checks) but the same `webhook-id`, your idempotency-by-`webhook-id` logic continues to work.

## URL requirements

Production endpoints must be public HTTPS URLs. Localhost, private network addresses, and link-local IPs are rejected at create time and at delivery time. To test locally, expose your receiver through a public tunnel (ngrok, Cloudflare Tunnel) and register that URL.

## Testing deliveries before you have a receiver

You don't need production infrastructure to see a real signed delivery.

1. **Local receiver behind a tunnel.** Run your handler locally (say, on port 4000), expose it with `ngrok http 4000` or `cloudflared tunnel`, register the tunnel URL via [`POST /v1/webhooks`](/api-reference/monitoring/webhooks/create), and store the returned secret in your local env. Trigger a test delivery with [`POST /v1/webhooks/{id}/test`](/api-reference/monitoring/webhooks/test), the envelope shape matches `alert.created`, so your verifier exercises the same path it will in production. Test deliveries are free and do not count toward auto-disable.
2. **Request-bin style.** Point a temporary endpoint at any HTTPS request inspector (a webhook.site-style bin or your own one-file server behind a tunnel), register it, and fire a synthetic ping. You'll see the full envelope plus the `webhook-id` / `webhook-timestamp` / `webhook-signature` headers, everything you need to develop your verifier against real bytes.

When you ship to production, `PATCH` the endpoint with the production URL (see [Changing the URL](#changing-the-url)), the same secret keeps working.

<Warning>
  Anything you send to a third-party request bin is visible to that service. Use pattern 2 only with the synthetic `webhook.test` ping, not with real alert traffic.
</Warning>

## Cost

Webhook deliveries are billed per successful delivery and per redelivery. Test deliveries are free.
