Verifying the webhook signature
How to use the Tiklify-Signature header to check that a request came from Tiklify and was not changed on the way. With Node.js and PHP examples.
Updated Oct 16, 2026
Every webhook carries a Tiklify-Signature header. Check it before you process the event: it proves the request came from Tiklify unchanged.
Tiklify-Signature: t=1760607000,v1=0b1e5ab4c9d0f3b6c0f2d9b5c1a4e7f8d2c3b4a5968778695a4b3c2d1e0f9a8b
t is the signing time in seconds (Unix), v1 is an HMAC-SHA256 in hex. The key is the webhook's whole "Signing secret", including the whsec_ prefix. What gets signed is the value of t, a dot and the request body exactly as it arrived:
1760607000.{"schemaVersion":2,"eventId":"3ed902c6-ce55-42d9-84f6-39c5de0e7435","eventType":"submission.approved",...}
Steps
- Read the raw request body before parsing the JSON.
- Take
tandv1from the header. Reject a request with an oldt, for example over 5 minutes away from your clock. Retries pass too: we sign every attempt with the current time. - Compute the HMAC-SHA256 and compare it with
v1using a constant-time comparison. - Store the event and answer with a 2xx code straight away: we wait 10 seconds for your answer.
- Process it afterwards. Delivery order is not guaranteed:
campaign.endedand the lastcampaign.status_changedshare oneoccurredAtand can arrive in either order. Skip a knowneventIdand order byoccurredAtplus your own state.
Return a 2xx code only for a valid signature. Any other answer counts as a failed delivery.
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.TIKLIFY_WEBHOOK_SECRET; // the whole secret, including whsec_
const TOLERANCE_SECONDS = 300; // 5 minutes
const app = express();
// express.raw keeps the body as a Buffer, exactly as it arrived
app.post('/webhooks/tiklify', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('Tiklify-Signature') ?? '';
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=', 2)));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!/^\d+$/.test(parts.t ?? '') || age > TOLERANCE_SECONDS) {
return res.sendStatus(400);
}
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${parts.t}.`)
.update(req.body)
.digest();
const received = Buffer.from(parts.v1 ?? '', 'hex');
if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
enqueue(event); // your queue: process later, deduplicate by event.eventId
res.sendStatus(204);
});
PHP
<?php
$secret = getenv('TIKLIFY_WEBHOOK_SECRET'); // the whole secret, including whsec_
$tolerance = 300; // 5 minutes
$body = file_get_contents('php://input'); // the raw body, exactly as it arrived
$parts = [];
foreach (explode(',', $_SERVER['HTTP_TIKLIFY_SIGNATURE'] ?? '') as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, '');
$parts[$key] = $value;
}
$t = $parts['t'] ?? '';
if (!ctype_digit($t) || abs(time() - (int) $t) > $tolerance) {
http_response_code(400);
exit;
}
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
if (!hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
enqueue($event); // your queue: process later, deduplicate by $event['eventId']
http_response_code(204);
Still stuck? Write to us at hello@tiklify.com.
Related
- API keys and webhooksWhere to create an API key and a webhook, which events a webhook sends and why it switches itself off after failures.
- Webhook eventsWhen each of the ten events arrives, which fields the body carries and how to handle repeated deliveries.
- Public APIKey authentication, five endpoints for campaigns and videos, pagination, response fields, errors and the request limit.