Developer API and webhooks

Developer API reference

Create and manage QR codes through the API and respond to signed webhook events.

The customqrcode.io public API lets you create and manage dynamic QR codes, import codes in bulk, and subscribe to scan/create/update events over webhooks. Every endpoint lives under https://customqrcode.io/api/v1 and is authenticated with a Bearer API token. Create one from Dashboard → API & webhooks.

Free QR image API

Need a single QR code with no account, no API token, and no rate-limit paperwork? Hit this endpoint with a plain GET request and an image comes straight back through the same encode-and-render pipeline every other customqrcode.io code runs through, with no auth and nothing persisted.

GET/api/qr?data=&size=&format=&ec=

Render a static QR code image. No authentication required.

curl "https://customqrcode.io/api/qr?data=https://example.com&size=300" --output qr.png

Or drop it straight into an <img> tag. Every response is cacheable forever, so browsers and CDNs only fetch it once per URL:

<img src="https://customqrcode.io/api/qr?data=https://example.com" alt="QR code" />
ParamDefaultNotes
data(required)The text or URL to encode, 1–1500 characters, URL-encoded.
size300Pixel width/height, 64–1024. Out-of-range values are clamped, never rejected.
formatpngpng or svg.
ecMError-correction level: L, M, Q, or H (higher survives more damage but is denser).

Limited to 60 requests/minute per IP; a rate-limited request gets 429 with a Retry-After header. Responses carry Cache-Control: public, max-age=31536000, immutable and Access-Control-Allow-Origin: *, so it's safe to call directly from any site or app.

API reference

The full contract for every endpoint below, including request and response schemas, status codes, and exact error shapes, is published as an OpenAPI 3.1 document at https://customqrcode.io/api/openapi.json. Import that URL directly into Postman (Import → Link) or Insomnia (Import → From URL) to get every request pre-built, or point Swagger UI / Redoc at it to browse interactively.

curl https://customqrcode.io/api/v1/codes \
  -H "Authorization: Bearer qr_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json"
curl -X POST https://customqrcode.io/api/v1/codes \
  -H "Authorization: Bearer qr_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "dynamic", "typeId": "url", "inputs": { "url": "https://example.com" } }'
curl "https://customqrcode.io/api/qr?data=https://example.com&size=300" --output qr.png

Authentication

