Skip to content

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 pendingprocessingcompleted (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 202 as done. The async 202 means accepted, not finished. A user that violates a uniqueness constraint still counts toward failed after 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 are PATCH and require a public_id per item.
  • Retrying a partial sync batch blindly. It's safe here because archive/unarchive are idempotent — but read the failed entries first, since a not_found won't fix itself on retry.

See also Errors, retries & idempotency for the general retry rules.