Skip to content
Open beta — everything’s free right now, and your rate is locked when it ends.

Verify webhook signatures

Every delivery carries X-Tabla-Signature: HMAC-SHA256 over the timestamp and the raw body, keyed with the webhook's secret. Working verification code in Node, Python, and PHP — tested against real deliveries.

Updated August 2026

A webhook URL is a plain URL. Anything that learns it can POST to it, and without a check, your endpoint can't tell a forged request from a real delivery. The fix costs six lines: Tabla signs every delivery with a secret only you and Tabla hold, and you recompute the signature before trusting the body.

If your endpoint only feeds a dashboard, you can skip this. The moment it ships goods, sends email, or moves money — verify.

What arrives

Three headers ride on every delivery. From a real captured one:

Content-Type: application/json
X-Tabla-Delivery: dlv_k6d7adu95w49.1
X-Tabla-Timestamp: 1786316454
X-Tabla-Signature: sha256=eea59eaf4e84427522e92012eb450d26e89f903b77704bb78e4a5573f9437f5c
  • X-Tabla-Delivery — the delivery's id and the attempt number, joined by a dot. dlv_k6d7adu95w49.1 is attempt 1; a retry of the same delivery would say .2.
  • X-Tabla-Timestamp — when this attempt was signed, in Unix seconds.
  • X-Tabla-Signaturesha256= followed by hex-encoded HMAC-SHA256 over timestamp + "." + body, keyed with the webhook's signing secret. The timestamp inside the MAC is what makes a captured request expire: replaying it later means replaying its old timestamp, which your freshness check refuses.

Every attempt is signed at send time — a retry or a replay carries a fresh timestamp and a fresh signature, so your check works identically for all of them.

The secret is on the webhook's page, under Signing secret — visible to editors and owners, never to viewers. It's generated per webhook, so one receiver's compromise never spends another webhook's trust.

The two rules

  1. Verify the raw body — the exact bytes received, before any JSON parser touches them. Parse-then-restringify can reorder keys or change whitespace, and then a genuine delivery fails your check.
  2. Compare in constant time. A plain === leaks, byte by byte, how much of the signature an attacker got right. Every language below has a constant-time compare; use it.

Node

const { createHmac, timingSafeEqual } = require("node:crypto");

// rawBody: string or Buffer of the EXACT request body.
function verifyTablaSignature(rawBody, timestamp, signatureHeader, secret) {
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false; // older than 5 minutes
  const expected =
    "sha256=" +
    createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader);
  return a.length === b.length && timingSafeEqual(a, b);
}

In Express, mount the route with express.raw({ type: "application/json" }) so req.body is the untouched Buffer, then:

app.post("/hook", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyTablaSignature(
    req.body,
    req.get("x-tabla-timestamp"),
    req.get("x-tabla-signature"),
    process.env.TABLA_WEBHOOK_SECRET,
  );
  if (!ok) return res.status(401).end();
  const delivery = JSON.parse(req.body); // parse only after verifying
  res.status(200).end(); // acknowledge fast, work after
});

This exact function was run against real captured deliveries — thirteen of thirteen verified, and a single flipped byte in the body failed, as it should.

Python

import hmac, hashlib, time

def verify_tabla_signature(raw_body: bytes, timestamp: str, signature_header: str, secret: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    mac = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256)
    expected = "sha256=" + mac.hexdigest()
    return hmac.compare_digest(expected, signature_header)

In Flask, the raw bytes are request.get_data(); in Django, request.body.

PHP

function verify_tabla_signature(string $rawBody, string $timestamp, string $signatureHeader, string $secret): bool {
    if (abs(time() - (int)$timestamp) > 300) return false;
    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $signatureHeader);
}
// $rawBody = file_get_contents('php://input');

The Python and PHP functions were run against the same captured deliveries: every real signature verified, every tampered body failed.

Rotating the secret

If the secret leaks — it landed in a log, a laptop walked away — regenerate it: Signing secret → Regenerate… on the webhook's page, or over the API:

curl -X POST https://tabladb.com/api/webhooks/whk_wn6xcsbfk3by/secret \
  -H "Authorization: Bearer key_xxxx.your-secret"

One honest edge: the old secret stops verifying the moment the new one exists — there is no overlap window where both sign. Deliveries attempted after the rotation (including retries of earlier failures) are signed with the new secret, so update the secret on your receiver first, expect a brief window of 401s from your own verifier, then replay anything that failed during the swap.

The freshness window is yours

Tabla always sends the timestamp; you choose how stale is too stale. Five minutes (the 300 above) absorbs clock drift and retries comfortably. What the window buys you: a captured request can't be re-sent to your endpoint next week, because its timestamp — bound into the MAC — no longer passes.

For what's inside the body once you trust it, see webhook payloads.

Tabla is the database we build these on.

A no-code database with real Postgres underneath: every feature on every plan, a million records per database, and your whole database back out in one file, any day.

More guides