Bulk operations & async jobs¶
When you need to act on many resources at once — onboard a thousand users, assign a course to a department, reschedule a batch of events — use the bulk endpoints instead of looping over single-resource calls. There are two bulk patterns in the API, and they behave differently. Knowing which is which is the whole game.
Two patterns¶
| Pattern | Used by | Response | How you learn the outcome |
|---|---|---|---|
| Asynchronous job | users, assignments, enrollments, events, event series | 202 Accepted immediately |
Poll a status endpoint until completed. |
| Synchronous batch | content-item archive / unarchive, content-item tagging | 200 / 207 / 422 immediately |
Per-item results are in the response body. |
The async pattern is for work that takes time (it may fan out into notifications, integrations, or downstream records). The synchronous pattern is for fast, self-contained changes where you want the per-item verdict right away.
Asynchronous jobs¶
Flow at a glance¶
sequenceDiagram
participant Your app
participant PlusPlus API
Your app->>PlusPlus API: POST /users/bulk/ { users: [...] }
PlusPlus API-->>Your app: 202 { id, status: "pending" }
loop until status is completed or failed
Your app->>PlusPlus API: GET /users/bulk/{id}/
PlusPlus API-->>Your app: 200 { status, succeeded, failed }
end
1. Submit the batch¶
Post an array of items. The wrapper key matches the resource (users, assignments, enrollments, events, event_series):
curl -X POST https://acme.plusplus.app/api/v2/users/bulk/ \
-H "Authorization: Bearer pp_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"users": [
{ "email": "jane.doe@acme.com", "name": "Jane Doe", "role": "regular", "employee_id": "EMP-1234" },
{ "email": "john.smith@acme.com", "name": "John Smith", "role": "regular", "employee_id": "EMP-1235" }
]
}'
The response is 202 Accepted — the work has been queued, not finished:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"total": 2,
"processed": 0,
"succeeded": 0,
"failed": 0,
"created_at": "2026-06-02T10:30:45.123Z"
}
Hold on to id — it's how you check progress.
2. Poll for completion¶
Call the matching status endpoint until status is terminal:
curl https://acme.plusplus.app/api/v2/users/bulk/550e8400-e29b-41d4-a716-446655440000/ \
-H "Authorization: Bearer pp_your_token_here"
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"total": 2,
"processed": 2,
"succeeded": 2,
"failed": 0,
"created_at": "2026-06-02T10:30:45.123Z"
}
status moves through pending → processing → completed (or failed). Poll every 2–5 seconds; most batches finish in seconds.
3. Verify the results¶
The status response gives you counts (succeeded / failed), not per-item detail. To see exactly what landed, query the resource afterward:
curl "https://acme.plusplus.app/api/v2/users/?email=jane.doe@acme.com" \
-H "Authorization: Bearer pp_your_token_here"
The async endpoints¶
| Resource | Submit | Poll | Wrapper key | Max items |
|---|---|---|---|---|
| Users | POST /users/bulk/ |
GET /users/bulk/{id}/ |
users |
500 |
| Assignments | POST /assignments/bulk/ |
GET /assignments/bulk/{id}/ |
assignments |
200 |
| Enrollments | POST /enrollments/bulk/ |
GET /enrollments/bulk/{id}/ |
enrollments |
200 |
| Events | PATCH /events/bulk/ |
GET /events/bulk/{id}/ |
events |
200 |
| Event series | PATCH /event-series/bulk/ |
GET /event-series/bulk/{id}/ |
event_series |
200 |
Bulk event updates use PATCH
Bulk create flows (users, assignments, enrollments) are POST. Bulk update flows (events, event_series) are PATCH, and each item must include the public_id of the resource to update plus the fields to change. Omitted fields are left untouched. Timeslot changes aren't supported in bulk — use PATCH /events/{public_id}/ for those.
A bulk assignment create looks like this:
curl -X POST https://acme.plusplus.app/api/v2/assignments/bulk/ \
-H "Authorization: Bearer pp_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"assignments": [
{ "user_id": "8f3a…", "content_item_id": "a1b2…", "due_date": "2026-12-31" },
{ "user_id": "9c4b…", "content_item_id": "a1b2…", "due_date": "2026-12-31" }
]
}'
Synchronous batches¶
A handful of content-item operations — archive / unarchive and tagging — process the whole batch in one request and return the per-item outcome immediately, no polling. They all share the same request envelope (items plus a public_id per item), the same 200 / 207 / 422 status convention, and the same per-item result body.
Archiving and unarchiving¶
curl -X POST https://acme.plusplus.app/api/v2/content-items/bulk/archive/ \
-H "Authorization: Bearer pp_your_token_here" \
-H "Content-Type: application/json" \
-d '{ "items": [ { "public_id": "a1b2…" }, { "public_id": "c3d4…" } ] }'
Each item is processed independently in its own savepoint, so one failure doesn't roll back the rest. The HTTP status tells you the shape of the outcome at a glance:
200— every item succeeded.207 Multi-Status— some succeeded, some failed.422— every item failed.
{
"data": {
"succeeded": [ { "public_id": "a1b2…", "index": 0 } ],
"failed": [
{
"index": 1,
"error": { "code": "not_found", "message": "Content item not found.", "request_id": "req_a1b2c3d4" }
}
]
},
"meta": { "total": 2, "succeeded_count": 1, "failed_count": 1 }
}
The index field maps each result back to its position in your request array. These operations are idempotent — archiving an already-archived item counts as a success — so they're safe to retry.
Up to 100 items per synchronous batch.
Tagging many items at once¶
The same pattern applies tags to content items regardless of their type. Send the items to tag plus the tags to apply (by name); non-existent tag names are created automatically on add/replace. The HTTP verb selects the operation:
| Verb | Effect on each item |
|---|---|
POST /content-items/bulk/tags/ |
Add the listed tags, keeping any existing ones. |
DELETE /content-items/bulk/tags/ |
Remove the listed tags; names not attached to an item are ignored. |
PUT /content-items/bulk/tags/ |
Replace the item's entire tag set with the listed tags. |
curl -X POST https://acme.plusplus.app/api/v2/content-items/bulk/tags/ \
-H "Authorization: Bearer pp_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"items": [ { "public_id": "a1b2…" }, { "public_id": "c3d4…" } ],
"tags": ["onboarding", "engineering"]
}'
The response shape, status codes (200 / 207 / 422), and per-item index mapping are identical to archive above. Add and remove are idempotent; replace is naturally idempotent for the same tag set. Up to 100 items per request.
For tagging a single item or filtering content by tag, see the /content-items/{public_id}/tags sub-resource and the tag / tag_all parameters on GET /content-items in the API reference.
Common pitfalls¶
- Treating
202as done. The async202means accepted, not finished. A user that violates a uniqueness constraint still counts towardfailedafter processing — you only see it when you poll. - Polling too aggressively. Every poll counts against your rate limit. 2–5 second intervals are plenty.
- Expecting per-item errors from async jobs. The async status endpoint returns counts only. When
failed > 0, reconcile by querying the resource (e.g. which emails exist) to find the gaps. - Mixing up POST and PATCH. Creates are
POST; the event/event-series bulk updates arePATCHand require apublic_idper item. - Retrying a partial sync batch blindly. It's safe here because archive/unarchive are idempotent — but read the
failedentries first, since anot_foundwon't fix itself on retry.
See also Errors, retries & idempotency for the general retry rules.