Skip to content

Instantly share code, notes, and snippets.

@onahprosper
Created May 5, 2026 18:31
Show Gist options
  • Select an option

  • Save onahprosper/a9f87c4377944ab82ac55dc761b1ccf3 to your computer and use it in GitHub Desktop.

Select an option

Save onahprosper/a9f87c4377944ab82ac55dc761b1ccf3 to your computer and use it in GitHub Desktop.
Paycrest X-Paycrest-Signature verification (raw body, hex compare, optional trailing newline)

Paycrest webhook signature helpers

Verifies POST bodies from Paycrest’s sender webhooks using the X-Paycrest-Signature header (HMAC-SHA256, hex digest).

Requirements

  • Node.js (crypto: createHmac, timingSafeEqual)

Secret

Use your sender API secret (dashboard / same value used for Sender API HMAC). Not the public API-Key UUID.

Body

Pass the raw request body as a Buffer (or UTF-8 string of the exact wire bytes). Do not verify using JSON.stringify of a parsed object — ordering and spacing won’t match.

Express example

import express from "express";
import { verifyPaycrestWebhookFromRequest } from "./verify-paycrest-webhook.js";

const app = express();
app.post(
  "/webhooks/paycrest",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = verifyPaycrestWebhookFromRequest(
      req.body,
      req.headers as Record<string, string | string[] | undefined>,
      process.env.PAYCREST_SECRET,
    );
    if (!ok) return res.status(401).send("Invalid signature");
    const payload = JSON.parse(req.body.toString("utf8"));
    // ...
    res.sendStatus(200);
  },
);
/**
* Paycrest aggregator webhook HMAC verification (same rules as aggregator/utils).
*
* Export `verifyPaycrestWebhookSignature` / `getPaycrestSignatureHeader` into your
* ramp processor (e.g. PaycrestProcessor.verifySignature) — use exact raw body bytes
* and plaintext PAYCREST_SECRET (API secret). Do not use Buffer.from(..., "hex")
* on the header; compare hex strings as UTF-8 (Go hmac.Equal on ascii hex).
*
* Legacy aggregator quirk: webhook body was json.Encoder.Encode (trailing newline) while
* HMAC used json.Marshal (no trailing newline). Verification tries the raw body then the
* same bytes without a single trailing CRLF/LF so both shapes work.
*
* Run self-test: npm run test-webhook
*/
import dotenv from "dotenv";
import { createHmac, timingSafeEqual } from "crypto";
function webhookBodyToBuffer(rawBody: string | Buffer): Buffer {
return Buffer.isBuffer(rawBody)
? rawBody
: Buffer.from(rawBody as string, "utf8");
}
/** Remove one trailing LF, or one trailing CRLF (json.Encoder newline artifact). */
export function stripPaycrestWebhookTrailingNewline(body: Buffer): Buffer {
if (
body.length >= 2 &&
body[body.length - 2] === 0x0d &&
body[body.length - 1] === 0x0a
) {
return body.subarray(0, body.length - 2);
}
if (body.length >= 1 && body[body.length - 1] === 0x0a) {
return body.subarray(0, body.length - 1);
}
return body;
}
/** HMAC-SHA256 hex digest Paycrest sends in `X-Paycrest-Signature` (for debugging comparisons). */
export function computePaycrestWebhookSignatureHex(
rawBody: string | Buffer,
secret: string,
): string {
const key = secret.trim();
const bodyBuf = webhookBodyToBuffer(rawBody);
return createHmac("sha256", key).update(bodyBuf).digest("hex");
}
function macHexMatches(received: string, bodyBuf: Buffer, key: string): boolean {
const expected = computePaycrestWebhookSignatureHex(bodyBuf, key).toLowerCase();
const r = received.trim().toLowerCase();
if (r.length !== expected.length) {
return false;
}
try {
return timingSafeEqual(
Buffer.from(expected, "utf8"),
Buffer.from(r, "utf8"),
);
} catch {
return false;
}
}
export function verifyPaycrestWebhookSignature(
rawBody: string | Buffer,
signatureHeader: string | undefined,
secret: string,
): boolean {
const key = secret.trim();
if (!key || !signatureHeader?.trim()) {
return false;
}
const received = signatureHeader.trim().toLowerCase();
const primary = webhookBodyToBuffer(rawBody);
const trimmed = stripPaycrestWebhookTrailingNewline(primary);
if (primary.equals(trimmed)) {
return macHexMatches(received, primary, key);
}
return (
macHexMatches(received, primary, key) || macHexMatches(received, trimmed, key)
);
}
export function getPaycrestSignatureHeader(
headers?: Record<string, string | string[] | undefined>,
): string | undefined {
if (!headers) {
return undefined;
}
for (const [k, v] of Object.entries(headers)) {
if (k.toLowerCase() === "x-paycrest-signature") {
const val = Array.isArray(v) ? v[0] : v;
return typeof val === "string" ? val : undefined;
}
}
return undefined;
}
/** Drop-in for processors: env secret + headers + raw body. */
export function verifyPaycrestWebhookFromRequest(
rawBody: string | Buffer,
headers: Record<string, string | string[] | undefined> | undefined,
secret: string | undefined,
): boolean {
const sig = getPaycrestSignatureHeader(headers);
return verifyPaycrestWebhookSignature(rawBody, sig, secret || "");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment