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

Set up an endpoint

1

Build the receiver

Accept POST application/json and verify the Standard Webhooks headers, see Verify signatures below.
2

Register it

POST /v1/webhooks registers your endpoint. The signing secret is returned once in the response, store it before the response is discarded.
3

Verify deliveries

Use the SDK helper or any Standard Webhooks-compatible library to verify the HMAC-SHA256 signature on every delivery, before you parse the body.

Event types

alert.created is the only event you can subscribe to today via enabled_events on POST /v1/webhooks or PATCH /v1/webhooks/{id}. Any other slug returns 400.

webhook.test is not subscribable

webhook.test is delivered only when you call POST /v1/webhooks/{id}/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 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).
Route on data.event_type, not on the envelope type.

The five trademark event types

A watch that omits trigger_events receives the first three. To receive retracted / corrected, list them explicitly:
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.

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

Payload shape

Every delivery uses the same envelope:
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

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 spec. Three signed headers, plus one unsigned attempt counter:
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.

Verify signatures

TypeScript / Node, SDK helper

The @signa-so/sdk package exports a thin wrapper over standardwebhooks:
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

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:
Security note: webhook-attempt is not part of the signed envelope. Per the Standard Webhooks spec, 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: 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:

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} 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, 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.

Changing the URL

To migrate an endpoint to a new URL (domain rename, infrastructure move), PATCH the endpoint with the new value:
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 before relying on it, test deliveries are free and do not affect auto-disable counters.

Rotation

Call POST /v1/webhooks/{id}/rotate-secret to roll the signing secret. For 24 hours both secrets are valid, Signa signs every delivery with both:
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):
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, a raw UUID, not a prefixed ID:
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, and store the returned secret in your local env. Trigger a test delivery with POST /v1/webhooks/{id}/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), the same secret keeps working.
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.

Cost

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