Mert Özen Further With Every Line
Software Architecture REST API Design — #08

Bulk, Batch and Asynchronous APIs: Bulk Operations, Partial Success, Webhooks or Polling, and the Rate Limit Contract

Mert Özen Nov 15, 2026 13 min 7 views
Bulk, Batch and Asynchronous APIs: Bulk Operations, Partial Success, Webhooks or Polling, and the Rate Limit Contract

The contract of a bulk API spells out the size limit, which errors reject the whole request and which reject a single line, how partial success is reported, how the outcome is delivered, and the quota. At Bookstore we design the schools' bulk order as a 201 with a line-by-line report, a 422 with allOrNothing and a long-running operation with Prefer: respond-async, add the webhook to the contract, and see the RateLimit header draft and 429 with real responses.

Version note: The code in this post was built and run with C# 14 and .NET 10 (ASP.NET Core 10.0.15); the HTTP examples are real responses from this application, and the OpenAPI 3.2 document was linted with Redocly CLI 2.78.0 and Spectral 6.17.1.

Guess First

Lakeside School, an institutional customer of Bookstore, the online bookstore used as the shared example across this site's series, sends its reading list as a bulk order at the start of the term. Real lists run up to 500 lines; here is a short seven-line example. Nevin Hanım's system sends the list to the new bulk order endpoint:

$ cat batch.json
{"unitId":"main-campus","lines":[
 {"isbn":"9780451524935","quantity":15},
 {"isbn":"9780060850524","quantity":10},
 {"isbn":"9780441172719","quantity":30},
 {"isbn":"9780743273565","quantity":12},
 {"isbn":"9780000000002","quantity":5},
 {"isbn":"9780451524935","quantity":5},
 {"isbn":"9780261102217","quantity":18}
]}

$ curl -s -i -X POST -H "Authorization: Bearer nevin" -H "Content-Type: application/json" -H 'Idempotency-Key: "lakeside-2026-11-reading-list"' --data @batch.json http://127.0.0.1:52318/institutions/lakeside-school/order-batches | tee batch-created.txt | grep -v '^{'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 23:29:06 GMT
Server: Kestrel
Content-Language: en
Location: /institutions/lakeside-school/order-batches/01a1dc64-a869-7aa4-8c17-eadef77b3831
Transfer-Encoding: chunked
Api-Version: 2026-09-01
Deprecation: @1794700800
Sunset: Mon, 17 May 2027 00:00:00 GMT
Link: <https://api.example.com/docs/versions/2026-09-01>; rel="deprecation"; type="text/html"
RateLimit-Policy: "batches";q=5;w=60
RateLimit: "batches";r=4;t=60

$ tail -1 batch-created.txt | jq -c '{orderId, acceptedLines, rejectedLines}, (.results[] | select(.status == "Rejected") | {line, isbn, type: .problem.type, detail: .problem.detail})'
{"orderId":"01a1dc64-a869-7b66-a998-7fa62adae003","acceptedLines":4,"rejectedLines":3}
{"line":3,"isbn":"9780441172719","type":"https://api.example.com/problems/insufficient-stock","detail":"Only 14 copies of Dune are available."}
{"line":5,"isbn":"9780000000002","type":"https://api.example.com/problems/not-found","detail":"Nothing exists at /books/9780000000002."}
{"line":6,"isbn":"9780451524935","type":"https://api.example.com/problems/validation-failed","detail":"Fix the fields listed in errors."}

Three of the seven lines were rejected, yet the response is 201 Created. Four questions: Is 201 right for a request with three rejected lines, or should it have been 207 Multi-Status, 400 or 422? How does the client know which lines were ordered? Why was line 6 rejected? What does the RateLimit line in the headers say?

Why a Separate Bulk Endpoint?

Before the bulk order endpoint existed, the schools' integrations sent the list line by line: 500 lines, 500 separate requests. Each request means its own authentication, its own transaction and its own network round trip; a single school's start-of-term loop slowed down the whole API. And when a loop broke at request 312, nobody knew where it had stopped. The bulk endpoint moves this loop to the server and returns the outcome as one report.

