Skip to content
API key

Guide

Bulk creation and automation

How to create cards for a whole team. The API has no batch endpoint, so every approach sends one request per person. What differs is who does the translating and who prevents duplicates.

Three ways to create many cards

Ways to create many cards
ApproachYou sendDuplicate protectionBest for
A loop over POST /cardsQRBold’s field names, one person per request.Only an idempotency key, which covers retries for 24 hours.An integration you write and control.
A campaign endpointYour own JSON, unchanged, one person per request.Built in: one card per identifier, permanently.An HR or CRM export, or a vendor doing the work.
Spreadsheet importA file, uploaded by a person in the dashboard.Handled by the importer.A one-off, with no code. Not an API.

A paced loop over POST /cards

POST/cards

The pattern has four rules:

  1. One request at a time. Sequential is simplest and fast enough: at 100 requests a minute, 1,000 people take ten minutes.
  2. Stay under the limit. A key on a plan with REST access may send 120 requests a minute. Pause about 600 ms between requests and you will not meet a 429.
  3. One idempotency key per person, stored with that person, and sent again on every retry.
  4. Record every outcome. The API does not remember your batch. Your job has to know who succeeded, who failed and why.
bulk-create.mjs
// bulk-create.mjs — one POST /cards per person, paced under the rate limit.
import { randomUUID } from 'node:crypto';

const BASE_URL = 'https://api.qrbold.com/api/public/v1';
const API_KEY = process.env.QRBOLD_API_KEY;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// In a real job these come from your directory or CRM. Give each person a key
// that stays the same across retries of the job: store it with the person.
const people = [
  { ref: 'EMP-1001', idempotencyKey: randomUUID(), fields: { firstName: 'Ada', lastName: 'Lovelace' } },
  { ref: 'EMP-1002', idempotencyKey: randomUUID(), fields: { firstName: 'Charles', lastName: 'Babbage' } },
];

async function createCard(person, attempt = 1) {
  let res;
  try {
    res = await fetch(`${BASE_URL}/cards`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': person.idempotencyKey,
      },
      body: JSON.stringify({ fields: person.fields }),
      signal: AbortSignal.timeout(30_000),
    });
  } catch (networkError) {
    if (attempt >= 4) throw networkError;
    await sleep(1000 * 2 ** attempt);
    return createCard(person, attempt + 1); // same key: safe to repeat
  }

  if (res.status === 429) {
    // RateLimit-Reset is the number of seconds until the window resets.
    const wait = Number(res.headers.get('RateLimit-Reset') ?? 60);
    await sleep((wait + 1) * 1000);
    return createCard(person, attempt);
  }
  if (res.status >= 500 && attempt < 4) {
    await sleep(1000 * 2 ** attempt);
    return createCard(person, attempt + 1);
  }

  const body = await res.json().catch(() => null);
  if (!res.ok) return { ref: person.ref, ok: false, status: res.status, code: body?.error?.code, details: body?.error?.details };
  return { ref: person.ref, ok: true, id: body.id, url: body.url, paused: body.paused };
}

const results = [];
for (const person of people) {
  results.push(await createCard(person));
  await sleep(600); // about 100 requests a minute, under a 120-a-minute limit
}

console.table(results);
const failed = results.filter((r) => !r.ok);
if (failed.length) console.error(`${failed.length} of ${results.length} failed. Fix and re-run only those.`);

Before a large run, check the plan has room: a run that passes the plan’s card allowance starts answering 403 limit_exhausted_cards part-way through. The cards created before that point stay created.

From a CSV file

Read the file in your own code, turn each row into a fields object, and feed the rows to the loop above. Put list-shaped values into the right shape, for example an email column becomes emails: [{ label: "Work", value: row.email }].

Campaign endpoints

What is it?
An endpoint, set up once in the dashboard, that turns one record of your own JSON into one card.
Why would I use it?
So an HR or CRM system can post the record it already has, with no translation code, and never create two cards for one person.
What do I need first?
A campaign (a group in the dashboard’s Bulk area) with its API configured and switched on.
Which endpoint do I use?
POST /campaigns/:campaignId/cards, after testing with …/cards/test.
What result should I expect?
A card object, with externalId set to your identifier for the person.

