---
title: "Response Conventions"
description: "Status codes, error envelope, retries, and idempotency across the PgBeam API."
canonical: "https://pgbeam.com/docs/api/response-conventions"
last-updated: "2026-09-14T19:37:21.000Z"
---

# Response Conventions

> Status codes, error envelope, retries, and idempotency across the PgBeam API.

URL: https://pgbeam.com/docs/api/response-conventions

Every endpoint follows the same conventions for status codes, error responses, retries, and idempotency.

## Status codes

Code

Meaning

Description

`200`

OK

Success with a response body

`201`

Created

A resource was created

`204`

No Content

Success, no response body

`4xx`

Client error

Bad request, unauthorized, not found, etc.

`5xx`

Server error

Something went wrong on our end

## Pagination

Every collection pages the same way, whatever it is listing.

`page_size` sets how many items to return. The maximum is 100 and the default is 20. Asking for more than the maximum is rejected with `400`.

`page_token` continues from where the last page stopped. Omit it for the first page.

`next_page_token` comes back in the body when there is another page. Its absence means you have reached the end.

## Follow the Link header instead

Every page that has a successor also carries an RFC 8288 `Link` header:

The target is a relative URI-reference, which RFC 8288 resolves against the URL you requested. It keeps your filters, your sort and your page size, so following it needs no knowledge of this API beyond "fetch `rel=next` until there isn't one". Resolve it against the response URL and that is the whole loop:

`new URL(next, url)` is what resolves the reference, and it is a no-op for an absolute target, so the same loop works either way. Most Link header libraries and every browser `fetch` in a page do this for you.

The target stays relative deliberately. An absolute one would have to name a host, and the only hosts available at that point come from the request headers you sent, so a poisoned header could point `rel=next` at somebody else's server and your `Authorization` header would follow it there. A relative target can only ever resolve to the origin you already chose.

When there is no next page there is no `Link` header. Stopping on its absence is what terminates the loop.

## The token is opaque

`page_token` is a string to hand back, not a value to construct. Some collections put a record id in it, one puts an offset, and which is which is free to change. Parsing it, incrementing it, or building one yourself will break.

## Error document

Every error is an RFC 9457 problem detail, served as `application/problem+json`:

Field

Type

Description

`type`

`string`

URI identifying the condition; resolves to its entry in the error catalog

`title`

`string`

Short summary of the condition

`status`

`integer`

HTTP status, repeated so the document stands alone

`detail`

`string`

Explanation of this occurrence, written for a person

`instance`

`string`

Path of the request that failed

`code`

`string`

Machine-readable identity; the same identity as `type`, as a short token

`request_id`

`string`

Correlation id, also in the `X-Request-Id` header

`errors`

`array`

Field-level detail on a validation failure, each entry `{ field, detail }`

## Error codes

Every code, what causes it and what to do about it is in the error catalog. Branch on `code`, not on `detail`: the detail is written for a person and is free to change, and two conditions can share a status. A `403` is either a permissions problem (`FORBIDDEN`) or a billing one (`PLAN_LIMIT_REACHED`), and those are resolved differently.

## Conditional requests

Every JSON read carries an `ETag`, and `Cache-Control: private, no-cache`. The data is per-tenant, so no shared cache may keep it, and a stored copy has to be revalidated before use. The tag is what makes revalidating cheap.

## Polling without transferring

Send the tag back in `If-None-Match`. If nothing has changed you get `304 Not Modified` with no body.

`If-None-Match` accepts a comma-separated list, and `*` matches any current representation.

## Writing without losing someone else's change

This is the half that matters. Without a precondition, a write is last-writer-wins: read a policy profile, decide what to change, write it back, and any edit that landed in between is silently gone.

Send the `ETag` from the read in `If-Match` on the write. The write proceeds only if the resource still matches; otherwise it is refused with `412 Precondition Failed` and **nothing is changed**.

The `412` carries the current tag, so the recovery is: re-read, re-apply your change, retry with the new tag.

Supported on the six resources you read and write back: `updateProject`, `updateDatabase`, `updatePolicyProfile`, `updateWebhookEndpoint`, `updateHoneytoken` and `updateAgentCredentialStatus`. Omitting the header keeps the previous unconditional behaviour, so this is opt-in.

## How it relates to Idempotency-Key

They cover different failures and compose.

Prevents

`Idempotency-Key`

A retry creating a **second** resource

`If-Match`

A write landing on a resource that is **no longer the one you read**

An agent doing read-modify-write under retry wants both.

## Retries

## Retryable status codes

The SDKs automatically retry requests that fail with these status codes:

Status

Retryable

Notes

408

**Yes**

Request timeout

429

**Yes**

Rate limited — respects `Retry-After`

502

**Yes**

Bad gateway

503

**Yes**

Service unavailable — respects `Retry-After`

504

**Yes**

Gateway timeout

Network error

**Yes**

Connection refused, DNS failure, timeout

All other codes

No

Returned immediately

## Exponential backoff with jitter

The wait between retries follows this formula:

Setting

Default

Description

Max retries

5

Total retry attempts (`0` to disable)

Initial delay

500ms

First backoff interval

Max delay

30s

Backoff ceiling

## Retry-After header

When the server returns a `Retry-After` header (on 429 or 503), the SDKs use that value instead of their own computed backoff. Both formats are supported:

**Integer seconds:** `Retry-After: 60`

**HTTP-date:** `Retry-After: Thu, 01 Jan 2026 00:00:00 GMT`

## Idempotency

POST and PATCH requests include an `Idempotency-Key` header so retries never double-create resources.

Keys are UUID v4, generated once per SDK call and reused across all attempts

Sent on every attempt (including the first) so the server can deduplicate even if the initial request succeeds but the client loses the response

GET and DELETE are naturally idempotent, so no key is needed

The server caches idempotent responses for **24 hours**

Success responses and 4xx responses are cached. A 4xx is the server's settled answer about that exact request, so a retry carrying the same key is answered from the cache rather than running the handler again

Every 5xx, plus 408 and 429, is **not** cached. A server error means the request produced no settled answer, so the next retry with the same key executes the handler again

That holds for a retry that arrives while the first attempt is still running, not only for one that waits its turn. A second request carrying a key another request is already working on waits for that one to finish and is then answered from the cache. If the first attempt fails without settling, the waiting request claims the key and runs the handler itself, so it gets a real answer rather than being told the operation is still in progress

The operations that accept a key declare `Idempotency-Key` as a header parameter in the OpenAPI spec, so it appears in the generated SDKs and in any client generated from the contract. Those are the operations that create a resource or mint a secret, where a duplicate actually costs something. The server honours the header on any POST or PATCH.

## Reusing a key with a different body

A key is a promise about one specific request, so the server records a fingerprint of the request body alongside it. Sending the same key with a different body returns **409 Conflict** rather than replaying the first response:

The code is what tells this apart from the other 409s. See the error catalog for the full list.

Without this the second request would be answered from the cache with the first request's result, so a client that reused a key by mistake would be handed a `201` describing a resource it never asked to create. Generate a fresh key per logical operation and the case never arises.

## Scope

Keys are scoped to the calling organization, or to the user when the token is account-scoped and carries no organization. One tenant can never read another's cached response, and a request that identifies neither is not cached at all. Keys are stored in the control-plane database rather than a cache, so a key lives for its full 24 hours and is not evicted early under memory pressure.

See the TypeScript SDK and Go SDK docs for language-specific configuration.