Set up webhooks - ION Manual

Webhooks

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:

Stick with polling when:

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:

Common resources customers subscribe to:

Resource Typical use case
RUNS Track when production runs are created, updated, or completed
RUN_STEPS React when a run step is signed off, failed, or has a measurement recorded
PARTS Sync the part library into an external ERP
PARTS_INVENTORY Mirror inventory state into an MES or warehouse system
ISSUES Push quality events into a ticketing system
PURCHASE_ORDERS Notify procurement on PO state changes
FILE_ATTACHMENTS React to new uploads

Payload shape

Every delivery is a JSON POST with this shape:

{
  "event_id": 123456,
  "subscription_id": 42,
  "resource": "run_steps",
  "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.

Custom headers

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

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_STEPS",
    "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.

Monitor deliveries in ION

The same delivery log is browsable in the app: in ION, go to Developer Platform > Webhook Deliveries. The Summary banner counts loaded deliveries by status, and each row shows one attempt: Timestamp, Status ( succeeded, retrying, sending, or failed; a retried delivery passes through several), HTTP Status, Receiver Endpoint, and Response Body. The response body is usually the fastest clue when a delivery fails. Click Load More to page through older deliveries.