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

Use enabled_events on POST /v1/webhooks or PATCH /v1/webhooks/{id}. All three values are accepted at all times; anything else returns 400 with an unknown event type message naming the allowed set.
trademark.status_changed and office_action.issued are accepted for subscription today but are not yet emitted. They start delivering when the API events projector is enabled; until then an endpoint subscribed to them receives nothing. Subscribe now if you want them from the first delivery — the accepted set does not change when the projector is turned on.

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 direct semantic events

Subscribe directly to trademark.status_changed or office_action.issued for register facts about marks you own. These direct events are emitted only for marks in at least one of your organization’s portfolios (and only once the API events projector is enabled). An organization that monitors a mark only through watches receives corresponding watch alerts, but no direct semantic-event delivery. Set portfolio_id (ptf_*) on an endpoint to limit delivery to marks in that portfolio. The scope applies to every event type, alert.created included: an alert is delivered to a portfolio-scoped endpoint only when the mark it matched was a member of that portfolio at the time the event was recorded. Later portfolio changes never rewrite past deliveries. Leave portfolio_id unset to receive everything the endpoint is subscribed to, across all portfolios and all watch matches. If you want alerts for marks you do not own — the normal case for similarity, class and owner watches — leave portfolio_id unset on the endpoint that receives them. Two rules decide what you receive. Recency. An office action is delivered only if it was issued within the last 90 days. When Signa first ingests a mark, or first fetches its full prosecution history, that history is stored without any deliveries: those actions are the mark’s past, not news. The window is wide enough to cover the slowest office reporting we see, so a genuine refusal always lands inside it. One action, one event. Offices frequently record a single action as several rows, such as a written action, its mailing, and the notification, sometimes on consecutive days. Signa clusters rows that fall within two days of each other on the same class and designation and delivers one office_action.issued for the group, keyed to the earliest date. If the mailed twin arrives in a later sync, it does not produce a second delivery. Two designations of the same Madrid mark, or two classes of the same refusal, remain separate actions and are delivered separately. A mark added to your portfolio today does not replay its past actions. Watch-derived trademark changes continue to arrive as alert.created deliveries. The following fields describe that watch path. The envelope type is always alert.created. The derived trademark event type is data.alert.event.type. Route on that nested field, 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 remains Internal-tagged and held, but it is populated: every alert.created event is projected, and trademark.* events are projected for marks in one of your organization’s portfolios when the event projector is enabled. The surface may still change before its Internal tag is removed.
On GET /v1/events, portfolio_id behaves exactly like a portfolio-scoped webhook endpoint: it membership-filters every family, alert.created included, against the snapshot taken when the event was recorded.

Payload shape

The envelope and data shape depend on the event type.

alert.created

data contains event_id, alert_id, and the self-contained alert under alert. 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.

trademark.status_changed

This event uses the normal evt_* ID and payload_version. Its data is a trademark change, not an alert:
The SDK types are TrademarkStatusChangedData and TrademarkStatusChangedEvent.

Field names in changed_fields and changes

Both use the public field names of the trademark resource, not our internal column names, and both use the same keys. The trademark resource nests status under a status object while the event payload is flat, so every status.<key> appears here as status_<key> — except status.primary, which is status. Renames worth knowing: Fields we track internally but do not publish on the trademark resource are omitted from both lists rather than renamed, so changed_fields can be shorter than the number of columns that actually moved. Every name you see is a field you can read back from GET /v1/trademarks/{id}. The list is additive: new entries may appear, existing ones are not renamed within payload_version: 1.

office_action.issued

This event describes one newly stored trademark_event office-action row:
Nullable source fields remain null when the office did not supply them. Office codes use the public ST.3 office vocabulary. jurisdiction_code is the shape-normalized designation jurisdiction (trimmed, uppercased, and limited to two letters); malformed values fail closed to null and are never routed through the office map. The SDK types are OfficeActionIssuedData and OfficeActionIssuedEvent.

occurred_at is our clock, source_date is the office’s

