Webhooks

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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t is the time Tiquo signed the request, in Unix seconds.
  • v1 is an HMAC-SHA256 of {t}.{raw request body}, keyed with your webhook's signing secret and written as lowercase hex.

To verify a request:

  1. 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.
  2. Split the header on , and = to get t and v1.
  3. Compute the HMAC-SHA256 of t, a full stop, and the raw body, using your signing secret, as lowercase hex.
  4. Compare your result with v1 using a constant-time comparison.
  5. Reject the request if t is 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 "", 200

alreadyHandled, 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.

Sur cette page