A batch endpoint that returns one status code for five different operations forces every client to guess which items actually failed. The fix is to stop treating the top-level response code as the verdict: return 200 (or 207) for the envelope itself, then attach a per-item results array where each entry carries its own status, id, and error detail. Partial success becomes explicit data, not something a client has to infer.

Quick Answer: Return 200 or 207 for the batch request itself, then include a results array where each item carries its own status code, identifier, and error object. Reserve true all-or-nothing (transactional) behavior for operations where partial completion would be unsafe or confusing, and treat batch size limits and idempotency keys as first-class parts of the contract, not implementation details you bolt on later.

Batch and bulk endpoints exist for one reason: reducing round-trips. A client that needs to update 200 line items, tag 500 contacts, or import a CSV of 10,000 rows shouldn't have to make 10,000 HTTP calls. That's a legitimate job to be done — the same reduce-friction instinct covered in the complete guide to Jobs to Be Done applies directly to API design: clients aren't buying an endpoint, they're hiring it to get many things done in one round-trip.

But collapsing N operations into one HTTP call also collapses N possible outcomes into one response, and HTTP's status-code vocabulary was never built for that. Get the contract wrong and you'll spend the next two years fielding support tickets from integrators who assumed 200 meant "all five succeeded."

This is a design decision, not an implementation detail — which is exactly why it belongs in the API contract before anyone writes a handler. It sits alongside the broader discipline covered in the complete guide to API product design: a batch endpoint's failure semantics are as much a product decision as its resource shape.

The Core Problem: What Status Code Do You Return When 3 of 5 Succeed?

There is no single correct HTTP status code for a batch where some items succeed and others fail — 200, 207, and 422 are all individually defensible, and each one implies a different contract to the client. The actual fix isn't hunting for the "right" code; it's refusing to let the top-level code be the only signal of outcome.

