API Access
Webhooks
Every time one of your dynamic QR codes is scanned, we can POST the details to a URL you control — available to Advanced and Business accounts. No polling, no API key, and the event arrives within seconds of the scan.
Setting one up
Add your endpoint under Webhooks on My Profile. Two rules apply to the URL:
- It must use
https. The payload describes real scans of your codes, so it doesn't travel in clear text. - It must be reachable from the public internet. An address on your own network —
localhost,10.x,192.168.x— is rejected, both when you save it and again on every delivery.
You'll be shown a signing secret once, when the webhook is created. Copy it then; we only ever show its last four characters afterwards. If you lose it, delete the webhook and add it again to get a new one.
Webhooks apply to every dynamic code on your account, including ones you created before adding the webhook. There's nothing to configure per code.
What arrives
A POST with a JSON body, carrying the same fields your scan analytics record:
{
"event": "scan",
"code": "QyjipG5h",
"destination_url": "https://example.com/spring-menu",
"scanned_at": "2026-08-18T21:04:11Z",
"country": "United States",
"region": "Nevada",
"city": "Las Vegas",
"device_type": "mobile",
"os": "iOS",
"browser": "Safari",
"routed_by": null,
"visitor_hash": "9f2c1a77b4e30d58"
}
Three of those are worth explaining:
routed_byis the index of the smart-routing rule that chose the destination, ornullwhen the code's default destination was used.visitor_hashis a rotating, one-way value used to tell repeat scans apart within a single day. It is not an identifier for a person, and it deliberately cannot be reversed into an IP address — we never store one.- There is no raw
User-Agent. We don't keep one, for the same reason.
Verifying the signature
Every delivery carries an X-1337-Signature header:
X-1337-Signature: t=1787094794,v1=5f2b...c91d
t is the Unix timestamp of the delivery, and v1 is an HMAC-SHA256 over the string <t>.<raw request body>, keyed with your signing secret.
Verify against the raw body bytes, before any JSON parsing. Re-serialising the parsed object will change the bytes and the signature will not match.
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp, received = parts["t"], parts["v1"]
# Reject anything old, or a captured delivery can be replayed forever.
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, received)
The timestamp is inside the signature precisely so that a delivery someone captured cannot be replayed at you later — rejecting old timestamps is what turns that property into protection, so don't skip it. Use a constant-time comparison (hmac.compare_digest above) rather than ==.
What we expect back
Any 2xx. We don't read your response body, and we don't follow redirects — a 301 or 302 is recorded as a failure, so point the webhook at its final URL.
Reply quickly and do the work afterwards. We give up after 5 seconds, and a slow endpoint looks exactly like a broken one.
Deliveries are at-least-once. A failed delivery is retried twice, so your handler should tolerate seeing the same scan more than once — the code and scanned_at pair is a reasonable key to deduplicate on.
When your endpoint goes down
Failures are shown on the webhook in My Profile, with the reason for the most recent one. After 20 consecutive failures the webhook is disabled automatically and stops being attempted.
Consecutive is the operative word: a single success resets the count, so an endpoint with an occasional bad hour is never disabled for it. Re-enable a disabled webhook from the same panel once you've fixed the endpoint — the counter clears when you do.
Events are not queued while a webhook is disabled or failing. A scan that couldn't be delivered is gone from the webhook's point of view. Your scan analytics still record it — the analytics view on a dynamic code is always the complete record, and it is what to reconcile against if you suspect you missed something.
Limits and scope
- Up to 5 webhooks per account. Each one receives every event.
scanis the only event type today.- Codes in a business shared library are owned by the team rather than by a person, and don't currently fire webhooks — this covers the dynamic codes on your own account.
- The endpoint URL can't be edited. To point somewhere else, delete the webhook and add a new one — which also issues a new signing secret, so the change is visible to whoever is receiving.
Related
- API getting started — keys, authentication and rate limits.
- API reference — every route.
- Dynamic QR codes — creating codes, routing rules and scan analytics.
- Privacy and GDPR — exactly what a scan event contains, and what it deliberately doesn't.