QRQR Code Agency
API reference

Webhooks

Subscribe to scan events and quota thresholds. Verify HMAC signatures. Inspect deliveries.

Webhooks

Subscribe a URL on your server; we POST a signed JSON payload every time a matching event fires.

Webhooks require Pro plan or higher.

For a step-by-step tutorial including signature verification code, see Listening for scan events.

Event types

EventWhen it fires
scan.createdEvery scan of a dynamic QR (after edge logging)
dynamic_qr.createdA new dynamic QR is created
dynamic_qr.updatedA dynamic QR is updated (destination, name, active state, variants)
quota.threshold_75The owner's monthly quota crosses 75%
quota.threshold_100The owner's monthly quota reaches 100%

You can subscribe to any subset; we only deliver matching events.

List subscriptions

GET /api/v1/webhooks/
Authorization: Bearer <jwt>

Response: 200 OK

{
 "results": [
 {
 "id": 7,
 "url": "https://your-server.example.com/qrstudio/webhook",
 "description": "Real-time CRM sync",
 "events": ["scan.created", "quota.threshold_75"],
 "status": "active",
 "consecutive_failures": 0,
 "last_delivery_at": "2026-05-08T12:14:01Z",
 "last_response_status": 200,
 "created_at": "2026-04-01T10:00:00Z"
 }
 ]
}

The signing secret is never returned on list. It is shown only once at creation and on rotation.

Create a subscription

POST /api/v1/webhooks/
Authorization: Bearer <jwt>
Content-Type: application/json

{
 "url": "https://your-server.example.com/qrstudio/webhook",
 "description": "Real-time CRM sync",
 "events": ["scan.created", "quota.threshold_75", "quota.threshold_100"]
}
FieldTypeRequiredDescription
urlURLyesHTTPS only. SSRF protected (no loopback, no private IPs). Max 2 000 chars.
descriptionstringUp to 200 chars.
eventsarray of enumyesOne or more of the event types listed above.

Response: 201 Created

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

danger: Save the secret now secret is shown only once. Store it in your secrets manager and use it to verify the X-QRStudio-Signature header on every delivery. Lose it, rotate.

403 Forbidden

Plan does not include webhooks.

{ "detail": "Webhooks require Pro plan or higher." }

400 Bad Request

{ "url": "refusing to register webhook: resolves to private/internal IP 10.0.0.5" }

Update

PATCH /api/v1/webhooks/{id}/
Authorization: Bearer <jwt>
Content-Type: application/json

{ "events": ["scan.created"] }

Editable fields: url, description, events, status (set to disabled to pause without deleting).

Delete

DELETE /api/v1/webhooks/{id}/
Authorization: Bearer <jwt>

Returns 204 No Content. The webhook is permanently removed; in-flight deliveries are not retried.

Rotate the secret

POST /api/v1/webhooks/{id}/rotate/
Authorization: Bearer <jwt>

Response: 200 OK

{
 "id": 7,
 "secret": "whsec_NewKzAbC123...",
 "rotated_at": "2026-05-08T14:30:00Z"
}

The old secret is invalidated immediately. Any delivery dispatched after the rotation is signed with the new secret. Update your secrets manager and redeploy before clients drop deliveries.

List deliveries

GET /api/v1/webhooks/{id}/deliveries/?page=1&page_size=25
Authorization: Bearer <jwt>

Response: 200 OK

{
 "count": 1247,
 "next": "https://api.qrstudio.agency/api/v1/webhooks/7/deliveries/?page=2",
 "previous": null,
 "results": [
 {
 "id": 891,
 "event": "scan.created",
 "response_status": 200,
 "response_body": "ok",
 "duration_ms": 87,
 "error": "",
 "succeeded": true,
 "created_at": "2026-05-08T14:01:23Z"
 },
 {
 "id": 892,
 "event": "scan.created",
 "response_status": null,
 "response_body": "",
 "duration_ms": 5012,
 "error": "Read timed out",
 "succeeded": false,
 "created_at": "2026-05-08T14:02:01Z"
 }
 ]
}

Deliveries are retained for 30 days.

Signature header

Every delivery POST carries:

X-QRStudio-Signature: t=1746727283,v1=4f7c2a1b8e9d...
ComponentMeaning
tUnix timestamp (seconds) of dispatch
v1HMAC_SHA256(secret, f"{t}.{request_body}") hex-encoded

Verify in constant time. Reject deliveries older than 5 minutes.

import hmac, hashlib

def verify(secret: str, body: bytes, header: str, max_age: int = 300) -> bool:
 parts = dict(p.split("=", 1) for p in header.split(","))
 t, v1 = parts["t"], parts["v1"]
 if abs(int(time.time()) - int(t)) > max_age:
 return False
 expected = hmac.new(
 secret.encode(),
 f"{t}.{body.decode()}".encode(),
 hashlib.sha256,
 ).hexdigest()
 return hmac.compare_digest(v1, expected)

Delivery shape

Every event posted is JSON with the same envelope:

{
 "type": "scan.created",
 "id": "evt_aBc12dEf",
 "created_at": "2026-05-08T14:01:23Z",
 "data": 
}
Envelope fieldDescription
typeThe event type (one of the values in the table above)
idUnique event id (evt_...). Use to dedupe on retries.
created_atISO-8601 UTC timestamp when the event was generated
dataEvent-specific payload (see below)

scan.created data

{
 "short_id": "aBc12dEf",
 "name": "Spring 2026 menu",
 "owner_id": 42,
 "destination_url": "https://example.com/menus/spring-2026",
 "scanned_at": "2026-05-08T14: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 data

{
 "short_id": "aBc12dEf",
 "name": "Spring 2026 menu",
 "destination_url": "https://example.com/menus/spring-2026",
 "owner_id": 42
}

dynamic_qr.updated data

{
 "short_id": "aBc12dEf",
 "changes": {
 "destination_url": {
 "old": "https://example.com/menus/spring-2026",
 "new": "https://example.com/menus/summer-2026"
 }
 },
 "owner_id": 42
}

quota.threshold_75 / quota.threshold_100 data

{
 "owner_id": 42,
 "plan": "starter",
 "monthly_quota": 500,
 "used_this_month": 375,
 "threshold": 75
}

Retry policy

Failed deliveries (non-2xx, timeout, connection error) retry 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. Re-enable from the dashboard or via PATCH.

Hard limits

LimitValue
Max active webhooks per workspace25
Max URL length2 000 chars
Max events per subscription5 (the full enum)
Max retry attempts5
Delivery timeout5 seconds
Delivery retention30 days

See also

On this page