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 these values are accepted at all times; anything else returns 400 with an unknown event type message naming the allowed set.
trademark.status_changed, trademark.corrected and office_action.issued are delivered only for marks in at least one of your organization’s portfolios. An endpoint subscribed to them receives nothing until a mark in a portfolio changes status or receives an office action.
Corrections come with status changes. An endpoint subscribed to trademark.status_changed also receives trademark.corrected, with no configuration change. The envelope type stays trademark.corrected, so route on it if you treat a correction differently from news. Listing trademark.corrected on its own subscribes an endpoint to corrections only. Your enabled_events is stored exactly as you sent it.
Accept and ignore event types you do not handle. Respond 2xx to any envelope type your handler does not recognize, and skip it. Signa can add event types to a subscription, as it did with trademark.corrected. A non-2xx response is retried and counts toward auto-disable, so a handler that rejects an unknown type can disable the endpoint and stop the deliveries it does handle.

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; a trademark.status_changed subscription also brings status corrections (trademark.corrected, see Payload shape). These direct events are emitted only for marks in at least one of your organization’s portfolios. 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. USPTO: examiner actions only. For USPTO marks, only an action the office issues counts, for example a non-final, final or subsequent final refusal, a priority action, a suspension letter or inquiry, an examiner’s amendment or a post-registration audit action (67 USPTO codes today). CNEA and SNEA are examiner’s amendments, no response due: the event records that the examiner amended the application. Ten more raise it after a later update: subsequent finals and priority actions issued during statement-of-use examination (GSSF, GSS1, SSFR, SSRR, GSPR, GSPS, SPRA), subsequent finals in a reexamination or expungement (BSRF, BESF) and the office action on a statement of use (INCS). Rows the USPTO files under the same heading for your own filings, transactions with WIPO (such as a grant of protection), suspension checks, abandonment notices and decisions on petitions (grants, denials, dismissals) stay on the mark’s events and do not raise office_action.issued. An abandonment raises trademark.status_changed. A mark added to your portfolio today does not replay its past actions. Recorded, not always pushed. Every direct event is written to the events feed, but a few are never pushed to your endpoint: events from a sync run that is not eligible for delivery (bulk backfills and historical re-reads of an office’s records), events that reach delivery more than 96 hours after Signa recorded them, and anything past the first 5,000 events one sync run produces for your organization. A push held back this way is not sent later, so reconcile from the feed on a schedule (see Replay from a checkpoint). 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.
Status corrections are corrected. When Signa fixes a status it published wrongly (for example a mark shown as expired that the office records as renewed), the status change is derived as trademark.corrected and never also as trademark.status_changed or trademark.updated. The same holds when office records that reached Signa late show a mark lapsing or a dead mark returning. This is deliberate: corrections are old news, so only watches that opt in receive them. Any other change Signa stores with a correction or from late office records, including marks Signa first learns of from late records, raises no alert, and a change that only relabels where a status comes from (status.basis, for example office to computed) never raises one. This opt-in applies to watches only. For marks in your portfolios a status correction is also a direct trademark.corrected event (see Payload shape), delivered to every endpoint subscribed to trademark.status_changed.

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 and offices, plus, for similarity watches, its similarity channels and min_tier gate.
  • 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 published beta endpoint (List Events). Every alert.created event is recorded there, and trademark.* and office_action.* events, status corrections included, are recorded for marks in one of your organization’s portfolios. The per-event data shapes are versioned by payload_version.
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 versions

Every customer event carries payload_version, and the version decides the shape of data. An event keeps the version it was stored with, forever: automatic retries, redelivery, and GET /v1/events/{id} of a v1 event are v1, even after newer events are v2. Branch on the envelope’s payload_version, never on the delivery date. New events are v2 from September 27, 2026. Events recorded before then stay v1 when retried, redelivered or replayed, so for the 30-day replay window your handler sees both, in any order. A handler that works on both:
trademark.status_changed, trademark.corrected and alert.created keep the same keys in both versions; only the change vocabulary inside changed_fields, changes and alert.event.diff[] differs (see field names). REST alerts (GET /v1/alerts) always use the version being emitted.

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 a payload_version. In payload_version: 2 the values follow the trademark resource as well: status_basis uses the status.basis vocabulary (office, event_derived, dispatch_derived, computed); an expiry_date or renewal_due_date side the office did not state is null, and a change between two Signa-computed values is omitted entirely; status_raw_label is the office’s verbatim label or null; registration_number has no WO prefix; office_record_id is null for a Madrid key Signa composed. expiry_date_basis and renewal_due_date_basis never appear in v2. Version 1 is frozen: its names and values stay exactly as they were. A record update whose every change is hidden by these rules (for example, a Signa-computed expiry date recomputed to another computed date) raises no trademark.updated alert at all, under either payload version, so no alert arrives with nothing to show. One exception around the switch to v2: an alert raised from a change recorded before the release can show an empty v2 diff for the dates whose provenance was not captured then.

