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:
- Parses the
X-Plugipay-Signatureheader (t=<ts>, v1=<hex>). - Rejects if the timestamp is more than
toleranceSec(default 300s / 5 minutes) off current time. - Computes
HMAC-SHA256(secret, <ts>.<rawBody>)and compares to thev1value in constant time. - Returns the parsed
WebhookEvent(discriminated union) on success. - Throws a
PlugipayErroron 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 usingexpress.json().- Return
200only after you've durably accepted the event (queued it, written to DB, etc.). A200tells Plugipay "delivered, don't retry". - Return
5xxto trigger retry. Return4xx(including401for 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
- Reference — the full method list, including
webhookEndpoints. - API webhooks — the complete event catalog and retry policy.
- Portal → Webhooks — managing endpoints in the dashboard.