Signature Verification
Always verify webhook signatures before processing the payload. This ensures the request is genuinely from Ignite and hasn't been tampered with.
Why Verify Signatures?
Webhook endpoints are publicly accessible URLs. Without signature verification, anyone could send fake events to your endpoint. Signature verification ensures:
- Authenticity — The request comes from Ignite
- Integrity — The payload hasn't been modified in transit
Signature Format
Each webhook request includes an X-Webhook-Signature header with the following format:
t=1705316400000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd| Component | Description |
|---|---|
t | Unix timestamp in milliseconds when the signature was generated |
v1 | HMAC-SHA256 signature (hex encoded) |
Verification Algorithm
The signature is computed over a combination of the timestamp and the request body. There are two approaches:
Option A: Raw Body (Recommended)
signed_payload = timestamp + "." + raw_body
signature = HMAC-SHA256(signed_payload, signing_secret)Option B: JSON.stringify
signed_payload = timestamp + "." + JSON.stringify(body)
signature = HMAC-SHA256(signed_payload, signing_secret)Where raw_body is the exact bytes received from the request, and JSON.stringify(body) re-serializes the parsed JSON object.
Verification Approaches
There are two approaches to verify the signature. Choose the one that fits your use case:
| Approach | Reliability | Complexity | Use When |
|---|---|---|---|
| Raw Body | Guaranteed | Higher | Production systems, high reliability requirements |
| JSON.stringify | Very High | Lower | Quick integrations, when raw body access is difficult |
Why the difference?
The signature is computed over the exact bytes sent by Ignite. When using JSON.stringify() on the parsed body:
- Usually works:
JSON.stringify()is deterministic for the same JavaScript object - Potential issues: Key ordering in nested objects, number formatting (
1.0vs1), or Unicode escaping could theoretically differ between serializers
In practice, using JSON.stringify() works reliably because both Ignite and your code use standard JSON serialization. However, using the raw body provides a 100% guarantee.
Option A: Using Raw Body (Recommended)
This approach uses the exact bytes received, ensuring the signature always matches.
import { createHmac, timingSafeEqual } from "crypto";
import { NextApiRequest, NextApiResponse } from "next";
// Disable the default body parser to access raw body
export const config = {
api: {
bodyParser: false,
},
};
async function getRawBody(req: NextApiRequest): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString('utf-8');
}
function isValidSignature(rawBody: string, signatureHeader: string | string[] | undefined, secret: string): boolean {
try {
if (typeof signatureHeader !== "string") {
return false;
}
const time = signatureHeader.match(/t=(\d+)/)?.[1];
const token = signatureHeader.match(/v1=(\w+)/)?.[1];
if (!time || !token) {
return false;
}
const signedPayload = `${time}.${rawBody}`;
const hmac = createHmac("sha256", secret);
const calculatedSignature = hmac.update(signedPayload).digest("hex");
return timingSafeEqual(Buffer.from(token), Buffer.from(calculatedSignature));
} catch {
return false;
}
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(405).end();
}
const rawBody = await getRawBody(req);
if (!isValidSignature(rawBody, req.headers["x-webhook-signature"], process.env.WEBHOOK_SIGNING_SECRET!)) {
return res.status(403).end();
}
const body = JSON.parse(rawBody);
// Process the webhook...
return res.status(200).json({ received: true });
}Option B: Using JSON.stringify (Simpler)
This approach re-serializes the parsed body. It's simpler but relies on consistent JSON serialization between Ignite and your code.
This approach works reliably in practice because both sides use standard JSON.stringify() without formatting options. Use this when raw body access is inconvenient or when you need a quick integration.
import { createHmac, timingSafeEqual } from "crypto";
import { NextApiRequest, NextApiResponse } from "next";
// Using the default body parser (no config export needed)
function isValidSignature(req: NextApiRequest, secret: string): boolean {
try {
const signature = req.headers["x-webhook-signature"];
if (typeof signature !== "string") {
return false;
}
const time = signature.match(/t=(\d+)/)?.[1];
const token = signature.match(/v1=(\w+)/)?.[1];
if (!time || !token) {
return false;
}
const signedPayload = `${time}.${JSON.stringify(req.body)}`;
const hmac = createHmac("sha256", secret);
const calculatedSignature = hmac.update(signedPayload).digest("hex");
return timingSafeEqual(Buffer.from(token), Buffer.from(calculatedSignature));
} catch {
return false;
}
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(405).end();
}
if (!isValidSignature(req, process.env.WEBHOOK_SIGNING_SECRET!)) {
return res.status(403).end();
}
const body = req.body;
// Process the webhook...
return res.status(200).json({ received: true });
}Timestamp Validation (Optional)
To prevent replay attacks, you can also validate that the timestamp is recent:
function isTimestampValid(signatureHeader: string, toleranceMs = 300000): boolean {
const time = signatureHeader.match(/t=(\d+)/)?.[1];
if (!time) return false;
const timestamp = parseInt(time, 10);
const now = Date.now();
// Reject if timestamp is more than 5 minutes old (default)
return Math.abs(now - timestamp) <= toleranceMs;
}Troubleshooting
Signature Mismatch
If signature verification fails, check the following:
- Correct Secret — Ensure you're using the signing secret from the webhook subscription, not an API token
- Complete Header — Make sure you're reading the full
X-Webhook-Signatureheader including botht=andv1=parts - Body Integrity — If using Option B, ensure no middleware is modifying the body before verification
Debugging
Add logging to help diagnose signature issues:
function isValidSignature(rawBody, signatureHeader, secret) {
const time = signatureHeader.match(/t=(\d+)/)?.[1];
const token = signatureHeader.match(/v1=(\w+)/)?.[1];
const signedPayload = `${time}.${rawBody}`;
const calculatedSignature = crypto.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
console.log('Received signature:', token);
console.log('Calculated signature:', calculatedSignature);
console.log('Payload length:', rawBody.length);
console.log('First 100 chars:', rawBody.substring(0, 100));
return token === calculatedSignature;
}