trademark.corrected

A status correction of a portfolio mark: Signa published a status that was wrong (for example a mark shown as expired that the office records as renewed), or office records reached Signa late and show the mark lapsing or a dead mark returning. data is the trademark.status_changed body above plus correction:
The changes show the status Signa had published (before) and the corrected one (after). A correction is old news about the mark, not a new office action: source_date and changes.status_effective_date tell you when the office recorded the underlying fact. It is delivered to endpoints subscribed to trademark.status_changed or trademark.corrected, and recorded in the events feed as type: trademark.corrected, where event_type=trademark.status_changed returns it too. A change that only relabels where a status comes from (status_basis) is never a correction event. The SDK types are TrademarkCorrectedData, TrademarkCorrection and TrademarkCorrectedEvent.

office_action.issued

This event describes one newly stored office-action row of the mark’s prosecution history. In payload_version: 2 the row is nested as trademark_event, exactly as GET /v1/trademarks/{id}/events returns it:
payload_version 2
A v1 event (emitted before the release, and every retry or replay of one) carries the same row flattened beside the identity fields:
payload_version 1
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 OfficeActionIssuedDataV1, OfficeActionIssuedDataV2 and OfficeActionIssuedEvent, which is discriminated by payload_version.

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 the action’s date (event_date in v1, trademark_event.event_date in v2). 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. That 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.

Deadline events

Webhook delivery of deadline.* events is not available yet. Until it is, creating or updating an endpoint whose enabled_events names a deadline.* type answers 422 with deadline_webhooks_unavailable. Read deadline events from GET /v1/events meanwhile: they are recorded there as each change commits, with the same body described below.
The four deadline.* events keep a copy of your docket in step without polling. Each carries the deadline exactly as GET /v1/deadlines/{id} served it when the event was recorded, without history:
cause is office_data when new office data moved a computed deadline, rule_change when Signa corrected or updated the deadline rules for its office, and user when an API key changed it. changes lists every changed field once, named as the deadline object names it (status, due_date, grace_expiry_date, engine_state, review_required, computation.base_due_date, …), the same names as the deadline’s history. Adding or removing marks does not raise an event. Adding a mark to a portfolio puts its deadlines on the docket without deadline.created, and removing it sets them to engine_state: untracked without deadline.updated. You made that change; read the docket afterwards with GET /v1/deadlines. A rules update can move deadlines across your whole docket at once. Each moved deadline is a deadline.updated with cause rule_change, under the same per-run push limit as office-data changes (below), so a large update reaches the events feed in full even when not every event is pushed. deadline.due_soon fires once per deadline and offset, when the daily docket check finds the offset crossed. If that check did not run while two offsets passed, you receive only the most recent one. A deadline whose due date moves starts over at the next offset it reaches. Done and dismissed deadlines raise none. A run of the daily check pushes at most 500 deadline.* events to one organization, whatever their cause. Beyond that they are recorded in the events feed but not pushed; the same holds for events reaching delivery more than 96 hours after they were recorded. A portfolio-scoped endpoint receives a deadline event when its mark is in the portfolio, or when it is a manual deadline filed under it. Deadline events carry the docket’s notes and assignees, so they need the portfolios:manage scope, as the docket does. Every webhook endpoint route requires it already. In the events feed, a key without it does not see deadline.* events: they are left out of the list, a deadline.* event_type filter answers 403, and a deadline event’s id answers 404. The SDK types are DeadlineEvent, DeadlineCreatedData, DeadlineUpdatedData, DeadlineDueSoonData and DeadlineDeletedData.

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_basis, status_raw_code, status_raw_label). In payload_version: 2 (and in REST alerts once v2 is emitted) the diff follows the v2 names and values: office_record_id and status_basis, office-stated dates only. A v1 alert.created keeps source_primary_id and status_source. 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. 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). 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. GET /v1/events/{id} returns each event in the version it was stored with (see Payload versions).

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.