payhook-ng
v0.1.1
Published
Lightweight webhook signature verification for Paystack and Flutterwave — HMAC-SHA512, replay prevention, Next.js & Express support. Zero dependencies.
Maintainers
Readme
payhook-ng
Verify Paystack and Flutterwave webhooks in 3 lines of code. No boilerplate HMAC logic, no raw crypto calls, no signature bugs.
import { paystack } from 'payhook-ng';
export async function POST(req: Request) {
const result = await paystack.verifyPaystackRequest(req, process.env.PAYSTACK_SECRET_KEY!);
if (!result.ok) return new Response(result.message, { status: 400 });
// result.payload is typed and verified
return new Response('ok');
}Why payhook-ng?
Every Paystack or Flutterwave integration needs webhook verification. Without it, anyone can send fake payment confirmations to your endpoint. The standard approach means writing HMAC-SHA512 logic by hand, getting timing-safe comparisons right, and hoping you didn't introduce a subtle security bug.
payhook-ng handles all of this with a single function call:
- Paystack HMAC-SHA512 signature verification
- Flutterwave secret hash verification
- Timing-safe comparisons that prevent timing attacks — no
===on signatures - Replay prevention with pluggable idempotency stores (in-memory + Redis)
- Stale event rejection based on event timestamps
- Next.js App Router and Express integrations out of the box
- Zero runtime dependencies — just
node:crypto - Full TypeScript types for webhook payloads
Install
npm install payhook-ngQuick Start
Paystack — Next.js App Router
import { paystack } from 'payhook-ng';
export const runtime = 'nodejs';
export async function POST(req: Request) {
const result = await paystack.verifyPaystackRequest(req, process.env.PAYSTACK_SECRET_KEY!);
if (!result.ok) {
return new Response(result.message, { status: 400 });
}
console.log(result.payload.event); // e.g. 'charge.success'
return new Response('ok');
}Flutterwave — Next.js App Router
import { flutterwave } from 'payhook-ng';
export const runtime = 'nodejs';
export async function POST(req: Request) {
const result = await flutterwave.verifyFlutterwaveRequest(req, process.env.FLUTTERWAVE_SECRET_HASH!);
if (!result.ok) {
return new Response(result.message, { status: 400 });
}
console.log(result.payload.event);
return new Response('ok');
}Unified API — auto-detects provider
Handle both Paystack and Flutterwave on a single endpoint:
import { verify } from 'payhook-ng';
export async function POST(req: Request) {
const rawBody = await req.text();
const headers = Object.fromEntries(req.headers);
const result = await verify(rawBody, headers, {
paystackSecret: process.env.PAYSTACK_SECRET_KEY!,
flutterwaveSecretHash: process.env.FLUTTERWAVE_SECRET_HASH!,
});
if (!result.ok) {
return new Response(result.message, { status: 400 });
}
console.log(result.provider); // 'paystack' | 'flutterwave'
return new Response('ok');
}Next.js Wrapper — withPayhook
Zero-boilerplate route handler:
import { withPayhook } from 'payhook-ng/nextjs';
export const POST = withPayhook(
{ paystackSecret: process.env.PAYSTACK_SECRET_KEY! },
async (payload, request) => {
console.log(payload.event);
return new Response('ok');
}
);Express Middleware
import express from 'express';
import { payhookMiddleware } from 'payhook-ng/express';
const app = express();
app.post(
'/webhooks',
express.raw({ type: 'application/json' }),
payhookMiddleware({
paystackSecret: process.env.PAYSTACK_SECRET_KEY!,
flutterwaveSecretHash: process.env.FLUTTERWAVE_SECRET_HASH!,
}),
(req, res) => {
const payload = req.webhook; // Verified payload
res.sendStatus(200);
}
);Important: Use
express.raw()to preserve raw body bytes. Parsing the body first invalidates the signature.
Factory Pattern — createPayhook
Centralize config once, reuse everywhere:
import { createPayhook, InMemoryIdempotencyStore } from 'payhook-ng';
const payhook = createPayhook({
paystackSecret: process.env.PAYSTACK_SECRET_KEY!,
flutterwaveSecretHash: process.env.FLUTTERWAVE_SECRET_HASH!,
idempotencyStore: new InMemoryIdempotencyStore(),
maxAgeSeconds: 3600,
});
export async function POST(req: Request) {
const rawBody = await req.text();
const headers = Object.fromEntries(req.headers);
const result = await payhook.verify(rawBody, headers);
if (!result.ok) {
return new Response(result.message, { status: 400 });
}
return new Response('ok');
}Replay Prevention
Attackers can capture a valid webhook and resend it. payhook-ng prevents this with idempotency stores that track processed event IDs.
In-Memory Store
For single-server deployments:
import { createPayhook, InMemoryIdempotencyStore } from 'payhook-ng';
const payhook = createPayhook({
paystackSecret: process.env.PAYSTACK_SECRET_KEY!,
idempotencyStore: new InMemoryIdempotencyStore(),
idempotencyTTL: 600, // 10 minutes (default)
});Redis Store
For multi-server production deployments:
import { createPayhook, RedisIdempotencyStore } from 'payhook-ng';
import Redis from 'ioredis';
const redis = new Redis();
const payhook = createPayhook({
paystackSecret: process.env.PAYSTACK_SECRET_KEY!,
idempotencyStore: new RedisIdempotencyStore(redis),
idempotencyTTL: 600,
});RedisIdempotencyStore accepts any client with get(key) and set(key, value, ...args) — compatible with both ioredis and redis (node-redis).
Stale Event Rejection
Reject events older than a threshold:
const payhook = createPayhook({
paystackSecret: process.env.PAYSTACK_SECRET_KEY!,
maxAgeSeconds: 3600, // Reject events older than 1 hour
});Subpath Imports
import { ... } from 'payhook-ng'; // Unified API, types, errors, stores
import { ... } from 'payhook-ng/paystack'; // Paystack-specific
import { ... } from 'payhook-ng/flutterwave'; // Flutterwave-specific
import { ... } from 'payhook-ng/nextjs'; // Next.js App Router wrapper
import { ... } from 'payhook-ng/express'; // Express middlewareError Handling
All result objects include a typed code field for exhaustive error handling:
| Code | Description |
|------|-------------|
| PAYHOOK_MISSING_HEADER | Required signature header is missing |
| PAYHOOK_INVALID_SIGNATURE | Signature verification failed |
| PAYHOOK_INVALID_JSON | Request body is not valid JSON |
| PAYHOOK_REPLAY_ATTACK | Duplicate event ID detected |
| PAYHOOK_STALE_EVENT | Event timestamp exceeds maxAgeSeconds |
OrThrow variants are available if you prefer exceptions:
import { paystack } from 'payhook-ng';
try {
const payload = paystack.verifyPaystackWebhookOrThrow({ rawBody, secret, headers });
} catch (err) {
if (err instanceof PayhookError) {
console.log(err.code); // typed PayhookErrorCode
}
}