Skip to main content

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:
  1. A row change is committed via SQLAlchemy ORM.
  2. A PostgreSQL audit trigger writes a logged_actions record with the change.
  3. After commit, a message goes to the webhook queue with the transaction ID.
  4. The fan-out worker reads logged_actions, builds one delivery per matching subscription, and queues each.
  5. 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:
ResourceTypical use case
RUNTrack when production runs are created, updated, or completed
RUN_STEPReact when a step is signed off, failed, or has a measurement recorded
PARTSync the part catalog 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
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" }
  }
}
FieldMeaning
event_idUnique ID for this delivery (idempotency key — see Idempotency)
subscription_idWhich subscription fired this delivery
resource, actionThe (resource, action) pair
transaction_idThe DB transaction that produced this change. Multiple events from the same transaction share this ID — 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/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.

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 (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:
  1. Register against your sandbox tenant (Sandbox guide).
  2. Trigger known events — create, update, delete a run step in the sandbox UI; 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/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