> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useqrkit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive signed event notifications when things happen in your workspace

## Overview

Webhooks push events to your HTTPS endpoint as they happen. Endpoints are managed entirely via the API (`/v1/webhooks`); there is no dashboard UI.

| Constraint                  | Value                                         |
| --------------------------- | --------------------------------------------- |
| Max endpoints per workspace | 10 (`api` source) + 50 per integration source |
| Plan required               | Paid (Starter or above)                       |
| Timeout per delivery        | 10 seconds                                    |

Endpoints carry a `source` (`api` by default; integration platforms like Zapier,
Make and n8n set their own when they register endpoints on your behalf). Caps are
counted per source, so integration-managed endpoints never crowd out your own.

Sign each endpoint's secret into your server and verify every delivery before processing. Respond with a `2xx` status within 10 seconds; run any heavy processing asynchronously after sending the response.

## Event types

| Type              | Fires when                                                                       |
| ----------------- | -------------------------------------------------------------------------------- |
| `qr.created`      | A QR code is created via API or dashboard                                        |
| `qr.updated`      | A QR code's name, target, design, tags, or active state changes                  |
| `qr.deleted`      | A QR code is deleted                                                             |
| `qr.scanned`      | A dynamic QR code is scanned                                                     |
| `batch.completed` | All items in a batch job have finished processing                                |
| `batch.failed`    | A batch job failed globally (not item-level errors)                              |
| `ping`            | Sent by the test endpoint (`POST /v1/webhooks/{id}/test`) to verify connectivity |

### Event envelope

Every delivery sends a JSON body with this shape:

```json theme={null}
{
  "id": "evt_abc123",
  "object": "event",
  "type": "qr.created",
  "created_at": "2026-06-11T12:00:00.000Z",
  "data": { }
}
```

### Scan event privacy

Scan events (`qr.scanned`) never include IP addresses or precise location. The payload carries only coarse, aggregate-grade fields: `qr_code_id`, `scanned_at`, `country`, `city`, `device`, `os`, `browser`, `referrer`, `language`.

Example `qr.scanned` data object:

```json theme={null}
{
  "qr_code_id": "qr_abc123",
  "scanned_at": "2026-06-11T12:00:00.000Z",
  "country": "US",
  "city": "New York",
  "device": "mobile",
  "os": "iOS",
  "browser": "Safari",
  "referrer": "https://example.com",
  "language": "en-US"
}
```

## Verifying signatures

Every delivery includes a `QRKit-Signature` header:

```
QRKit-Signature: t=1718099696,v1=a3b2c1…
```

The `v1` value is `HMAC-SHA256(secret, "{t}.{raw_request_body}")` computed over the **raw** (unparsed) request body. Always verify against the raw bytes before JSON-parsing.

**Reject deliveries where `t` differs from your server clock by more than 5 minutes** to prevent replay attacks. Use a constant-time comparison to avoid timing side-channels.

During the 24-hour window after calling `rotate-secret`, deliveries carry **two** `v1=` signatures (new secret first, then old). Verifying against either one passes — this lets you roll your secret without any downtime.

Additional headers on every delivery:

| Header             | Description                            |
| ------------------ | -------------------------------------- |
| `QRKit-Event-Id`   | The event `id` (use for deduplication) |
| `QRKit-Event-Type` | The event `type` (e.g. `qr.created`)   |

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto')

  function verifyQrkitSignature(header, rawBody, secret, toleranceSec = 300) {
    const parts = header.split(',')
    const t = Number(parts.find(p => p.startsWith('t='))?.slice(2))
    if (!Number.isFinite(t)) return false
    if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false
    const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
    return parts.filter(p => p.startsWith('v1=')).some(p => {
      const got = Buffer.from(p.slice(3))
      const exp = Buffer.from(expected)
      return got.length === exp.length && crypto.timingSafeEqual(got, exp)
    })
  }
  ```

  ```python Python theme={null}
  import hmac, hashlib, time

  def verify_qrkit_signature(header, raw_body, secret, tolerance_sec=300):
      parts = header.split(",")
      t = next((p[2:] for p in parts if p.startswith("t=")), None)
      if t is None or not t.isdigit():
          return False
      if abs(int(time.time()) - int(t)) > tolerance_sec:
          return False
      expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
      return any(hmac.compare_digest(p[3:], expected) for p in parts if p.startswith("v1="))
  ```
</CodeGroup>

## Delivery & retries

A delivery succeeds when your endpoint returns any `2xx` status within 10 seconds. Failed deliveries (non-2xx, timeout, or connection error) are retried on this schedule:

| Attempt | Delay after previous attempt |
| ------- | ---------------------------- |
| 1       | Immediate                    |
| 2       | 1 minute                     |
| 3       | 5 minutes                    |
| 4       | 30 minutes                   |
| 5       | 2 hours                      |
| 6       | 6 hours                      |
| 7       | 24 hours                     |

After 7 failed attempts (\~33 hours total) the delivery is marked `failed` with no further retries.

**Delivery semantics:**

* **At-least-once** — the same event may be delivered more than once. Deduplicate by the event `id` field (also in the `QRKit-Event-Id` header).
* **Ordering not guaranteed** — process events idempotently. If you need authoritative object state, re-fetch via the REST API or the events archive.

**Auto-disable:** An endpoint with sustained failures is automatically disabled and the workspace owner is emailed. Re-enable it once your server is healthy:

```bash theme={null}
curl -X PATCH "https://api.useqrkit.com/v1/webhooks/{id}" \
  -H "Authorization: Bearer qr_live_..." \
  -H "Content-Type: application/json" \
  -d '{"status": "enabled"}'
```

**Permanent unsubscribe:** Respond `410 Gone` to any delivery to immediately disable the endpoint with no further retries.

## Secret rotation

Rotate your endpoint secret without downtime:

```bash theme={null}
curl -X POST "https://api.useqrkit.com/v1/webhooks/{id}/rotate-secret" \
  -H "Authorization: Bearer qr_live_..."
```

The response returns the new secret **once** — store it immediately. The old secret continues to sign deliveries for 24 hours, so you can update your server before the old key expires.

## Testing

Send a `ping` event through the real delivery pipeline to verify your endpoint is reachable:

```bash theme={null}
curl -X POST "https://api.useqrkit.com/v1/webhooks/{id}/test" \
  -H "Authorization: Bearer qr_live_..."
```

The response includes the full delivery result (status code, response body, latency). Inspect past delivery attempts for any endpoint:

```bash theme={null}
curl "https://api.useqrkit.com/v1/webhooks/{id}/deliveries" \
  -H "Authorization: Bearer qr_live_..."
```

## Event archive

QRKit stores all events for 30 days regardless of whether any webhook endpoints are registered. Use the archive to recover events your endpoint missed.

```bash theme={null}
# List recent events, optionally filtered by type
curl "https://api.useqrkit.com/v1/events?type=qr.scanned" \
  -H "Authorization: Bearer qr_live_..."

# Fetch a single event by ID
curl "https://api.useqrkit.com/v1/events/evt_abc123" \
  -H "Authorization: Bearer qr_live_..."
```

<Note>
  The archive only lists events — it does not re-trigger deliveries. To replay an event to your endpoint, re-fetch the object via the REST API and process it in your application.
</Note>
