Webhooks
Signature verification
LuniPay signs every webhook delivery with HMAC-SHA256. Verifying the signature proves the request really came from LuniPay and has not been tampered with in transit.
With the SDK (recommended)
The LuniPay SDKs ship a one-liner helper that parses the header, verifies the HMAC, rejects stale timestamps, and returns the parsed event. Prefer this over rolling your own crypto.
import { Webhook, WebhookSignatureError } from 'lunipay';
export async function POST(req: Request) {
const body = await req.text(); // raw string — do NOT JSON.parse yet
const sig = req.headers.get('LuniPay-Signature');
try {
const event = Webhook.constructEvent(
body,
sig,
process.env.LUNIPAY_WEBHOOK_SECRET!,
);
// event.type, event.data.object, … typed via LuniPayEvent<T>
if (event.type === 'checkout.session.completed') {
// fulfill the order
}
} catch (err) {
if (err instanceof WebhookSignatureError) {
return new Response('bad signature', { status: 400 });
}
throw err;
}
return new Response(null, { status: 200 });
}Everything below this section is for authors who cannot use the SDK — edge runtimes without node:crypto, or languages without an official LuniPay SDK. If you have the SDK, you do not need to read further.
The LuniPay-Signature header
Every delivery includes a header of the form:
LuniPay-Signature: t=1713100900,v1=6dca0b8bb0e7f35a3b4f2a1f6f8e9c8a4f4fba87d9bc7f8e2c3d4e5f6a7b8c9dt— unix timestamp (seconds) when LuniPay signed the request.v1— hex-encoded HMAC-SHA256 over{t}.{raw_body}, keyed with your endpoint's signing secret.
Verification algorithm
- Read the raw request body as a string — never
JSON.parseit first. - Split the
LuniPay-Signatureheader on,and extracttandv1. - Compute
HMAC-SHA256(secret, `${t}.${body}`)and hex-encode the result. - Compare your hex against
v1using a timing-safe comparison (not===). - Optional but recommended: reject the request if
tis more than 5 minutes in the past — guards against replay attacks.
Use the raw body
Node.js / TypeScript
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyLuniPaySignature(
rawBody: string,
header: string,
secret: string,
): boolean {
const [tsPart, sigPart] = header.split(',');
if (!tsPart?.startsWith('t=') || !sigPart?.startsWith('v1=')) return false;
const timestamp = Number(tsPart.slice(2));
const signature = sigPart.slice(3);
if (!Number.isFinite(timestamp)) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + rawBody)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python
import hmac
import hashlib
import time
TOLERANCE = 300
def verify_lunipay_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts.get("t", "0"))
sig = parts.get("v1", "")
if abs(int(time.time()) - ts) > TOLERANCE:
return False
signed_payload = f"{ts}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)Common mistakes
- Parsing JSON before hashing — the whitespace is different and the hashes will never match.
- Using string equality (
===) — this leaks a timing side channel. - Forgetting to refresh the signing secret when you rotate the endpoint.
- Verifying with the test secret against a live event (or vice versa) — livemodes have separate endpoints and separate secrets.
After you verify
Once the signature is valid, parse the body into JSON, read event.type, and run your business logic. Deduplicate by event.id — LuniPay may retry the same event, and the retry is byte-for-byte identical.