Webhooks
How Easy Labs notifies your server about asynchronous events — endpoint setup, signature verification, retry behavior, and the full event catalog.
Easy Labs delivers asynchronous notifications about account activity by POST-ing JSON to an HTTPS endpoint you register. Use webhooks to react to payments completing, subscriptions renewing, disputes opening, or settlements landing — without polling.
Quickstart
- Register an endpoint via the
webhooksAPI or the dashboard. You'll get a one-timesecret— store it immediately, it's never re-exposed. - Receive deliveries at your URL — Easy Labs
POSTs a signed JSON payload. - Verify the signature with
EasyWebhooks.constructEventbefore trusting the payload. - Respond
2xxwithin 5 seconds. Anything else — including a timeout — triggers a retry.
import express from 'express';
import { EasyWebhooks } from '@easylabs/node';
const app = express();
// IMPORTANT: use raw body — JSON re-stringifying breaks the signature.
app.post(
'/webhooks/easy',
express.raw({ type: 'application/json' }),
(req, res) => {
const event = EasyWebhooks.constructEvent(
req.body.toString('utf8'),
req.header('x-easy-webhook-signature') ?? '',
process.env.EASY_WEBHOOK_SECRET!,
);
switch (event.type) {
case 'payment.created':
// ...
break;
case 'subscription.updated':
// ...
break;
}
res.status(204).end();
},
);Delivery format
Each delivery is an HTTPS POST to your registered endpoint URL with these headers:
| Header | Value |
|---|---|
content-type | application/json |
x-easy-webhook-signature | t=<unix>,sha256=<hex> — see Verifying deliveries. May carry more than one sha256= entry during a secret rotation |
x-easy-event | The event type, e.g. payment.created |
x-easy-delivery-id | The event id. Dedupe on this — see below |
x-easy-webhook-attempt | Attempt number |
Deliveries are at-least-once. The same event can arrive more than once —
for example if a delivery is still in flight when its processing window
lapses. Treat x-easy-delivery-id as an idempotency key and make your handler
safe to run twice with the same event.
The body is a JSON WebhookEvent:
{
"id": "evt_01HABCDEFGHIJK",
"type": "payment.created",
"created_at": "2026-05-03T12:34:56.789Z",
"created": "2026-05-03T12:34:56.789Z",
"api_version": "2026-02-27",
"data": { /* event-specific payload */ },
"previous_attributes": { /* present on `*.updated` events */ },
"requested": {
"id": "req_01HXYZ...",
"idempotency_key": "your-key"
}
}Verifying deliveries
The signing secret returned at registration is your shared key. Verify every delivery before acting on it — anyone who knows your endpoint URL can POST to it.
Node.js
import { EasyWebhooks } from '@easylabs/node';
const event = EasyWebhooks.constructEvent(
rawBody, // exact request body string
req.header('x-easy-webhook-signature')!, // t=<unix>,sha256=<hex>
process.env.EASY_WEBHOOK_SECRET!,
);constructEvent throws an EasyApiError (status 400) on any of:
- Missing or malformed signature header
- Timestamp outside the tolerance window (default 300s, either direction)
- No signature matches the computed HMAC (timing-safe comparison)
- Body is not valid JSON
On success it returns a typed WebhookEvent.
The signature format
x-easy-webhook-signature: t=1753996800,sha256=<hex>[,sha256=<hex>]The signed string is <timestamp>.<raw body> — not the body alone:
HMAC-SHA256(secret, `${t}.${rawBody}`)Two things follow from that, and both matter:
The timestamp is covered by the signature. It cannot be edited without
invalidating it, which is what makes checking freshness worthwhile. Reject any
delivery whose t is further than your tolerance from your own clock —
in either direction — and do it before comparing the HMAC.
There may be more than one sha256= entry. During a secret rotation we
sign each delivery once per valid secret, so both the outgoing and incoming
secret verify during the overlap window. Accept the delivery if any entry
matches; that is what lets you deploy a rotated secret at your own pace.
Ignore components you do not recognise, so a future addition to this header
does not break your receiver.
Other languages
All three SDKs ship a verifier — prefer it over hand-rolling.
from easylabs.webhooks import Webhooks
event = Webhooks.construct_event(
payload=raw_body, # bytes preferred
signature=request.headers["x-easy-webhook-signature"],
secret=os.environ["EASY_WEBHOOK_SECRET"],
)event = EasyLabs::Webhooks.construct_event(
payload: request.body.read,
signature: request.headers["X-Easy-Webhook-Signature"],
secret: ENV.fetch("EASY_WEBHOOK_SECRET"),
# replay_window: 300 by default; pass nil to disable the freshness check
)Native EasyWebhooks helpers for Ruby and Python ship with a future SDK round — until then, use the manual verification above.
Replay protection
The current signature covers the body only — there is no signed timestamp. If you receive the same x-easy-delivery-id twice (because Easy Labs retried after a network blip and your server actually succeeded), idempotency is your responsibility: dedupe on event.id or x-easy-delivery-id.
A signed timestamp + tolerance: parameter is on the SDK gap-fix tracker (item #2). When it ships, EasyWebhooks.constructEvent will accept a tolerance argument and reject deliveries older than the window.
Retry behavior
Easy Labs retries any delivery that doesn't return a 2xx.
- Timeout: 5 seconds. The request is aborted after that and counts as a failed attempt. Do real work after you respond — acknowledge fast, process asynchronously.
- 3 attempts total, with 1 minute then 5 minutes between them. The whole retry window is therefore about 6 minutes, plus up to one sweep interval.
- The attempt number is on every delivery via
x-easy-webhook-attempt. - Redirects are not followed. A
3xxis recorded as a failed delivery, so point the endpoint at its final URL.
If your endpoint fails enough consecutive deliveries, the endpoint's consecutive_failures counter increments and Easy Labs eventually marks it disabled. Re-enable it via the dashboard or the update endpoint API.
To replay a delivery on demand, find it in the dashboard's webhook log and click "Resend" — useful for testing handlers without waiting for real activity.
Event types
Subscribe to specific events when registering an endpoint, or use ["*"] to catch everything. The full catalog (38 event types as of api_version 2026-02-27):
This list is served by the API at GET /v1/api/webhooks/event-types, which is
the same catalog it dispatches from — so it cannot drift from what is actually
sent. Fetch it if you are building a subscription UI.
Payments
payment.created— a new payment is initiatedpayment.updated— payment status changed (succeeded, failed, refunded, etc.)refund.created— a refund is initiatedrefund.updated— refund status changedauthorization.created— an authorization is created (manual capture flow)authorization.updated— authorization status changed. A void arrives as an.updatedcarryingis_void: trueand avoid_status— there is no separate voided eventcheckout.session.completed— a hosted/embedded Checkout session closes successfullycheckout.session.crypto_confirmed— a crypto checkout confirms on-chain
Subscriptions
subscription.created— a new subscription is createdsubscription.updated— subscription fields change (plan, quantity, billing cycle, etc.)subscription.deleted— subscription canceledsubscription.paused— subscription paused (collection paused or fully halted)subscription.resumed— subscription resumedsubscription.trial_will_end— fires 3 days before a trial endssubscription.pending_update_applied— a scheduled update took effectsubscription.price_change.scheduled— a price change is scheduledsubscription.price_change.canceled— a scheduled price change is canceledsubscription.price_change.applied— a scheduled price change takes effect
Invoices
invoice.created— a draft invoice is createdinvoice.finalized— invoice is finalized and ready to send/chargeinvoice.paid— invoice fully paidinvoice.payment_failed— payment attempt failed (triggers dunning)invoice.upcoming— fires before the next renewal so you can preview the invoiceinvoice.voided— invoice voidedinvoice.marked_uncollectible— invoice flagged unrecoverable
Discounts
coupon.created/coupon.updated/coupon.deletedpromotion_code.created/promotion_code.updated/promotion_code.deleted
Revenue recovery
revenue_recovery.action_completed— a dunning recovery step fired (retry, email, etc.)
Customers
customer.created— a customer is created through the API, an SDK or the dashboardcustomer.updated— customer details change
Payloads are Easy's own shape. They carry your customer id and the
external_customer_id you supplied — never a payment-processor identifier.
Disputes
dispute.created— a chargeback or pre-dispute opensdispute.updated— dispute moves through its lifecycle (under-review, won, lost, etc.)
Treasury
settlement.created— a settlement batch lands and its payout is initiated
Testing
test.webhook— sent on demand viaPOST /v1/api/webhooks/:id/send-test, so you can verify signature checking end to end before relying on real events. It goes through the same queue and signing path as any other event
Endpoint management
Webhook endpoints are managed under the account-and-operations API:
POST /webhooks— register a new endpoint (returns the signingsecretonce)GET /webhooks— list endpointsGET /webhooks/{id}— get a single endpoint (no secret in response)PATCH /webhooks/{id}— update URL, events, or active statusDELETE /webhooks/{id}— remove an endpoint
Each endpoint exposes:
interface WebhookEndpoint {
id: string;
url: string;
events: string[]; // e.g. ["payment.created", "*"]
active: boolean;
status: 'enabled' | 'disabled';
consecutive_failures: number;
last_triggered_at: string | null;
created_at: string;
updated_at: string;
}Inspecting deliveries
Every delivery attempt is logged. Query the delivery log to debug failures or replay events:
const deliveries = await client.listWebhookDeliveries({
endpoint_id: 'whe_01HABCD...',
success: false,
created_after: '2026-05-01T00:00:00Z',
limit: 50,
});The dashboard surfaces the same data with one-click "Resend" — useful for replaying without writing code.
Related
- Webhooks API — endpoint management operations
- Node SDK reference —
EasyWebhooks.constructEventand theWebhookEventtype - SDK gap-fix tracker #2 — signed timestamp + replay protection roadmap