Easy Labs
API

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

  1. Register an endpoint via the webhooks API or the dashboard. You'll get a one-time secret — store it immediately, it's never re-exposed.
  2. Receive deliveries at your URL — Easy Labs POSTs a signed JSON payload.
  3. Verify the signature with EasyWebhooks.constructEvent before trusting the payload.
  4. Respond 2xx within 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:

HeaderValue
content-typeapplication/json
x-easy-webhook-signaturet=<unix>,sha256=<hex> — see Verifying deliveries. May carry more than one sha256= entry during a secret rotation
x-easy-eventThe event type, e.g. payment.created
x-easy-delivery-idThe event id. Dedupe on this — see below
x-easy-webhook-attemptAttempt 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 3xx is 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 initiated
  • payment.updated — payment status changed (succeeded, failed, refunded, etc.)
  • refund.created — a refund is initiated
  • refund.updated — refund status changed
  • authorization.created — an authorization is created (manual capture flow)
  • authorization.updated — authorization status changed. A void arrives as an .updated carrying is_void: true and a void_status — there is no separate voided event
  • checkout.session.completed — a hosted/embedded Checkout session closes successfully
  • checkout.session.crypto_confirmed — a crypto checkout confirms on-chain

Subscriptions

  • subscription.created — a new subscription is created
  • subscription.updated — subscription fields change (plan, quantity, billing cycle, etc.)
  • subscription.deleted — subscription canceled
  • subscription.paused — subscription paused (collection paused or fully halted)
  • subscription.resumed — subscription resumed
  • subscription.trial_will_end — fires 3 days before a trial ends
  • subscription.pending_update_applied — a scheduled update took effect
  • subscription.price_change.scheduled — a price change is scheduled
  • subscription.price_change.canceled — a scheduled price change is canceled
  • subscription.price_change.applied — a scheduled price change takes effect

Invoices

  • invoice.created — a draft invoice is created
  • invoice.finalized — invoice is finalized and ready to send/charge
  • invoice.paid — invoice fully paid
  • invoice.payment_failed — payment attempt failed (triggers dunning)
  • invoice.upcoming — fires before the next renewal so you can preview the invoice
  • invoice.voided — invoice voided
  • invoice.marked_uncollectible — invoice flagged unrecoverable

Discounts

  • coupon.created / coupon.updated / coupon.deleted
  • promotion_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 dashboard
  • customer.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 opens
  • dispute.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 via POST /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 signing secret once)
  • GET /webhooks — list endpoints
  • GET /webhooks/{id} — get a single endpoint (no secret in response)
  • PATCH /webhooks/{id} — update URL, events, or active status
  • DELETE /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.

On this page