KuikCodeDocs

Scan webhooks

Receive every scan of a code as a signed JSON POST, within a second of it happening.

A scan webhook is an https endpoint of yours that receives one scan.created event per scan. Set it per code under Advanced, Send scans to a webhook, or for many codes at once through the API. Scan webhooks are included with the Business plan.

Delivery

POST /hooks/kuikcode HTTP/1.1
Content-Type: application/json
User-Agent: KuikCode-Webhook/1.0
X-KuikCode-Event: scan.created
X-KuikCode-Delivery: 3f1c9a4e-0d5b-4c7e-9a2f-6b8d1e0c2a44
X-KuikCode-Timestamp: 1757251331
X-KuikCode-Signature: t=1757251331,v1=8c2e...

{
  "event": "scan.created",
  "timestamp": "2026-09-06T14:02:11.482Z",
  "code": { "id": "Ab3dE9xYz", "name": "Spring menu" },
  "scan": {
    "scanId": "01J9...",
    "country": "US",
    "region": "NJ",
    "city": "Montclair",
    "deviceType": "mobile",
    "os": "iOS",
    "browser": "Safari",
    "language": "en",
    "timezone": "America/New_York",
    "destination": "https://example.com/spring-menu",
    "matchedRule": null
  }
}

code.id is the code's nine-character short code. matchedRule names the Smart Rule that decided the scan, or is null. A test fired from the dashboard or the API carries an extra "test": true.

The first attempt goes out right after the redirect, from a queue, with a ten-second timeout. The receiving URL is read at send time, so editing a webhook's URL redirects its queued retries as well, and pausing or deleting the webhook stops them. Answer with any 2xx as soon as you have stored the event and do your processing afterwards. A code can feed up to five endpoints.

Retries

Delivery is at least once. When your endpoint cannot be reached, times out, or answers 408, 425, 429 or any 5xx, the same payload is sent again, up to five attempts in total:

AttemptWaits
230 seconds
32 minutes
410 minutes
51 hour

Each wait gets a little jitter, and a Retry-After header on a 429 or 503 is honored up to one hour. A 4xx other than those three is treated as your final answer and the delivery is marked failed without further attempts.

Because a retry can arrive after a slow success, deduplicate on X-KuikCode-Delivery (the same value on every attempt of one event) or on scan.scanId inside the payload. The body bytes are identical across attempts, so a signature computed once stays valid.

Delivery history

Under the webhook in the editor, Recent deliveries lists the last attempts with their status and the code your server answered.

The webhook card of a saved code: URL, Reveal secret and Rotate, the Delivering switch, Send test, and Recent deliveries with Delivered, Retrying and Failed rows
The webhook card of a saved code: URL, Reveal secret and Rotate, the Delivering switch, Send test, and Recent deliveries with Delivered, Retrying and Failed rows

Failed deliveries carry a Retry button that queues the same payload again under the same delivery id. The Delivering / Paused switch pauses an endpoint without disconnecting its codes: scans are still counted, nothing is sent while paused.

Through the API, GET /webhooks/{id}/deliveries pages through one endpoint's history, GET /webhooks/deliveries covers the whole workspace, and POST /webhooks/deliveries/{deliveryId}/retry re-queues one. PATCH /webhooks/{id} with "enabled": false pauses.

Verify the signature

Each endpoint has a secret. In the editor it is hidden until you press Reveal secret; through the API it is returned by create, by POST /webhooks/{id}/secret and by a rotation, to read-and-write keys only. Plain reads never carry it, since the secret lets its holder forge deliveries.

The signature header is t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256, under your secret, of the string <t>.<raw body>: the timestamp, a dot, then the body bytes exactly as received, before JSON parsing. Reject a delivery whose timestamp is more than five minutes old, so a captured request cannot be replayed later.

import { createHmac, timingSafeEqual } from "node:crypto"

const TOLERANCE_SECONDS = 300

// `secrets` is one secret, or [new, old] during the day after a rotation.
export function verify(rawBody, header, secrets, now = Math.floor(Date.now() / 1000)) {
  const t = Number(header?.match(/(?:^|,)t=(\d+)/)?.[1])
  if (!Number.isFinite(t) || Math.abs(now - t) > TOLERANCE_SECONDS) return false
  const received = [...(header.matchAll(/(?:^|,)v1=([0-9a-f]+)/g)].map((m) => Buffer.from(m[1]))
  return [secrets].flat().some((secret) => {
    const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"))
    return received.some((sig) => sig.length === expected.length && timingSafeEqual(sig, expected))
  })
}

Compute over the body bytes exactly as received. Comparing with timingSafeEqual rather than === keeps the check constant-time.

Rotate the secret

Rotate in the editor, or POST /webhooks/{id}/rotate-secret, mints a new secret and returns it. For the next 24 hours every delivery carries two v1 values, one per secret, so a receiver that still checks the old one keeps working while you roll the new one out. Verify against either; after the day is over only the new secret signs.

Endpoint rules

  • https only, on a public hostname. Private hosts, IP literals, localhost and URLs with credentials are refused when you save.
  • One endpoint per URL per workspace. Pointing another code at the same URL connects it to the existing endpoint and secret.
  • Send test in the editor, or POST /webhooks/{id}/test, fires a sample event and reports the status your server returned.

Managing endpoints through the API

GET /webhooks, POST /webhooks with the codes to connect, PATCH to change the URL, pause with enabled, or replace the exact set of connected codes, DELETE to remove. Secrets: POST /webhooks/{id}/secret reveals, POST /webhooks/{id}/rotate-secret rotates. History: GET /webhooks/{id}/deliveries and POST /webhooks/deliveries/{deliveryId}/retry. See the reference.

On this page