Skip to content
← BlogDeveloper

The job pattern: how to bulk-import contacts into Norbelys correctly

Importing 20,000 contacts one POST at a time will rate-limit you and hide partial failures. Here's the job pattern — create a job, poll for progress — and why it exists.

By Gabriel Lara, Developer Relations, Norbelys

Founder-reviewed ·How we research and correct articles

The obvious way to import a CSV of contacts through an API is a for loop: read each row, POST /v1/people, move to the next row. It works for a spreadsheet of 30 people. It falls apart at the volumes cold-email tooling actually deals with — a few thousand rows is normal, tens of thousands isn’t rare — for three reasons that don’t show up until you’re already mid-import.

The Norbelys API has a dedicated pattern for this: bulk work is a job resource. You create one job, the platform processes it asynchronously, and you poll it for progress instead of driving thousands of individual requests yourself.

Why looping POST /v1/people breaks down

You’ll hit rate limits. A tight loop of individual creates is exactly the shape of traffic rate limiting exists to catch — thousands of requests in a short window from one API key, indistinguishable at the edge from a runaway script. You’ll spend more code handling 429s than the import itself would otherwise need.

Partial failure becomes your problem to track. Row 4,183 of 20,000 has a malformed email address and the create fails. Do you stop the whole import? Keep going and lose track of which rows failed? With a loop, you’re building your own bookkeeping for something the platform already models as first-class state.

You get zero progress visibility for free. A large import can take minutes. A loop gives you nothing to show a user except “still going” — no done/total counter, no way for a UI to render a progress bar without you inventing one client-side and hoping it stays in sync with reality.

The pattern

POST /v1/jobs
  { type: "import", sourceR2Key: "uploads/org_.../contacts.csv" }
  → 202 { id: "job_...", type: "import", status: "processing", total, done, failed }

GET  /v1/jobs/:id      → { status, total, done, failed, report }
GET  /v1/jobs          → recent jobs, filterable by ?type= and ?status=

You create the job once, then poll GET /v1/jobs/:id until status moves out of processing. done and failed climb as the platform works through the file, so a UI can render an honest progress bar instead of a spinner.

A realistic end-to-end import

Bulk-importing a contact list

  1. Upload the CSV

    Upload the file to your org's storage first (the app UI or an authenticated upload endpoint hands you back an R2 object key). The job references this key rather than accepting an inline file body — large uploads and job processing are decoupled on purpose.

  2. Create the import job

    POST /v1/jobs with { type: "import", sourceR2Key }. The response is 202 Accepted with a job id and status: "processing" immediately — the import runs in the background, not on this request.

  3. Poll for progress

    GET /v1/jobs/:id on an interval (a few seconds is reasonable for a UI; back off further for a script). Watch total, done and failed climb as rows are processed.

  4. Read the report on completion

    When status leaves processing, the job carries a report — which rows succeeded, which failed and why (bad email, missing required field, suppressed address). Handle failures explicitly instead of assuming an import that returned 202 fully succeeded.

bulk-import.sh
$ curl -s -X POST https://api.norbelys.com/v1/jobs \$ -H "Authorization: Bearer $NORBELYS_API_KEY" \$ -H "Content-Type: application/json" \$ -H "Idempotency-Key: import-2026-07-16-acme" \$ -d '{"type":"import","sourceR2Key":"uploads/org_8h2.../acme-list.csv"}'{ "id": "job_qz91k...", "type": "import", "status": "processing", "total": 4200, "done": 0, "failed": 0 }# poll a few seconds later$ curl -s https://api.norbelys.com/v1/jobs/job_qz91k... \$ -H "Authorization: Bearer $NORBELYS_API_KEY"{ "id": "job_qz91k...", "status": "processing", "total": 4200, "done": 2850, "failed": 12 }# poll again once status leaves "processing"{ "id": "job_qz91k...", "status": "completed", "total": 4200, "done": 4188, "failed": 12, "report": { "failedRows": [ { "row": 4183, "reason": "invalid_email" }, ... ] } }

Note the Idempotency-Key header on the create call — worth setting to something derived from the source file (its R2 key or a hash) so a retried request after a network blip doesn’t kick off the same import twice.

Reading nextCursor for imported people afterward

Once the job completes, the newly created contacts show up like any other row — list them with the same cursor-paginated GET /v1/people you’d use for anything else. The job pattern handles getting rows in; ordinary resource endpoints handle reading them back out. There’s no special “imported contacts” view — importing four thousand rows through a job and creating one row through POST /v1/people produce identical rows in the same collection.

The same pattern covers more than import

job isn’t an import-only mechanism — it’s the general shape for anything that touches many records asynchronously. A campaign activation that enrolls an entire audience runs the same way: POST /v1/jobs {type: "program_activate", programId} mass-enrolls contacts and starts sending, with the same status/total/done shape you’d poll for an import. If you’ve built the polling loop once, you already know how to watch any bulk operation Norbelys exposes.

See the developer portal for the full job schema per type, and audience management for how imported contacts flow into segments and suppression checks.

Bulk import FAQ

What happens to rows that fail validation during import?

They're recorded individually in the job's report with a reason (invalid email, missing required field, suppressed address) and counted in failed. The job still completes for every other row rather than aborting the whole import over one bad line.

Can I import into a suppressed list and have those rows sent to anyway?

No. Suppression is enforced at multiple points in the pipeline, including on import — a row whose address or domain is already suppressed is skipped rather than created as an active contact ready to send to.

How large can a single import job be?

Check the current limits in the OpenAPI spec at the developer portal, since they're tuned over time. The job pattern itself doesn't impose an architectural ceiling the way looping individual creates does — very large files are exactly the case it was built for.

Should I use the job pattern for a one-off single contact creation?

No — for a single record, POST /v1/people directly is simpler and faster. Reach for a job when you're processing a list, not a single row.