What this is and who needs it

Most events on your EPD account happen because of something asynchronous — a card finishes settling, a renewal succeeds, a chargeback opens. Polling the API for these is wasteful. Webhooks flip the model: EPD calls you with a signed JSON payload the moment something happens.

If you need to update an order status when a charge succeeds, dunning when a renewal fails, or grant access when a subscription activates — you need webhooks.

How EPD signs webhooks

Every webhook request includes a header that proves it came from EPD and has not been replayed:

EPD-Signature: t=1707350400,v1=5f8c0b7f4e9d3a2c1b6e8f0a9d3c2b1e5f8c0b7f4e9d3a2c1b6e8f0a9d3c2b
FieldMeaning
tUnix timestamp (seconds) when EPD sent the webhook
v1Hex-encoded HMAC-SHA256 of <t>.<raw_request_body> using the endpoint’s signing secret

Your endpoint’s signing secret starts with whsec_ and is shown once when you create the endpoint. Treat it like a password.

Setup

Create an HTTPS endpoint in your app

Build a route that accepts POST with a JSON body. EPD only delivers to HTTPS URLs (https://...).

Register the endpoint

In the Merchant Portal, open the gear icon (top right) and choose Webhooks. Or create the endpoint programmatically via POST /v1/webhook_endpoints.

On the Webhooks list, click + Create Endpoint (top right). Pick the events to subscribe to — e.g. order.*, subscription.* — and pin the API version you want payloads delivered with. Pinning a version means EPD keeps delivering events in that schema even after newer versions ship.

Copy the signing secret

EPD shows the whsec_... secret once in a modal right after the endpoint is created. Copy it into your secrets manager before dismissing the dialog — there is no way to read it back later. Each endpoint has its own secret.

Verify and respond fast

Verify the signature, return 2xx within 30 seconds, then process the event asynchronously if it needs more work.

Verifying the signature

import crypto from "node:crypto";

export function verifyWebhook(rawBody, signatureHeader, secret) {
  // Header looks like: "t=1707350400,v1=5f8c0b7f..."
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("="))
  );
  const timestamp = parseInt(parts.t, 10);
  const signature = parts.v1;

  // 1. Reject anything older than 5 minutes (replay protection)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > 300) return false;

  // 2. Recompute HMAC-SHA256 of "<t>.<rawBody>"
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  // 3. Constant-time compare
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature),
  );
}
import hmac
import hashlib
import time

def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
    parts = dict(p.split("=") for p in signature_header.split(","))
    timestamp = int(parts["t"])
    signature = parts["v1"]

    # 1. Reject anything older than 5 minutes
    if abs(int(time.time()) - timestamp) > 300:
        return False

    # 2. Recompute HMAC-SHA256 of "<t>.<rawBody>"
    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    # 3. Constant-time compare
    return hmac.compare_digest(expected, signature)

Always verify against the raw request body — exact bytes as received. Frameworks that parse JSON and re-stringify it will change byte order and break the signature. In Express, use express.raw({ type: 'application/json' }) on the webhook route.

Replay protection

Reject any request whose timestamp is more than 5 minutes (300 seconds) away from your server clock — past or future. Without this check, an attacker who once captured a valid webhook could resend it forever.

Retry behavior

If your endpoint returns a non-2xx status or the request times out, EPD retries the event on a fixed schedule. Make your handler idempotent — the same event can be delivered more than once.

PropertyValue
Total attempts7 (one initial delivery + 6 retries)
Retry schedule1 min, 5 min, 30 min, 2 h, 8 h, 24 h after the previous attempt
Retry window~35 hours from the first attempt; the event is then dead-lettered
Timeout30 seconds per attempt

You can also manually replay any past event from the Merchant Portal — useful when you ship a bug-fix and want to re-process events that failed.

The endpoint detail page shows 24-hour delivery health, every recent attempt with its status (Pending, Delivered, Dead Letter), the next retry time, and a Replay link for events that gave up.

Rotating the signing secret

If you suspect a secret was leaked, rotate it from the endpoint detail page (the Rotate button next to the masked secret) or via the rotate_webhook_secret MCP tool (or POST /v1/webhook_endpoints/:id/rotate_secret). EPD supports a grace period during which both old and new secrets are accepted, so you can update your code without missing events. The portal shows when the previous secret stops being accepted.

Common event types

The exact catalog depends on your API version. You pick which families to subscribe to when you create the endpoint.

Event familyExamples
customer.*customer.created, customer.updated, customer.deleted, customer.payment_method.updated
order.*order.created, order.succeeded, order.failed, order.refunded, order.partially_refunded, order.refund_failed, order.voided
subscription.*subscription.created, subscription.updated, subscription.charged, subscription.canceled, subscription.billing.updated
checkout_session.*checkout_session.created, checkout_session.tokenized, checkout_session.succeeded, checkout_session.failed, checkout_session.refunded, checkout_session.partially_refunded, checkout_session.canceled, checkout_session.expired

You can subscribe to an entire family with a wildcard (e.g. order.*) or to all events with *.

Tools you can use