Verify webhook signatures
Tidewire signs every delivery, so your endpoint can check that it really came from Tidewire and wasn’t changed on the way. Verify the signature before you trust the payload: the SDKs do it in one call, and doing it yourself takes about twenty lines.
How signing works
Every delivery carries a Tidewire-Signature header with a timestamp and a signature:
Tidewire-Signature: t=1727354000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
| Part | What it is |
|---|---|
t | When Tidewire signed the delivery, as a Unix timestamp in seconds. |
v1 | The signature: an HMAC-SHA256, hex-encoded. During a secret rotation there are two. |
To make the signature, Tidewire joins the timestamp and the raw request body with a period, and signs the result with your endpoint secret:
signed_payload = timestamp + "." + raw_body
v1 = hex(HMAC_SHA256(endpoint_secret, signed_payload))
Your endpoint secret starts with twsec_. It isn’t your API key: every endpoint has its own secret, which you’ll find under Endpoints in the dashboard. Keep it on your server.
Verify with the SDK
Install the SDK with npm install @tidewire/node or pip install tidewire. Then call verify() with the raw body, the header and your secret. It returns the event, or throws if the signature is wrong or too old.
import express from "express";
import Tidewire from "@tidewire/node";
const tidewire = new Tidewire(process.env.TIDEWIRE_API_KEY);
const app = express();
const raw = express.raw({ type: "application/json" });
// The raw body, on this route only: the signature covers the exact bytes.
app.post("/webhooks/tidewire", raw, (req, res) => {
let event;
try {
event = tidewire.webhooks.verify(
req.body,
req.get("Tidewire-Signature"),
process.env.TIDEWIRE_WEBHOOK_SECRET, // twsec_…
);
} catch (err) {
return res.status(400).send(`Webhook error: ${err.message}`);
}
res.sendStatus(200); // Acknowledge first, process later.
jobs.enqueue(event);
});
import os
from flask import Flask, request
from tidewire import Tidewire, SignatureError
tidewire = Tidewire(os.environ["TIDEWIRE_API_KEY"])
app = Flask(__name__)
@app.post("/webhooks/tidewire")
def tidewire_webhook():
try:
event = tidewire.webhooks.verify(
request.get_data(), # the raw bytes, not request.json
request.headers.get("Tidewire-Signature", ""),
os.environ["TIDEWIRE_WEBHOOK_SECRET"], # twsec_…
)
except SignatureError as err:
return f"Webhook error: {err}", 400
jobs.enqueue(event) # Acknowledge first, process later.
return "", 200
The Go and Ruby SDKs work the same way: webhooks.verify takes the raw body, the header and your secret.
Verify it yourself
No SDK for your language? Verifying takes five steps:
- Read the header. Split
Tidewire-Signatureon commas, then each part on=. Keeptand everyv1. - Rebuild the signed payload: the timestamp, a period, and the raw request body, byte for byte.
- Sign it with HMAC-SHA256, using your endpoint secret as the key, and hex-encode the result.
- Compare it with each
v1in constant time. If none match, reject the delivery with a400. - Check the timestamp. Reject it if it’s more than 300 seconds away from your clock.
import crypto from "node:crypto";
export function verifySignature(rawBody, header, secret, tolerance = 300) {
const parts = header.split(",").map((part) => part.split("="));
const timestamp = Number(parts.find(([key]) => key === "t")?.[1]);
const signatures = parts.filter(([key]) => key === "v1").map(([, v]) => v);
const expected = crypto.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const matches = signatures.some((signature) =>
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)));
if (!matches) throw new Error("No matching signature");
if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) {
throw new Error("Signature is too old");
}
return JSON.parse(rawBody);
}
Never compare signatures with ===. A normal string comparison stops at the first different character, and the time that takes leaks how much of a forged signature is right.
Replay protection
Every signature includes the moment it was made, which makes a captured request useless later. Signatures older than five minutes are rejected, to stop replayed deliveries. The SDKs check this for you: verify() accepts a tolerance option in seconds (default 300), but keep the window as short as your setup allows.
Retries are signed again when they’re sent, so a retried delivery never arrives with an old timestamp. If fresh deliveries still fail the check, look at your server’s clock, or at a queue that holds requests before verifying them.
The time window stops old requests from being replayed, not duplicates. To handle the same delivery twice safely, store each Tidewire-Delivery-Id and skip the ones you’ve seen. See Retries and idempotency.
Rotate your secret
Rotate the secret on a schedule, or when someone with access leaves. Nothing goes down while you do:
- In the dashboard, open Endpoints, choose the endpoint, and click Roll secret.
- For the next 24 hours, two secrets are valid. Tidewire signs every delivery with both, so the header carries two
v1signatures, and servers with either secret accept it. - Deploy the new secret before the 24 hours are up. After that, only the new one signs.
If a secret has leaked, choose Expire now instead, and the old secret stops working immediately.
Common problems
The signature never matches
You’re almost certainly verifying a parsed body. Tidewire signs the raw bytes, and JSON that was parsed and serialized again never matches: whitespace and key order change. In Express, use express.raw({ type: "application/json" }) on the webhook route only, and keep express.json() for the rest of your app.
Valid deliveries are rejected as too old
Your server’s clock is probably off. The five-minute check uses your clock, so keep it in sync with NTP. On containers and serverless platforms, the host’s clock is usually right; on long-running VMs, it drifts.
It works in testing, but not in production
Check that you’re using the right endpoint’s secret. Every endpoint has its own, and test mode endpoints never share secrets with live ones. The endpoint that sent a delivery is in its Tidewire-Endpoint header.