> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.tryenvoy.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.tryenvoy.ai/_mcp/server.

# Webhooks

Webhooks deliver events to your server the moment they happen, so you do not have to poll. Each delivery is a JSON POST to a URL you register, signed with HMAC-SHA256 so you can verify it came from us.

Our envelope and signature scheme implement the [Standard Webhooks](https://www.standardwebhooks.com/) specification. Any verifier from that ecosystem will work out of the box.

## Quickstart

### 1. Register an endpoint

Register from the dashboard at [Organization → Webhooks](https://tryenvoy.ai/app/organization#webhooks). You supply the destination URL and pick the [event types](#event-catalog) you want delivered. There is no wildcard, so subscribers opt in to each event type explicitly.

After creation you receive a signing secret of the form `whsec_…`. **Store it immediately. It is not retrievable later.** If you lose it, [rotate it](#secret-rotation).

![Organization → Webhooks panel showing a registered endpoint, its subscribed event types, status, and row actions](/_fern-img/2c3c1f568d2f28a4130b2aabe17dadd98e4f2d83705f42dc8829b20f5abb52bb.webp)

Programmatic management of endpoints (create, list, update, delete) is available in the API reference.

### 2. Receive a delivery

Every delivery is a POST to your registered URL with this envelope:

```json
{
  "id": "msg_3f2504e0-4f89-41d3-9a0c-0305e82c3301",
  "type": "elp_test.scored",
  "timestamp": "2026-05-26T19:33:42Z",
  "data": {
    "id": "elp_550e8400-e29b-41d4-a716-446655440000",
    "status": "COMPLETED",
    "scoring_status": "SCORED",
    "...": "full ELP test response"
  }
}
```

Three signing headers travel with the body:

| Header              | Example                                    | What it is                                       |
| ------------------- | ------------------------------------------ | ------------------------------------------------ |
| `webhook-id`        | `msg_3f2504e0-4f89-41d3-9a0c-0305e82c3301` | Stable per logical message. Same across retries. |
| `webhook-timestamp` | `1748287322`                               | Unix seconds when we signed the payload.         |
| `webhook-signature` | `v1,K8U3...base64hmac... v1,N9F2...`       | Space-separated `v1,<sig>` per active secret.    |

Always respond with a **2xx within 15 seconds**. Anything else is treated as a delivery failure and triggers a retry.

### 3. Verify the signature

Use the official Standard Webhooks library for your language. The verifier takes your `whsec_…` secret plus the request body and the three `webhook-*` headers, and tells you whether the signature is valid.

**Python** ([`standardwebhooks` on PyPI](https://pypi.org/project/standardwebhooks/)):

```python
from standardwebhooks import Webhook

wh = Webhook(signing_secret)              # "whsec_..."
payload = wh.verify(request_body, request_headers)  # raises on failure
```

**TypeScript / Node** ([`standardwebhooks` on npm](https://www.npmjs.com/package/standardwebhooks)):

```typescript
import { Webhook } from "standardwebhooks";

const wh = new Webhook(signingSecret);    // "whsec_..."
const payload = wh.verify(requestBody, requestHeaders); // throws on failure
```

For any other language (Go, Ruby, PHP, Rust, Java, .NET, and more), pick the official SDK from [standardwebhooks.com/#sdks](https://www.standardwebhooks.com/#sdks). All follow the same interface: hand it the secret, body, and headers; get back the verified payload or an error.

## Idempotency

`webhook-id` is stable across retries. If the same `webhook-id` arrives twice, it is the same logical event. Process once, ignore the rest.

```
on_webhook(headers, body):
    msg_id = headers["webhook-id"]
    if seen(msg_id):
        return 200  # already processed; ack so we stop retrying
    process(body)
    record_seen(msg_id)
    return 200
```

Retries are exponential with jitter. We retry on any non-2xx response or connection failure for up to \~24 hours, then give up. Every attempt is captured in the per-endpoint delivery log at [Organization → Webhooks → \{endpoint}](https://tryenvoy.ai/app/organization#webhooks). Expand a row to see the request payload and response. Failed and dead deliveries can be replayed from there.

![Endpoint detail page. Delivery log with a mix of delivered and failed attempts; expand a row to see the response code and body.](/_fern-img/c176d3bd4d9a9ef1131a1825d104bb9fd7f1326a3f8a2a3daa0ef174e7567f61.webp)

## Secret rotation

Rotate from the dashboard at [Organization → Webhooks](https://tryenvoy.ai/app/organization#webhooks). Pick the endpoint row and select Rotate secret.

Rotation mints a fresh `whsec_…` secret and keeps the previous one valid for **24 hours**. During the overlap window, `webhook-signature` includes one `v1,<sig>` per active secret, so verification with either succeeds. Update your stored secret to the new value any time within those 24 hours.

![Rotate-secret modal showing the new whsec\_… signing secret and the 24-hour grace window](/_fern-img/5abeb42e4ac07a57915537db606a841c5e0b4da08ebeb8f6d992730b5ba5471b.webp)

## Reserved headers

`webhook-id`, `webhook-timestamp`, and `webhook-signature` are always set by us. If you configure custom headers on your endpoint that collide with these names, the custom values are silently dropped at delivery time. That prevents an endpoint from being tricked into signing its own deliveries.

## Event catalog

| Event type        | Fires when                                                                                                                             | Body                                                                                                     |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `elp_test.scored` | A driver's ELP test completes post-call scoring.                                                                                       | Same shape as the get-ELP-test response. See the [ELP Tests guide](/pages/elp-tests).                    |
| `elp_test.failed` | A driver's ELP test reaches a terminal failure state (call never connected, call ended without scoring, or scoring exhausted retries). | Same shape as the get-ELP-test response. `status` / `scoring_status` / `notes` carry the failure detail. |

The authoritative list of subscribable event types lives in the API reference.

## Next steps

* [ELP Tests](/pages/elp-tests). Worked example using `elp_test.scored`.
* [Standard Webhooks spec](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md). The underlying signing and envelope contract.