Every trademark.* and office_action.* body carries both, and they answer different questions. occurred_at is when Signa produced the event. For these two families that is ingestion time: the moment we stored the change. (On alert.created the equivalent moment is when the alert was created — the meaning is per family, but it is always our clock, never the office’s.) Nothing about it changed; it is just now written down. source_date is the office-reported data date of the change as we stored it, YYYY-MM-DD, or null when the feed reports none. On office_action.issued it equals event_date. The two differ by the office publication lag, typically 1 to 14 days. Sort a docket by source_date — meaningful within one office — and use occurred_at to answer “when did I learn this”. Across offices you are comparing different kinds of date: it is day precision, and what it means varies by office (USPTO transaction date, WIPO gazette date, snapshot offices the date we crawled). source_date is not a legal effective date. For that, read changes.status_effective_date. We deliberately do not fold the effective date into source_date: an office re-crawl can backdate it by years, which would make your feed travel backwards on a mark that was merely re-read. When the effective date moves, it shows up in changes like any other field.

portfolios — your reference, frozen at send time

Both families carry portfolios: the portfolios that mark was in when the event was recorded, each with the external_ref you set on the membership. Empty when the mark was in none. alert.created does not carry it.
This is a snapshot, not a live lookup. If you re-key a matter reference tomorrow, events you already received keep the value they were sent with — which is what makes a redelivery byte-identical to the original delivery, and keeps GET /v1/events/{id} in agreement with the webhook body you signed against. An external_ref of null means unknown or unset (including events recorded before we started snapshotting it), never “cleared”. The SDK type is EventPortfolioRef.

webhook.test

Test deliveries deliberately use a test_<hex> ID, omit payload_version, and carry the stored test payload (normally { "type": "ping" }):
Across all types, id equals the webhook-id header and timestamp is the signing-time ISO 8601 UTC instant. payload_version is present on versioned customer events and absent on webhook.test.

data.alert fields

Diff field names

Every path is the name the field has on the trademark resource — status, mark_text, publication_date, ir_number — or, for a changed child collection, the collection name: owners, attorneys, classifications, media. Child-collection entries are opaque ({ path, op: "changed" }, no from/to); parent fields carry real from/to values. Status is flattened. The resource nests status under an object; the diff is flat, so status.primary is status and every other sub-field is status_<key> (status_stage, status_reason, status_challenges, status_effective_date, status_source, status_raw_code, status_raw_label). Fields Signa tracks internally but does not publish never appear in the diff at all — they are dropped rather than renamed, so you can always look up a path in the trademark resource. Those drops happen before the diff size cap and are not truncation: diff_truncated still means only that entries or values were clipped to fit the wire budget.
Beta break, effective on deploy. Until this change, path carried Signa’s internal column names: status_primary, mark_text_primary, publication_date_first, international_registration_number, challenge_states, and raw child table names such as trademark_owners. They are now status, mark_text, publication_date, ir_number, status_challenges and owners. event.summary follows the same names, so the headline reads "Status changed: …" where it used to read "Status primary changed: …". Redelivered and retried deliveries of alerts created BEFORE the change are re-emitted with the new names too — headline included, because it is re-derived from the translated diff — so a consumer only ever has to understand one vocabulary.
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 suppress deliveries at the infrastructure layer using either webhook-id alone or the tuple (webhook-id, webhook-attempt). A stuck attempt can be re-POSTed in place during crash recovery with the same values for both headers, so either key can swallow a legitimate recovery delivery. Verify every request, pass it to your application, and make the business effect idempotent on webhook-id:
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 observability, never for deduplication or 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.

Replay from a checkpoint

Retries cover a receiver that is briefly down. For a longer outage — or a bug that made you drop deliveries you acknowledged with a 200 — replay the events feed instead. Every webhook you receive is also a row in your organization’s event ledger, addressable by the same id: the webhook-id header IS the evt_* id. Persist the last event id you fully processed, then ask for everything after it, oldest first:
after is an exclusive forward checkpoint and is valid only with sort=id (pairing it with cursor, or with the default -id, is a 400). Walk pages with the returned pagination.cursor until has_more is false, and advance your stored checkpoint only after each page is durably handled — replay is idempotent by evt_*, so re-processing a page is safe. The ledger is retained 30 days. A checkpoint older than that returns:
with HTTP 410. Treat it as “your gap is wider than our history”: drop the checkpoint, re-read the feed from the oldest available page (?sort=id&limit=100, no after), and reconcile the rest from POST /v1/alerts/lookup or a GET /v1/alerts sweep — alerts are kept for 90 days, three times the event window.
The events feed is beta: the ledger, the ids and the replay rail are stable enough to build a recovery path on, but the per-event data payloads are versioned by payload_version and their shape is not yet promised.

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, automatic retries, and manual redeliveries cost 0 credits, and so do test deliveries. Monitoring is billed on the daily watch meter, not per delivery; see Get Credit Pricing.