Webhooks
A webhook sends a POST request with a JSON body to your server within seconds of an event. Webhooks are part of the Pro and Business plans; each workspace can have up to three endpoints. The workspace owner manages them in the panel under Integrations → Webhooks.
Events
| Event | Sent when |
|---|---|
scan.created | a counted scan of a dynamic code is stored (test scans and scans over the plan quota are not sent) |
lead.created | a visitor sends a form on one of your pages |
ping | you press Send test event in the panel |
Request
Headers: Content-Type: application/json, Scanteo-Event, Scanteo-Event-Id (the same for every retry) and Scanteo-Signature. Example body of a scan:
{
"id": "01JD3Q9X8M4V2C7B5N6P1R0S9T",
"type": "scan.created",
"created_at": "2027-01-15T08:00:02Z",
"data": {
"code": { "id": 42, "name": "Spring poster", "short_code": "aB3xY9k" },
"scanned_at": "2027-01-15T08:00:01.250Z",
"unique": true,
"country": "PL",
"city": "Warsaw",
"device": "smartphone",
"os": "iOS",
"browser": "Mobile Safari",
"referrer_host": null,
"variant": null
}
}
A lead.created event carries page, form (id and kind), qr_code_id and fields with what the visitor typed. Treat it as personal data.
Verify the signature
Scanteo-Signature looks like t=1800000000,v1=5257a8…. v1 is the HMAC-SHA256 of {t}.{raw body} with your endpoint secret (shown once when you create the endpoint). Compute it over the raw body, compare in constant time and reject requests older than five minutes; that stops replayed requests.
// PHP
function scanteoSignatureIsValid(string $secret, string $header, string $body): bool
{
parse_str(str_replace(',', '&', $header), $parts);
$t = $parts['t'] ?? '';
if (! ctype_digit($t) || abs(time() - (int) $t) > 300) {
return false;
}
return hash_equals(hash_hmac('sha256', $t.'.'.$body, $secret), $parts['v1'] ?? '');
}
// Node.js (express.raw({ type: 'application/json' }) keeps the raw body)
import { createHmac, timingSafeEqual } from 'node:crypto';
export function scanteoSignatureIsValid(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const given = Buffer.from(parts.v1 ?? '', 'hex');
return given.length === 32 && timingSafeEqual(Buffer.from(expected, 'hex'), given);
}
Answer and retries
- Answer with any
2xxstatus within 10 seconds; do the heavy work later. - Redirects are not followed: use the final address. Only public
https://addresses are accepted. - Failed deliveries are retried after 1 and 5 minutes, then 30 minutes, 2, 6, 12 and 24 hours, twice: nine attempts over about three days.
- If the last attempt fails, the endpoint is turned off and the workspace owner gets an email. After fixing your server, turn it on again and resend failed events from the delivery list (kept for 30 days).
- The same event can arrive twice (for example after a timeout): use
Scanteo-Event-Idto ignore duplicates.