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. If you use the BabySea Node.js SDK, signature verification is handled for you:
TypeScript
import { verifyWebhook } from 'babysea-sdk/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'
}

Signature format

BabySea includes a X-BabySea-Signature header on every delivery:
Terminal
X-BabySea-Signature: t=...,v1=...
PartDescription
tUnix timestamp (seconds) when the signature was created
v1HMAC-SHA256 hex digest of {t}.{rawBody} using your webhook secret

Manual verification steps

All steps below require the raw request body as a string. If you’re using Express, configure your route with express.text({ type: 'application/json' }) instead of express.json(). The default JSON body parser re-serializes the payload, which changes whitespace and key order, breaking the signature.

1. Check timestamp freshness first

Reject deliveries where t is more than 5 minutes old to prevent replay attacks. 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=...,v1=..."
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
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 too old");
}

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 - not a re-serialized JSON object. Parsing and re-encoding JSON may change whitespace or key order, which will invalidate 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. Use a constant-time comparison to prevent timing attacks:
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");
}

Convenience headers

In addition to X-BabySea-Signature, every delivery includes these headers that let you route or filter events without parsing the JSON body:
HeaderValue
X-BabySea-EventEvent type, e.g. generation.completed
X-BabySea-Delivery-IdUUID matching webhook_delivery_id in the payload (use as an idempotency key)
X-BabySea-TimestampISO 8601 timestamp matching webhook_timestamp in the payload
TypeScript
// Route without JSON.parse
const event = request.headers["x-babysea-event"];
const deliveryId = request.headers["x-babysea-delivery-id"];

Your webhook secret

Your secret is displayed once when you register a webhook endpoint in your BabySea workspace. Store it as an environment variable, never commit it to source code. If you suspect your secret has been compromised, you can rotate the secret. BabySea will begin signing all new deliveries with the new secret immediately.

Troubleshooting

1. Signature mismatch (Invalid signature)

The most common cause is body parsing. If your framework parses the JSON body before your handler runs, you’re verifying against a re-serialized string instead of the original bytes BabySea signed. Fix: Configure your route to receive the raw body as a string:
  • Express: express.text({ type: 'application/json' }) on the route.
  • Next.js App Router: await req.text() instead of await req.json().
  • Fastify: Use rawBody with the fastify-raw-body plugin.

2. Timestamp too old

Your server’s clock is more than 5 minutes out of sync with UTC, or the webhook was delayed in transit. Fix: Ensure your server uses NTP for time sync. If you need more tolerance, pass a custom toleranceSeconds value to verifyWebhook() (not recommended beyond 10 minutes).

3. 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.