> ## 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. The SDK helper does this for you -- see [Verify webhook signatures](/guides/monitoring/webhook-signature-verification).
  </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. See [Verify webhook signatures](/guides/monitoring/webhook-signature-verification) for cURL, Node, Python, and Go recipes.
  </Step>
</Steps>

## Event types

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

`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.

## Payload shape

Every delivery uses the same envelope:

```json theme={null}
{
  "type": "alert.created",
  "id": "alt_018f9b2e-9b6c-7c9c-b4f1-1234567890ab",
  "timestamp": "2026-05-08T14:32:11.428Z",
  "data": { /* event-specific -- 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`      | Event-specific payload.                                                                                              |

### `alert.created` `data` fields

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 `data` body is **self-contained and rich**: it carries the **full alert object** — the same canonical resource you'd get from [`GET /v1/alerts/{id}`](/api-reference/monitoring/alerts/retrieve) (minus `evaluation_epoch`, which is carried as a flat field) — **alongside** the preserved legacy flat fields. You no longer need to call back to the REST API to render an alert: the snapshot, diff, watch name, deadline, and `customer_reference` are all inline.

#### Rich object fields (self-contained)

| Field                | Type                                                  | Description                                                                                                                                                                                                                                                                                        |
| -------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                 | `string` (`alt_*`)                                    | Prefixed alert ID.                                                                                                                                                                                                                                                                                 |
| `object`             | `'alert'`                                             | Resource type discriminator.                                                                                                                                                                                                                                                                       |
| `schema_version`     | `string`                                              | Alerts v2 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 / pre-v2 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 a `links.self`.                                                                                     |
| `deadline`           | `{ severity, opposition_window_status, must_act_by }` | Severity ranking, opposition-window state, and ISO 8601 action deadline (`null` fields when none apply).                                                                                                                                                                                           |
| `timestamps`         | `{ occurred_at, ingested_at?, created_at }`           | When the source change occurred, was ingested (omitted when no change row), and when the alert was created.                                                                                                                                                                                        |
| `links`              | `{ trademark, watch }`                                | Relative REST paths to the matched trademark and the watch.                                                                                                                                                                                                                                        |

#### Flat fields (legacy, always present)

These backward-compatible top-level fields are preserved alongside the rich object so existing consumers never break. IDs are prefixed.

| Field                      | Type                                                                                                                         | Description                                                                                                                                                   |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `alert_id`                 | `string` (`alt_*`)                                                                                                           | Same value as `id`. Pass to [`GET /v1/alerts/{id}`](/api-reference/monitoring/alerts/retrieve).                                                               |
| `watch_id`                 | `string` (`wat_*`)                                                                                                           | The watch that fired (same value as `watch.id`). Pass to [`GET /v1/watches/{id}`](/api-reference/monitoring/watches/retrieve).                                |
| `trademark_id`             | `string` (`tm_*`)                                                                                                            | The matched trademark (same value as `trademark.id`). Pass to [`GET /v1/trademarks/{id}`](/api-reference/trademarks/get-trademark).                           |
| `trademark_record_id`      | `string` (`tm_*`)                                                                                                            | **Deprecated alias of `trademark_id`** (same value). Will be removed in v1.1 — switch consumers to `trademark_id`.                                            |
| `event_type`               | `'trademark.created' \| 'trademark.updated' \| 'trademark.status_changed' \| 'trademark.retracted' \| 'trademark.corrected'` | Why the alert fired (same value as `event.type`). `retracted` / `corrected` only arrive when the watch [opted in](/guides/monitoring/watches#trigger_events). |
| `evaluation_epoch`         | `number`                                                                                                                     | The watch's evaluation epoch at the time the alert fired. Increments when a watch is internally re-evaluated; useful for distinguishing alerts across epochs. |
| `content_version`          | `number`                                                                                                                     | Snapshot of the trademark's version at evaluation time.                                                                                                       |
| `severity`                 | `'normal' \| 'high' \| 'critical'`                                                                                           | Severity ranking. Drives notification routing on your side.                                                                                                   |
| `must_act_by`              | `string \| null`                                                                                                             | ISO 8601 deadline (typically opposition window close). `null` when no deadline applies.                                                                       |
| `opposition_window_status` | `'open' \| 'closing_soon' \| 'critical' \| 'closed' \| null`                                                                 | Window state for this mark. `null` when no window applies.                                                                                                    |
| `source_data_hash`         | `string \| null`                                                                                                             | Hash of the source payload that triggered the alert (best-effort audit aid).                                                                                  |

<Note>
  **The rich half is best-effort — feature-detect it.** On the rare occasion the alert row can't be hydrated at dispatch time (e.g. hard-deleted between emit and dispatch), the body falls back to **flat fields only**. Consumers that read rich fields should guard for their absence — e.g. `if (data.event) { ... } else { /* fall back to data.alert_id + a REST fetch */ }`. The flat fields are **always** present.
</Note>

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 [Verify webhook signatures](/guides/monitoring/webhook-signature-verification#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>

## Retry policy

Failed deliveries are retried with exponential backoff:

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

Each delay carries **±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`. This protects against rapid-double-rotate incidents where an admin's "did the first rotation apply?" reflex silently breaks every receiver still on the previous key.

### 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. Two patterns:

1. **Request-bin style.** Point a temporary endpoint at any HTTPS request inspector (e.g. a webhook.site-style bin or your own one-file server behind a tunnel), register it, and fire a synthetic ping:

   ```bash theme={null}
   # Register the temporary receiver
   curl -X POST "https://api.signa.so/v1/webhooks" \
     -H "Authorization: Bearer $SIGNA_API_KEY" \
     -H "Content-Type: application/json" \
     -H "Idempotency-Key: create-test-bin-2026-06-12" \
     -d '{"url": "https://your-bin.example.com/inbox", "enabled_events": ["alert.created"]}'

   # Send a webhook.test ping to it
   curl -X POST "https://api.signa.so/v1/webhooks/whk_.../test" \
     -H "Authorization: Bearer $SIGNA_API_KEY" \
     -H "Idempotency-Key: ping-whk-2026-06-12"
   ```

   You'll see the full envelope plus the `webhook-id` / `webhook-timestamp` / `webhook-signature` headers, which is everything you need to develop your verifier against real bytes.

2. **Local receiver behind a tunnel.** Run your actual handler locally, expose it with `ngrok http 3000` or `cloudflared tunnel`, and register the tunnel URL. Iterate on signature verification with [`POST /v1/webhooks/{id}/test`](/api-reference/monitoring/webhooks/test) — test deliveries are free, are never retried, and never count toward auto-disable.

   Delete the temporary endpoint (or `PATCH` it to your production URL) when you're done — a dead registered endpoint accumulates failed deliveries until it auto-disables.

<Warning>
  Anything you send to a third-party request bin is visible to that service.
  Use pattern 1 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.
