Webhooks

Receive real-time events from GigaQR: QR created, updated, deleted, scanned, subscription changed.

Webhooks let you react to activity in your GigaQR workspace without polling. Register a URL in Dashboard → Developer → Webhooks, pick which events you care about, and we'll POST a signed JSON payload every time one of them happens.

Events

  • qr.created — a new QR was created (via dashboard, API, or embed widget).
  • qr.updated — destination, name, tags, or styling changed.
  • qr.deleted — soft-deleted.
  • qr.scanned — someone scanned a QR. One event per scan.
  • bulk.completed — a bulk job finished.
  • subscription.updated — plan changed.
  • page.submitted — a GigaPage form was submitted.

Payload shape

POST https://your-service.example.com/gigaqr
Content-Type: application/json
X-GigaQR-Event: qr.scanned
X-GigaQR-Delivery: 1c2b...
X-GigaQR-Signature: t=1719440000,v1=9f86...

{
  "event": "qr.scanned",
  "deliveryId": "1c2b...",
  "createdAt": "2026-04-21T12:34:56Z",
  "data": {
    "qrId": "7f8c...",
    "hash": "aB3x1q",
    "scannedAt": "2026-04-21T12:34:55Z",
    "country": "US",
    "city": "Brooklyn",
    "device": "mobile",
    "os": "iOS"
  }
}

Verifying signatures

Every delivery is signed with your webhook's secret (shown once at create time). The X-GigaQR-Signature header contains a unix timestamp and an HMAC-SHA256 of timestamp.rawBody keyed by your secret:

import crypto from 'node:crypto'

function verify(rawBody: string, header: string, secret: string) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=') as [string, string])
  )
  const t = parts.t
  const sig = parts.v1
  if (!t || !sig) return false

  // Reject replays older than 5 minutes.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(t + '.' + rawBody)
    .digest('hex')
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
}

Always verify over the raw request body, before any JSON parsing — a reformat will change the bytes and invalidate the signature.

Retries

If your endpoint doesn't return a 2xx within 10 seconds, we retry with exponential backoff for up to 24 hours: at 30s, 2m, 10m, 30m, 2h, 6h, and 24h. After the last failure, the delivery is marked failed and the webhook is paused if it has accumulated too many consecutive failures.

Replay a specific failed delivery from Dashboard → Developer → Webhooks → Deliveries, or call POST /api/v1/developer/webhooks/{id}/deliveries/{deliveryId}/replay from a Clerk-authenticated session.

Scoping

By default, a webhook receives events from the whole workspace. To scope a webhook to a single QR or a single GigaPage, set scopeQrId or scopePageId when you create the endpoint.

Delivery modes

  • realtime — one POST per event, fired immediately.
  • batched_5m — events are coalesced into a 5-minute batch to save the receiver processing overhead. The payload becomes an array under events. Useful for high-scan QRs where you only care about rollups.

Alternatives

If a webhook receiver is overkill, poll /api/v1/qr or /api/v1/qr/{id}/scans directly — the REST API is cursor-paginated and plays well with a simple cron.