# Guide to Stedi Webhooks: Features and Best Practices

Stedi webhooks tell your systems when something changes in your healthcare transactions: a payer enrollment goes live or is rejected, a provider task is assigned, a file is processed or fails, or an ERA or claim acknowledgment arrives. If you use Stedi, the healthcare clearinghouse API, these webhooks are how you learn about those changes without polling.

This guide covers how Stedi event destinations are configured and signed, the events and their thin payloads, where Stedi's documentation disagrees with itself, and the practices that keep a Stedi receiver reliable.

## What are Stedi webhooks?

Stedi calls its webhooks **event destinations**. Each destination has a name, an HTTPS URL and a list of event types, and Stedi sends a JSON `POST` to the URL for every matching event. This guide covers event destinations, not the webhooks of Stedi's older EDI platform.

Events are **thin**. A payload tells you which resource changed and how, but not the new state. To act on an event, you fetch the resource through Stedi's API.

Deliveries are signed according to the Standard Webhooks specification. Each request carries `webhook-id`, `webhook-timestamp` and `webhook-signature` headers, and the signature is a base64 HMAC-SHA256 of the id, timestamp and raw body. One detail differs from most Standard Webhooks senders: `webhook-timestamp` is when Stedi **created** the event, not when it sent it.

## Stedi webhook features

| Feature | Details |
| --- | --- |
| Configuration | Stedi portal (developer settings, **Event destinations**) or the Events API |
| Limit | 16 event destinations per account |
| URL | HTTPS, pointing directly at your receiver. Redirects are not followed |
| Signature headers | `webhook-id` (`msg_{UUID}`), `webhook-timestamp`, `webhook-signature` (`v1,<base64>`) |
| Scheme | Standard Webhooks: HMAC-SHA256, base64 |
| Signed content | `<webhook-id>.<webhook-timestamp>.<raw body>` |
| Secret | One per destination, `whsec_...`, readable any time in the portal or API |
| Rotation | Old secret stays valid for an expiry period (default 24 hours), with two signatures sent |
| Timestamp | When Stedi created the event, not when it delivered it |
| Other headers | `event-id`, `destination-id`, `attempt-number`, `attempt-type`, `user-agent` (`Stedi-Events/...`) |
| Success | `2xx` only. `3xx` is a failure |
| Retries | 5 minutes, 30 minutes, 2 hours, 8 hours, 24 hours, 48 hours |
| Auto-disable | After the last retry fails. Re-enable the destination by hand |
| Notifications | In-app and email after 2 hours and 24 hours of failures |
| Test event | `event.ping`, from the **Ping** button |

Stedi publishes no source IPs. If a firewall, WAF or CDN needs a rule, Stedi suggests matching the stable `Stedi-Events/` prefix of the `User-Agent` header.

## Common events

Stedi documents 12 event types.

| Area | Event | Fires when |
| --- | --- | --- |
| Testing | `event.ping` | You click **Ping** on a destination |
| Enrollment | `enrollment.activated` | A transaction enrollment request is set to `LIVE` |
| Enrollment | `enrollment.rejected` | An enrollment request is set to `REJECTED` |
| Enrollment | `enrollment.updated` | An enrollment request changes |
| Enrollment tasks | `enrollment.task.assigned`, `enrollment.task.completed`, `enrollment.task.deleted` | Stedi assigns a task to the provider, the provider completes it, or Stedi deletes it |
| Files | `file.processed`, `file.failed` | Stedi processes a file, or fails to |
| Transactions | `transaction.processed` | Stedi receives and translates a transaction |
| OAuth apps | `app.installed`, `app.uninstalled` | A customer installs or removes your OAuth app |

Every event uses the same envelope. Here is Stedi's `transaction.processed` example:

```json
{
  "account": "11111111-2222-3333-4444-555555555555",
  "created": "2026-03-30T23:19:00.501Z",
  "environment": "PRODUCTION",
  "id": "evt_a81659f1-16a5-9bec-03e1-0ba8ab5e9652",
  "object": "v1.event",
  "resource": {
    "id": "019bb508-dc63-73a1-8ddc-9d4720299072",
    "type": "transaction"
  },
  "relatedResources": [
    { "id": "019bb508-dc63-73a1-8ddc-9d4720299072", "type": "transaction.x12.835" },
    { "id": "01997873-bebb-7b33-81ef-f408866dfb2c", "type": "execution" }
  ],
  "type": "transaction.processed"
}

```

`type` names the event, and `resource` points at what changed. `relatedResources` appears on task, transaction and app events. Here it tells you the transaction is an 835 ERA and gives the execution it belongs to, which is enough to decide whether to fetch it. `object` is always `v1.event`, the version of the envelope.

## Setting up Stedi webhooks

In the portal:

1. Go to **Event destinations** in your developer settings and click **+ New destination**.
2. Enter a name, your HTTPS URL (pointing straight at the receiver, with no redirect), an optional description, and the event types you want.
3. Click **Create destination**. Stedi shows the signing secret in a modal. Store it server-side, for example as `STEDI_WEBHOOK_SECRET`.
4. Open the destination, go to **Event deliveries** and click **Ping** to send an `event.ping`.

Or through the API:

```bash
curl --request POST \
  --url "https://events.us.stedi.com/2026-02-01/destinations" \
  --header "Idempotency-Key: $(uuidgen)" \
  --header "Authorization: $STEDI_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "destinationUrl": "https://example.com/webhooks/stedi",
    "eventTypes": ["enrollment.activated", "transaction.processed"],
    "name": "Production receiver",
    "status": "ENABLED"
  }'

```

The response includes `signingSecret`. Unlike many providers, Stedi lets you read the secret again later, from the destination's **Overview** tab or `GET /destinations/{id}/secret`.