Every request to /api/v1/* must carry a Bearer token in the Authorization header. Tokens are scoped to your account (or workspace, if the token was minted while a workspace was active) and are shown in full exactly once at creation time. A token loses access only if it's revoked. There is no session-cookie auth on this surface: a signed-out request with a valid token works exactly like a signed-in one.

curl https://customqrcode.io/api/v1/me \
  -H "Authorization: Bearer qr_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
{
  "scope": "user",
  "ownerId": "b6b6f9d0-2b7b-4c9e-9c0a-6a2b8e9f5b41",
  "tokenPrefix": "qr_AbC12dEf",
  "name": "Ada Lovelace"
}

A missing or unknown token returns 401 invalid_token; a revoked token returns 401 revoked_token; a valid token whose plan doesn't include API access returns 403 not_entitled.

Codes

Dynamic codes are the persistent resource: create, read, update, list, and delete them. Static codes are stateless artifacts. A create call returns the encoded payload and a styled SVG, and nothing is saved.

POST/api/v1/codes

Create a dynamic or static code.

curl -X POST https://customqrcode.io/api/v1/codes \
  -H "Authorization: Bearer qr_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "dynamic",
    "typeId": "url",
    "inputs": { "url": "https://example.com/spring-sale" },
    "name": "Spring sale flyer"
  }'

Response (201):

{
  "id": "9c6a5e2d-2a34-4e0a-9d9a-2f0a6b1c7e88",
  "slug": "sprng4",
  "shortUrl": "https://qr.customqrcode.io/sprng4",
  "contentType": "url",
  "destination": "https://example.com/spring-sale",
  "status": "active",
  "createdAt": "2026-06-01T12:00:00.000Z",
  "updatedAt": "2026-06-01T12:00:00.000Z",
  "rules": { "activeFrom": null, "activeUntil": null, "paused": false, "scanCap": null, "capReached": false },
  "passwordProtected": false
}

Optional rules (activeFrom, activeUntil, paused, scanCap, from 1 to 1,000,000) and a password of 4–72 characters apply at create time. For native payload types, a static request (kind: "static") returns 200 { payload, svg } instead and persists nothing. URL-backed types require kind: "dynamic"so the QR encodes a tracked redirect rather than the destination.

GET/api/v1/codes?limit=&cursor=

List your codes, newest first.

curl "https://customqrcode.io/api/v1/codes?limit=50" \
  -H "Authorization: Bearer qr_xxx"
{
  "data": [
    {
      "id": "9c6a5e2d-2a34-4e0a-9d9a-2f0a6b1c7e88",
      "slug": "sprng4",
      "shortUrl": "https://qr.customqrcode.io/sprng4",
      "contentType": "url",
      "destination": "https://example.com/spring-sale",
      "status": "active",
      "createdAt": "2026-06-01T12:00:00.000Z",
      "updatedAt": "2026-06-01T12:00:00.000Z",
      "rules": { "activeFrom": null, "activeUntil": null, "paused": false, "scanCap": null, "capReached": false },
      "passwordProtected": false
    }
  ],
  "nextCursor": null
}

limit defaults to 50, max 200. Pass the returned nextCursor back as ?cursor= to page forward; a null cursor means you're on the last page.

GET/api/v1/codes/{id}

Fetch a single code by id.

curl https://customqrcode.io/api/v1/codes/9c6a5e2d-2a34-4e0a-9d9a-2f0a6b1c7e88 \
  -H "Authorization: Bearer qr_xxx"

Returns the same shape as the create response. An id that doesn't exist or belongs to someone else returns 404 not_found (never 403, so ownership is never leaked).

PATCH/api/v1/codes/{id}

Update a code's inputs, rules, or password.

curl -X PATCH https://customqrcode.io/api/v1/codes/9c6a5e2d-2a34-4e0a-9d9a-2f0a6b1c7e88 \
  -H "Authorization: Bearer qr_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "rules": { "paused": true } }'

Accepts any combination of inputs, rules, and password (at least one required); returns the updated code resource. Pass "password": null to remove password protection; non-null passwords must be 4–72 characters.

DELETE/api/v1/codes/{id}

Delete a dynamic code.

curl -X DELETE https://customqrcode.io/api/v1/codes/9c6a5e2d-2a34-4e0a-9d9a-2f0a6b1c7e88   -H "Authorization: Bearer qr_xxx"

Returns 204 with no body. The code is soft-deleted and its resolver record is tombstoned, so the short URL stops redirecting. A missing or out-of-scope id returns 404 not_found.

Bulk CSV import

Create up to 1,000 dynamic url codes in one call. The same importer backs Dashboard → Bulk import.

Template (header row required: name,url, order-insensitive, case-insensitive, BOM tolerated; name is optional):

name,url
Spring flyer,https://example.com/spring
Store window,https://example.com/store
POST/api/v1/codes/bulk

Upload a CSV (raw text/csv body, or multipart/form-data with a file field) and get a code back for every row.

curl -X POST https://customqrcode.io/api/v1/codes/bulk \
  -H "Authorization: Bearer qr_xxx" \
  -H "Content-Type: text/csv" \
  --data-binary @codes.csv
{
  "total": 2,
  "created": 2,
  "failed": 0,
  "rows": [
    {
      "row": 1, "name": "Spring flyer", "url": "https://example.com/spring", "status": "created",
      "codeId": "9c6a5e2d-2a34-4e0a-9d9a-2f0a6b1c7e88", "slug": "sprng4",
      "shortUrl": "https://qr.customqrcode.io/sprng4"
    },
    {
      "row": 2, "name": "Store window", "url": "https://example.com/store", "status": "created",
      "codeId": "0b8d6a41-8b7a-4a0d-9a3e-2c7e5f1a0d9b", "slug": "strwnd",
      "shortUrl": "https://qr.customqrcode.io/strwnd"
    }
  ]
}

Send Accept: text/csv to get the same row-by-row result back as a downloadable CSV (row,name,url,status,code_id,slug,short_url,error) instead of JSON. Rows fail independently, so one bad URL doesn't block the rest of the file. A file over 1,000 rows or 1 MB returns 413 before anything is created.

Webhooks

Subscribe a URL to one or more event types; we deliver a signed POST with a JSON body for every matching event, with automatic retries. Manage subscriptions from Dashboard → API & webhooks or the endpoints below.

POST/api/v1/webhooks

Register a webhook subscription.

curl -X POST https://customqrcode.io/api/v1/webhooks \
  -H "Authorization: Bearer qr_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/webhooks/inbound", "events": ["code.scanned", "code.created"] }'
{
  "id": "3e2a9c40-6b7e-4a10-9d1a-8f5c2b7e1a90",
  "url": "https://example.com/webhooks/inbound",
  "events": ["code.scanned", "code.created"],
  "secret": "whsec_2Jz9x1kQmR7pT4nW8yB0cAeVdFhLsUoI",
  "status": "active",
  "createdAt": "2026-06-01T12:00:00.000Z"
}

The target URL must be publicly reachable over https (no localhost or private-network addresses). The secret is your verification key. It is it's returned again on every GET, so store it wherever your receiver reads its config.

GET/api/v1/webhooks

List your webhook subscriptions.

curl https://customqrcode.io/api/v1/webhooks \
  -H "Authorization: Bearer qr_xxx"
DELETE/api/v1/webhooks/{id}

Remove a webhook subscription.

curl -X DELETE https://customqrcode.io/api/v1/webhooks/3e2a9c40-6b7e-4a10-9d1a-8f5c2b7e1a90 \
  -H "Authorization: Bearer qr_xxx"

Returns 204 with no body, or 404 if the id doesn't belong to you.

Event types

EventFires whendata payload
code.scannedA QR code visit was resolved or blocked by a rule or password.codeId, slug, occurredAt, outcome, blockReason, geo, device, os, browser
code.createdA dynamic code was created (dashboard, API, or bulk import).codeId, slug, shortUrl, contentType, destination, createdAt
code.updatedA dynamic code's destination, rules, or password changed.codeId, slug, changes, destination, updatedAt

Every delivery body has the shape { id, type, createdAt, data }, where id is the delivery's own id and data is the event-specific object listed above. Password-protected scans never fire a second event on unlock. The physical scan (with blockReason: "password") is the only event.

Verifying signatures

Every delivery carries three headers using the Standard Webhooks scheme: webhook-id, webhook-timestamp (unix seconds), and webhook-signature (v1,<base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}"))>). Recompute the HMAC over the exact signed string and compare it to the header in constant time; also reject timestamps too far from "now" to prevent replay of a captured payload.

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhookSignature(secret, headers, body, toleranceSec = 300) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatureHeader = headers["webhook-signature"];
  if (!id || !timestamp || !signatureHeader) return false;

  const timestampSec = Number(timestamp);
  if (!Number.isFinite(timestampSec)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestampSec) > toleranceSec) return false;

  // secret looks like "whsec_<base64>" - the key is everything after the prefix.
  const encodedKey = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
  const key = Buffer.from(encodedKey, "base64");
  const mac = createHmac("sha256", key).update(`${id}.${timestampSec}.${body}`).digest("base64");
  const expected = `v1,${mac}`;

  // webhook-signature may carry multiple space-separated "v1,<sig>" values.
  return signatureHeader.split(" ").some((candidate) => {
    const a = Buffer.from(candidate, "utf8");
    const b = Buffer.from(expected, "utf8");
    return a.length === b.length && timingSafeEqual(a, b);
  });
}

Verify against the raw request body before any JSON parsing or re-serialization. Re-stringifying a parsed body can change whitespace and break the signature check.

Retries

A delivery that doesn't get a 2xx response is retried after 30s, 2m, 10m, 1h, then 4h. If the sixth delivery attempt also fails, it's marked dead. Every retry keeps the same webhook-id, so receivers can safely deduplicate repeated attempts. A subscription that racks up 20 consecutive dead deliveries is automatically disabled; re-create it once your receiver is healthy again.

Rate limits

BucketLimitApplies to
api120 requests / minute / tokenEvery /api/v1 endpoint except bulk import
api_bulk5 requests / minute / tokenPOST /api/v1/codes/bulk

A rate-limited request gets 429 rate_limited with a Retry-After header (seconds) telling you when to try again.

Errors

Every error response is a JSON object of the same shape, and always carries an x-request-id header worth including if you reach out to support:

{
  "error": { "code": "not_entitled", "message": "The API isn't available on your current plan." }
}

Questions? Reach us at support@customqrcode.io.