Webhooks let your service react to changes in ION without polling. You register a receiver, which is a URL plus a shared secret then subscribe it to specific events. ION then posts a JSON message to your URL whenever a matching change is committed.
When to use webhooks vs polling
Use webhooks when:
- Your system can accept inbound HTTP.
- You want low-latency reactions to changes.
- You want to avoid polling for changes.
Stick with polling when:
- Your system can’t accept inbound HTTP.
- You only need data on a schedule.
- You need a pull-only model for compliance.
How delivery works
ION fires a webhook on the change itself, not on the API call that made it. Any committed change to a subscribed (resource, action) triggers a delivery, no matter where it came from: the GraphQL API, the ION UI, or an automated action.
Once a matching change commits, ION builds one delivery per matching subscription and POSTs the JSON body to your receiver URL with the x-ion-signature header. Delivery is at-least-once: if your receiver returns a non-success response, ION retries.
Event types
A subscription is a (resource, action) pair where:
action is one of CREATE, UPDATE, or DELETE, the operation that fires the webhook.
resource is one of ION’s webhookable entities. The schema exposes the full list: 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 run step is signed off, failed, or has a measurement recorded |
PART | Sync the part library 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 |
Payload shape
Every 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, used as the idempotency key. See Idempotency and deduplication |
subscription_id | Which subscription fired this delivery |
resource, action | The (resource, action) pair |
transaction_id | The DB transaction that produced this change. Events from the same transaction share this ID, which is 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 and after values. old is null on CREATE; new is null on DELETE |
data.old and data.new contain only the fields that changed, and one side
is always null on a CREATE or DELETE, so don’t assume both are populated.
If you need the full current state of the entity, requery it through 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 reach your endpoint. Only you and ION hold the secret.
The signature signs the receiver URL plus the data field’s keys and values (alphabetically sorted, null values excluded), not the raw body:
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))
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. A 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, such as auth tokens for downstream gateways or routing hints |
Custom headers are useful when your receiver sits behind an API gateway that requires its own auth, such as an internal API key. Register them once per receiver via WebhookHeader mutations and ION attaches them to every delivery.
Delivery guarantees and retries
- At-least-once. ION redelivers 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 failed event is preserved so you can inspect it by querying
webhookEvents.
- No ordering guarantees between independent transactions. Two updates to the same row in one transaction always arrive before any update in a later transaction, but parallel transactions can interleave.
- No exactly-once. Build your receiver to be idempotent.
ION times out a delivery that takes longer than 5 seconds to respond. If your
processing is slow, acknowledge with a 200 right away and queue the work to
run asynchronously downstream.
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.
Register a receiver
Webhook receivers are registered via the GraphQL API. Create the receiver first:
mutation CreateReceiver($input: CreateWebhookReceiverInput!) {
createWebhookReceiver(input: $input) {
webhookReceiver {
id
name
webhookUri
sharedSecret # capture this once, needed to verify signatures
contentType
expectedResponseCode
}
}
}
{
"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 in the response is your HMAC key for signature verification. It’s shown once, so store it like an API key.
Capture the sharedSecret at creation. It’s returned exactly once. If you
lose it, you have to delete and recreate the receiver.
The webhookUri must be stable. Every change requires re-registering the
receiver. A fixed Ngrok tunnel works for local testing. A tunnel URL that
rotates on each restart does not work.
Subscribe to events
Add one or more subscriptions to the receiver. Each subscription is one (resource, action) tuple:
mutation Subscribe($input: CreateWebhookSubscriptionInput!) {
createWebhookSubscription(input: $input) {
webhookSubscription {
id
resource
action
active
}
}
}
{
"input": {
"receiverId": 42,
"resource": "RUN_STEP",
"action": "UPDATE",
"active": true
}
}
A receiver can have multiple subscriptions.
Test the receiver
Before pointing production traffic at a new receiver:
- Register against your sandbox tenant. See the Sandbox guide.
- Trigger known events. Create, update, and delete a run step in the sandbox UI, then 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 and response bodies ION recorded.
query FailedEvents($subscriptionId: ID!) {
webhookEvents(
filters: { subscriptionId: { eq: $subscriptionId }, status: { eq: FAILED } }
first: 50
) {
edges {
node {
id
responseStatusCode
responseReceivedAt
requestData
responseData
}
}
}
}
This is the fastest way to debug “my receiver isn’t firing.” The cause is usually a non-200 response code, an SSL certificate issue, or a firewall.