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.failed or enrollment.activated events?
  • Why is my Stedi webhook-signature verification failing, or failing only on retries?
  • How do I dedupe Stedi webhook retries?

Verification (core)

HeaderValue
webhook-idmsg_{UUID}, the same on every retry
webhook-timestampUnix seconds when Stedi created the event (not when it delivered it)
webhook-signaturev1,<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_TIMESTAMP_TOLERANCE_SECONDS, 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

EventFired when
transaction.processedA transaction is received and translated (835 ERA, 277CA, or your JSON claim to X12)
file.processedA file is processed (for example a payer's 277CA)
file.failedA file can't be processed (parse or SFTP connection problems)
enrollment.activatedA transaction enrollment request goes LIVE
enrollment.rejectedThe payer rejected the enrollment
enrollment.updatedAn enrollment's status, payer, contacts, tasks or documents change
enrollment.task.assigned / .completed / .deletedA provider enrollment task is assigned, completed or removed
app.installed / app.uninstalledA customer installs or removes your OAuth app
event.pingTest event from the Ping button

Full payloads: references/overview.md and Stedi's event types.

Delivery Semantics

  • Only 2xx counts 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 4, 2026

View on GitHub →