Verify signatures
Confirm a webhook really came from Harepost by checking its HMAC signature.
When you send with "sign": true, Harepost signs the delivery so your endpoint can confirm it genuinely came from Harepost and was not altered in transit. Your endpoint recomputes the same signature using your account’s signing secret and compares.
The signature header
A signed delivery carries an X-Harepost-Signature header:
X-Harepost-Signature: t=1749574968,v1=3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b
It has two parts, comma-separated:
tis the Unix timestamp (seconds) when the signature was created.v1is the HMAC-SHA256 signature, hex-encoded.
How the signature is computed
Harepost builds the signed value by joining the timestamp and the raw request body with a period, then computing HMAC-SHA256 over it with your signing secret:
signed_value = "{t}.{body}"
signature = HMAC_SHA256(signing_secret, signed_value)
The body is the exact bytes delivered to your endpoint. To verify, read the raw request body before any JSON parsing, since re-serializing can change the bytes and break the comparison.
Your signing secret
Each account has one signing secret, prefixed whsec_. It is returned when you sign up, and you can retrieve it at any time:
curl https://api.harepost.com/v1/signing-secret \
-H "Authorization: Bearer hp_live_YOUR_KEY"
{ "signing_secret": "whsec_..." }
Unlike your API key, the signing secret is a shared secret: both you and Harepost hold it to compute the same signature. Keep it on your server.
Verifying a webhook
Read the raw body, recompute the HMAC, and compare in constant time. A Node.js example:
import crypto from "node:crypto";
function verifyHarepost(rawBody, signatureHeader, signingSecret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("=")),
);
const timestamp = parts.t;
const received = parts.v1;
if (!timestamp || !received) return false;
const expected = crypto
.createHmac("sha256", signingSecret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(received);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
Pass the raw request body as a string, the full X-Harepost-Signature value, and your signing secret. Use a constant-time comparison like timingSafeEqual rather than ===, so the check does not leak timing information.
Rejecting old requests
The timestamp lets you reject stale or replayed deliveries. After verifying the signature, check that t is recent, for example within five minutes of now, and reject it otherwise:
const ageSeconds = Math.floor(Date.now() / 1000) - Number(timestamp);
if (ageSeconds > 300) return false;
Notes
- Signing is per request. A delivery is only signed when you send it with
"sign": true. - The signature covers the body, so an empty body is signed as an empty string.
- See the send reference for where
signfits in the request.