Webhook verification
Every webhook delivery is signed with HMAC-SHA256. Verify the signature to confirm the request actually came from shipmail.
Signature headers
| Header | Description |
|---|---|
| X-ShipMail-Signature | Hex-encoded HMAC-SHA256 signature. |
| X-ShipMail-Timestamp | Unix timestamp in seconds when the delivery was signed. |
| X-ShipMail-Event-Id | Unique 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.