Easy Labs
SDKsNode.js

Webhooks

Verify and consume webhooks from the Easy API.

Easy Labs delivers events as HTTPS POSTs with a JSON WebhookEvent body and four headers:

HeaderDescription
x-easy-webhook-signaturesha256=<hex> HMAC of the raw body using your endpoint secret.
x-easy-eventThe event type, e.g. payment.created.
x-easy-delivery-idUUID identifying this delivery attempt.
x-easy-webhook-attemptAttempt number, 13.

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:

EventFires when
payment.created, payment.updatedA transfer is created or transitions state.
refund.created, refund.updatedA reversal is opened or completes.
authorization.created, authorization.updated, authorization.voidedAuth-and-capture lifecycle.
subscription.created, subscription.updated, subscription.deletedSubscription lifecycle.
subscription.paused, subscription.resumed, subscription.trial_will_endPause / trial transitions.
subscription.pending_update_applied, subscription.pending_update_expiredScheduled item changes.
invoice.created, invoice.finalized, invoice.paid, invoice.payment_failed, invoice.upcoming, invoice.voided, invoice.marked_uncollectibleInvoice lifecycle.
revenue_recovery.action_completedA dunning automation action ran.
coupon.*, promotion_code.*Promo lifecycle.
identity.created, identity.updatedCustomer changes.
settlement.createdA settlement was opened.
dispute.created, dispute.updatedChargeback lifecycle.
checkout.session.completed, checkout.session.crypto_confirmedEmbedded checkout finished.
test.webhookSynthetic event for endpoint health checks.

Subscribe to specific events when you register the endpoint, or pass ["*"] (the default) to receive every event type.

On this page