Response envelope and errors
Every JSON response from the bAInquet API uses one wrapper, and every error code in this catalogue is documented at a stable URI so SDKs can map it to a typed error.
The envelope
One top-level boolean, ok, discriminates the union. ok: true carries data; ok: false carries error. Both branches carry meta, and both carry the ok field so one client code path handles both.
Success (Content-Type: application/json):
{
"ok": true,
"data": { "...": "typed payload" },
"meta": { "requestId": "req_01J...", "cursor": "...", "limit": 25 }
}Error (Content-Type: application/problem+json, RFC 9457 compatible):
{
"ok": false,
"error": {
"code": "website.not_verified",
"message": "Website must be verified before publishing.",
"type": "https://docs.bainquet.online/errors/website.not_verified",
"status": 409,
"requestId": "c0ffee00-0000-4000-8000-000000000002",
"retryable": false,
"details": { "verificationState": "pending" }
}
}Branch on ok, not on HTTP status
A 200 can carry a partial-batch ingest result where individual items failed (see partial success). 204 No Content carries no body at all (no envelope), used for idempotent deletes and acks.
Success fields
| Field | Type | Notes |
|---|---|---|
ok | boolean | always true on this branch |
data | object, array, or null | the typed payload; an object for single-resource reads, an array for list reads (with meta.cursor), null only where a 200 action returns no body |
meta | object | present on list responses and whenever requestId is surfaced |
meta fields
| Field | Type | Notes |
|---|---|---|
requestId | string (uuidv4) | correlation id; echoes X-Request-Id or is server-minted; appears in logs and traces, and as error.requestId on the error branch |
cursor | string or null | opaque next-page cursor; null means end of list; absent means the response is not a paginated collection |
limit | integer | page size actually applied after clamping to the server max |
total | integer or null | optional total count; null when a count is unavailable or expensive (cursor pagination does not guarantee total) |
error fields
| Field | Type | Notes |
|---|---|---|
code | string | namespaced machine code from the catalogue; stable forever; SDKs map it to a typed error |
message | string | human-readable English, safe to log; not localized and not for verbatim end-user display (localize off code) |
type | string (URI) | https://docs.bainquet.online/errors/{code}; the RFC 9457 type member |
status | integer | HTTP status, mirrors the response status line |
requestId | string (uuidv4) | same correlation id meta.requestId would carry |
retryable | boolean | true means the client may retry the same request (honor Retry-After if present) |
details | object or null | code-specific structured context; never contains secrets, tokens, SQL, or stack traces |
The error body is a superset of RFC 9457 problem+json: type, status, and a renamed detail (the envelope calls it message) map directly; code, details, retryable, and the top-level ok are RFC-permitted extensions. error.code is the primary programmatic key.
details shapes
error.details is code-specific but follows fixed shapes so SDKs can type it:
| Variant | Used by | Shape |
|---|---|---|
| Field errors | validation.failed | { "fields": [ { "field": "<JSON path>", "code": "string", "message": "string" } ] } |
| Quota | quota.exceeded | { "limit": int, "used": int, "resetAt": "<ISO-8601>" } |
| Conflict | publish.conflict, ingest.duplicate | { "conflictingId": "uuid" } |
| Verification | website.not_verified, verification.* | `{ "verificationState": "pending |
| Size | ingest.payload_too_large | { "maxBytes": int, "gotBytes": int } |
| Plan gate | billing.payment_required | { "requiredPlan": "string" } |
| Empty | everything else | {} (emit {}, never null, when the code defines no context) |
Pagination
Pagination is cursor-based only. The first page omits cursor; later pages echo back the opaque meta.cursor value verbatim.
GET /v1/websites/site_123/knowledge/search?q=price&limit=2&cursor=eyJrIjoiZmFjdF8wNDIifQ
Authorization: Bearer <USER_JWT>{
"ok": true,
"data": [ { "...": "hit 1" }, { "...": "hit 2" } ],
"meta": { "cursor": "eyJrIjoiZmFjdF8wNDQifQ", "limit": 2, "total": null, "requestId": "c0ffee00-...-0001" }
}- The cursor is opaque: a base64url server-signed keyset token, not an offset or a raw primary key. Treat it as a blob.
limitis clamped to a server max (default 50, max 200); the applied value comes back inmeta.limit.meta.cursor: nullmeans the last page. A non-null cursor never appears on an emptydata: []page.- A cursor may become invalid after a retention or partition operation; reusing it then returns
422 validation.stale_cursor, and the client restarts from page one.
Partial success (batch semantics)
Transport and item outcome are separate layers. A batch that was received and processed returns ok: true (HTTP 200) even when some items failed; the per-item results live inside data, never as an envelope error. An ok: false body is reserved for envelope-level failure such as auth, a malformed body, or a whole-request rejection.
{
"ok": true,
"data": {
"accepted": 8,
"skipped": 1,
"errored": 1,
"results": [
{ "id": "p_1", "status": "accepted" },
{ "id": "p_2", "status": "skipped", "reason": "idempotent_replay" },
{ "id": "p_3", "status": "error", "code": "validation.failed",
"details": { "fields": [ { "field": "url", "code": "format", "message": "invalid URL" } ] } }
]
},
"meta": { "requestId": "c0ffee00-...-0005" }
}Per-item status is accepted, skipped, or error. A skipped item carries a reason of idempotent_replay, unchanged_checksum, or tombstoned. An error item reuses a catalogue code. See Ingestion and signing for the full batch flow.
Error-code catalogue
Codes are namespaced, additive-only, and immutable: a code's meaning, HTTP status, and retryable default never change once shipped. The authoritative registry is packages/shared/src/error-codes.ts; throwing AppError("code", ...) derives the status and retryable flag from that map, so a code not in the map cannot be emitted. The tables below reproduce the registry as shipped in contractVersion 0.4.0 (the 2026-06-15 reconciliation added the precise auth, ingest, connector, validation, and billing codes that the auth, ingestion, and connector specs already referenced).
auth.*
| Code | HTTP | Retryable | Meaning |
|---|---|---|---|
auth.unauthenticated | 401 | no | no or invalid principal credential |
auth.invalid_credentials | 401 | no | wrong email or password on login |
auth.token_expired | 401 | no | access JWT or connector token expired; refresh |
auth.token_revoked | 401 | no | refresh-rotation reuse or revocation; re-login |
auth.refresh_invalid | 401 | no | refresh token not recognized |
auth.refresh_reuse_detected | 401 | no | a rotated refresh token was replayed |
auth.session_revoked | 401 | no | session ended (logout-all, password change) |
auth.token_version_stale | 401 | no | token issued before a tokenVersion bump |
auth.invalid_signature | 401 | no | connector HMAC X-Signature mismatch |
auth.timestamp_skew | 401 | yes | X-Timestamp outside the +/-300s window; re-sign |
auth.replay_detected | 409 | no | a previously seen nonce was replayed |
auth.mfa_required | 401 | no | second factor required (2FA, see note) |
auth.mfa_invalid | 401 | no | second-factor code rejected (2FA, see note) |
auth.email_unverified | 403 | no | action needs a verified email |
auth.email_taken | 409 | no | registration email already in use |
auth.forbidden | 403 | no | authenticated but RBAC role lacks permission |
auth.forbidden_role | 403 | no | role below the required rung |
auth.forbidden_scope | 403 | no | credential used outside its scope |
auth.scope_violation | 403 | no | connector or key used outside org/website/sourceType scope |
auth.tenant_mismatch | 403 | no | cross-tenant access blocked |
auth.principal_role_misuse | 400 | no | wrong principal type for the route |
auth.service_credential_external | 401 | no | internal service credential used externally |
auth.reauth_required | 401 | no | step-up re-authentication required |
auth.google_unconfigured | 503 | no | Google sign-in not configured on this deployment |
2FA codes
auth.mfa_required (the account has 2FA enabled and no code was supplied) and auth.mfa_invalid (the code or recovery code was wrong) are returned by the live /v1/auth/2fa/* flow and the login second-factor gate.
validation.*
| Code | HTTP | Retryable | Meaning |
|---|---|---|---|
validation.failed | 422 | no | one or more fields invalid; details.fields[] populated |
validation.malformed_body | 400 | no | body is not valid JSON or wrong content-type |
validation.unsupported_field | 422 | no | unknown or forbidden field for this resource |
validation.missing_idempotency_key | 400 | no | a mutating ingest call lacks Idempotency-Key |
validation.stale_cursor | 422 | no | pagination cursor no longer valid; restart from page one |
validation.scope_unknown | 422 | no | requested scope is not a known scope |
validation.scope_forbidden | 422 | no | requested scope not permitted for this principal |
validation.ambiguous_credential | 400 | no | more than one credential presented |
website.* and verification.*
| Code | HTTP | Retryable | Meaning |
|---|---|---|---|
website.not_found | 404 | no | unknown website id, or not visible to the principal |
website.not_verified | 409 | no | action requires a verified website |
verification.in_grace | 409 | yes | verification in grace; retry after a re-check |
verification.failed | 409 | no | verification state failed or revoked |
verification.method_unsupported | 422 | no | requested verification method tier not available |
connector.* and ingest.*
| Code | HTTP | Retryable | Meaning |
|---|---|---|---|
connector.unknown_type | 422 | no | X-Connector-Type not registered |
connector.version_unsupported | 426 | no | X-Connector-Version below the minimum supported |
connector.token_revoked | 403 | no | connector token revoked |
connector.scope_violation | 403 | no | connector used outside its bound scope |
connector.replay_detected | 409 | no | connector nonce replayed |
connector.rotation_in_progress | 409 | yes | a token rotation is in flight; retry shortly |
ingest.payload_too_large | 413 | no | batch or item exceeds the per-tenant size cap |
ingest.batch_too_large | 413 | no | batch exceeds the item-count cap (500) |
ingest.duplicate | 409 | no | idempotency replay with a conflicting body for the same key |
ingest.idempotency_conflict | 409 | no | idempotency key reused with different content |
ingest.item_rejected | 422 | no | whole-request item rejection (per-item failures live in data) |
ingest.item_invalid | 422 | no | a single item failed item-level validation |
ingest.unknown_item | 404 | no | referenced item id does not exist |
ingest.batch_rejected | 422 | no | the whole batch was rejected (partial: false) |
ingest.schema_conflict | 409 | no | POST /v1/ingest/schema mapping conflicts with an existing one |
ingest.schema_invalid | 400 | no | schema-advertisement body is invalid |
quota.* and billing.*
| Code | HTTP | Retryable | Meaning |
|---|---|---|---|
quota.exceeded | 429 | yes | plan limit hit; details:{limit,used,resetAt}; Retry-After set |
quota.rate_limited | 429 | yes | per-tenant or global abuse rate limit; Retry-After set |
billing.payment_required | 402 | no | feature gated to a paid plan; details.requiredPlan |
billing.plan_required | 402 | no | action requires a higher plan |
billing.subscription_inactive | 402 | no | org subscription lapsed or canceled |
billing.unconfigured | 503 | no | payments not wired on this deployment (see note) |
Billing
The entitlements and usage reads are live (plan-derived constants). Live payment processing is wired at launch; in the current build a checkout attempt surfaces billing.unconfigured (503), framed as a server-config gap rather than a card failure. See REST API.
publish.*, admin.*, and generic
| Code | HTTP | Retryable | Meaning |
|---|---|---|---|
publish.conflict | 409 | yes | concurrent publish of the same node; details.conflictingId |
publish.nothing_to_publish | 409 | no | no derived node content to publish |
publish.review_required | 409 | no | unreviewed required-importance facts remain; details.pendingCount |
admin.action_forbidden | 403 | no | action restricted to admin or super_admin |
internal.error | 500 | no | unhandled server fault; requestId is the only safe handle |
service.unavailable | 503 | yes | a dependency (queue, store) is temporarily down; Retry-After set |
privacy.*
GDPR and takedown intake returns 202 Accepted on the happy path; these codes cover the error edges. Note that organization-level access control reuses auth.* and privacy.*; there is no organization.* namespace.
| Code | HTTP | Retryable | Meaning |
|---|---|---|---|
privacy.erasure_forbidden | 403 | no | caller is not the data subject or lacks organization_owner |
privacy.erasure_in_progress | 409 | yes | an erasure for this subject is already running; the request collapses onto it |
privacy.subject_not_found | 404 | no | no matching data subject or scope (existence not leaked) |
privacy.takedown_forbidden | 403 | no | takedown requested by a principal lacking authority over the target |
privacy.takedown_target_not_found | 404 | no | takedown target does not exist or is not visible |
privacy.consent_required | 403 | no | action needs a lawful-basis or consent record that is absent |
Branch on the registry codes
Earlier spec prose referenced a few code names that the server never emitted (for example auth.signature_invalid and ingest.item_invalid as a body code). The 2026-06-15 registry reconciliation resolved this: the codes above are exactly what the server can throw. Branch on these, not on names from older drafts.
Producer guarantees
- Exactly one of
dataorerroris present, matched took. error.codealways exists in this catalogue;error.statusequals the HTTP status line;error.typeis the canonical docs URI for the code.requestIdis present on every response (successmeta.requestIdorerror.requestId).- A
Retry-Afterheader accompanies anyretryable: true429 or 503. - Secrets, PII, SQL, and stack traces never appear in
messageordetails.
Consumers must tolerate unknown meta, error, and details fields for forward-compatibility: adding a code or an optional field is a minor, non-breaking change.