QRQR Code Agency
Guides

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 2xx within 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:

AttemptDelay after previous
1immediate
21 minute
35 minutes
430 minutes
52 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 error mentions a connection refusal, your firewall is blocking us. Whitelist the IP ranges from https://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 200 from your server marks the delivery successful. If your server returns 200 but crashes before processing, the next retry will repeat. Implement idempotency by tracking event.id (evt_...) in your database.

What is next

On this page