BroadNet
HTTP API & webhooksAPI design

API errors (problem+json) and pagination (cursor, Link header)

Updated 6 min readStandards-based
Direct answer

RFC 9457 problem+json standardises error responses as a JSON object with type, title, status, detail and instance, served as application/problem+json — so clients parse errors uniformly. Pagination splits large collections into pages using a cursor (stable) or offset (simple), with next/prev links carried in the RFC 8288 Link header.

§01

problem+json: a standard error object

Bespoke error shapes force every client to special-case every API. RFC 9457 (which obsoletes RFC 7807) fixes a single, extensible JSON error format, served with the media type application/problem+json so a client knows to parse it as a problem. The object has five standard members: type, a URI identifying the problem class and doubling as a link to human docs; title, a short, stable, human-readable summary of that class; status, the HTTP status code echoed into the body; detail, a human-readable explanation specific to this occurrence; and instance, a URI identifying this particular occurrence. Any additional members (a validation-errors array, a request id, a retry hint) extend the object without breaking the standard ones, so a generic client reads the common fields and a specific client also reads the extensions.

RFC 9457 problem+json error bodyjson
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://example/problems/invalid-destination",
  "title": "Invalid destination number",
  "status": 422,
  "detail": "The field 'to' is not a valid E.164 number.",
  "instance": "/v1/messages/req-01HZY9",
  "errors": [ { "field": "to", "issue": "not_e164" } ]
}
§02

Type, title and instance

The discipline that makes problem+json useful is keeping type and title stable and machine-oriented while letting detail and instance vary per occurrence. A client should branch on type (a stable URI), never on the prose in detail or title, because detail is free to change and may be localised. The type URI ideally resolves to documentation for that error class, so a developer following it lands on an explanation and a remedy. instance identifies the specific failed request — useful to quote in a support ticket — and often mirrors a request id the API also returns in a header. Because type and title identify the class and status/detail/instance describe the occurrence, one problem type can be reused across many failures without ambiguity.

§03

Error taxonomy: retryable vs terminal

Beyond the shape, the client needs to know what to do, and the status-code class answers that. A 4xx is a client error: the request as sent will never succeed, so retrying it unchanged is pointless — the fix is to correct the request (400/422 for bad input, 401/403 for auth, 404 for a missing resource, 409 for a conflict). A 5xx is a server or upstream error: the request may well succeed later, so it is retryable with backoff — ideally under the same Idempotency-Key so the retry cannot double-send. 429 is the special transient case: retryable, but only after the Retry-After delay. Encoding this in the status code (and reinforcing it with a problem type) lets a client decide automatically between fix-and-resend, back-off-and-retry, and give-up.

Error class and the correct client action
ClassMeaningRetryable?Client action
400 / 422Malformed / invalid inputNoFix the request, then resend
401 / 403Unauthenticated / forbiddenNoFix credentials or scope
404Resource not foundNoCorrect the identifier
409State conflict (e.g. key reuse)NoResolve the conflict
429Rate limitedYes (delayed)Wait Retry-After, then retry
500 / 502 / 503Server / upstream failureYesBackoff + retry under same idempotency key
§04

Pagination: cursor vs offset

A collection endpoint that could return millions of messages must page. The two mainstream models trade simplicity against correctness. Offset pagination (?limit=50&offset=100) is trivial and lets a client jump to an arbitrary page, but it is unstable on a changing collection: if rows are inserted or deleted between pages, items shift and the client skips or repeats records — and deep offsets get slow because the store must scan and discard everything before them. Cursor (keyset) pagination hands the client an opaque cursor that encodes the position of the last item seen (for example an id and timestamp); the next request asks for rows after that cursor. It is stable under concurrent writes and stays fast at any depth because the query seeks directly to the cursor, at the cost of not supporting arbitrary page jumps. For high-volume, append-heavy resources like message logs, cursor pagination is the correct default.

