Skip to content

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

  1. Read the raw request body before parsing the JSON.
  2. Take t and v1 from the header. Reject a request with an old t, for example over 5 minutes away from your clock. Retries pass too: we sign every attempt with the current time.
  3. Compute the HMAC-SHA256 and compare it with v1 using a constant-time comparison.
  4. Store the event and answer with a 2xx code straight away: we wait 10 seconds for your answer.
  5. Process it afterwards. Delivery order is not guaranteed: campaign.ended and the last campaign.status_changed share one occurredAt and can arrive in either order. Skip a known eventId and order by occurredAt plus 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.

Verifying the webhook signature — Tiklify Help