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 richeventobject, best-effort (feature-detect it — see Payload shape).
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.
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:Verify signatures
TypeScript / Node, SDK helper
The@signa-so/sdk package exports a thin wrapper over standardwebhooks:
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
Usewebhook-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-attemptis not part of the signed envelope. Per the Standard Webhooks spec, onlywebhook-id,webhook-timestamp, and the body are signed. An attacker who replays a captured request can set anywebhook-attemptvalue 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. Reject401on 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_failuresreaches 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.
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 withPATCH /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:
POST /v1/webhooks/{id}/test before relying on it, test deliveries are free and do not affect auto-disable counters.
Rotation
CallPOST /v1/webhooks/{id}/rotate-secret to roll the signing secret. For 24 hours both secrets are valid, Signa signs every delivery with both:
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, passforce=true (in the request body or as ?force=true on the URL):
- 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_rotatedaudit log entry with the optionalreason.
Redelivery
If your receiver is down for a stretch and deliveries land instatus: "exhausted", replay them manually. The delivery ID is the id field from GET /v1/webhooks/{id}/deliveries, a raw UUID, not a prefixed ID:
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.- Local receiver behind a tunnel. Run your handler locally (say, on port 4000), expose it with
ngrok http 4000orcloudflared tunnel, register the tunnel URL viaPOST /v1/webhooks, and store the returned secret in your local env. Trigger a test delivery withPOST /v1/webhooks/{id}/test, the envelope shape matchesalert.created, so your verifier exercises the same path it will in production. Test deliveries are free and do not count toward auto-disable. - 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-signatureheaders, everything you need to develop your verifier against real bytes.
PATCH the endpoint with the production URL (see Changing the URL), the same secret keeps working.