Skip to main content
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:
ResourceTypical use case
RUNTrack when production runs are created, updated, or completed
RUN_STEPReact when a run step is signed off, failed, or has a measurement recorded
PARTSync the part library into an external ERP
PART_INVENTORYMirror inventory state into an MES or warehouse system
ISSUEPush quality events into a ticketing system
PURCHASE_ORDERNotify procurement on PO state changes
FILE_ATTACHMENTReact 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" }
  }
}
FieldMeaning
event_idUnique ID for this delivery, used as the idempotency key. See Idempotency and deduplication
subscription_idWhich subscription fired this delivery
resource, actionThe (resource, action) pair
transaction_idThe DB transaction that produced this change. Events from the same transaction share this ID, which is useful for correlating
occurred_atWhen the change committed (UTC, ISO 8601)
data.idThe primary key of the affected row
data.etagThe new etag of the row, for follow-up writes
data.old, data.newThe 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.

Custom headers

HeaderPurpose
content-typeapplication/json, controlled by the receiver’s contentType setting
x-ion-signatureHMAC signature. See Signature verification
Custom headersAnything 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:
  1. Register against your sandbox tenant. See the Sandbox guide.
  2. Trigger known events. Create, update, and delete a run step in the sandbox UI, then verify your receiver logs the deliveries.
  3. 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.