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
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.
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.
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.
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.
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.