Cursor vs offset pagination
PropertyOffsetCursor (keyset)
Request?limit=50&offset=100?limit=50&after=<cursor>
Arbitrary page jumpYesNo (sequential only)
Stable under inserts/deletesNo — items shiftYes
Deep-page performanceDegrades (scan + discard)Constant (seek to cursor)
Best forSmall, stable collectionsLarge, append-heavy logs
§05

The Link header (RFC 8288)

Rather than bury next/previous URLs in the body, an API can carry them in the standard Link header defined by RFC 8288 (Web Linking), using registered relation types — next, prev, first, last. The client follows the rel="next" link to advance without constructing URLs itself or knowing the cursor scheme, which keeps the pagination mechanism entirely server-controlled and lets it evolve (offset today, cursor tomorrow) without breaking clients. A common belt-and-braces approach also mirrors the links into the JSON body for clients that do not read headers, but the Link header is the standardised, transport-level home for them and is the same mechanism a browser or a well-behaved API client already understands.

Pagination via the RFC 8288 Link headerhttp
HTTP/1.1 200 OK
Content-Type: application/json
Link: </v1/messages?after=01HZY8Q3M6&limit=50>; rel="next",
      </v1/messages?limit=50>; rel="first"

{ "data": [ /* up to 50 messages */ ],
  "page": { "count": 50, "has_more": true } }
§06

Page-size limits and defaults

Page size must be bounded on the server, not left to the client, or a single request for a huge page becomes a denial-of-service vector and a source of unpredictable latency. The pattern is a sensible default (used when the client omits limit) and a hard maximum the server silently clamps to — a client asking for more simply receives the maximum. Advertising both the effective page size and whether more pages remain (a has_more flag or the presence of a rel="next" link) lets a client loop safely: keep following next until there is no next. Combined with cursor pagination and problem+json errors, this gives a collection API that is predictable to consume at any scale. BroadNet documents its concrete error codes and pagination for developers.

FAQ

Frequently asked

What is problem+json and why use it?

problem+json (RFC 9457, which obsoletes RFC 7807) is a standard JSON error format served as application/problem+json, with the members type, title, status, detail and instance. It lets every client parse errors the same way instead of special-casing a bespoke shape per API, and it is extensible so extra members can be added without breaking the standard ones.

Which problem+json field should a client branch on?

type — a stable URI identifying the error class. title and detail are human-readable and may change or be localised, so code should never switch on their prose. The type URI ideally resolves to documentation for that error, while status, detail and instance describe the specific occurrence.

How does a client know whether to retry an error?

From the status class. A 4xx is a client error that will not succeed unchanged, so fix the request rather than retry. A 5xx is a server/upstream failure that may succeed later, so retry with backoff under the same idempotency key. 429 is retryable but only after the Retry-After delay.

What is the difference between cursor and offset pagination?

Offset (?limit&offset) is simple and supports jumping to any page but is unstable when rows are inserted or deleted between requests and slows down at deep offsets. Cursor (keyset) pagination passes an opaque marker of the last item seen and asks for rows after it — stable under concurrent writes and fast at any depth, but sequential only. Cursor is the right default for large message logs.

How does the RFC 8288 Link header help pagination?

It carries the next, prev, first and last page URLs in a standard header using registered relation types, so the client follows rel="next" to advance without building URLs or knowing the cursor scheme. That keeps the pagination mechanism server-controlled and free to evolve, and it is a mechanism generic HTTP clients already understand.

Why cap page size on the server?

Because an unbounded page size lets one request pull an enormous result set, which is a denial-of-service risk and gives unpredictable latency. The server applies a default when limit is omitted and silently clamps to a hard maximum, and it advertises whether more pages remain so clients can loop by following next until it is absent.

References

Source specifications

The published standards this page is grounded in. Opens in a new tab.

BNMaintained by the BroadNet engineering team·Carrier-grade messaging since 2003
Put it on the wire

Build with a real SMPP bind and REST API.

Request test access with real test keys: send to your own verified number first, then move to production across 200+ countries once your sender identity and routes are approved.