Verifying requests
Check that a webhook request came from Tiquo, and handle each event once
Your endpoint URL can be called by anyone who knows it. Before acting on a request, check its tiquo-signature header to confirm it came from Tiquo and wasn't changed on the way.
How the signature works
Each request has a tiquo-signature header in this form:
tiquo-signature: t=1794185870,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtis the time Tiquo signed the request, in Unix seconds.v1is an HMAC-SHA256 of{t}.{raw request body}, keyed with your webhook's signing secret and written as lowercase hex.
To verify a request:
- Read the raw request body exactly as it arrived, before any JSON parsing. Parsing and re-serialising the JSON changes the bytes and the signature won't match.
- Split the header on
,and=to gettandv1. - Compute the HMAC-SHA256 of
t, a full stop, and the raw body, using your signing secret, as lowercase hex. - Compare your result with
v1using a constant-time comparison. - Reject the request if
tis more than 5 minutes from your server's current time. This stops an old request from being replayed against your endpoint.
Respond with 400 if verification fails. Tiquo treats any response other than 2xx as a failure and retries it, so a request that fails verification because your secret is out of date will be tried again once you fix it.
The signing secret starts with whsec_. Use the whole string, including the prefix, as the HMAC key.
Skip events you have already handled
Tiquo delivers each event at least once. The same event can reach you more than once: after a retry, when someone replays it from Settings > Webhooks, or if a delivery succeeded but Tiquo couldn't record the result. Every copy carries the same tiquo-event-id header and the same id in the body.
Store the IDs of events you have handled, and return 200 without doing the work again when an ID repeats. Keep the IDs for at least as long as Tiquo retries (about 44 hours), or for 30 days to cover replays.
Examples
import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.TIQUO_WEBHOOK_SECRET;
// express.raw keeps the body as bytes, so the signature can be checked.
app.post("/webhooks/tiquo", express.raw({ type: "application/json" }), async (req, res) => {
const body = req.body.toString("utf8");
const parts = Object.fromEntries(
(req.get("tiquo-signature") ?? "").split(",").map((part) => part.split("=")),
);
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${body}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
if (
!fresh ||
!parts.v1 ||
parts.v1.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
) {
return res.status(400).send("Invalid signature");
}
const eventId = req.get("tiquo-event-id");
if (await alreadyHandled(eventId)) return res.sendStatus(200);
const event = JSON.parse(body);
await handleEvent(event); // queue slow work: reply within 10 seconds
await markHandled(eventId);
res.sendStatus(200);
});import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["TIQUO_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/tiquo")
def tiquo_webhook():
body = request.get_data() # raw bytes, before JSON parsing
parts = dict(
part.split("=", 1)
for part in request.headers.get("tiquo-signature", "").split(",")
if "=" in part
)
timestamp = parts.get("t", "")
expected = hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
fresh = timestamp.isdigit() and abs(time.time() - int(timestamp)) < 300
if not fresh or not hmac.compare_digest(parts.get("v1", ""), expected):
return "Invalid signature", 400
event_id = request.headers["tiquo-event-id"]
if already_handled(event_id):
return "", 200
event = json.loads(body)
handle_event(event) # queue slow work: reply within 10 seconds
mark_handled(event_id)
return "", 200alreadyHandled, handleEvent and markHandled stand for your own code, for example a table of processed event IDs with a unique constraint.
Custom headers
You can also add your own static headers to a webhook, such as an Authorization header with a token your endpoint checks. Custom headers are a useful extra check, but always verify the signature as well: only the signature proves the body hasn't been changed.