A webhook endpoint is a URL you control that receives signed POST requests from Signa whenever a subscribed event fires.
Set up an endpoint
Register it
POST /v1/webhooks registers your endpoint. The signing secret is returned once in the response — store it before the response is discarded.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);
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 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.
Payload shape
Every delivery uses the same envelope:
{
"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.
| Field | Type | Description |
|---|
alert_id | string (alt_*) | Pass to GET /v1/alerts/{id} for the full alert. |
watch_id | string (wat_*) | The watch that fired. Pass to GET /v1/watches/{id}. |
trademark_id | string (tm_*) | The matched trademark. Pass to GET /v1/trademarks/{id}. Matches the field name on the REST Alert resource. |
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' | Why the alert fired. |
evaluation_epoch | number | Increments when you call replay. Useful for distinguishing post-replay alerts. |
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). |
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:
| 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 for safe usage. |
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.
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. |
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-enable with PATCH /v1/webhooks/{id} ({"status": "active"}) once you’ve fixed the receiver. The consecutive-failure counter resets to 0 on the next successful delivery.
Changing the URL
To migrate an endpoint to a new URL (domain rename, infrastructure move), PATCH the endpoint with the new value:
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 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:
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):
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 — a raw UUID, not a prefixed ID:
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:
-
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:
# 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.
-
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 — 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.
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.
Cost
Webhook deliveries are billed per successful delivery and per redelivery. Test deliveries are free.