Gareth Wilson Gareth Wilson

Guide to Stedi Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationStedi portal (developer settings, Event destinations) or the Events API
Limit16 event destinations per account
URLHTTPS, pointing directly at your receiver. Redirects are not followed
Signature headerswebhook-id (msg_{UUID}), webhook-timestamp, webhook-signature (v1,<base64>)
SchemeStandard Webhooks: HMAC-SHA256, base64
Signed content<webhook-id>.<webhook-timestamp>.<raw body>
SecretOne per destination, whsec_..., readable any time in the portal or API
RotationOld secret stays valid for an expiry period (default 24 hours), with two signatures sent
TimestampWhen Stedi created the event, not when it delivered it
Other headersevent-id, destination-id, attempt-number, attempt-type, user-agent (Stedi-Events/...)
Success2xx only. 3xx is a failure
Retries5 minutes, 30 minutes, 2 hours, 8 hours, 24 hours, 48 hours
Auto-disableAfter the last retry fails. Re-enable the destination by hand
NotificationsIn-app and email after 2 hours and 24 hours of failures
Test eventevent.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.

AreaEventFires when
Testingevent.pingYou click Ping on a destination
Enrollmentenrollment.activatedA transaction enrollment request is set to LIVE
Enrollmentenrollment.rejectedAn enrollment request is set to REJECTED
Enrollmentenrollment.updatedAn enrollment request changes
Enrollment tasksenrollment.task.assigned, enrollment.task.completed, enrollment.task.deletedStedi assigns a task to the provider, the provider completes it, or Stedi deletes it
Filesfile.processed, file.failedStedi processes a file, or fails to
Transactionstransaction.processedStedi receives and translates a transaction
OAuth appsapp.installed, app.uninstalledA customer installs or removes your OAuth app

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

{
  "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:

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.

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:

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

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.

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 sits in front of your receiver, verifies every Stedi signature, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle Stedi webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.