Long-running operations, webhooks and bulk endpoints
Some work cannot finish inside an HTTP request. Returning 202 with a status resource is the pattern that keeps clients and servers honest.
202 and a status resource
POST /reports HTTP/1.1
Content-Type: application/json
{"from":"2026-01-01","to":"2026-06-30","format":"csv"}
HTTP/1.1 202 Accepted
Location: /operations/7f3c9a1e
Retry-After: 5
{"id":"7f3c9a1e","state":"pending"}GET /operations/7f3c9a1e HTTP/1.1
HTTP/1.1 200 OK
{"id":"7f3c9a1e","state":"running","progress":0.4,
"startedAt":"2026-09-18T10:00:01Z"}
# ...later...
HTTP/1.1 200 OK
{"id":"7f3c9a1e","state":"succeeded",
"result":{"href":"/reports/2026-H1.csv","expiresAt":"2026-09-25T10:00:00Z"}}
# and on failure
{"id":"7f3c9a1e","state":"failed",
"error":{"code":"source_unavailable","detail":"warehouse timeout"}}- 202 means accepted, not done. Any other success code tells the client something untrue.
- The operation resource is itself a resource: it can be listed, expired and deleted.
- Include a terminal state and a result location. A client polling forever because it never learned the job finished is a bug in the API, not the client.
- Support cancellation with
DELETE /operations/{id}or a cancel action, and document whether cancellation is best-effort. - Expire operation records so the store does not grow without limit, and return 410 Gone afterwards with a clear error code.
Webhooks that can be trusted
import hmac, hashlib, time
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET = b"whsec_..." # per-subscription, not global
TOLERANCE = 300 # seconds of clock skew allowed
@app.post("/webhooks/orders")
def receive():
raw = request.get_data() # the exact bytes, no re-encoding
ts = request.headers.get("X-Signature-Timestamp", "")
sig = request.headers.get("X-Signature", "")
if abs(time.time() - int(ts or 0)) > TOLERANCE:
return jsonify(error="stale_timestamp"), 400
expected = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
return jsonify(error="bad_signature"), 401
event = request.get_json(force=True)
if seen(event["id"]): # idempotent consumer
return "", 200
queue.enqueue(event)
mark_seen(event["id"])
return "", 202| Concern | Server duty | Consumer duty |
|---|---|---|
| Authenticity | Sign the payload and timestamp | Verify before parsing |
| Replay | Include an event id and a timestamp | Reject stale timestamps, deduplicate by id |
| Delivery | Retry with exponential backoff, cap attempts | Return 2xx quickly, then process |
| Ordering | Do not promise it; include a sequence or timestamp | Handle out-of-order events |
| Failure | Disable a subscription after sustained failure and notify | Expose a health endpoint for the receiver |
| Observability | Log the delivery id and the response code | Store the delivery id with the side effect |
⚠️
A webhook receiver should do one thing in the request: authenticate and enqueue. Any real work in the handler turns a slow dependency into missed deliveries, because the sender will time out and retry a job you were already performing.
FAQ
Polling or webhooks?
Both. Webhooks as the fast path, plus a poll on a slow schedule as a reconciliation safety net for deliveries that were lost while the receiver was down.
How do I make a bulk endpoint safe?
Return a per-item result array with a summary count, support an idempotency key for the whole batch, and set an explicit limit on the number of items. Reject the entire batch on validation failure only if a partial apply would leave inconsistent data.
Related
Idempotency, retries and rate limiting Testing and contract testing REST APIs
Last refreshed 2026-09-18.