QRQR Code Agency
Guides

Listening for scan events

Receive a signed POST every time someone scans your dynamic QR. Subscribe, verify, handle.

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

Create a public endpoint on your server

Flask example
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.

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-09T14:00:00Z"
}

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.

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-09T14:01:23Z"
    },
    {
      "id": 892,
      "event": "scan.created",
      "response_status": 503,
      "duration_ms": 5012,
      "error": "Read timed out",
      "created_at": "2026-05-09T14:02:01Z"
    }
  ]
}

Event payloads

scan.created

{
  "type": "scan.created",
  "id": "evt_aBc12dEf",
  "created_at": "2026-05-09T14: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-09T14: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-09T13: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

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.

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.

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