Documentation
Webhooks
Being told when something happens, rather than polling for it. Signature verification with working code, the retry schedule, and the replayable delivery log.
A webhook is an HTTP request we make to an address you choose, when something happens. You add an endpoint on your app’s webhooks page, choose which events you want, and we POST JSON to it.
Endpoints are per app and per environment. A test click is never delivered to the endpoint your live billing system listens to, because the two are different rows — not the same row with a flag on the payload that somebody has to remember to check.
The events
| Type | When it fires |
|---|---|
| link.created | A link was created, through the dashboard or the API. Useful for mirroring links into your own system, or for telling a team channel. |
| click.recorded | Somebody tapped a link. This is the highest-volume event by a long way and it fires for every tap that is not a bot preview — expect it to be noisy on a live campaign. |
| install.attributed | A new install was matched to a link. Carries the confidence and the method, so your system can treat a certain match differently from an inferred one. It does NOT fire for an install we could not attribute. |
| event.tracked | Your app reported an event through the SDK — a purchase, a signup, anything you called track() with. Carries the attributed link when there is one, which is what makes it worth receiving rather than reading from your own analytics. |
What arrives
Every request has the same envelope. The data object is the only part that differs by event type.
{
"id": "8f14e45f-ea1c-4b2f-9a1d-2c3b4a5d6e7f",
"type": "install.attributed",
"version": "v1",
"createdAt": "2026-08-13T09:14:22.481Z",
"appId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
"environment": "live",
"data": {
"installId": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
"linkId": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70",
"alias": "spring-sale",
"method": "install_referrer",
"confidence": 1,
"platform": "android",
"country": "GB"
}
}And four headers:
X-QubeRoute-Signature— what you verify. See below.X-QubeRoute-Event— the type, so you can route without parsing the body first.X-QubeRoute-Event-Id— the same value asidin the body. Stable across retries, which is what lets you make your handler idempotent.X-QubeRoute-Delivery— the delivery number, which changes on each retry. Useful in your logs, useless for deduplication.
Verifying the signature
Do this before you do anything else with the body. Your endpoint is a URL on the public internet that accepts POSTs. Without verification, anybody who learns it can tell your system an install was attributed to whatever link they like — and on a product where an attribution can trigger an affiliate payment, that is a way of stealing money.
The header looks like this:
t=1754983200,v1=6f3c9e0d4a1b8e2f7c5d0a9b3e8f1c6d2a7b4e9f0c3d8a5b1e6f2c9d4a7b0e3ft is a Unix timestamp in seconds and v1 is an HMAC-SHA256, in lowercase hexadecimal, of the string `${t}.${rawBody}`, keyed with your endpoint’s signing secret.
The timestamp is inside the signed string, not merely beside it. That is the point of it: a timestamp you could edit would be no defence against replay, because an attacker would edit it. Because it is signed, changing it invalidates the signature — so a captured request stops working once your tolerance passes.
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const SECRET = process.env.QUBEROUTE_WEBHOOK_SECRET
const TOLERANCE_SECONDS = 300
// THE RAW BODY, NOT THE PARSED ONE. This is the mistake that costs an
// afternoon: express.json() gives you an object, and re-serialising it does
// not reproduce the bytes we signed — key order and spacing differ.
app.post('/hooks/quberoute', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8')
const header = req.get('X-QubeRoute-Signature') ?? ''
const parts = Object.fromEntries(
header.split(',').map((piece) => {
const index = piece.indexOf('=')
return [piece.slice(0, index).trim(), piece.slice(index + 1).trim()]
}),
)
const timestamp = Number(parts.t)
if (!Number.isFinite(timestamp)) return res.sendStatus(400)
// Both directions. "now - t > tolerance" accepts a timestamp from the
// future, and that is the version people write.
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) {
return res.sendStatus(400)
}
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.${raw}`)
.digest('hex')
// Constant time. A plain === stops at the first differing character, so how
// long it takes leaks how much of a guess was right.
const a = Buffer.from(expected, 'utf8')
const b = Buffer.from(parts.v1 ?? '', 'utf8')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(400)
}
const event = JSON.parse(raw)
// ACKNOWLEDGE FIRST, WORK AFTERWARDS. See the note on timeouts below.
res.sendStatus(200)
void handle(event).catch((error) => console.error(error))
})import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["QUBEROUTE_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
@app.post("/hooks/quberoute")
def quberoute_webhook():
raw = request.get_data() # bytes, not request.json
header = request.headers.get("X-QubeRoute-Signature", "")
parts = dict(
piece.strip().split("=", 1) for piece in header.split(",") if "=" in piece
)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return "", 400
if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
return "", 400
expected = hmac.new(
SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
return "", 400
event = json.loads(raw)
enqueue_for_processing(event) # acknowledge first, work afterwards
return "", 200Answer quickly
We wait 10 seconds for your server and then give up on that attempt. Any 2xx is success; anything else, including a redirect, is a failure and will be retried.
A redirect is deliberately not followed. If your endpoint redirects, you have almost certainly given us the wrong address, and following it would deliver your events somewhere you did not configure.
Acknowledge the request and do the work afterwards. A handler that takes a minute is a handler that times out and gets sent the same event eight more times.
Retries
A failed delivery is retried 8 times after the first attempt, at increasing intervals:
- Attempt 2: 10 seconds after the previous one
- Attempt 3: 30 seconds after the previous one
- Attempt 4: 2 minutes after the previous one
- Attempt 5: 10 minutes after the previous one
- Attempt 6: 30 minutes after the previous one
- Attempt 7: 2 hours after the previous one
- Attempt 8: 6 hours after the previous one
- Attempt 9: 1 day after the previous one
That spans a little over a day in total, which covers an overnight outage. After the last one the delivery is marked dead and nothing further is sent. Dead is its own state rather than a kind of failure, because “we are still trying” and “we gave up” need different things from you.
Every retry carries the same event id. Dedupe on X-QubeRoute-Event-Id and a duplicate delivery costs you nothing.
When we switch an endpoint off
If five deliveries in a row exhaust every retry — roughly five days of your endpoint answering nothing at all — we disable it and say so on the endpoint’s page. Nothing further is queued until you switch it back on.
We do that visibly rather than quietly going silent, because silence looks exactly like us losing your events. The deliveries we gave up on stay in the log and can be replayed once you are back.
The delivery log
Every attempt is recorded: the status, the code your server returned, how long it took, the error, and the exact body we sent. You can replay any of them.
A replay is a new delivery carrying the original body and the original event id — not a reset of the old one. The log goes on saying what happened the first time, because “it says delivered now” is not an answer to “why did my system miss it on Tuesday”.
Rotating the secret
You can generate a new signing secret at any time. The old one stops working immediately. There is no overlap window, which is less convenient to deploy against and is the right trade: a leaked secret that stays valid for an hour after you have done the one thing you know to do about it is not really revoked.
What we promise
The payloads above are v1, and within it:
- we will never remove or rename a field in a payload, or change its meaning, units or type
- we will never stop sending an event type your endpoint is subscribed to
- we will never change the signature scheme without sending both for at least six months
- we will always send the same event id for a retry, so you can make handling it idempotent
- we will always retry a failed delivery on the published schedule, and always show you what happened
We may add fields to a payload and add new event types. A handler that ignores what it does not recognise will keep working.
Next: the API reference, or deferred matching and how well it works.
Ask the documentation
It answers from these pages only, and links what it used. If the answer is not here it says so rather than guessing — then email [email protected].