POST /cards makes you speak QRBold’s vocabulary. A campaign endpoint reverses that: somebody configures the template, the field mapping and the short-link rule once, and after that your system posts what it already produces.

  1. Your record

    The JSON your HR system exports

  2. Campaign

    Applies the template and the mapping

  3. Identifier check

    Refuses a person it already has

  4. Card

    Live at its public url

Two payload modes

A campaign uses one mode, chosen when its API is activated. The endpoint accepts only that mode; it does not guess.

Campaign payload modes
mappednative
The body isYour own JSON, in your own shapeA card document in QRBold’s field names
Configured byA mapping built in the dashboardNothing: the template defines the shape
CoversThe fields the mapping namesEverything the template shows, including gallery, videos and team
Keys the campaign does not knowIgnoredIgnored with a warning, or refused with 422 if the campaign has strict keys on
Best forAn export you cannot changeAn integration you control

In native mode a body is a complete statement of the card. A section the body leaves out is a section the card does not show.

Set up a campaign

Setting up is done in the dashboard, by a person. The API cannot create or configure a campaign.

  1. In the dashboard, open Bulk and create or open a digital business card group.
  2. Open its API tab.
  3. Choose the template, then either map your JSON’s fields to card fields or choose native mode.
  4. Choose which field of your payload is the person’s identifier, such as an employee number.
  5. Activate the configuration and switch the API on.
  6. Optionally create a campaign-scoped key on the same tab, to hand to a vendor.

The tab shows the campaign’s id. You can also find it through the API:

curl "https://api.qrbold.com/api/public/v1/campaigns" \
  -H "Authorization: Bearer $QRBOLD_API_KEY"
200 response
{
  "object": "list",
  "data": [
    {
      "object": "campaign",
      "id": "cmf3k0b5q0004",
      "name": "New starters 2026",
      "status": "active",
      "apiEnabled": true,
      "templateId": "cmf3k1p7d0002",
      "createdAt": "2026-09-15T11:00:00.000Z"
    }
  ],
  "pagination": {
    "total": 1,
    "limit": 20,
    "offset": 0,
    "hasMore": false
  }
}

apiEnabled must be true and status must be active before the campaign accepts cards. To see the exact shape a campaign expects, with a sample you can post back, read its schema:

curl "https://api.qrbold.com/api/public/v1/campaigns/cmf3k0b5q0004/schema" \
  -H "Authorization: Bearer $QRBOLD_API_KEY"

The schema is worked out from the template each time, so it changes when the template does. stale: true means the template changed after the configuration was activated: requests still work, but read the schema again.

Dry-run a payload

POST/campaigns/:campaignId/cards/test

This is the closest thing QRBold has to a test mode. It runs a payload through the campaign exactly as the real endpoint would and creates nothing.

curl -X POST "https://api.qrbold.com/api/public/v1/campaigns/cmf3k0b5q0004/cards/test" \
  -H "Authorization: Bearer $QRBOLD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "employee": {
      "id": "EMP-1001",
      "name": {
        "first": "Ada",
        "last": "Lovelace"
      },
      "contact": {
        "email": "ada@example.com"
      }
    }
  }'
200 response
{
  "object": "dry_run",
  "ok": true,
  "externalId": "EMP-1001",
  "resolvedFields": {
    "firstName": "Ada",
    "lastName": "Lovelace",
    "emails.0.value": "ada@example.com"
  },
  "unmappedPaths": [],
  "shortCode": null,
  "warnings": [],
  "errors": []
}
  • The status is 200 whether or not the payload would be accepted. Read ok.
  • resolvedFields shows what the card would contain. unmappedPaths lists the parts of your payload the campaign ignored.
  • A duplicate identifier appears as a warning here, not an error, because a test payload may reuse an id. Mapping and required-field problems are errors.

Create cards in a campaign

POST/campaigns/:campaignId/cards

Post one record. Do not send templateId; the campaign owns it. This example is a mapped-mode body, in a made-up HR system’s own shape:

curl -X POST "https://api.qrbold.com/api/public/v1/campaigns/cmf3k0b5q0004/cards" \
  -H "Authorization: Bearer $QRBOLD_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "employee": {
      "id": "EMP-1001",
      "name": {
        "first": "Ada",
        "last": "Lovelace"
      },
      "contact": {
        "email": "ada@example.com",
        "mobile": "+44 20 7946 0958"
      }
    }
  }'