Consider a bulk order-update endpoint that accepts an array of five order patches. Three apply cleanly; two fail validation. Three naive responses, and why each one breaks down:

  • Return 200 unconditionally. Simple for the server to reason about, but a client that only checks the top-level status code will treat this as a full success and never look inside the body. Two orders silently didn't update.
  • Return 422 or 400 if any item fails. This over-signals failure. A client that retries on non-2xx will resubmit all five items, including the three that already succeeded — and if those aren't idempotent, you've just created duplicates.
  • Return 207 Multi-Status. Technically correct (it's the WebDAV status code, defined in RFC 4918, built for exactly this "multiple independent results in one response" case) but under-adopted outside WebDAV and S3-style APIs, so many HTTP clients and monitoring dashboards don't have special handling for it and will bucket it as "not quite success, not quite failure."

None of these three is wrong in isolation. The mistake is choosing one and assuming the status code alone tells the whole story. HTTP semantics (formalized in RFC 9110) describe what a single resource's response should look like — they were never designed to describe five independent outcomes bundled into one message.

The practical resolution: pick a top-level code that communicates "the batch request itself was accepted and processed" (200 for most APIs, 207 if your ecosystem already expects Multi-Status), and push the actual per-item verdicts into the response body where they can't be missed. A summary block (total, succeeded, failed) at the top of that body gives monitoring and logging something to key off without parsing the whole array.

Transactional vs Per-Item Results: Two Failure Models Compared

All-or-nothing (transactional) batches guarantee every item succeeds or none do, trading throughput and latency for a simple client contract. Per-item batches let operations succeed or fail independently, trading that simplicity for higher throughput and resilience. Choose transactional when items are causally dependent; choose per-item when they're parallel, independent operations.

The decision usually comes down to whether the items in the batch have a relationship to each other, or whether they just happen to be traveling together for efficiency's sake.

DimensionAll-or-nothing (transactional)Per-item results
Consistency guaranteeEvery item commits, or none doEach item commits or fails independently
Best fitCausally linked operations (ledger entries, multi-step workflows)Independent parallel operations (bulk tag, bulk import, bulk delete)
Typical status code200 on success, 409/422 on any failure (nothing applied)200 or 207, with a per-item array regardless of overall outcome
Client retry logicSafe to retry the whole batch as-isMust retry only the failed subset, using per-item ids
Error surfaceOne error, describing the first (or all) conflicting itemsN errors, one per failed item, each independently actionable
Real-world analogueDatabase transaction, payment ledger postingAWS S3 DeleteObjects, SQS SendMessageBatch, bulk CSV import

A sentence version of that table: transactional batches are simpler to consume but slower to design correctly under load (you need real rollback semantics), while per-item batches are more forgiving operationally but push more parsing responsibility onto every client that calls the endpoint.

Many teams reach for RPC-style batch actions instead of forcing bulk semantics onto REST's resource model — an endpoint like POST /orders:batchUpdate rather than pretending a batch is itself a RESTful resource. That tension between resource-oriented and action-oriented design is exactly what the REST vs. RPC vs. GraphQL decision framework walks through, and batch operations are one of the clearest cases where pure REST starts to strain.

Hybrid batches: sub-transactions inside a larger batch

Some domains need both. A payroll batch might group line items by employee, guaranteeing each employee's set of adjustments is all-or-nothing, while still returning per-employee-group results across the batch. Document this explicitly — a groupId field on each item and a groups summary alongside the flat results array — rather than leaving clients to reverse-engineer which items were atomic with which.

The Per-Item Result Envelope: A Contract Clients Can Actually Parse

A per-item result envelope needs four things at minimum: a stable correlation key back to the request item, a status that mirrors familiar HTTP semantics, a success payload or a structured error (never both), and a batch-level summary so clients don't have to count array entries to know the outcome. Skip any one of these and integrators start writing fragile, index-based parsing code.

Here's a reference shape for a bulk order-update response:

{
  "batchId": "batch_9f2a1c",
  "status": "partial_success",
  "summary": { "total": 5, "succeeded": 3, "failed": 2 },
  "results": [
    {
      "index": 0,
      "clientReferenceId": "order-1042",
      "status": 200,
      "data": { "id": "order_1042", "state": "updated" }
    },
    {
      "index": 1,
      "clientReferenceId": "order-1043",
      "status": 422,
      "error": {
        "code": "VALIDATION_ERROR",
        "field": "quantity",
        "message": "quantity must be greater than 0"
      }
    },
    {
      "index": 2,
      "clientReferenceId": "order-1044",
      "status": 409,
      "error": {
        "code": "STATE_CONFLICT",
        "message": "order already shipped; cannot modify line items"
      }
    }
  ]
}

A few details matter more than they look:

  1. Preserve request order and echo an index. Clients built the request array in a specific order; returning results out of order without an index makes correlation a guessing game.
  2. Accept and echo a client-supplied reference id. index alone breaks the moment a client retries a subset of a batch — a stable id (order id, line number, UUID the client generated) survives reordering and partial retries.
  3. Use real HTTP status codes inside status, not custom strings. A 422 inside the envelope means the same thing a 422 at the top level would mean for a single-resource call — reuse the vocabulary integrators already know instead of inventing "status": "failed_validation".
  4. Never mix data and error on the same result object. A client checking if (result.error) should never also need to check whether data happens to be present.

This is squarely a job for whoever owns the contract, not an accident of whatever the ORM happens to serialize. Specifying the API contract is a core PM job precisely because decisions like "what does a failed item look like" determine how much integration pain your partners feel for years.

It also touches resource modeling directly: a batch response is itself a resource worth naming and versioning deliberately, which is the same discipline covered in resource modeling and choosing the right nounsresults[] is a noun with its own shape, not an afterthought bolted onto whatever the single-item endpoint returns.

Two precedents worth studying instead of inventing your own vocabulary from scratch: Google's API Improvement Proposals (AIP-231/233/234, the design guidelines Google's API council publishes for its own bulk methods) specify per-item results using the same google.rpc.Status shape single-resource errors use, and AWS's DeleteObjects and SendMessageBatch APIs return explicit Successful[] and Failed[] arrays rather than one blended list — both are worth mirroring rather than reinventing.

Batch Size Limits and Idempotency: Protecting the Backend and the Retry

Batch size limits exist to keep latency predictable and protect downstream systems from unbounded fan-out; idempotency exists to make retrying a partially-failed batch safe instead of destructive. Both need to be explicit, documented parts of the contract — an undocumented limit just becomes a 500 error your integrators discover in production.

How big should a batch be?

There's no universal number — it depends on per-item work, downstream fan-out, and your latency budget — but real-world APIs converge on surprisingly similar caps, which is a useful sanity check against inventing your own from nothing.

APIBatch limitWhy it's roughly there
AWS SQS SendMessageBatch10 messages / 256 KB totalKeeps a single call's latency and payload bounded and predictable
AWS S3 DeleteObjects1,000 keys per requestBalances round-trip savings against a single request's blast radius
Google Cloud Pub/Sub publish batching~1,000 messages or 10 MB, whichever firstCaps memory and serialization cost per publish call
Stripe bulk file/data uploadsChunked, not unboundedAvoids one oversized request blocking the processing queue

Translate the pattern, don't copy the number: cap batch size to something your p99 latency budget can absorb, reject oversized batches with a clear 400 at the door rather than silently truncating the array, and state the limit in the spec so client SDKs can chunk proactively instead of discovering the ceiling via failed requests.

