Webhooks
Receive real-time notifications when something happens on your EPD account — and verify they are real.
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
| Field | Meaning |
|---|---|
t | Unix timestamp (seconds) when EPD sent the webhook |
v1 | Hex-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
Build a route that accepts POST with a JSON body. EPD only delivers to HTTPS URLs (https://...).
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.
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 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.
| Property | Value |
|---|---|
| Total attempts | 7 (one initial delivery + 6 retries) |
| Retry schedule | 1 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 |
| Timeout | 30 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 family | Examples |
|---|---|
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 *.