Verify the signature
How Vipter signs each webhook, tested code in Node.js, PHP and Python to check the signature, how to rotate the secret without losing events and the mistakes that most often make verification fail.
Anyone who finds out your endpoint URL can send a POST to it. The signature proves that the event came from Vipter and that the body was not changed along the way. Verify the signature on every request, before reading the JSON.
Before you start
- The endpoint's secret, which starts with
whsec_. It is shown only once, right after you create the endpoint or rotate the secret. See Receive events in your system. - Access to the raw request body in your framework, before any conversion to JSON.
How the signature is computed
On each delivery attempt, Vipter:
- Takes the current time in Unix seconds:
t. - Builds the text
<t>.<body>: the value oft, a dot and the body exactly as it is sent. - Computes the HMAC SHA-256 of that text, using the endpoint's secret as the key, and writes the result in lowercase hexadecimal: 64 characters.
- Sends the header
Vipter-Signature: t=<t>,v1=<hmac>.
The HMAC key is the whole secret, as text, including the whsec_ prefix. Don't remove the prefix or decode the secret.
During a secret rotation, the header carries one v1= for each valid secret, the current one first:
Vipter-Signature: t=1790604192,v1=<hmac with the new secret>,v1=<hmac with the previous secret>To check it, recompute the HMAC with your secret and compare it with each v1=. One match is enough. Then reject a t too far from your server's clock, so that an old captured request can't be replayed. The examples below accept up to 5 minutes, the same tolerance as the snippet the dashboard shows under Verification snippet (Node.js). Since t is recomputed on each attempt, retries don't run into this tolerance.
Verification code
The three examples were tested with signatures generated the same way Vipter generates them: a body with accented characters, one and two v1=, the previous secret during a rotation, a changed body, a reformatted body and a t 10 minutes late. Each one is a complete server that answers 200 for a valid signature and 400 for the others.
The function is the same as in the dashboard. It takes the body as text. The server uses only the standard library of Node.js 18 or newer.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyVipterSignature(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return header.split(',').filter((p) => p.startsWith('v1=')).some((p) => {
const given = Buffer.from(p.slice(3), 'hex');
return given.length === expected.length / 2 && timingSafeEqual(given, Buffer.from(expected, 'hex'));
});
}
createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const rawBody = Buffer.concat(chunks).toString('utf8');
if (!verifyVipterSignature(rawBody, req.headers['vipter-signature'] ?? '', process.env.VIPTER_WEBHOOK_SECRET)) {
res.writeHead(400).end('invalid signature');
return;
}
const event = JSON.parse(rawBody);
// Store event.id and skip the event if it was already processed.
res.writeHead(200).end(`ok ${event.type}`);
});
}).listen(Number(process.env.PORT ?? 8000));With Express, use express.raw({ type: 'application/json' }) on the webhook route and pass req.body.toString('utf8') to the function, as in the example in Receive events in your system. In a Next.js route handler, read the body with await request.text().
Test without waiting for an event
To test your code locally, generate a signature with openssl, the same way Vipter does, and send it with curl:
SECRET="$VIPTER_WEBHOOK_SECRET"
URL="http://localhost:8000/"
BODY='{"id":"evt_000000000000000000000000","object":"event","type":"customer.created","created":1790604190,"livemode":false,"api_version":"2026-09-01","data":{"object":{"object":"customer","id":"cust_test","email":"test@example.com","name":"Test Customer","test":true}}}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -sS -X POST "$URL" \
-H 'Content-Type: application/json' \
-H "Vipter-Signature: t=$T,v1=$SIG" \
-H 'Vipter-Event-Id: evt_000000000000000000000000' \
-H 'Vipter-Event-Type: customer.created' \
--data-binary "$BODY" -w ' HTTP %{http_code}\n'The three servers above answer ok customer.created HTTP 200. Once the endpoint is published, use Send test event in the dashboard for the end-to-end test.
Rotate the secret
The Rotate secret button generates a new secret and shows it only once. The previous secret remains valid for 24 hours from the rotation.
During those 24 hours, each delivery carries two v1=: one with the new secret and another with the previous one. Since the code above accepts any v1= that matches, your server keeps accepting events with the old secret and starts accepting them with the new one as soon as you change the environment variable. To rotate without losing events:
- Click Rotate secret and copy the new secret.
- Update the secret on your server and deploy, within 24 hours.
- Send a test event and check for the
succeededstatus under Recent deliveries.
After the 24 hours, only the new secret signs. Rotating again within that window invalidates the oldest secret right away: only the current one and the one immediately before it are valid at the same time. A resent delivery is signed at the moment it is sent, with the secrets valid at that moment.
Common problems
-
The signature never matches
The body was converted to JSON and back to text before verification. Any difference, such as spaces, key order or
éinstead ofé, changes the HMAC. Verify the raw body. In Express, a globalexpress.json()consumes the body before your route: register the webhook route before it or useexpress.rawon that route only. -
It matches in testing, fails with accented characters
The body was read or converted with another encoding. The HMAC is computed over the UTF-8 bytes of the body. In Node.js, join the
Bufferchunks and convert withutf8at the end, not chunk by chunk. -
It fails after a few minutes, or on every event on one server
The server's clock is behind or ahead, and
tfalls outside the 5-minute tolerance. Sync the clock with NTP. Raising the tolerance hides the problem and weakens the protection against replay. -
It fails with the right secret
The secret belongs to another endpoint, the
whsec_prefix was removed, or there is a space or line break at the end of the environment variable. Each endpoint has its own secret. The endpoint's card shows the end of the current secret so you can check it. -
It checks
createdinstead oftThe signature uses the
tfrom the header. The body'screatedis the time of the event and doesn't change on retries. -
It compares with
==An ordinary string comparison can leak, through the response time, how many characters match. Use
timingSafeEqualin Node.js,hash_equalsin PHP andhmac.compare_digestin Python.
What to do next
- See how to handle repeated and out-of-order events.
- Understand the retry schedule for when verification or the server fails.
Envelope format
The request Vipter sends to your endpoint, field by field, with the headers, the response deadline, how to handle repeated events and what Vipter guarantees about ordering.
Retries, disabling and resending
When Vipter retries a failed delivery, when it disables the endpoint on its own, what happens to events while it is off, and how to use the test event and the resend button in the dashboard.