Errors
Every failure, on every route, has the same shape:
{ "error": { "code": "forbidden", "message": "Your role does not allow that.", "detail": {}, "remedy": "This needs the admin role or above. An owner or admin can change yours.", "requestId": "9a1c2e3f-..." }}| Field | Always present | What it is |
|---|---|---|
code |
Yes | A stable identifier for the kind of failure. Safe to branch on. |
message |
Yes | What went wrong, written for a person. |
detail |
No | Structured particulars, when there are any — the fields of a validation failure, or a GitHub rate limit’s retryAt. |
remedy |
No | What to do about it, when there is something to do. |
requestId |
Yes | The value of the response’s X-Request-Id header. Quote it when asking about a specific failure. |
A body that fails validation before it reaches a handler is always
invalid_request, with detail.issues holding the field-by-field problems
Zod found.
The codes
Section titled “The codes”code |
Status | Meaning | Remedy |
|---|---|---|---|
unauthenticated |
401 | No credential was sent, or the one sent is not valid — including an unknown, revoked or expired API key. | Send a session cookie, a session token, or a valid pb_... key. |
forbidden |
403 | The credential is valid, but the actor’s role or the key’s scopes do not allow this action. | Ask an owner or admin to change your role, or use a key with the needed scope. |
not_found |
404 | Nothing with that id exists, or it exists in a workspace or portal this credential cannot see. | Both cases answer the same way on purpose — an id from another tenant is never confirmed to exist. |
invalid_request |
422 | The request body or query did not match the shape the route expects. | Check field names and types against /openapi.json; detail.issues names the fields. |
conflict |
409 | The request collides with something that already exists — a slug already taken, a duplicate invitation, an installation another workspace already holds. | The message says what collided. Choose a different value, or the object already exists. |
payment_required |
402 | The workspace’s subscription has lapsed. | Reads still work; a write needs the workspace’s billing brought current. See Billing. |
payload_too_large |
413 | An upload is larger than the cap. | Send a smaller file. This is separate from invalid_request because the fix is not a field, it is the file. |
unsupported_media_type |
415 | An upload’s bytes are not a kind Pealboard stores. | Send one of the accepted types. |
rate_limited |
429 | Either Pealboard’s own rate limit or GitHub’s was reached. | See Rate limits — the two have different remedies and rate_limited covers both. |
github_refused |
502 | GitHub refused the call, did not answer, or could not be reached. Nothing in Pealboard’s cache changed. | The message and detail say which. On an unreachable GitHub the result of the request is genuinely unknown — check GitHub before retrying a write. |
upstream_failed |
502 | A dependency other than GitHub refused or did not answer — mail, Stripe. | Retry later. Nothing was written if the failure happened before Pealboard’s own state changed. |
internal |
500 | Something failed inside Pealboard that is not one of the above. | Quote requestId to support. |
Sign-in failures are not this envelope
Section titled “Sign-in failures are not this envelope”Better Auth’s endpoints under /auth and /portal/auth are browser redirects,
not API calls that return JSON. A failed sign-in redirects to the
application’s own /auth/error?error=<code> — the workspace application for
/auth, the portal the request arrived on for /portal/auth. The error
values are Better Auth’s, not this API’s; the ones a person actually meets are
email_not_found (a GitHub account with no address Pealboard’s App may read),
account_not_linked, signup_disabled, please_restart_the_process (an
expired or reused sign-in state) and unable_to_create_user. Treat an
unrecognized value as a generic failure and offer the sign-in screen again.