To rotate, use the portal or `POST /destinations/{id}/secret/rotate`, and set how long the previous secret remains valid. Until that period ends, each delivery carries two signatures, one per secret.

## Securing Stedi webhooks

Stedi says its implementation follows Standard Webhooks, and its own sample verifies with the `standardwebhooks` library. So the signed string is `<webhook-id>.<webhook-timestamp>.<raw body>`, the key is the base64-decoded part of the secret after `whsec_`, and the signature is base64. `webhook-signature` can hold several space-separated `v1,<signature>` entries during rotation, and any one matching is enough.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyStediSignature(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatureHeader = headers['webhook-signature'];
  if (!id || !timestamp || !signatureHeader || !secret) return false;

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

  return signatureHeader.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);
  });
}

const app = express();

app.post('/webhooks/stedi', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.STEDI_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyStediSignature(req.body, req.headers, secret)) {
    return res.sendStatus(401);
  }

  // Optional: check webhook-timestamp. It is the event's creation time,
  // so retries up to 48 hours later carry an old value.

  const event = JSON.parse(req.body.toString('utf8'));
  // Dedupe on req.headers['webhook-id'], then fetch event.resource from the Stedi API.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_stedi_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature_header = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signature_header or not secret:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode(), expected):
            return True
    return False

```

The `standardwebhooks` package does the same check, but it rejects timestamps more than 5 minutes from the current time, with no option to turn that off. Because Stedi's timestamp is the event's creation time, the package rejects every retry after the first few minutes and every event you recover through the API later. Use the manual check above, or apply your own, wider window.

### Stedi's manual steps leave out the id

Stedi's page also lists step-by-step instructions that build the signed string as `<timestamp>.<body>`, without `webhook-id`, and use the secret without saying to decode it. That contradicts the Standard Webhooks specification and the library sample on the same page. Follow the specification: sign `<webhook-id>.<webhook-timestamp>.<body>` with the decoded key.

> **Make Stedi webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies Stedi's Standard Webhooks signatures (including during rotation) without imposing a timestamp window, answers Stedi with an immediate `200 OK`, and durably queues every enrollment, file and transaction event before your service sees it.

## Stedi webhook limitations and pain points

### Old timestamps fail strict verifiers

**The Problem:** Retries and recovered events fail verification even though the signature is valid.

**Why It Happens:** `webhook-timestamp` is when Stedi created the event. A retry sent 8 or 48 hours later still carries that time. Libraries with a fixed 5-minute tolerance, and receivers that copy that default, reject them, and Stedi's own docs say recovered events will "likely be outside your normal tolerance window".

**Workarounds:**

* Verify the signature without a timestamp check, or use a window measured in days.
* Deduplicate on `webhook-id` instead of relying on freshness.

**How Hookdeck Can Help:** Hookdeck's Stedi source type verifies the signature without enforcing a timestamp window, so retries and replays are accepted.

### Thin events need an API call

**The Problem:** The webhook does not contain the data you need.

**Why It Happens:** Stedi sends only the resource's id and type. The enrollment status, the ERA contents or the file details have to be fetched.

**Workarounds:**

* Fetch the resource with Stedi's API before acting, which also gives you the current state if events arrive out of order.
* Use `relatedResources` to decide whether a fetch is needed at all.

**How Hookdeck Can Help:** Hookdeck filters can route on `type` and `relatedResources`, so only the events a service needs reach it.

### Redirects fail deliveries

**The Problem:** Deliveries fail even though the endpoint works in a browser.

**Why It Happens:** Stedi does not follow redirects and treats any `3xx` as a failure. A trailing-slash rewrite or an HTTP to HTTPS hop is enough to fail every event.

**Workarounds:**

* Register the exact final HTTPS URL.
* Check the response code on the destination's **Event deliveries** tab.

**How Hookdeck Can Help:** A Hookdeck source URL answers directly, without redirects, and its request log shows each event as it arrives.

### Failure ends in a disabled destination

**The Problem:** After two days of failures, Stedi stops sending events entirely.

**Why It Happens:** Stedi retries for up to 48 hours and then disables the destination. Events created while it is disabled must be recovered through the Events API.

**Workarounds:**

* Act on the 2-hour and 24-hour failure emails.
* After an outage, re-enable the destination and list events with a `FAILED` status through the List Event Destination Events endpoint, then fetch and process each one.

**How Hookdeck Can Help:** Hookdeck accepts each event as it arrives and stores it, retries delivery to your service on your own schedule, and lets you replay anything that failed, so your downtime does not disable the Stedi destination.

## Best practices

### Verify against the raw body

The signature covers the exact bytes. Use `express.raw()` in Express, `await request.text()` in a Next.js route handler and `await request.body()` in FastAPI.

### Deduplicate on `webhook-id`

Stedi recommends `webhook-id` for detecting duplicates, which can come from automatic retries or from your own recovery process. Record each id before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Don't rely on order

Stedi does not guarantee delivery order. Fetch the resource and act on its current state, not on the order events arrived in.

### Handle rotation

Check every `v1` entry in `webhook-signature`, so deliveries keep verifying while both secrets are valid.

### Allowlist the User-Agent, not the comment

If a WAF blocks Stedi, allow the `Stedi-Events/` prefix. The text in parentheses after it can change without notice.

### Acknowledge fast and process asynchronously

Return `2xx` once the event is verified and stored, then fetch the resource and do the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Stedi event destinations sign every delivery with Standard Webhooks, over the id, timestamp and raw body with the decoded `whsec_` key. Follow the specification rather than the manual steps that omit the id, don't apply a short timestamp window to a timestamp that records creation, deduplicate on `webhook-id`, fetch resources before acting, and register a URL that never redirects.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every Stedi signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Stedi webhooks reliably in minutes.