Agent skill
Stedi Webhooks Skill
Receive and verify Stedi event destination webhooks (healthcare EDI clearinghouse). Use when setting up a Stedi webhook endpoint, getting the whsec_ signing secret, debugging webhook-id / webhook-timestamp / webhook-signature (Standard Webhooks) verification, or handling events like transaction.processed, file.processed, file.failed, enrollment.activated and enrollment.rejected.
Install this skill
npx skills add hookdeck/webhook-skills --skill stedi-webhooks
Stedi is a healthcare EDI clearinghouse API (eligibility, claims, ERAs, payer enrollment). Its event destinations POST thin JSON events to your HTTPS URL, signed per the Standard Webhooks spec. This skill covers event destinations, not Stedi's Legacy webhooks.
When to Use This Skill
- How do I receive Stedi webhooks?
- How do I verify a Stedi webhook signature?
- Where do I find my Stedi event destination signing secret?
- How do I handle
transaction.processed,file.failedorenrollment.activatedevents? - Why is my Stedi
webhook-signatureverification failing, or failing only on retries? - How do I dedupe Stedi webhook retries?
Verification (core)
| Header | Value |
|---|---|
webhook-id | msg_{UUID}, the same on every retry |
webhook-timestamp | Unix seconds when Stedi created the event (not when it delivered it) |
webhook-signature | v1,<base64 HMAC-SHA256>, several space-separated entries while a rotated secret is valid |
Signed content is {webhook-id}.{webhook-timestamp}.{raw body}, HMAC-SHA256, base64. The key is the base64-decoded part of the destination's secret after whsec_. Stedi's docs prose says timestamp.body (no id). That contradicts the Standard Webhooks spec and the standardwebhooks library the same page uses, so sign id.timestamp.body.
const crypto = require('crypto');
// rawBody: exact request bytes (express.raw({ type: '*/*' })), NOT re-serialised JSON
function verifyStediWebhook(rawBody, headers, secret) {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto.createHmac('sha256', key)
.update(`${headers['webhook-id']}.${headers['webhook-timestamp']}.`)
.update(rawBody)
.digest('base64');
return headers['webhook-signature'].split(' ').some((entry) => {
const [version, sig] = entry.split(',');
const received = Buffer.from(sig || '');
return version === 'v1' && received.length === expected.length &&
crypto.timingSafeEqual(received, Buffer.from(expected));
});
}
The standardwebhooks package (new Webhook(secret).verify(rawBody, headers); Python: from standardwebhooks.webhooks import Webhook) implements the same check, but it hard-codes a 5-minute timestamp window. Stedi retries for up to 48 hours with the original creation timestamp, so the package rejects every retry and every manual recovery. The examples verify manually with an optional window (STEDI_, off by default). Details in references/verification.md.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Event Envelope
Events are thin: they say what changed, not the new state. Fetch the resource through the Stedi API.
{ "id": "evt_a81659f1-...", "object": "v1.event", "type": "enrollment.activated",
"account": "11111111-2222-3333-4444-555555555555", "environment": "PRODUCTION",
"created": "2026-03-30T23:19:00.501Z",
"resource": { "id": "019bb508-dc63-73a1-8ddc-9d4720299072", "type": "enrollment" } }
Some events add relatedResources (for example the transaction type and execution ID on transaction.processed). Dedupe on the webhook-id header.
Common Event Types
| Event | Fired when |
|---|---|
transaction.processed | A transaction is received and translated (835 ERA, 277CA, or your JSON claim to X12) |
file.processed | A file is processed (for example a payer's 277CA) |
file.failed | A file can't be processed (parse or SFTP connection problems) |
enrollment.activated | A transaction enrollment request goes LIVE |
enrollment.rejected | The payer rejected the enrollment |
enrollment.updated | An enrollment's status, payer, contacts, tasks or documents change |
enrollment.task.assigned / .completed / .deleted | A provider enrollment task is assigned, completed or removed |
app.installed / app.uninstalled | A customer installs or removes your OAuth app |
event.ping | Test event from the Ping button |
Full payloads: references/overview.md and Stedi's event types.
Delivery Semantics
- Only
2xxcounts as success. Redirects (3xx) are failures, so point the destination straight at the receiver (HTTPS required). - Retries after 5 minutes, 30 minutes, 2 hours, 8 hours, 24 hours and 48 hours. If all fail, Stedi disables the destination until you re-enable it.
- Order isn't guaranteed. Duplicates happen (retries, manual recovery). Both reuse the same
webhook-id. - Allowlist the
Stedi-Events/User-Agent prefix if a WAF or CDN blocks the requests. Other delivery headers:event-id,destination-id,attempt-number,attempt-type.
Environment Variables
STEDI_WEBHOOK_SECRET=whsec_xxxxx # destination's signing secret; handlers fail closed (500) without it
STEDI_TIMESTAMP_TOLERANCE_SECONDS=0 # optional replay window; 0 disables (retries look old)
Get the secret from the destination's Overview tab, or GET /destinations/{id}/secret. See references/setup.md.
Local Development
# Express / Next.js (use 8000 for FastAPI). No install or account required.
npx hookdeck-cli listen 3000 stedi --path /webhooks/stedi
Create an event destination with the printed URL, then click Ping on its Event deliveries tab.
Reference Materials
- references/overview.md - Event catalog, payload shapes, retries
- references/setup.md - Create destinations (portal and API), secrets, rotation, testing
- references/verification.md - Manual and library verification, timestamp caveat, debugging