Errors & pagination
Reference
Greenlight’s MCP tools and webhooks use the same conventions for errors and pagination. One page; every surface works the same way.
Error envelope
Section titled “Error envelope”Every error response — REST, MCP, webhook delivery failure — uses this flat shape:
{ "code": "app.slug_in_use", "message": "An app with slug 'expense-tracker' already exists in this organization.", "details": { "slug": "expense-tracker" }, "next_steps": "Pick a different slug, or ask the current owner to add you as a co-owner.", "request_id": "req_01HXYZ4M2P"}codeis stable across versions and uses<domain>.<reason>snake_case. Clients should switch oncode, not onmessage.messageis human-readable, one sentence. Greenlight reserves the right to improve wording.details(optional) carries structured, code-specific context — which field failed validation, which check failed in the policy gate, and so on.next_steps(optional) is an actionable hint for the caller.request_idis unique per request. Including it in a support ticket gets to the root cause fastest.
Over MCP the same body comes back as JSON text inside content, with isError: true and no structuredContent — a tool’s advertised output schema describes its success payload, so an error body (which doesn’t match that shape) never goes in structuredContent. Parse content[0].text as JSON to get code / message / next_steps / request_id.
HTTP status mapping
Section titled “HTTP status mapping”| HTTP | When |
|---|---|
400 |
Input failed validation, or a connected-database query/response shape was rejected. details identifies ownership and retry safety for SQL calls. |
401 |
No valid session, bearer token, or required actor token. details.issuer identifies the configured identity provider when known. Code is auth.token_invalid (missing/malformed) or auth.token_expired (TTL elapsed — re-authenticate). |
403 |
Authenticated, but the caller isn’t authorized for this action. |
404 |
The target resource doesn’t exist or the caller can’t see it. |
409 |
The action conflicts with current state (e.g. a slug in use, or a stale Knowledge base_version). |
429 |
The caller’s bucket is exhausted. Retry-After indicates when to retry. details.retry_after_ms carries the same value in milliseconds. |
413 |
The explicit request-body cap was exceeded (validation.body_too_large). |
499 |
The caller disconnected while connected-database work was queued or active. This is a Greenlight response code for observability, not proof that a write rolled back. |
500 |
A platform error. The audit log captures these; report with the request_id. |
502 |
An external dependency or its network/auth path failed. Retry only when the code’s contract says the operation is replay-safe; SQL errors state this explicitly. |
503 |
A bounded platform resource is temporarily full. The SQL gateway returns proxy.query_capacity with Retry-After. |
Error code catalog
Section titled “Error code catalog”The most common stable codes. Not exhaustive; the full set lives in the platform’s shared error module.
| Code | Meaning |
|---|---|
app.slug_in_use |
Another app in the org already owns this slug. |
app.not_found |
The app doesn’t exist or the caller can’t see it. |
app.not_owned |
Authenticated, but the caller isn’t an owner or co-owner of this app. |
app.invalid_slug |
Slug doesn’t match the required pattern. |
app.not_deployed |
The app has no live deployment URL yet (e.g. a preview URL was requested before first deploy). |
app.unreachable |
curlApp could not complete an HTTP response from the deployed app. Check diagnostics and logs; details.hit_app says whether a response began before the failure. |
permission.scope_denied |
Proxy call to an integration the app isn’t granted. |
permission.pending |
The app used an integration whose grant is still in IT review. |
proxy.actor_token_required |
A user-delegated integration call omitted the actor token. |
proxy.query_failed |
A connected-database /query statement was rejected by the database itself (syntax, permission, cast); the message is the database’s own. |
proxy.query_timeout |
A connected-database /query statement exceeded the execution-time cap. Narrow it; do not replay a possible write based on this code alone. |
proxy.query_result_unsupported |
A query returned a second result grid, too many columns, or an unsafe/unbounded native type. Reshape the result or cast to a bounded scalar type. |
proxy.query_capacity |
The SQL execution bulkhead is full. The statement did not start; honor Retry-After. |
proxy.query_canceled |
The caller disconnected while SQL work was queued or active. Inspect execution_outcome before reasoning about a write. |
proxy.upstream_unreachable |
The connected database or another upstream could not be reached. SQL responses mask the driver message and carry structured ownership/outcome fields. |
internal.audit_unavailable |
Greenlight could not persist the required SQL audit event. The database operation may already have committed. |
internal.unexpected |
Unexpected platform fault; internal detail is masked. Report the request_id. |
ai.model_denied |
The AI request used a model alias not granted to the app. |
auth.session_expired |
The session token expired or was revoked. |
auth.token_invalid |
No valid bearer token or the token is malformed/revoked. |
auth.token_expired |
The bearer token’s TTL elapsed; re-authenticate via the agent plugin. |
rate_limit.exceeded |
The caller’s bucket is exhausted. |
env.reserved_name |
envSet tried to set a platform-owned name (e.g. DATABASE_URL). |
env.policy_denied |
Org policy blocked the env operation. |
validation.body_invalid |
Request body failed validation; details carries the issue path. |
Connected-database query errors
Section titled “Connected-database query errors”Every Azure SQL gateway error adds a stable decision record under details:
| Field | Meaning |
|---|---|
fault_origin |
The responsible boundary: request (caller SQL/shape), policy (grant or database-role denial), connected_database (the upstream database), platform (Greenlight), or unknown when the boundary cannot prove responsibility. |
retryable |
The same logical operation could plausibly succeed later. This is not permission to replay it. |
safe_to_retry |
Replaying the identical request cannot duplicate a side effect. Automatically retry only when this and retryable are both true. |
execution_outcome |
not_started or unknown. Once execution may have begun, Greenlight does not claim commit or rollback certainty. |
database_code |
The SQL Server error number when available. Switch on this field, not database-message prose. |
This is how a client distinguishes platform fault from its own fault: fault_origin: "platform" means Greenlight failed; "request" means the submitted query or result shape needs correction; "policy" means the operation is not allowed; and "connected_database" identifies the external database. "unknown" is intentional when a transport boundary cannot assign blame safely. The same request_id appears in the error body and X-Request-Id response header and correlates to Greenlight logs and audit.
Idempotency
Section titled “Idempotency”Mutating MCP tools accept an idempotency_key. A retried call with the same key and payload returns the original response rather than performing the work twice. Use a fresh UUID v4 per logical operation, and reuse the same one only for retries of that operation.
Pagination
Section titled “Pagination”List endpoints — REST and MCP — use opaque cursor pagination, not offset/limit. Offsets are unsafe under concurrent inserts (they double-count or skip) and get expensive at large offsets. They accept limit (default 20, max 100) and cursor; the response includes next_cursor, or null on the last page.
{ "items": [ /* … */ ], "next_cursor": "eyJrIjoiMjAyNi0wMy0xMlQxNDowODoxMVoiLCJpZCI6IjhhM2IxYzRmIn0="}To paginate forward, pass next_cursor back as cursor. To start from the beginning, omit cursor. There is no offset parameter.
Cursors are opaque: they encode the position needed to resume the query, but the encoding is not part of the API contract. Do not parse a cursor; do not generate one.
Filtering
Section titled “Filtering”Where a list endpoint accepts filters, they are query params applied by the server, so a filtered read covers the whole collection rather than the page you happened to fetch. Several filters combine as AND. Multi-value filters accept either repeated (?tag=a&tag=b) or comma-separated (?tag=a,b) form, and substring search is case-insensitive with %/_ treated literally.
A cursor belongs to the filter set that produced it. When you change a filter, start again without a cursor. Some endpoints bind the filter into the cursor and reject a mismatched one as validation.cursor_invalid rather than returning a page from a set you did not ask for, so carrying a cursor across a filter change surfaces as an error rather than a silent reshuffle.
Stable sort
Section titled “Stable sort”Each list has a defined sort key — documented per endpoint in the platform API conventions — with a stable tiebreaker so an item appears exactly once during a sustained pagination even as others are added concurrently. Many lists are newest-first by creation time; the apps catalog is newest-deploy first (never-deployed apps last). The cursor encodes that position; you never sort client-side.
Some endpoints also accept an explicit ordering (sort and order) over a small set of fields. A cursor belongs to the ordering that produced it, so pass the same sort and order on every page of a walk; changing either means starting again without a cursor. Sending a cursor with a different ordering is rejected as validation.cursor_invalid rather than silently returning a page from the wrong sequence.