Skip to content

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

The four event types
TypeWhen it fires
link.createdA link was created, through the dashboard or the API. Useful for mirroring links into your own system, or for telling a team channel.
click.recordedSomebody 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.attributedA 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.trackedYour 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.

POST to your endpointjson
{
  "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 as id in 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:

X-QubeRoute-Signaturetext
t=1754983200,v1=6f3c9e0d4a1b8e2f7c5d0a9b3e8f1c6d2a7b4e9f0c3d8a5b1e6f2c9d4a7b0e3f

t 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.

Node.js — Expressjavascript
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))
})
Python — Flaskpython
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 "", 200

Answer 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].

← All documentation