> ## 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.

# Set up webhooks

> Register, subscribe, secure, and test a webhook receiver so your service reacts to changes in ION.

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](/api-reference/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:

```json theme={null}
{
  "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](#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`               |

<Note>
  `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.
</Note>

## 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:

```text theme={null}
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:

```python theme={null}
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](#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.

<Tip>
  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.
</Tip>

## Idempotency and deduplication

Use `event_id` as your idempotency key. Maintain a recent-events store on your side and short-circuit re-deliveries:

```python theme={null}
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:

```graphql theme={null}
mutation CreateReceiver($input: CreateWebhookReceiverInput!) {
  createWebhookReceiver(input: $input) {
    webhookReceiver {
      id
      name
      webhookUri
      sharedSecret # capture this once, needed to verify signatures
      contentType
      expectedResponseCode
    }
  }
}
```

```json theme={null}
{
  "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](#signature-verification). It's shown once, so store it like an API key.

<Warning>
  Capture the `sharedSecret` at creation. It's returned exactly once. If you
  lose it, you have to delete and recreate the receiver.
</Warning>

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:

```graphql theme={null}
mutation Subscribe($input: CreateWebhookSubscriptionInput!) {
  createWebhookSubscription(input: $input) {
    webhookSubscription {
      id
      resource
      action
      active
    }
  }
}
```

```json theme={null}
{
  "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](/api-reference/sandbox).
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.

```graphql theme={null}
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.

## Related

* [Authentication](/api-reference/authentication/overview)
* [Sandbox](/api-reference/sandbox)
* [API playground](/api-reference/playground)
* [Error codes](/api-reference/error-codes)
