Bulk operations
Create, update and delete up to 100 records per request with Kustomer's asynchronous bulk API, then poll for completion and match per-item results with _clientRef.
Bulk endpoints accept up to 100 records per request and process them asynchronously. One bulk request uses one unit of your rate limit instead of 100, which makes bulk the right tool for imports, nightly syncs and backfills.
Diagram source
sequenceDiagram
autonumber
participant You
participant API as Kustomer API
You->>API: POST /v1/customers/bulk [ …up to 100 items ]
API-->>You: 201 { data: { type: "bulk", id, status: "queued" } }
loop until status = complete
You->>API: GET /v1/bulk/{id}
API-->>You: { status: queued | running | complete, batches: { complete, total } }
end
You->>API: GET /v1/bulk/{id}/batches
API-->>You: [ { successCount, failureCount, objectIds, results[] } ]Supported resources
Bulk deletes take ?ids= with a comma-separated list.
1. Submit a job
Send an array of the same objects you'd send to the single-record endpoint. Add _clientRef to each item so you can match results back to your source rows:
curl -X POST "https://acme.api.kustomerapp.com/v1/customers/bulk" \
-H "Authorization: Bearer $KUSTOMER_API_KEY" \
-H "Content-Type: application/json" \
-d '[
{ "_clientRef": "crm-001", "name": "Alice Smith", "externalId": "crm-001", "emails": [{ "type": "home", "email": "alice@example.com" }] },
{ "_clientRef": "crm-002", "name": "Bob Jones", "externalId": "crm-002", "emails": [{ "type": "home", "email": "bob@example.com" }] }
]'The response is a bulk operation. Store its id:
{
"data": {
"type": "bulk",
"id": "5b3bddb67f8469003df370a4",
"attributes": { "status": "queued" }
}
}_clientRef rules
- Optional, but all or nothing: if any item has
_clientRef, every item must. A mix returns400. - A non-empty string of up to 255 characters, unique within the request.
- Supported on creates and on array-shaped updates. Not supported on
?ids=updates or on deletes.
2. Poll for completion
get/v1/bulk/{id}
batches.complete and batches.total show progress, and batchErrors is true if any batch had failures. Poll every few seconds with backoff. Each poll uses your rate limit.
3. Read the results
get/v1/bulk/{id}/batches
{
"data": [
{
"type": "bulk_batch",
"id": "507f1f77bcf86cd799439021",
"attributes": {
"status": "complete",
"successCount": 2,
"failureCount": 1,
"objectIds": ["507f1f77bcf86cd799439031", "507f1f77bcf86cd799439032"],
"results": [
{ "_clientRef": "crm-001", "objectId": "507f1f77bcf86cd799439031", "status": "created" },
{ "_clientRef": "crm-002", "objectId": "507f1f77bcf86cd799439032", "status": "created" },
{ "_clientRef": "crm-003", "status": "error", "error": "duplicate detected" }
],
"errors": []
}
}
]
}Each results entry has status (created, updated or error), the objectId on success, and an error reason on failure. results only appears when you sent _clientRef. Without it, use objectIds, or look records up by externalId once the job completes.
Updates and deletes
Send an array; each item needs its id:
[
{ "_clientRef": "crm-001", "id": "507f1f77bcf86cd799439031", "custom": { "loyaltyTierStr": "gold" } },
{ "_clientRef": "crm-002", "id": "507f1f77bcf86cd799439032", "custom": { "loyaltyTierStr": "silver" } }
]Pass the IDs in the query string and one object in the body:
curl -X PUT "https://acme.api.kustomerapp.com/v1/customers/bulk?ids=507f1f77bcf86cd799439031,507f1f77bcf86cd799439032" \
-H "Authorization: Bearer $KUSTOMER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "tags": ["migrated"] }'curl -X DELETE "https://acme.api.kustomerapp.com/v1/customers/bulk?ids=507f1f77bcf86cd799439031,507f1f77bcf86cd799439032" \
-H "Authorization: Bearer $KUSTOMER_API_KEY"Updates merge by default. Add ?replace=true to replace each record entirely.
A complete import script
const base = `https://${process.env.KUSTOMER_ORG}.api.kustomerapp.com`
const headers = { Authorization: `Bearer ${process.env.KUSTOMER_API_KEY}`, 'Content-Type': 'application/json' }
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
type Row = { id: string; name: string; email: string }
async function importCustomers(rows: Row[]) {
const failures: { ref: string; error: string }[] = []
for (let i = 0; i < rows.length; i += 100) {
const chunk = rows.slice(i, i + 100)
const body = chunk.map((r) => ({ _clientRef: r.id, externalId: r.id, name: r.name, emails: [{ type: 'home', email: r.email }] }))
const job = await fetch(`${base}/v1/customers/bulk`, { method: 'POST', headers, body: JSON.stringify(body) }).then((r) => r.json())
let status = job.data.attributes.status
for (let delay = 1000; status !== 'complete'; delay = Math.min(delay * 2, 15000)) {
await sleep(delay)
status = (await fetch(`${base}/v1/bulk/${job.data.id}`, { headers }).then((r) => r.json())).data.attributes.status
}
const batches = await fetch(`${base}/v1/bulk/${job.data.id}/batches`, { headers }).then((r) => r.json())
for (const b of batches.data) for (const r of b.attributes.results ?? []) if (r.status === 'error') failures.push({ ref: r._clientRef, error: r.error })
}
return failures
}Tips
- Set
externalIdas well as_clientRef._clientRefmatches results within one job;externalIdlets you find the record forever. - Use
importedAtfor historical data. It exempts conversations, messages and KObjects from the per-customer create limit. - Order matters across jobs. Create customers before their conversations, and conversations before their messages. Wait for each job to complete before you submit dependent records.
- Don't send duplicates in one job. Two items with the same
externalIdin a single request can race.