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

- 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](https://docs.firstresonance.io/api-reference/playground) and inspect the `ResourceEnum` type.

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](https://docs.firstresonance.io/api-reference/guides/webhooks#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](https://docs.firstresonance.io/api-reference/guides/webhooks#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](https://docs.firstresonance.io/api-reference/guides/webhooks#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](https://docs.firstresonance.io/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.

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