The first decision in a bulk design is the size limit. In Bookstore a bulk order takes at most 500 lines, and the limit is written in the contract (BS-70). Google's AIP-233 sets no fixed limit for batch create but asks for the limit to be documented. The second decision is which errors reject the whole request. Bookstore's rule (BS-71): a request that does not match the schema gets a 400 as a whole, while lines that fail business rules are reported one by one. Broken JSON or a line with quantity zero means a bug in the client's code; there is no point sending it without fixing it. A book that is out of stock, on the other hand, is something the client cannot know.

$ jq -n '{unitId: "main-campus", lines: [range(501) | {isbn: "9780441172719", quantity: 1}]}' | curl -s -X POST -H "Authorization: Bearer nevin" -H "Content-Type: application/json" -H 'Idempotency-Key: "lakeside-too-long"' --data @- http://127.0.0.1:52318/institutions/lakeside-school/order-batches | jq -c '{status, errors}'
{"status":400,"errors":{"lines":["out-of-range"]}}

$ jq '.lines[1].quantity = 0' batch.json | curl -s -X POST -H "Authorization: Bearer nevin" -H "Content-Type: application/json" -H 'Idempotency-Key: "lakeside-zero"' --data @- http://127.0.0.1:52318/institutions/lakeside-school/order-batches | jq -c '{status, errors}'
{"status":400,"errors":{"lines[1].quantity":["out-of-range"]}}

The key of the validation error is the request's JSON path, following the rule from post 04: lines[1].quantity, the second line with a zero-based index. In the line report numbers start at 1, because the reader of the report is often a person and "line 6" should match the row in Excel. Having two numbering schemes looks like an inconsistency, but it is deliberate, and the contract states both explicitly. The database side of taking a bulk file into a staging table and producing a line-by-line error file is covered in the Solution Patterns series post "Bulk Import with a Validation Report: Upload Excel/CSV, Staging Table, Row-Level Error File".

Atomic or Partial Success?

The real question is what happens when some lines are rejected. There are two options: do nothing (atomic) or process the accepted lines and report the rejected ones (partial success). The guidelines disagree here. According to AIP-233, a synchronous batch create must be atomic; partial success is possible only for asynchronous methods that return a long-running operation, with a field that lists the failed requests by index. Zalando, in rule #152, requires bulk requests to return a 207 Multi-Status carrying the status of each item, even when every item fails.

Bookstore chose a third way and wrote it into ADR-0006. A bulk order is a resource in its own right: a record added to the order-batches collection, with a report that stays. The request creates this resource, so it returns 201 and Location points to the report; the accepted lines become a single order. 207 Multi-Status comes not from HTTP but from the WebDAV RFC and defines an XML body with an HTTP status code per item. Our lines are not HTTP requests, and reading a report in a 2xx response is easier for every client than handling 207 separately. The school also needs partial success: they do not want an out-of-stock book to stop the order for 499 others. For clients that want atomic behavior there is allOrNothing:

$ curl -s -H "Authorization: Bearer nevin" http://127.0.0.1:52318/orders/$(tail -1 batch-created.txt | jq -r .orderId) | jq -c '{status, unitId, items: [.items[] | {title, quantity, unitPrice: .unitPrice.amount}], total: .total.amount}'
{"status":"Pending","unitId":"main-campus","items":[{"title":"Nineteen Eighty-Four","quantity":15,"unitPrice":197.10},{"title":"Brave New World","quantity":10,"unitPrice":224.10},{"title":"The Great Gatsby","quantity":12,"unitPrice":179.10},{"title":"The Hobbit","quantity":18,"unitPrice":314.10}],"total":13000.50}

