Webhook verification

Every webhook delivery is signed with HMAC-SHA256. Verify the signature to confirm the request actually came from shipmail.

Signature headers

HeaderDescription
X-ShipMail-SignatureHex-encoded HMAC-SHA256 signature.
X-ShipMail-TimestampUnix timestamp in seconds when the delivery was signed.
X-ShipMail-Event-IdUnique event ID for deduplication.

Signed payload

The signature is computed over a payload string that combines the timestamp and the raw request body:

v1={timestamp}\n{raw_body}

The \n is a literal newline character. The signature is the hex-encoded HMAC-SHA256 of this string using your webhook secret as the key.

Node.js example

import { createHmac, timingSafeEqual } from "crypto"; function verifyWebhook(secret, signature, timestamp, body) { // Reject stale deliveries (older than 5 minutes) const age = Math.floor(Date.now() / 1000) - Number(timestamp); if (Math.abs(age) > 300) { return false; } const payload = `v1=${timestamp}\n${body}`; const expected = createHmac("sha256", secret) .update(payload) .digest("hex"); if (expected.length !== signature.length) { return false; } return timingSafeEqual( Buffer.from(expected, "hex"), Buffer.from(signature, "hex") ); }

Use timingSafeEqual to compare signatures. Standard string comparison is vulnerable to timing attacks.

Timestamp tolerance

Reject deliveries where the X-ShipMail-Timestamp is more than 5 minutes old. This guards against replay attacks.

Event envelope

The request body is a JSON object with a consistent envelope:

{ "event_id": "evt_abc123", "event_type": "message.received", "created_at": "2025-01-15T10:00:00.000Z", "data": { ... } }

Use event_id for deduplication. The data field contains the event-specific payload.

Secret rotation

When you rotate a webhook secret via POST /v1/webhooks/:id/rotate-secret, deliveries include an additional X-ShipMail-Signature-Previous header signed with the old secret. This header is present for 24 hours after rotation.

During the grace period, verify against both signatures: try the new secret first, then fall back to the previous signature. This lets you deploy the new secret without dropping any deliveries.