Webhooks

Plugipay POSTs a signed event to your endpoint whenever a payment, refund, subscription, or invoice changes state. The SDK ships a single helper, verifyWebhook, that validates the signature and returns a typed WebhookEvent ready to switch on.

This page covers: wiring up an Express endpoint (and other frameworks), the signature contract, every event type the SDK currently knows about, and a handful of patterns. For the underlying signature recipe, see API webhooks.

What verifyWebhook does

import { verifyWebhook, type WebhookEvent } from '@forjio/plugipay-node';

function verifyWebhook(
  rawBody: string | Buffer,
  signatureHeader: string | string[] | undefined,
  secret: string,
  opts?: { toleranceSec?: number },
): WebhookEvent;

It:

  1. Parses the X-Plugipay-Signature header (t=<ts>, v1=<hex>).
  2. Rejects if the timestamp is more than toleranceSec (default 300s / 5 minutes) off current time.
  3. Computes HMAC-SHA256(secret, <ts>.<rawBody>) and compares to the v1 value in constant time.
  4. Returns the parsed WebhookEvent (discriminated union) on success.
  5. Throws a PlugipayError on any failure.

The error codes it can throw:

Code Meaning
signature_missing No X-Plugipay-Signature header.
signature_malformed Header doesn't contain t= and v1=.
signature_stale Timestamp outside the tolerance window.
signature_invalid Signature didn't match.

All have status: 401.

Where the secret comes from

Each webhook endpoint has its own signing secret, shown once when you create the endpoint in Settings → Webhooks (or via plugipay.webhookEndpoints.create). It's not the same as your API HMAC secret — webhooks use a per-endpoint secret so you can rotate one without touching the other.

Store it as PLUGIPAY_WEBHOOK_SECRET (or similar) in your env config.

Don't reuse your API secret. The webhook secret comes from the dashboard when you register the endpoint. Mixing them up will pass signature verification 0% of the time.

Express integration

The most important rule: verify the raw request bytes, not the parsed JSON. The signature is computed over the literal body Plugipay sent; if Express has already parsed and re-stringified it, byte representation diverges and verification fails.

import express from 'express';
import { verifyWebhook, PlugipayError } from '@forjio/plugipay-node';

const app = express();

app.post(
  '/webhooks/plugipay',
  express.raw({ type: 'application/json' }),   // ← critical: raw, not json
  (req, res) => {
    try {
      const event = verifyWebhook(
        req.body,                                  // Buffer of raw bytes
        req.headers['x-plugipay-signature'],
        process.env.PLUGIPAY_WEBHOOK_SECRET!,
      );

      handleEvent(event)
        .then(() => res.status(200).json({ received: true }))
        .catch((err) => {
          console.error('handler failed', err);
          res.status(500).end();
        });
    } catch (err) {
      if (err instanceof PlugipayError) {
        console.warn('rejecting webhook:', err.code, err.message);
        res.status(401).json({ error: err.code });
      } else {
        res.status(500).end();
      }
    }
  },
);

A few notes:

  • express.raw({ type: 'application/json' }) is per-route — other endpoints in the same app can keep using express.json().
  • Return 200 only after you've durably accepted the event (queued it, written to DB, etc.). A 200 tells Plugipay "delivered, don't retry".
  • Return 5xx to trigger retry. Return 4xx (including 401 for bad signature) to permanently reject — we won't retry.
  • Respond within 10 seconds. Queue heavy work; don't block the response on it.

Other frameworks

The same rules apply — pass raw body bytes, not parsed JSON.

Fastify

import Fastify from 'fastify';
import { verifyWebhook } from '@forjio/plugipay-node';

const app = Fastify();

app.addContentTypeParser(
  'application/json',
  { parseAs: 'buffer' },
  (_req, body, done) => done(null, body),
);

app.post('/webhooks/plugipay', async (req, reply) => {
  try {
    const event = verifyWebhook(
      req.body as Buffer,
      req.headers['x-plugipay-signature'],
      process.env.PLUGIPAY_WEBHOOK_SECRET!,
    );
    await handleEvent(event);
    return { received: true };
  } catch (err) {
    reply.code(401);
    return { error: (err as Error).message };
  }
});

Next.js App Router (route handler)

// app/api/webhooks/plugipay/route.ts
import { verifyWebhook } from '@forjio/plugipay-node';

export async function POST(req: Request) {
  const rawBody = await req.text();
  try {
    const event = verifyWebhook(
      rawBody,
      req.headers.get('x-plugipay-signature') ?? undefined,
      process.env.PLUGIPAY_WEBHOOK_SECRET!,
    );
    await handleEvent(event);
    return Response.json({ received: true });
  } catch (err) {
    return Response.json({ error: (err as Error).message }, { status: 401 });
  }
}

AWS Lambda (API Gateway)

import { verifyWebhook } from '@forjio/plugipay-node';