A silently truncated batch is worse than a rejected one. If a client sends 2,000 items and your server quietly processes the first 500, the client's summary.total won't match what it sent — and now it has to guess whether the missing 1,500 were dropped, queued, or never received at all.

Idempotency keys for batch requests

A batch that partially succeeds and then gets retried is the single most common way bulk endpoints create duplicate data. If a client resubmits the same five-item batch after a timeout, and three items already succeeded, blindly re-running all five means three duplicate orders.

The fix — popularized by Stripe's Idempotency-Key header pattern — extends cleanly to batches:

  1. Require an idempotency key on the whole batch request, not just individual items. The server stores the result of processing that key (including which items succeeded, which failed, and their responses).
  2. On a retry with the same key, return the cached envelope verbatim rather than re-executing anything — including the items that already succeeded the first time.
  3. Also accept a per-item client reference id (see the envelope example above) so a client can retry only the failed subset as a new, smaller batch with a new idempotency key, rather than resubmitting the whole thing.
  4. Expire idempotency keys on a defined window — Stripe documents roughly 24 hours for its own keys — and state that window in your API reference so clients don't assume indefinite dedup protection.

Idempotency and batch size limits are easy to treat as backend implementation trivia, but from a client's integration experience they're indistinguishable from the rest of the contract — a developer building against your API lives through the same friction points as any other customer journey, which is why mapping the customer journey is a useful lens even for developer-facing surfaces: the moment of "I retried and now I have duplicates" is a journey failure, not just a bug.

Where Prodinja Fits: Pinning Down the Contract Before You Build

Most partial-failure ambiguity ships because the batch contract gets decided implicitly, in whatever a first implementation happens to serialize, rather than explicitly, in a spec everyone reviewed. Prodinja's API Designing tool is built around specifying that contract up front — you define the batch request array shape and the per-item response envelope directly, down to example curl calls and generated spec.

That means questions like "what does item 2 of 5 failing actually look like" get answered in the design artifact, not debated in a pull request after two teams have already integrated against different assumptions. It doesn't run your batch logic for you — it's a design surface for pinning the contract down before code makes the decision by default.

Key Takeaways

  • A single top-level status code cannot represent a partial batch outcome — treat 200/207 at the envelope level and a per-item results array as two separate signals, not one.
  • Choose transactional (all-or-nothing) batches only when items are causally dependent; use per-item results for independent, parallel operations like bulk import or bulk delete.
  • Every per-item result needs a correlation key, a status, and either data or an error — never both — index alone breaks under partial retries.
  • Reuse real HTTP status semantics inside the envelope rather than inventing custom string enums; it's one less vocabulary integrators have to learn.
  • Batch size limits should be explicit, documented, and enforced with a clear rejection, not a silent truncation or an undocumented timeout.
  • Idempotency keys for batches must cover the whole request, caching the full per-item outcome so retries don't re-execute already-succeeded items.
  • The batch contract is a design decision, not an implementation detail — specify it before writing the handler, the same way you'd specify any other resource shape.

Frequently Asked Questions

What HTTP status code should a batch API return when only some items succeed?

Return 200 (or 207 Multi-Status if your ecosystem already expects it) at the top level, and put the real per-item outcomes in a results array with their own status codes. Don't pick a single blended code to represent five independent outcomes — it can't.

Should batch endpoints be transactional (all-or-nothing) or allow partial success?

It depends on whether the items are causally linked. Use all-or-nothing when partial completion would leave data inconsistent (ledger postings, multi-step workflows); use per-item partial success when items are independent operations that just happen to travel together, like bulk tagging or bulk deletes.

How large should I let a single batch or bulk request be?

Size limits should match your p99 latency budget and downstream fan-out, not an arbitrary round number. Real APIs cluster in similar ranges — SQS caps at 10 messages, S3 DeleteObjects at 1,000 keys — so start near that range, reject oversized requests explicitly with a 400, and document the ceiling rather than letting clients discover it via failure.

How do idempotency keys work for batch endpoints when a retry happens mid-failure?

An idempotency key should cover the entire batch request, with the server caching the full per-item outcome under that key. On retry with the same key, the server returns the cached envelope — including already-succeeded items — instead of re-executing anything, which is what prevents duplicate side effects from a client's retry logic.

Is REST a good fit for batch and bulk operations, or should I use RPC-style endpoints?

Pure resource-oriented REST strains under batch semantics, since a batch isn't really "a resource" in the CRUD sense — many APIs land on an RPC-flavored action endpoint like POST /orders:batchUpdate instead. Whether that's the right call for your API depends on the rest of your resource model, which is exactly the tradeoff covered in a REST-vs-RPC-vs-GraphQL decision framework.