Documentation Index
Fetch the complete documentation index at: https://yanhgming.mintlify.app/llms.txt
Use this file to discover all available pages before exploring further.
Every webhook Marlin sends includes a marlin-signature header. Verifying this header before processing an event ensures the request genuinely came from Marlin and was not tampered with in transit. Skipping verification exposes your handler to forged or replayed events.
The header uses the format:
marlin-signature: t=<unix_timestamp>,v1=<hmac_hex>
To verify the signature, compute HMAC-SHA256(<secret>, "<timestamp>.<raw_body>") and compare it to the v1 value using a timing-safe comparison. The @marlin/sdk package provides a verifyWebhook function that handles all of this for you.
Always pass the raw request body — the exact bytes received over the wire — to the verification function, not a parsed or re-serialized JSON object. Re-serializing changes whitespace and key order, which changes the HMAC and causes verification to fail.
verifyWebhook from @marlin/sdk
The verifyWebhook function parses the signature header, checks that the event timestamp is within the tolerance window (default 5 minutes), computes the expected HMAC, compares it using a timing-safe comparison, and returns the parsed WebhookEvent. It throws MarlinWebhookVerificationError if any step fails.
import { verifyWebhook } from "@marlin/sdk";
interface VerifyWebhookOptions {
/** Raw request body as a string. */
payload: string;
/** Value of the `marlin-signature` header. */
signature: string;
/** Webhook signing secret from the Marlin dashboard. */
secret: string;
/** Maximum age of the webhook event in seconds. Defaults to 300 (5 minutes). */
tolerance?: number;
}
function verifyWebhook(opts: VerifyWebhookOptions): WebhookEvent;
If verification fails, verifyWebhook throws a MarlinWebhookVerificationError:
import { MarlinWebhookVerificationError } from "@marlin/sdk";
try {
const event = verifyWebhook({ payload, signature, secret });
} catch (err) {
if (err instanceof MarlinWebhookVerificationError) {
console.error("Verification failed:", err.message);
// err.name === "MarlinWebhookVerificationError"
}
}
Possible error messages thrown by verifyWebhook:
"Webhook payload is empty."
"Webhook signature header is missing."
"Webhook secret is required."
"Invalid signature header format. Expected t=<timestamp>,v1=<hmac>."
"Invalid timestamp in signature header."
"Webhook timestamp is too old. Event is <N>s old, tolerance is <N>s."
"Webhook signature verification failed. The signature does not match the payload."
"Failed to parse webhook payload as JSON."
Express.js handler
import express from "express";
import { verifyWebhook, MarlinWebhookVerificationError } from "@marlin/sdk";
const app = express();
// Use express.raw() for the webhook route so you receive the raw body.
app.post(
"/webhooks/marlin",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["marlin-signature"];
if (!signature) {
return res.status(400).send("Missing marlin-signature header");
}
let event;
try {
event = verifyWebhook({
payload: req.body.toString("utf8"),
signature: signature,
secret: process.env.MARLIN_WEBHOOK_SECRET,
});
} catch (err) {
if (err instanceof MarlinWebhookVerificationError) {
console.error("Webhook verification failed:", err.message);
return res.status(400).send("Webhook verification failed");
}
throw err;
}
switch (event.type) {
case "invoice.paid":
// Fulfill the order for event.data.customerId
break;
case "subscription.canceled":
// Revoke access for event.data.customerId
break;
default:
console.log("Unhandled event type:", event.type);
}
res.status(200).send("OK");
}
);
Next.js App Router handler
// app/api/webhooks/marlin/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyWebhook, MarlinWebhookVerificationError } from "@marlin/sdk";
export async function POST(request: NextRequest) {
const signature = request.headers.get("marlin-signature");
if (!signature) {
return NextResponse.json(
{ error: "Missing marlin-signature header" },
{ status: 400 }
);
}
// Read the raw body as text before any JSON parsing.
const rawBody = await request.text();
let event;
try {
event = verifyWebhook({
payload: rawBody,
signature,
secret: process.env.MARLIN_WEBHOOK_SECRET!,
});
} catch (err) {
if (err instanceof MarlinWebhookVerificationError) {
console.error("Webhook verification failed:", err.message);
return NextResponse.json(
{ error: "Webhook verification failed" },
{ status: 400 }
);
}
throw err;
}
switch (event.type) {
case "invoice.paid":
// Fulfill the order for event.data.customerId
break;
case "subscription.activated":
// Provision access for event.data.customerId
break;
default:
console.log("Unhandled event type:", event.type);
}
return NextResponse.json({ received: true });
}
Manual verification (non-Node environments)
If you are not using the @marlin/sdk, you can verify the signature manually using any HMAC-SHA256 implementation.
Algorithm:
- Split the
marlin-signature header on , to get t=<timestamp> and v1=<hmac>.
- Check that the timestamp is within your tolerance window (e.g. ±300 seconds of now).
- Concatenate the signed content:
"<timestamp>.<raw_body>".
- Compute
HMAC-SHA256(<webhook_secret>, signed_content) and encode as lowercase hex.
- Compare your computed hex to the
v1 value using a constant-time comparison.
Python example:
import hmac
import hashlib
import time
def verify_marlin_webhook(payload: str, signature: str, secret: str, tolerance: int = 300) -> bool:
parts = {k: v for k, v in (p.split("=", 1) for p in signature.split(","))}
timestamp = parts.get("t")
received_hmac = parts.get("v1")
if not timestamp or not received_hmac:
raise ValueError("Invalid signature header format")
ts = int(timestamp)
if abs(time.time() - ts) > tolerance:
raise ValueError(f"Webhook timestamp is too old")
signed_content = f"{timestamp}.{payload}"
expected_hmac = hmac.new(
secret.encode("utf-8"),
signed_content.encode("utf-8"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_hmac, received_hmac):
raise ValueError("Signature verification failed")
return True