$ jq '.allOrNothing = true' batch.json | curl -s -X POST -H "Authorization: Bearer nevin" -H "Content-Type: application/json" -H 'Idempotency-Key: "lakeside-2026-11-strict"' --data @- http://127.0.0.1:52318/institutions/lakeside-school/order-batches | jq -c '{status, type, detail, rejected: [.results[] | select(.status == "Rejected") | .line]}'
{"status":422,"type":"https://api.example.com/problems/batch-rejected","detail":"No order was created: 3 of 7 lines were rejected; see results.","rejected":[3,5,6]}

The four accepted lines became a pending order at the 10% institutional discount. With allOrNothing the same list gets a 422 and no order is created; the results inside the problem still give the reasons line by line. This answers the first two questions from Guess First: 201 means "a bulk order record was created", not "every line was ordered". The client must always read the report, and that is written in the contract. The line results also reuse the error contract from post 04: each rejected line carries its own Problem Details object, and the problem types are the ones in the catalog. Line 6 was rejected with the code duplicate in errors because it has the same ISBN as line 1.

Authorization in Bulk Requests

The most dangerous mistake in a bulk endpoint is doing authorization only at the endpoint level and never looking inside the lines. The check "this user may place bulk orders" passes, and then the loop acts on whatever ids the client sent in each line without asking. Every resource check that exists for single requests (does this order belong to this user, is this unit this buyer's) must run for every item in a bulk request too (BS-73). In Bookstore the unit is a single field for the whole bulk order; lines cannot name another unit, and the check runs once:

$ jq '.unitId = "east-campus"' batch.json | curl -s -X POST -H "Authorization: Bearer nevin" -H "Content-Type: application/json" -H 'Idempotency-Key: "lakeside-east"' --data @- http://127.0.0.1:52318/institutions/lakeside-school/order-batches | jq -c '{status, type, detail}'
{"status":403,"type":"https://api.example.com/problems/unit-not-allowed","detail":"Buyers can only see and place the orders of their own unit (main-campus)."}

In a bulk endpoint whose lines touch different resources (for example "cancel these 40 orders"), this check must run on every line, and an unauthorized line must get the same answer as a line that does not exist; otherwise the bulk endpoint becomes a way to probe other people's order ids in a single request. This class of vulnerability, BOLA, is covered in detail in the API Security series. The bulk endpoint is also a resource consumption risk in itself: one request triggers 500 lines of work, so the size limit and the quota below have to be considered together.

Reporting the Outcome: Polling, Webhooks and SSE

Seven lines are processed in milliseconds, but every line means a stock reservation in the warehouse system, and a 500-line list can take seconds. So the same endpoint can turn into the long-running operation from post 07 with the Prefer: respond-async header defined in RFC 7240 (BS-74). The server says it applied the preference with Preference-Applied; had it not, the client would get an ordinary 201 and still work. A preference is a request, not an order.

$ curl -s -i -X POST -H "Authorization: Bearer grace" -H "Content-Type: application/json" -H 'Prefer: respond-async' -H 'Idempotency-Key: "city-library-2026-11"' -d '{"unitId":"central","lines":[{"isbn":"9780141439587","quantity":5},{"isbn":"9780553293357","quantity":4}]}' http://127.0.0.1:52318/institutions/city-library/order-batches | tee batch-accepted.txt | grep -E '^(HTTP|Retry-After|RateLimit|Preference-Applied|Operation-Location)'
HTTP/1.1 202 Accepted
Retry-After: 1
RateLimit-Policy: "batches";q=5;w=60
RateLimit: "batches";r=4;t=60
Preference-Applied: respond-async
Operation-Location: /operations/01a1dc64-a8eb-7269-aa36-c709c0007ee3

$ sleep 1; curl -s -H "Authorization: Bearer grace" "http://127.0.0.1:52318$(grep Operation-Location batch-accepted.txt | cut -d' ' -f2 | tr -d '\r')" | jq -c '{status, resourceLocation}'
{"status":"Succeeded","resourceLocation":"/institutions/city-library/order-batches/01a1dc64-a8f1-7255-b9d4-a689fb8b22d9"}

There are three ways to learn that the work is done. Polling, where the client asks at intervals, is the simplest: the client needs no publicly reachable address, so even a school system behind a firewall works; the cost is wasted requests and a delay of up to Retry-After. With a webhook, the server itself sends a request to a URL the client registered beforehand when the work finishes; there is no delay, but the client has to run an endpoint that is reachable from the internet, verifies signatures and drops repeated events. Server-Sent Events, where the server streams events to the client over an open HTTP connection, suit a progress bar while the user is on the screen, but they only work while the connection stays open and do not fit an integration's nightly job.

For now Bookstore offers polling and plans webhooks. Even the planned event is part of the contract (BS-76): since OpenAPI 3.1, the top-level webhooks field describes the requests an API will send out. The schools' integration teams can already see the payload:

webhooks:
  orderBatchCompleted:
    post:
      operationId: orderBatchCompletedWebhook
      summary: A bulk order finished (planned)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, type, occurredAt, data]
              properties:
                id:
                  type: string
                  description: Unique event id; receivers use it to drop duplicates.
                type:
                  type: string
                  const: order-batch.completed
                occurredAt:
                  type: string
                  format: date-time
                data:
                  type: object
                  required: [batchId, institutionId, acceptedLines, rejectedLines, links]