export const handler = async (event) => {
  // API Gateway delivers the body as a base64 string when isBase64Encoded
  const raw = event.isBase64Encoded
    ? Buffer.from(event.body, 'base64')
    : event.body;

  try {
    const parsed = verifyWebhook(
      raw,
      event.headers['x-plugipay-signature'],
      process.env.PLUGIPAY_WEBHOOK_SECRET!,
    );
    await handleEvent(parsed);
    return { statusCode: 200, body: JSON.stringify({ received: true }) };
  } catch (err) {
    return { statusCode: 401, body: JSON.stringify({ error: err.message }) };
  }
};

Switching on event types

verifyWebhook returns WebhookEvent, a discriminated union keyed on type. TypeScript narrows data.object to the right resource type inside each case:

import type { WebhookEvent } from '@forjio/plugipay-node';

async function handleEvent(event: WebhookEvent) {
  switch (event.type) {
    case 'plugipay.checkout_session.completed.v1':
      // event.data.object is CheckoutSession
      await fulfillOrder(event.data.object);
      break;

    case 'plugipay.checkout_session.expired.v1':
      // event.data.object is CheckoutSession
      await releaseReservation(event.data.object);
      break;

    case 'plugipay.invoice.paid.v1':
      // event.data.object is Invoice
      await markSubscriberActive(event.data.object.customerId);
      break;

    case 'plugipay.invoice.payment_failed.v1':
      // event.data.object is Invoice
      await sendDunningEmail(event.data.object);
      break;

    case 'plugipay.invoice.voided.v1':
      // event.data.object is Invoice
      await reverseInternalLedger(event.data.object);
      break;

    case 'plugipay.subscription.created.v1':
      // event.data.object is Subscription
      await provisionEntitlements(event.data.object);
      break;

    case 'plugipay.subscription.canceled.v1':
      // event.data.object is Subscription
      await scheduleEntitlementsExpiry(event.data.object);
      break;

    case 'plugipay.subscription.paused.v1':
      // event.data.object is Subscription
      await suspendEntitlements(event.data.object);
      break;

    default:
      // Exhaustiveness check — TS errors if you miss a case
      const _exhaustive: never = event;
  }
}

Event types the SDK currently knows about

The WebhookEvent union covers:

Type Carries
plugipay.checkout_session.completed.v1 CheckoutSession
plugipay.checkout_session.expired.v1 CheckoutSession
plugipay.invoice.created.v1 Invoice
plugipay.invoice.finalized.v1 Invoice
plugipay.invoice.paid.v1 Invoice
plugipay.invoice.payment_failed.v1 Invoice
plugipay.invoice.voided.v1 Invoice
plugipay.subscription.created.v1 Subscription
plugipay.subscription.canceled.v1 Subscription
plugipay.subscription.paused.v1 Subscription

The full event catalog (including payout, refund, and customer events) is in API webhooks. Plugipay emits more events than the SDK currently types — if you receive an event type the SDK doesn't know about, verifyWebhook still succeeds and returns the raw event, but TypeScript's exhaustiveness check above will treat it as the default branch. Update your SDK version to get new event types as we add them.

Deduplication

Webhooks are delivered at-least-once. The same event.id can arrive twice if a retry crosses with a delayed 200 from your endpoint.

Always dedupe on event.id:

async function handleEvent(event: WebhookEvent) {
  const alreadySeen = await db.events.findUnique({ where: { id: event.id } });
  if (alreadySeen) return; // duplicate — ack and move on

  await db.events.create({ data: { id: event.id, type: event.type, receivedAt: new Date() } });
  await processEvent(event);
}

A unique constraint on event.id plus an upsert is the bulletproof pattern.

Local development

To receive production (or test-mode) webhooks at your localhost dev server without exposing a public URL:

plugipay webhooks listen --forward-to http://localhost:3000/webhooks/plugipay

This opens a long-lived WebSocket to Plugipay and replays each event to your local endpoint. The signature is real — verification works exactly the same as production. See CLI → webhooks.

Tolerance and clock skew

The default 5-minute tolerance handles most clock drift. If your servers' clocks are tightly synced, tighten it for stronger replay protection:

verifyWebhook(req.body, sig, SECRET, { toleranceSec: 60 });

If you keep seeing signature_stale errors, sync your clock with NTP — the clock drift is on your side, not ours.

Common pitfalls

Verifying parsed JSON instead of raw bytes

express.json() will give you req.body as a parsed object — useful normally, fatal for signature verification because re-serialization changes byte representation. Use express.raw({ type: 'application/json' }) on the webhook route only.

Returning 200 too early

If you return 200 before durably enqueueing the event, a crash mid-handler loses the event — Plugipay considers it delivered and won't retry. Either:

  • Process synchronously and return 200 only on success.
  • Persist the event to a queue/DB first, then return 200.

Returning 5xx for business-rule rejection

Business rejections ("we don't want this event") should return 200 — you accepted delivery, you just ignored the content. Reserve 5xx for transient infrastructure failures where retry is genuinely useful.

Reusing the API secret as the webhook secret

Different secrets. The webhook secret was shown to you when you created the endpoint in the dashboard. The API secret is for outbound calls.

Not handling unknown event types

Plugipay can add new event types between SDK releases. Treat the default branch as "log and ack" — don't error on unknown types.

Next

Plugipay — Payments that don't tax your success