Webhooks
Verify and consume webhooks from the Easy API.
Easy Labs delivers events as HTTPS POSTs with a JSON WebhookEvent body and four headers:
| Header | Description |
|---|---|
x-easy-webhook-signature | sha256=<hex> HMAC of the raw body using your endpoint secret. |
x-easy-event | The event type, e.g. payment.created. |
x-easy-delivery-id | UUID identifying this delivery attempt. |
x-easy-webhook-attempt | Attempt number, 1–3. |
Register endpoints with registerWebhookEndpoint. The signing secret is returned only on creation — store it immediately.
Verifying signatures
EasyWebhooks.constructEvent(rawBody, signature, secret) validates the HMAC in constant time, parses the JSON body, and returns a typed WebhookEvent<T>. It throws EasyApiError (status 400) when the signature header is missing, malformed, or doesn't match. EasyApiError is exported as a runtime value from @easylabs/common (the @easylabs/node re-export is type-only), so use the common import for instanceof checks.
The rawBody argument must be the exact bytes you received. Do not pass a parsed object — re-stringifying changes whitespace and breaks the signature. Most frameworks expose the raw body via a config flag or a per-route opt-out.
Express
import express from "express";
import { EasyWebhooks } from "@easylabs/node";
import { EasyApiError } from "@easylabs/common";
const app = express();
app.post(
"/webhooks/easy",
express.raw({ type: "application/json" }),
(req, res) => {
try {
const event = EasyWebhooks.constructEvent(
req.body.toString("utf8"),
req.header("x-easy-webhook-signature") ?? "",
process.env.EASY_WEBHOOK_SECRET!,
);
// event.type is one of EASY_EVENT_TYPES, e.g. "payment.created"
console.log(event.id, event.type);
res.status(204).end();
} catch (err) {
if (err instanceof EasyApiError) return res.status(400).end();
throw err;
}
},
);Fastify
import Fastify from "fastify";
import { EasyWebhooks } from "@easylabs/node";
import { EasyApiError } from "@easylabs/common";
const app = Fastify();
// Capture the raw body for the webhook route
app.addContentTypeParser(
"application/json",
{ parseAs: "string" },
(_req, body, done) => done(null, body),
);
app.post("/webhooks/easy", async (req, reply) => {
try {
const event = EasyWebhooks.constructEvent(
req.body as string,
(req.headers["x-easy-webhook-signature"] as string) ?? "",
process.env.EASY_WEBHOOK_SECRET!,
);
// ...handle event...
return reply.code(204).send();
} catch (err) {
// Signature failures throw EasyApiError(status: 400) — surface that
// to the dispatcher instead of letting Fastify return a 500.
if (err instanceof EasyApiError) return reply.code(400).send();
throw err;
}
});Next.js Route Handler
// app/api/webhooks/easy/route.ts
import { EasyWebhooks } from "@easylabs/node";
import { EasyApiError } from "@easylabs/common";
export async function POST(req: Request) {
const rawBody = await req.text();
try {
const event = EasyWebhooks.constructEvent(
rawBody,
req.headers.get("x-easy-webhook-signature") ?? "",
process.env.EASY_WEBHOOK_SECRET!,
);
// ...handle event...
return new Response(null, { status: 204 });
} catch (err) {
if (err instanceof EasyApiError) return new Response(null, { status: 400 });
throw err;
}
}Replay protection
The signature alone protects integrity but not replay. Each delivery includes x-easy-delivery-id (UUID) and the body has event.id — store the delivery (or event) ID after first successful processing and reject duplicates. The dispatcher attempts a delivery up to 3 times (x-easy-webhook-attempt: 1..3), so your handler must be idempotent.
Inspect or replay a delivery server-side via easy.listWebhookDeliveries({ … }) or easy.listEndpointDeliveries(endpointId, { … }).
Common event types
The full list lives in the EASY_EVENT_TYPES const exported from @easylabs/node. Highlights:
| Event | Fires when |
|---|---|
payment.created, payment.updated | A transfer is created or transitions state. |
refund.created, refund.updated | A reversal is opened or completes. |
authorization.created, authorization.updated, authorization.voided | Auth-and-capture lifecycle. |
subscription.created, subscription.updated, subscription.deleted | Subscription lifecycle. |
subscription.paused, subscription.resumed, subscription.trial_will_end | Pause / trial transitions. |
subscription.pending_update_applied, subscription.pending_update_expired | Scheduled item changes. |
invoice.created, invoice.finalized, invoice.paid, invoice.payment_failed, invoice.upcoming, invoice.voided, invoice.marked_uncollectible | Invoice lifecycle. |
revenue_recovery.action_completed | A dunning automation action ran. |
coupon.*, promotion_code.* | Promo lifecycle. |
identity.created, identity.updated | Customer changes. |
settlement.created | A settlement was opened. |
dispute.created, dispute.updated | Chargeback lifecycle. |
checkout.session.completed, checkout.session.crypto_confirmed | Embedded checkout finished. |
test.webhook | Synthetic event for endpoint health checks. |
Subscribe to specific events when you register the endpoint, or pass ["*"] (the default) to receive every event type.