Skip to content

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.

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"
}
  • code is stable across versions and uses <domain>.<reason> snake_case. Clients should switch on code, not on message.
  • message is 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_id is 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 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.

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.

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.

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.

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.

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.

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.