Tutorial - Listening for scan events
Receive a signed POST every time someone scans your dynamic QR. Subscribe, verify, handle.
Tutorial: Listening for scan events
Subscribe a webhook to scan.created and we POST a JSON payload to
your URL every time a scanner hits one of your dynamic QRs. Verify the
HMAC signature, parse the body, do whatever your business needs (CRM
update, real-time dashboard, fraud alert).
Webhooks require Pro plan or higher.
What you will build
- A webhook subscription pointing at your server
- A handler that verifies the signature and processes the event
- A test trigger that fires a delivery without waiting for a real scan
Step 1: Create a public endpoint on your server
from flask import Flask, request, abort
import hmac, hashlib, os
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["QRSTUDIO_WEBHOOK_SECRET"]
@app.post("/qrstudio/webhook")
def webhook():
sig = request.headers.get("X-QRStudio-Signature", "")
body = request.get_data()
if not _verify(sig, body):
abort(401)
event = request.get_json()
if event["type"] == "scan.created":
print(f"Scan on {event['data']['short_id']} from {event['data']['country']}")
# ... your business logic here
return "", 200
def _verify(header: str, body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t", ""), parts.get("v1", "")
expected = hmac.new(
WEBHOOK_SECRET.encode(),
f"{t}.{body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(v1, expected)The endpoint must:
- Return
2xxwithin 5 seconds. Anything else counts as a delivery failure. - Be reachable on the public internet (loopback, private, link-local IPs are refused at subscription time).
- Be served over HTTPS.
Step 2: Register the subscription
curl -X POST https://api.qrstudio.agency/api/v1/webhooks/ \
-H "Authorization: Bearer <your-jwt>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.example.com/qrstudio/webhook",
"description": "Real-time CRM sync",
"events": ["scan.created", "quota.threshold_75", "quota.threshold_100"]
}'Response (the secret is shown once; store it in your secrets
manager):
{
"id": 7,
"url": "https://your-server.example.com/qrstudio/webhook",
"description": "Real-time CRM sync",
"events": ["scan.created", "quota.threshold_75", "quota.threshold_100"],
"secret": "whsec_aBc12dEf3GhI4jKlMnOpQrStUvWxYz",
"status": "active",
"consecutive_failures": 0,
"created_at": "2026-05-07T14:00:00Z"
}Step 3: Trigger a test delivery
The dashboard has a Send test event button. From the API:
curl -X POST https://api.qrstudio.agency/api/v1/webhooks/7/test/ \
-H "Authorization: Bearer <your-jwt>"We POST a synthetic scan.created event to your URL. Check your
server logs for the response.
Step 4: Inspect deliveries
Every dispatch is logged. Pull the recent history:
curl https://api.qrstudio.agency/api/v1/webhooks/7/deliveries/ \
-H "Authorization: Bearer <your-jwt>"{
"results": [
{
"id": 891,
"event": "scan.created",
"response_status": 200,
"duration_ms": 87,
"error": "",
"created_at": "2026-05-07T14:01:23Z"
},
{
"id": 892,
"event": "scan.created",
"response_status": 503,
"duration_ms": 5012,
"error": "Read timed out",
"created_at": "2026-05-07T14:02:01Z"
}
]
}Event payloads
scan.created
{
"type": "scan.created",
"id": "evt_aBc12dEf",
"created_at": "2026-05-07T14:01:23Z",
"data": {
"short_id": "aBc12dEf",
"name": "Spring 2026 menu",
"owner_id": 42,
"destination_url": "https://example.com/menus/spring-2026",
"scanned_at": "2026-05-07T14:01:23.451Z",
"country": "CA",
"city": "Montreal",
"device_family": "iPhone",
"os_family": "iOS",
"browser_family": "Safari",
"referer": "https://google.com/",
"user_agent": "Mozilla/5.0 ...",
"is_bot": false,
"variant_label": "(default)"
}
}dynamic_qr.created
Fires when a new dynamic QR is created (via API or via the
data_type: "dynamic" flow on /generate/).
{
"type": "dynamic_qr.created",
"id": "evt_xyz",
"created_at": "2026-05-07T13:00:00Z",
"data": {
"short_id": "aBc12dEf",
"name": "Spring 2026 menu",
"destination_url": "https://example.com/menus/spring-2026",
"owner_id": 42
}
}dynamic_qr.updated
Fires on any change to a dynamic QR (destination, name, is_active, variants).
quota.threshold_75 / quota.threshold_100
Fires when your monthly quota crosses the 75% or 100% mark. Useful for proactively buying credit packs or upgrading before customers hit a hard 429.
Retries
Failed deliveries (non-2xx, timeout, connection error) are retried with exponential backoff:
| Attempt | Delay after previous |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
After 5 consecutive failures the webhook moves to degraded. After 24
hours of continuous failures it is set to disabled and we stop
trying. You can re-enable from the dashboard or via PATCH.
Rotating the secret
If the secret leaks (committed to a public repo, exfiltrated by a breach), rotate it:
curl -X POST https://api.qrstudio.agency/api/v1/webhooks/7/rotate/ \
-H "Authorization: Bearer <your-jwt>"Response includes the new secret. The old one is invalidated immediately. Roll the new one into your secrets manager and redeploy.
Common issues
warning: I never receive deliveries Check the deliveries log. If
errormentions a connection refusal, your firewall is blocking us. Whitelist the IP ranges fromhttps://qrstudio.agency/docs/ip-ranges.json.
warning: Signature verification fails The most common cause is reading the body as text and re-serializing. The signature is computed over the raw bytes; verify before parsing JSON.
warning: Duplicate deliveries A
200from your server marks the delivery successful. If your server returns 200 but crashes before processing, the next retry will repeat. Implement idempotency by trackingevent.id(evt_...) in your database.
What is next
- Security model: the full signing scheme
- API reference: webhooks
- Dynamic campaign with analytics