Conventions

Conventions

The rules that hold on every endpoint, stated once here rather than repeated on all twenty-five.

Idempotency

Endpoints that change something accept an Idempotency-Key header. Repeating a request with the same key replays the original response instead of repeating the effect, so a retry after a timeout cannot create a second passport. Keys belong to one organization, so two customers never collide on the same value. Test and live are separate: a key already used in one mode is refused in the other rather than replayed. One exception, and it is a capability rather than a fact: the signed upload URL returned by `POST /documents` is re-issued on replay while the file is still outstanding, and omitted once it has arrived — an expired URL would be a faithful replay of nothing usable.

A key is remembered for 30 days. After that it is forgotten entirely, and a retry carrying it runs again rather than replaying — so a reconciliation job that may run later than that should mint a fresh key.

Request
curl "https://passportcraft.com/api/v1/organizations/{organization}/passports" \
  -X POST \
  -H "Authorization: Bearer $PASSPORTCRAFT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"category":"textile"}'

Pagination

Collections are paginated by cursor, never by offset. An offset is wrong under concurrent writes: a record inserted between two page fetches shifts every later record, and a client silently skips or duplicates rows.

Send next_cursor back as cursor to fetch the next page. A cursor encodes the filters it was issued under, so sending it with different filters is refused rather than answered with a page that quietly means something else.

Response
{
  "object": "list",
  "data": [],
  "has_more": true,
  "next_cursor": "eyJjIjoiMjAyNi0wOC0wMVQxNDowMzoyMi41MDFaIn0"
}

Conditional writes

Reading a passport returns an ETag carrying its version. Send it back as If-Match on a PATCH and the write is refused if the passport changed in between, which is the only way to make a read-modify-write safe against a concurrent editor.

Request
curl "https://passportcraft.com/api/v1/organizations/{organization}/passports/{id}" \
  -X PATCH \
  -H "Authorization: Bearer $PASSPORTCRAFT_API_KEY" \
  -H "If-Match: \"4\"" \
  -H "Content-Type: application/json" \
  -d '{"data":{"recycled_content_percentage":38}}'

Rate limits

Three counters run at once on every credentialed request and the narrowest one decides. The published figures are deliberately conservative starting points rather than tuned ceilings.

CounterRequestsWindow
key_org100060s
organization300060s
credential1000060s
destructive503600s

Every metered response carries RateLimit and RateLimit-Policy headers naming the counter that came closest to its limit. A refusal adds Retry-After. Wait for it rather than guessing.

A request that arrives without a usable credential is measured against a separate, much tighter counter of 60 requests a minute per client address. It never applies to a valid key.

Request size

A body larger than the ceiling for its endpoint is refused with payload_too_large before anything is read towards the database. The ceiling is checked twice: first against the declared Content-Length, which costs nothing, and then against the bytes that actually arrive, so a request that declares no length is bounded just the same.

EndpointsCeiling
Every endpoint that writes a single record256 KiB (262144 bytes)
Bulk import and unit batches4 MiB (4194304 bytes)

The bulk ceiling comes from the largest batch this API accepts, not from a round number: 500 import rows with every field of a category filled in, or 1000 serialized units. Neither fits inside the standard ceiling, which is exactly why those two endpoints have one of their own.

A body must arrive as application/json; application/merge-patch+json is accepted too. Anything else is refused with unsupported_media_type rather than failing inside the parser — the case worth knowing about is curl -d, which sends application/x-www-form-urlencoded unless told otherwise. A body sent with no Content-Type at all is read as JSON.

HTTP methods

An endpoint answers only the methods it documents. Any other method is refused with method_not_allowed and an Allow header naming the ones that do work, so a client reaching for the wrong verb learns the right one from the refusal itself. HEAD works wherever GET does, and OPTIONS works everywhere.

Response
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, PATCH, OPTIONS
Content-Type: application/json

{
  "error": {
    "code": "method_not_allowed",
    "documentation_url": "https://passportcraft.com/docs/api/errors#method_not_allowed",
    "request_id": "req_9Fv2KpQ0aXbT4Lmn",
    "details": { "method": "DELETE", "allowed": ["GET", "HEAD", "PATCH", "OPTIONS"] }
  }
}

Destructive operations

Unpublishing a passport and moving one to the trash both take a published record out of the market, so they draw on a separate cap alongside the ordinary rate limit. A loop of cheap, well-formed requests must not be able to take a whole catalogue dark.

Attaching a document to a field

Some url fields accept an uploaded document instead of a link — a test report or a declaration of conformity, for example. Upload the file first, then point the field at it by writing the document's id to that field's companion key in `data`. The field itself may stay empty: a documentable field counts as provided once a document is attached, so a passport with an empty url can still validate and publish.

You never have to construct that key by hand. A field that accepts documents carries two attributes in the category schema: `document_types`, listing the kinds it takes, and `document_id_key`, naming the exact key to write. Read the key from the schema rather than assuming the naming rule, which is the only supported way to find it. The companion is a property of the field, not a field of its own, so it never appears in the schema’s `fields` list.

One rule the schema does not spell out for you: the document's `access_tier` must match the field's. A public document cannot satisfy an authority-tier field, and the attach is refused with `access_tier_mismatch` — because a document whose tier differs from its field would not be shown where the field is shown. Create the document at the `access_tier` the field descriptor reports.

Request
curl "https://passportcraft.com/api/v1/organizations/{organization}/passports/{id}" \
  -X PATCH \
  -H "Authorization: Bearer $PASSPORTCRAFT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data":{"conformity_declaration_url__document_id":"doc_123"}}'

Clear the companion key to detach the document; the file itself stays in your library until you delete it. Deleting a document that a field still points at reports what it detached, and that report reaches only the caller that made the request.

A document's `upload_state` is `ready`, `pending` or `unknown`. `unknown` means we could not establish whether the bytes are in storage — either storage did not answer, or the row fell past the 25 byte-less documents one request will probe. Treat it as undetermined, not as absent: publishing refuses on `unknown` with `storage_unavailable` rather than telling you a file is missing, and a later read normally settles it.

Request identifiers

Every response carries a Request-Id header, on success just as much as on failure. Quote it in a support request and the exact call can be found.

The event feed keeps 30 days of history. An integration polling less often than that will miss changes and should reconcile against the passport list instead.

Conventions — PassportCraft API | PassportCraft