Skip to main content

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. If you use the BabySea Node.js SDK, signature verification is handled for you:
TypeScript
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:
StepWhat it does
Parse headerExtracts t and v1 from X-BabySea-Signature
Check timestamp freshnessRejects deliveries outside the default 5 minute tolerance window
Compute HMACCalculates HMAC-SHA256 of {timestamp}.{rawBody} using your secret
Compare safelyUses timing-safe comparison
Parse payloadReturns the parsed WebhookPayload object

Signature format

BabySea includes an X-BabySea-Signature header on every delivery:
X-BabySea-Signature: t=1705315200,v1=5d41402abc4b2a76b9719d911017c592
PartDescription
tUnix timestamp (seconds) when the signature was created
v1HMAC-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:
TypeScript
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:
TypeScript
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:
TypeScript
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:
TypeScript
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:
TypeScript
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

Python
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.
FrameworkFix
ExpressUse express.text({ type: 'application/json' }) on the route
Next.js App RouterUse await req.text() instead of await req.json()
FastifyUse rawBody with the fastify-raw-body plugin
FlaskUse 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.