The response is 201 with a card object whose externalId is your identifier:

201 response
{
  "object": "card",
  "id": "cmf3k2a9x0001",
  "name": "Ada Lovelace",
  "shortCode": "k3Vq8ZtB",
  "status": "published",
  "url": "https://qrbold.com/c/k3Vq8ZtB",
  "vcardUrl": "https://qrbold.com/c/k3Vq8ZtB/vcard",
  "templateId": "cmf3k1p7d0002",
  "externalId": "EMP-1001",
  "paused": false,
  "fields": {
    "firstName": "Ada",
    "lastName": "Lovelace",
    "title": "Head of Engineering",
    "company": "Analytical Engines Ltd",
    "photoUrl": "https://example.com/photos/ada.jpg",
    "companyLogoUrl": "https://example.com/brand/logo.png",
    "bio": "Builds calculating machines and the teams around them.",
    "emails": [
      {
        "label": "Work",
        "value": "ada@example.com"
      }
    ],
    "phones": [
      {
        "label": "Mobile",
        "value": "+44 20 7946 0958"
      }
    ],
    "links": [
      {
        "type": "website",
        "url": "example.com"
      },
      {
        "type": "linkedin",
        "url": "linkedin.com/in/ada-lovelace"
      }
    ],
    "address": {
      "city": "London",
      "country": "United Kingdom"
    }
  },
  "createdAt": "2026-10-09T09:30:00.000Z",
  "updatedAt": "2026-10-09T09:30:00.000Z"
}
  • The body must be a single JSON object of at most 1 MB. An array answers 400.
  • The response carries a Request-Id header. Log it; support can trace a request with it.
  • To create many, loop exactly as in the paced loop, posting to this URL instead.

Duplicates and identifiers

Your identifier is unique inside a campaign. Send the same person twice and the second request is refused:

409 response
{
  "error": {
    "type": "conflict",
    "code": "duplicate_external_id",
    "message": "A card with external id \"EMP-1001\" already exists in this campaign."
  }
}

That is the protection working, so treat 409 duplicate_external_id as “already done” and move on. A new idempotency key does not get round it, and is not meant to.

How duplicates are handled
SituationPOST /cardsCampaign endpoint
The same request retried with the same Idempotency-Key, within 24 hoursReturns the original cardReturns the original card
The same person sent again with a new key, or after 24 hoursCreates a second cardRefused with 409 duplicate_external_id
The person’s earlier card is in TrashCreates a new cardRefused with 409 duplicate_in_trash until it is restored or permanently deleted
Two people asking for the same custom slugThe second is refused with 400The second is refused with 409 short_link_already_exists

With plain POST /cards the API has no idea who a card is for, so preventing duplicates is your job: keep a table of person → card id, and check it before creating.

Updating a person’s card later

There is no “create or update” call. A card object carries its externalId, so store the card id when you create it and call PATCH /cards/:id when the person’s details change.

Partial failure and retries

Because each person is a separate request, a run can finish with some created and some not. Nothing is rolled back.

What to do with each outcome in a bulk run
OutcomeDo
201Record the id and the url. Check paused is false.
409 duplicate_external_idAlready created. Count it as done.
422, 400That person’s data is wrong. Record the details, skip, carry on with the rest.
403 limit_exhausted_cardsStop the run. Every further request will fail the same way until the plan has room.
401, other 403Stop the run. The key or the plan is the problem, not the data.
429Wait for RateLimit-Reset seconds, then send the same request again.
5xx, timeout, network errorRetry with the same Idempotency-Key, waiting longer each time, at most a few times.

Then re-run only the failures. With stored idempotency keys or a campaign endpoint, re-running the whole file is also safe.

Spreadsheet import in the dashboard

The dashboard’s Bulk area can create cards from an uploaded spreadsheet, on plans that include bulk import. It is a dashboard feature used by a person, with no REST endpoint behind it that an integration may call. Cards created that way are ordinary cards: they appear in GET /cards, and their QR images and analytics are available through the API like any other card’s.