Documentation Index
Fetch the complete documentation index at: https://docs.firstresonance.io/llms.txt
Use this file to discover all available pages before exploring further.
Overview
Webhooks let your service react to changes in ION without polling. You register a receiver (a URL + secret), subscribe it to specific (resource, action) pairs, and ION POSTs a JSON message to your URL every time a matching change is committed to the database.
ION’s webhook system is change-data-capture-driven — it watches the database itself, not the API layer. Any ORM-level update (whether from the GraphQL API, the UI, or an internal worker) that lands a row change for a subscribed (resource, action) triggers a webhook delivery. There’s no way to bypass webhook delivery from your application code, and no way for an internal action to “miss” a subscriber.
When to use webhooks vs polling
Use webhooks when:
- You want low-latency reactions to changes (a few seconds end-to-end).
- You want to avoid the API call volume polling implies.
- Your downstream system can accept inbound HTTP.
Stick with polling when:
- Your downstream system can’t expose an HTTPS endpoint to the internet.
- You only need data on a schedule (nightly ETL, hourly report).
- You need a strict pull-only model for compliance reasons.
How it works
Database change Your service
│ ▲
│ 1. ORM commit │
▼ │
PostgreSQL audit trigger ──▶ logged_actions │
│ │ │
│ 2. Post-commit message │ 3. Fan-out worker queries │
▼ ▼ │
webhook queue ─▶ fan-out worker ─▶ send-to-receiver ──────┘
(POST + HMAC)
Each step:
- A row change is committed via SQLAlchemy ORM.
- A PostgreSQL audit trigger writes a
logged_actions record with the change.
- After commit, a message goes to the webhook queue with the transaction ID.
- The fan-out worker reads
logged_actions, builds one delivery per matching subscription, and queues each.
- The send-to-receiver worker POSTs the JSON body to your receiver URL with the
x-ion-signature header.
This is at-least-once delivery — if your receiver returns a non-success response, ION will retry.
Registering a receiver
Webhook receivers are registered via the GraphQL API. Two mutations: one to create the receiver, one (or more) to add subscriptions.
mutation CreateReceiver($input: WebhookReceiverInput!) {
createWebhookReceiver(input: $input) {
webhookReceiver {
id
name
webhookUri
sharedSecret # capture this once — needed to verify signatures
contentType
expectedResponseCode
}
}
}
Variables:
{
"input": {
"name": "Acme Production Pipeline",
"description": "Triggers Acme ETL on run completion",
"webhookUri": "https://hooks.example.com/ion-events",
"contentType": "json",
"expectedResponseCode": 200
}
}
The sharedSecret returned in the response is your HMAC key for signature verification. You’ll see it once — store it like an API key.
Subscribing to events
Add subscriptions to the receiver:
mutation Subscribe($input: WebhookSubscriptionInput!) {
createWebhookSubscription(input: $input) {
webhookSubscription {
id
resource
action
active
}
}
}
Variables (subscribe to run-step updates):
{
"input": {
"receiverId": 42,
"resource": "RUN_STEP",
"action": "UPDATE",
"active": true
}
}
A receiver can have multiple subscriptions. Each subscription is a (resource, action) tuple.
Resources & actions
A subscription is (resource, action) where:
action ∈ { CREATE, UPDATE, DELETE } — the database operation that fires the webhook.
resource is one of ION’s webhookable entities. The full list is exposed by the schema (use the API Playground and inspect the ResourceEnum type).
Common resources customers subscribe to:
| Resource | Typical use case |
|---|
RUN | Track when production runs are created, updated, or completed |
RUN_STEP | React when a step is signed off, failed, or has a measurement recorded |
PART | Sync the part catalog into an external ERP |
PART_INVENTORY | Mirror inventory state into an MES or warehouse system |
ISSUE | Push quality events into a ticketing system |
PURCHASE_ORDER | Notify procurement on PO state changes |
FILE_ATTACHMENT | React to new uploads |
If you want a resource that isn’t in the enum, let your CSM know — adding new webhookable resources is a small platform change, not a customer change.
Payload shape
Every webhook delivery is a JSON POST with this shape:
{
"event_id": 123456,
"subscription_id": 42,
"resource": "run_step",
"action": "update",
"transaction_id": 9876543,
"occurred_at": "2026-04-26T19:33:42.108Z",
"data": {
"id": 1234,
"etag": "abc123",
"old": { "status": "in_progress" },
"new": { "status": "complete", "completed_at": "2026-04-26T19:33:42.108Z" }
}
}
| Field | Meaning |
|---|
event_id | Unique ID for this delivery (idempotency key — see Idempotency) |
subscription_id | Which subscription fired this delivery |
resource, action | The (resource, action) pair |
transaction_id | The DB transaction that produced this change. Multiple events from the same transaction share this ID — useful for correlating |
occurred_at | When the change committed (UTC, ISO 8601) |
data.id | The primary key of the affected row |
data.etag | The new etag of the row (for follow-up writes) |
data.old, data.new | The fields that changed and their before/after values. old is null on CREATE; new is null on DELETE |
data.old and data.new only contain fields that actually changed. If you need the full current state of the entity, requery it via the API using data.id. The webhook is a change notification, not a full snapshot.
Signature verification
Every delivery includes an HMAC signature in the x-ion-signature header. Verify it before trusting the payload — anyone can hit your endpoint, only ION knows the secret.
The signature is computed as:
params = sorted_alphabetically([f"{k}:{v}" for k, v in data.items() if v is not None])
signed_data = f"{webhook_uri}?{'&'.join(params)}".encode("utf-8")
signature = base64(hmac_sha1(shared_secret.encode("utf-8"), signed_data))
The signature signs the receiver URL plus the data field’s keys/values (alphabetically sorted, null values excluded), not the raw body. Implement the same construction on your side and compare:
import base64
import hmac
import hashlib
def verify_signature(secret: str, url: str, data: dict, header_signature: str) -> bool:
params = [f"{k}:{v}" for k, v in sorted(data.items()) if v is not None]
signed = f"{url}?{'&'.join(params)}".encode("utf-8")
expected = base64.b64encode(
hmac.new(secret.encode("utf-8"), signed, hashlib.sha1).digest()
).decode()
return hmac.compare_digest(expected, header_signature)
Always use hmac.compare_digest (or your language’s equivalent) — never ==. Constant-time comparison prevents timing attacks.
| Header | Purpose |
|---|
content-type | application/json (controlled by the receiver’s contentType setting) |
x-ion-signature | HMAC signature — see Signature verification |
| Custom headers | Anything you registered via addHeaderToWebhookReceiver (auth tokens for downstream gateways, routing hints, etc.) |
Custom headers are useful when your receiver sits behind an API gateway that requires its own auth (e.g. an internal API key). Register them once per receiver via WebhookHeader mutations and ION attaches them to every delivery.
Delivery guarantees & retries
- At-least-once. ION will redeliver if your receiver returns anything other than the configured
expectedResponseCode (default 200).
- Retries follow exponential backoff. Persistent failures eventually mark the event as failed; the dispatched event is preserved in
WebhookEvents for inspection.
- No ordering guarantees between independent transactions. Two updates to the same row in transaction T1 always arrive before any update in T2, but parallel transactions can interleave.
- No exactly-once. Build your receiver to be idempotent — see below.
Idempotency and deduplication
Use event_id as your idempotency key. Maintain a recent-events store on your side and short-circuit re-deliveries:
def handle(event):
if seen.contains(event["event_id"]):
return 200 # already processed; ack to stop retries
seen.add(event["event_id"])
process(event)
return 200
event_id is unique per delivery and stable across retries.
Testing your receiver
Before pointing production traffic at a new receiver:
- Register against your sandbox tenant (Sandbox guide).
- Trigger known events — create, update, delete a run step in the sandbox UI; verify your receiver logs the deliveries.
- Inspect failed deliveries — query
webhookEvents filtered by subscription_id and status = "failed" to see retry history and the request/response bodies ION recorded.
query FailedEvents($subscriptionId: Int!) {
webhookEvents(filters: { subscriptionId: $subscriptionId, status: "failed" }) {
id
occurredAt
requestData
responseStatus
responseData
}
}
This is the fastest way to debug “my receiver isn’t firing” — usually it’s a non-200 response code, an SSL certificate issue, or a firewall.
Tips
- Capture the
sharedSecret immediately. It’s returned exactly once at receiver creation. If you lose it, the only fix is to rotate by deleting and recreating the receiver.
- Use a stable receiver URL. Ngrok tunnels for local testing are fine, but every URL change requires a re-registration.
- Handle the
null data side correctly. data.old is null for CREATE, data.new is null for DELETE. Don’t assume both are populated.
- Don’t process synchronously past 5 seconds. ION’s send-to-receiver worker times out long-running responses; if your processing is slow, ack with 200 immediately and queue the work for an async pool downstream.
- Bring your own retry shield. ION retries on failure, but it’s still cheaper to never fail than to lean on retries — design your receiver for steady-state success.
- Authentication — credentials needed to register receivers
- Sandbox — register and test against non-production data first
- API Playground — explore the
ResourceEnum and WebhookSubscriptionActions enums
- Error Codes — the 4xx/5xx codes ION sees from your receiver