> ## Documentation Index
> Fetch the complete documentation index at: https://developers.opereit.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Receiving webhooks

> Get notified when your data changes in Opereit instead of polling for it

Webhooks let Opereit tell your service when something changes, so you don't have to poll the API to notice it. When an event happens, Opereit sends an HTTPS `POST` with a JSON body to a URL you choose.

## Set up an endpoint

To start receiving webhooks, send your account manager:

* The **HTTPS URL** that should receive events.
* The **event types** you want to subscribe to (see [Available events](#available-events)).

You get back a **signing secret** that starts with `whsec_`. You use it to [verify](#verify-signatures) that each request came from Opereit. Store it in a secrets manager and treat it like a password.

Each endpoint has its own secret. To rotate a secret or change the subscribed events, contact your account manager.

## Respond to a webhook

Return any `2xx` status within **10 seconds** to acknowledge the event. The response body is ignored.

If your endpoint returns any other status, times out, or can't be reached, Opereit retries the delivery with exponential backoff.

> [!TIP]
>
> Acknowledge first, process later. Store the event, return `200`, and do the work in the background so slow processing doesn't cause timeouts and retries.

## Handle duplicates and ordering

Build your handler with these delivery guarantees in mind:

* **At-least-once delivery.** The same event can arrive more than once, for example after a retry. Use the `webhook-id` header (the same value as the event `id`) to skip events you've already processed.
* **No ordering guarantee.** Events can arrive out of order. Compare `occurred_at` with the last event you processed for the same resource, and ignore older ones.
* **Unknown events and fields.** New event types and new fields can be added at any time. Ignore event types and fields you don't recognize instead of rejecting the request.

Opereit never removes or renames a field in an existing event type. A breaking change ships as a new event type, and you get advance notice.

If you need the current state of a resource, fetch it from the API, for example with [`GET /v1/claims/{id}`](https://developers.opereit.ai/api-reference/endpoint/claims/get). The API is always authoritative.

## Verify signatures

Every request is signed with HMAC-SHA256 using your endpoint's signing secret. Signing follows the [Standard Webhooks](https://www.standardwebhooks.com) specification, so you can use one of its [open-source libraries](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries) or the code below.

Each request includes these headers:

| Header | Example | Description |
| - | - | - |
| `webhook-id` | `evt_2pXk8cQb7L` | Unique event ID. Stays the same across retries. |
| `webhook-timestamp` | `1762084800` | When this delivery attempt was sent, in Unix seconds. |
| `webhook-signature` | `v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=` | One or more space-separated signatures, each prefixed with a version. |

To verify a request:

**1. Build the signed content**

Join the `webhook-id` header, the `webhook-timestamp` header, and the **raw** request body with a period: `{webhook-id}.{webhook-timestamp}.{body}`.

**2. Compute the expected signature**

Remove the `whsec_` prefix from your secret and base64-decode the rest to get the key. Compute HMAC-SHA256 of the signed content with that key, then base64-encode the result.

**3. Compare signatures**

Accept the request if any `v1,` entry in `webhook-signature` matches your expected signature. Use a constant-time comparison.

**4. Check the timestamp**

Reject the request if `webhook-timestamp` is more than **5 minutes** away from your current time. This protects you against replayed requests.

> [!WARNING]
>
> Sign the raw body bytes exactly as received. If your framework parses the JSON first and you re-serialize it, the bytes can change and the signature won't match.

```javascript Node.js theme={null}
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyWebhook(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;

  const sentAt = Number(timestamp);
  if (!Number.isInteger(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature, "base64");
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}
```

```python Python theme={null}
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_webhook(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures:
        return False

    try:
        sent_at = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest()).decode()

    for entry in signatures.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
```

If the signature doesn't match, return `401` and don't process the event.

### Rotate a secret

When your secret is rotated, Opereit signs each request with both the old and the new secret for a transition period, so `webhook-signature` contains two `v1,` entries. Since you accept a request if any signature matches, you can deploy the new secret without missing events.

## Event format

Every event body has the same envelope:

```json theme={null}
{
  "id": "evt_2pXk8cQb7L",
  "type": "claim.status_updated",
  "occurred_at": "2026-11-02T10:15:30.123Z",
  "data": {}
}
```

| Field | Description |
| - | - |
| `id` | Unique event ID. Same value as the `webhook-id` header. |
| `type` | The event type, named `<entity>.<action>`. |
| `occurred_at` | When the change happened, in ISO 8601 UTC. Retries keep the original value. |
| `data` | The object the event is about, as it is after the change. Its shape depends on `type`: a `claim.*` event carries a claim. |

## Available events

Event types are named `<entity>.<action>`, for example `claim.status_updated`, and `data` is always that entity. Related objects are referenced by ID. You subscribe to each event type separately. The [API reference](https://developers.opereit.ai/api-reference/endpoint/webhooks.md) lists every field of each event.

### Claims

| Event type | Sent when |
| - | - |
| [`claim.status_updated`](https://developers.opereit.ai/api-reference/endpoint/webhooks) | A claim's status, status reason, or both change. A change to the status reason alone also sends this event. |

New status reasons can be added over time. If you get a status reason `slug` you don't recognize, show its `label`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.