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
| Event | When it fires |
|---|---|
scan.created | Every scan of a dynamic QR (after edge logging) |
dynamic_qr.created | A new dynamic QR is created |
dynamic_qr.updated | A dynamic QR is updated (destination, name, active state, variants) |
quota.threshold_75 | The owner's monthly quota crosses 75% |
quota.threshold_100 | The 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"]
}| Field | Type | Required | Description |
|---|---|---|---|
url | URL | yes | HTTPS only. SSRF protected (no loopback, no private IPs). Max 2 000 chars. |
description | string | Up to 200 chars. | |
events | array of enum | yes | One 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
secretis shown only once. Store it in your secrets manager and use it to verify theX-QRStudio-Signatureheader 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...| Component | Meaning |
|---|---|
t | Unix timestamp (seconds) of dispatch |
v1 | HMAC_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 field | Description |
|---|---|
type | The event type (one of the values in the table above) |
id | Unique event id (evt_...). Use to dedupe on retries. |
created_at | ISO-8601 UTC timestamp when the event was generated |
data | Event-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:
| 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. Re-enable
from the dashboard or via PATCH.
Hard limits
| Limit | Value |
|---|---|
| Max active webhooks per workspace | 25 |
| Max URL length | 2 000 chars |
| Max events per subscription | 5 (the full enum) |
| Max retry attempts | 5 |
| Delivery timeout | 5 seconds |
| Delivery retention | 30 days |