The event carries the report's address, not the whole report. Webhooks are delivered at least once, their order is not guaranteed and an attacker may send a fake event; a client that receives a thin event reads the real data from the API with its own identity. Signing webhooks, delivering them through an outbox, retrying, and verifying them on the receiving side are covered in the External Service Integration and Resilience series posts "Sending Webhooks: Standard Webhooks, Signing, Outbox-Based Delivery and Retry Schedules" and "Receiving Webhooks: Signature Verification, Replay Protection, Fast 2xx and Idempotent Processing". When events are published through a message broker, writing the contract with AsyncAPI is covered in the Messaging and Event-Driven Architecture series post "Message Contracts and Schema Evolution: CloudEvents, AsyncAPI, and Versioning".

Writing the Rate Limit as a Contract

A bulk order is an expensive operation, so it has a quota: five bulk orders per minute per institution (BS-75). The main purpose of the quota is not to punish the client but to make the rule visible so the client can pace itself. For that we use the RateLimit header draft of the IETF working group for HTTP APIs:

$ for i in 1 2 3; do curl -s -D - -o /dev/null -X POST -H "Authorization: Bearer nevin" -H "Content-Type: application/json" -H "Idempotency-Key: \"lakeside-retry-$i\"" --data @batch.json http://127.0.0.1:52318/institutions/lakeside-school/order-batches | grep -E '^(HTTP|RateLimit|Retry-After)'; done
HTTP/1.1 201 Created
RateLimit-Policy: "batches";q=5;w=60
RateLimit: "batches";r=1;t=60
HTTP/1.1 201 Created
RateLimit-Policy: "batches";q=5;w=60
RateLimit: "batches";r=0;t=60
HTTP/1.1 429 Too Many Requests
Retry-After: 60
RateLimit-Policy: "batches";q=5;w=60
RateLimit: "batches";r=0;t=60

$ curl -s -X POST -H "Authorization: Bearer nevin" -H "Content-Type: application/json" -H 'Idempotency-Key: "lakeside-retry-4"' --data @batch.json http://127.0.0.1:52318/institutions/lakeside-school/order-batches | jq .
{
  "type": "https://api.example.com/problems/quota-exceeded",
  "title": "Quota exceeded",
  "status": 429,
  "detail": "Policy batches allows 5 requests per 60 seconds; wait for Retry-After seconds.",
  "instance": "/institutions/lakeside-school/order-batches",
  "violatedPolicies": [
    "batches"
  ],
  "traceId": "00-de552911bb54186dd8c3ec54d3eb5264-cea4749ab41137de-00"
}

