Why verify?
Anyone on the internet can POST to your webhook endpoint. Verifying the signature on each delivery proves the request came from BabySea and that the payload was not tampered with in transit.
Using the SDK (recommended)
If you use the BabySea Node.js SDK, signature verification is handled for you:
import { verifyWebhook } from 'babysea/webhooks';
export async function POST(req: Request) {
const rawBody = await req.text();
const signature = req.headers.get('x-babysea-signature') ?? '';
let payload;
try {
payload = await verifyWebhook(rawBody, signature, process.env.BABYSEA_WEBHOOK_SECRET!);
} catch {
return new Response('Invalid signature', { status: 400 });
}
// payload is typed and verified
console.log(payload.webhook_event); // 'generation.completed'
return new Response('OK', { status: 200 });
}
The SDK verifyWebhook function:
| Step | What it does |
|---|
| Parse header | Extracts t and v1 from X-BabySea-Signature |
| Check timestamp freshness | Rejects deliveries outside the default 5 minute tolerance window |
| Compute HMAC | Calculates HMAC-SHA256 of {timestamp}.{rawBody} using your secret |
| Compare safely | Uses timing-safe comparison |
| Parse payload | Returns the parsed WebhookPayload object |
BabySea includes an X-BabySea-Signature header on every delivery:
X-BabySea-Signature: t=1705315200,v1=5d41402abc4b2a76b9719d911017c592
| Part | Description |
|---|
t | Unix timestamp (seconds) when the signature was created |
v1 | HMAC-SHA256 hex digest of {t}.{rawBody} using your webhook secret |
Manual verification steps
If you cannot use the SDK, follow these four steps to verify manually.
All steps below require the raw request body as a string. If your framework parses the JSON body before your handler runs, you will be verifying against a re-serialized string instead of the original bytes BabySea signed.
1. Check timestamp freshness
Reject deliveries where t falls outside your tolerance window to prevent replay attacks. By default that window is 5 minutes. Doing this before the HMAC check lets you fail fast without performing crypto on stale requests:
const header = request.headers['x-babysea-signature']; // "t=1705315200,v1=5d41..."
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
const timestamp = parts['t'];
const signature = parts['v1'];
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) {
return response.status(400).send('Timestamp outside tolerance');
}
2. Build the signed payload
Concatenate the timestamp, a literal ., and the raw request body:
const signedPayload = `${timestamp}.${rawBody}`;
Use the raw request body bytes. Parsing and re-encoding JSON may change whitespace or key order, which invalidates the signature.
3. Compute the expected signature
HMAC-SHA256 the signed payload using your webhook secret:
import { createHmac } from 'crypto';
const expected = createHmac('sha256', webhookSecret)
.update(signedPayload)
.digest('hex');
4. Compare using a constant-time function
Do not use === for string comparison. It leaks timing information that could be exploited. Use a constant-time comparison:
import { timingSafeEqual } from 'crypto';
const valid = timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex'),
);
if (!valid) {
return response.status(400).send('Invalid signature');
}
Full manual example
Putting all four steps together:
import { createHmac, timingSafeEqual } from 'crypto';
function verifyBabySeaWebhook(
rawBody: string,
signatureHeader: string,
secret: string,
toleranceSeconds: number = 300,
): object {
// 1. Parse header
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=', 2)),
);
const timestamp = parts['t'];
const signature = parts['v1'];
if (!timestamp || !signature) {
throw new Error('Missing signature components');
}
// 2. Check timestamp freshness
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > toleranceSeconds) {
throw new Error('Timestamp outside tolerance');
}
// 3. Compute expected signature
const signedPayload = `${timestamp}.${rawBody}`;
const expected = createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// 4. Constant-time comparison
const valid = timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex'),
);
if (!valid) {
throw new Error('Invalid signature');
}
return JSON.parse(rawBody);
}
Python example
import hmac
import hashlib
import time
import json
def verify_babysea_webhook(raw_body: str, signature_header: str, secret: str, tolerance: int = 300) -> dict:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp = parts.get("t")
signature = parts.get("v1")
if not timestamp or not signature:
raise ValueError("Missing signature components")
# Check timestamp freshness
if abs(time.time() - int(timestamp)) > tolerance:
raise ValueError("Timestamp outside tolerance")
# Compute expected signature
signed_payload = f"{timestamp}.{raw_body}"
expected = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256,
).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(expected, signature):
raise ValueError("Invalid signature")
return json.loads(raw_body)
Troubleshooting
Signature mismatch (Invalid signature)
The most common cause is body parsing. If your framework parses the JSON body before your handler runs, you are verifying against a re-serialized string instead of the original bytes BabySea signed.
| Framework | Fix |
|---|
| Express | Use express.text({ type: 'application/json' }) on the route |
| Next.js App Router | Use await req.text() instead of await req.json() |
| Fastify | Use rawBody with the fastify-raw-body plugin |
| Flask | Use request.get_data(as_text=True) instead of request.json |
Timestamp outside tolerance
Your server’s clock is out of sync with UTC, or the webhook was delayed in transit beyond the tolerance window.
Ensure your server uses NTP for time synchronization. If you need more tolerance, the SDK helper accepts a fourth toleranceSeconds argument.
Signature works locally but fails in production
Check whether a reverse proxy, CDN, or API gateway is modifying the request body (e.g., re-encoding JSON, stripping whitespace, or decompressing gzip). The raw bytes must be identical to what BabySea sent.