DEV Community

Jeff
Jeff

Posted on Originally published at powerduck.com

Bulk and batch API endpoints: modeling create-many, partial success, and 207 Multi-Status in OpenAPI

The naive way to import 500 records is 500 POST calls. It is slow, it opens 500 connections, and when request 312 fails the client has no idea which of the rest landed, so it re-runs everything and creates 311 duplicates. A bulk endpoint accepts the whole set in one request and returns a result per item, so a single response tells the client exactly what succeeded and what to retry. The hard part is modeling partial failure honestly.

One request, one ordered result per item

Keep the request and response arrays aligned by index or by a client-supplied key. The client needs to match each result to the item it sent even when items are processed out of order:

paths:
  /contacts/bulk:
    post:
      summary: Create or update up to 1000 contacts in one batch
      operationId: bulkUpsertContacts
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkContactsRequest'
      responses:
        '200':
          description: Batch finished; inspect each item result (some items may have failed).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkContactsResponse'
        '207':
          description: Multi-status; each item carries its own HTTP status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkContactsResponse'
        '413':
          description: Batch exceeds the maximum item count.
Enter fullscreen mode Exit fullscreen mode

The request wraps the items and carries batch-level controls:

BulkContactsRequest:
  type: object
  required: [items]
  additionalProperties: false
  properties:
    items:
      type: array
      minItems: 1
      maxItems: 1000
      items:
        $ref: '#/components/schemas/BulkContactItem'

BulkContactItem:
  type: object
  required: [client_key, data]
  additionalProperties: false
  properties:
    client_key:
      type: string
      description: Caller-unique key for this item, echoed back for correlation and dedupe.
      example: row-0042
    data:
      $ref: '#/components/schemas/UpsertContact'
Enter fullscreen mode Exit fullscreen mode

200 versus 207: pick one rule and document it

There are two coherent conventions; mixing them is what confuses clients:

  • Always 200 with per-item statuses. The batch transport succeeded, so the outer status is 200, and each item carries its own status field. This is simple for clients that always parse the array.
  • 207 Multi-Status when any item fails. The outer status reflects that the batch is mixed; all-success can be 200 (or 207 with all 2xx items). 207 is defined by WebDAV (RFC 4918) but is widely used for bulk JSON APIs.

Whichever you choose, the envelope is the same; only the outer status changes. The per-item result needs the correlation key, an item-level status, the created resource (or id), and a structured error:

BulkContactsResponse:
  type: object
  required: [results]
  properties:
    results:
      type: array
      items:
        $ref: '#/components/schemas/BulkContactResult'
    summary:
      $ref: '#/components/schemas/BulkSummary'

BulkContactResult:
  type: object
  required: [client_key, status]
  properties:
    client_key: { type: string }
    status:
      type: integer
      description: Per-item HTTP status (201 created, 200 updated, 4xx/5xx failed).
      example: 201
    resource:
      $ref: '#/components/schemas/Contact'
    error:
      $ref: '#/components/schemas/ProblemDetail'

BulkSummary:
  type: object
  properties:
    total: { type: integer }
    succeeded: { type: integer }
    failed: { type: integer }
Enter fullscreen mode Exit fullscreen mode

Use RFC 9457 Problem Details for item errors so a failed row carries the same type, title, and field-level errors shape your single-item API already returns. Never collapse ten distinct validation failures into one string.

Atomic, all-or-nothing, or partial?

State the guarantee explicitly; clients cannot infer it:

Mode Behavior When to use
Atomic One failing item rolls back the whole batch, outer 400 Financial writes, referential integrity
Partial (default) Valid items commit, invalid items report errors, 200/207 Imports, bulk updates, sync
Best-effort async Batch accepted with 202, results fetched later Very large or slow jobs

Do not call an endpoint "bulk" and silently roll back on item 800 after reporting nothing; if the mode is atomic, say so and reject the whole request with the first validation errors. For partial mode, the contract is clear: the client retries only the failed client_keys, and because each item is an upsert keyed by the same client key, the retry neither duplicates nor double-applies.

Bounds, ordering, and idempotency

  • Cap the batch (maxItems) and document it; return 413 beyond the cap. Give clients a concrete number rather than an opaque body-size limit.
  • Do not require order, but preserve input order in results or echo client_key, so processing can be parallelized server-side.
  • Make the batch idempotent. One Idempotency-Key covers the whole request for transport-level retries; per-item client_key dedupes rows when the client rebuilds a smaller retry batch. The two operate at different levels and you need both.
  • Rate-limit by cost, not request count. One batch of 1000 is not equivalent to one GET; document that bulk calls consume a larger quota or use a dedicated limit.
  • Validate before writing where possible: return shape errors for the whole request (malformed JSON, over-cap) as an outer 400, and per-row semantic errors (duplicate email, missing relation) as item results.

When to switch to an async job

A batch that takes longer than your request timeout should not be held open. Accept it with 202 Accepted and a job resource, then stream or poll per-item results as they complete. Reserve synchronous bulk for jobs that finish inside your latency budget (a few hundred to a low thousand items). The same request and per-item result schemas carry over; only the delivery of results changes.

What codegen, mocks, and AI callers need

  • Generators produce typed arrays for both items and results when the envelope is explicit; a vague data: array of untyped objects forces every caller to cast.
  • A spec-driven mock should return a mixed batch on demand, some 201, one 422 with a Problem Detail, so the client's partial-failure handling is actually exercised.
  • An AI agent integrating a bulk API needs the mode (atomic vs partial), the correlation key, and the retry rule in the description; without them it will either assume all-or-nothing or resend the entire batch and create duplicates.

Checklist

  1. Align requests and results by index or, preferably, an echoed client_key.
  2. Decide outer-status convention (always 200 with item statuses, or 207 on partial failure) and document it.
  3. Return a per-item status, the resource or id, and a structured Problem Detail error, plus a summary count.
  4. State the atomicity guarantee; never silently roll back a "partial" batch.
  5. Cap item count with maxItems and a 413, validate the envelope up front, and report row errors individually.
  6. Combine a batch Idempotency-Key with per-item keys so retries never duplicate.
  7. Move oversized batches to a 202 async job reusing the same item schemas.
  8. Test a mixed batch and assert the client retries only the failed keys.

Get these right and a 500-row import becomes one fast call with an exact list of what to fix, instead of an afternoon of reconciling duplicates.

You can define the bulk envelope, generate typed item and result arrays, and mock a mixed 207 batch in one local-first workspace, right in your browser. For the structured per-item error shape, see RFC 9457 Problem Details for HTTP APIs.

Top comments (0)