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
ConcernServer dutyConsumer duty
AuthenticitySign the payload and timestampVerify before parsing
ReplayInclude an event id and a timestampReject stale timestamps, deduplicate by id
DeliveryRetry with exponential backoff, cap attemptsReturn 2xx quickly, then process
OrderingDo not promise it; include a sequence or timestampHandle out-of-order events
FailureDisable a subscription after sustained failure and notifyExpose a health endpoint for the receiver
ObservabilityLog the delivery id and the response codeStore 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.

Idempotency, retries and rate limiting Testing and contract testing REST APIs

Last refreshed 2026-09-18.