The draft defines two headers. RateLimit-Policy is the rule itself: "batches";q=5;w=60 means a quota of 5 units per 60 seconds under the policy named "batches"; it is expected to stay the same on every response. RateLimit is the current state: r is the remaining quota and t the number of seconds that quota applies to. The values are Structured Fields; the policy name is a quoted string. When the quota runs out the client gets 429 Too Many Requests (RFC 6585) and Retry-After; according to the draft, when both are present Retry-After takes precedence and must not point to a moment before the end of the window. Schema errors that get a 400 and requests over the size limit do not consume quota, while 403 and 422 do; the draft leaves it to the server which responses consume quota, and we wrote our choice into the contract. The problem type lives in our own catalog; the draft proposes a quota-exceeded type and a violated-policies member in the IANA registry, but since the draft is not an RFC that registration does not exist yet. We return the member as violatedPolicies, following our own naming rule.

We checked the draft's status on the datatracker on the day this post was written (15 November 2026): draft-ietf-httpapi-ratelimit-headers-11, dated 23 May 2026, an "Active Internet-Draft" and a working group document; it expires on 24 November 2026 and -12 has not been published yet. So these headers are not a standard and their format may change. Zalando's rule #153 still allows the trio X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset today, and large APIs such as GitHub use these older names too. We chose the draft and wrote "review the header format if the draft changes" into ADR-0006. What matters is not the header names but that the quota is a documented rule the client can read. The other dimensions of resource consumption (page size, query complexity, timeouts) are covered in the API Security series post on resource consumption.

In ASP.NET Core

ASP.NET Core has had a built-in rate limiting middleware since version 7 (AddRateLimiter, RequireRateLimiting), but two of its defaults need attention: it answers a rejected request with 503 (you have to set RejectionStatusCode for 429), and it does not write Retry-After itself; you have to read the lease's MetadataName.RetryAfter value in OnRejected and write it. Because it also does not expose the remaining quota on successful responses, in Bookstore we wrote the quota as a small endpoint filter: a fixed-window counter per institution, two headers on every response, and Problem Details when the quota runs out. Both the middleware's counters and this filter's live in memory; in an application running two instances the quota effectively doubles, so a shared store or a limit enforced at the gateway is needed. In the bulk order, OrderBatchRequest and its lines are validated by .NET 10's AddValidation; the Lines[1].Quantity key it produces for nested collections is turned into lines[1].quantity by the normalization from post 04. The rate limiting middleware itself is covered in the ASP.NET Core Web API series post "What's New in ASP.NET Core with .NET 7: MapGroup, TypedResults, Rate Limiting and Output Caching".

Common Pitfalls

Turning a bulk endpoint into a loop without authorization. Access control on the endpoint does not remove the need to check every resource the lines touch.

Unlimited bulk. If no limit is written above 500 lines, the first big customer times out with a 50,000-line request, and the next attempt starts the same work again.

Reading 201 as "all good". In a partial success contract, a success status code only says the operation was recorded; if the report is not read, the rejected lines are silently lost.

Leaving bulk requests without an Idempotency-Key. When a 500-line request that timed out is resent, two orders are created. In Bookstore the bulk order also requires the key from post 07.

Announcing the quota only through 429. If the client learns the rule only when it hits the wall, it cannot pace itself; showing the remaining quota on every response and documenting it means fewer 429s.

A Small Experiment

★☆☆ Generate a 50-line list and send it with Prefer: respond-async. Poll the monitor while respecting Retry-After; how does percentComplete progress?

★★☆ For a school whose lines come from Excel we also want to offer the report as CSV. Would you design this as a separate endpoint or as content negotiation with Accept: text/csv? How would you show the Problem Details of rejected lines in a CSV?

★★★ Next to the "5 per minute per institution" quota, add a "10,000 lines per day per institution" quota. How do you show both policies in RateLimit-Policy, and how does a request learn which one it violated? For a policy whose quota is measured in lines, look at the draft's qu parameter.

This post completes the second block of the series: how a contract changes over time, concurrent and repeated requests, long-running work and bulk operations. The last block is about managing the contract. In the next post we put the OpenAPI document we have been growing by hand at the center of a design-first flow, turn the style guide into Spectral rules, catch breaking changes automatically in pull requests, and generate a mock server and an Angular client from the contract.