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 totrademark.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.
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.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 anddata 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:
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:
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.
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" }):
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
Everypath 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.
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 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-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 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. 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.
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: thewebhook-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:
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.- 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.