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.

Every delivery includes a header of the form:

LuniPay-Signature: t=1713100900,v1=6dca0b8bb0e7f35a3b4f2a1f6f8e9c8a4f4fba87d9bc7f8e2c3d4e5f6a7b8c9d
  • t — 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

  1. Read the raw request body as a string — never JSON.parse it first.
  2. Split the LuniPay-Signature header on , and extract t and v1.
  3. Compute HMAC-SHA256(secret, `${t}.${body}`) and hex-encode the result.
  4. Compare your hex against v1 using a timing-safe comparison (not ===).
  5. Optional but recommended: reject the request if t is more than 5 minutes in the past — guards against replay attacks.

Use the raw body

The signature is computed over exact bytes. If a middleware parses and re-serializes the body before your handler sees it, the hash will not match — even if the data is semantically identical.

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.