REST API
Projects, layouts, the catalog, quotes, webhooks, embeds, and arrangement runs — described by
OpenAPI 3.1 documents with hosted reference pages. The base URL is https://api.sunnysdk.com.
Spec and reference
Section titled “Spec and reference”Use this guide for authentication, conventions, and choosing an API surface. The Scalar references provide each endpoint’s parameters, request and response schemas, required scopes, and copyable request examples. You can also send requests from the reference using your own credential; those requests run against the selected API server and can change workspace data. Use a sandbox workspace when trying writes.
| URL | What |
|---|---|
https://api.sunnysdk.com/docs |
Browsable reference for the unversioned API: every operation, schema, and scope. |
https://api.sunnysdk.com/openapi.json |
Its OpenAPI 3.1 document — feed it to a client generator or API tool. |
https://api.sunnysdk.com/v1/docs |
Browsable reference for the /v1 surface (beta). |
https://api.sunnysdk.com/v1/openapi.json |
The /v1 OpenAPI 3.1 document. |
All four are public; no credential needed. Anything not in a published document isn’t part of the public API.
Authentication
Section titled “Authentication”Every operation names the scope it needs. Two kinds of credential satisfy it.
OAuth Bearer token
Section titled “OAuth Bearer token”For user-facing or multi-tenant integrations: OAuth 2.1, authorization code with PKCE. It is the
same authorization server, scopes, and consent flow as the MCP server. Register a
client (a Client ID Metadata Document is preferred), authorize with
resource=https://api.sunnysdk.com, then send the token on every request. Without resource the
token is opaque and the API rejects it. See OAuth & scopes for discovery,
registration, and the full scope table.
curl https://api.sunnysdk.com/projects \ -H "Authorization: Bearer $TOKEN"API key
Section titled “API key”For server-to-server use without a browser sign-in, send a key in the x-api-key header. The
plaintext key is shown once, when you create it.
- Personal keys (
sunny_sk_…) act as you. Create them under Account → API keys. - Workspace keys (
sunny_wsk_…) are owned by the workspace, not a person, so they keep working when the member who created them leaves. A workspace owner or admin creates them under Organization → API keys.
curl https://api.sunnysdk.com/projects \ -H "x-api-key: sunny_wsk_…"You choose a key’s scopes when you create it. Personal keys can carry only the non-elevated action scopes. Workspace keys can also carry the workspace-level scopes; the ones marked opt-in are never selected by default and must be ticked on purpose.
| Scope | What it allows | Personal key | Workspace key |
|---|---|---|---|
projects:read | View projects | Yes | Yes |
projects:write | Edit projects | Yes | Yes |
quotes:read | View quotes | Yes | Yes |
arrange | Run arrangements | Yes | Yes |
workflows:run | Run render workflows | Yes | Yes |
workflows:read | View render workflows | Yes | Yes |
catalog:read | View catalog | Yes | Yes |
docs:read | Read product documentation | — | Yes |
embeds:read | View product embed analytics | — | Yes |
embeds:write | Manage product embeds | — | Yes |
webhooks:manage | Manage webhook endpoints | — | Yes |
quotes:write | Edit quotes | — | Yes |
org:admin | Administer the workspace | — | Opt-in |
audit:read | Read the workspace audit log | — | Opt-in |
quote_costs:read | See your dealer cost and margin | — | Opt-in |
orders:write | Import order files | — | Opt-in |
catalog:write | Curate the catalog | — | Opt-in |
The admin scope can’t be put on a key, and sign-in scopes (openid, profile, email,
offline_access) have no meaning on one.
Denials
Section titled “Denials”Denials follow RFC 6750: 401 invalid_token for a missing, invalid, or expired credential, and
403 insufficient_scope when the credential lacks the operation’s scope (the WWW-Authenticate
header names it). A credential that is present but wrong is never retried as anonymous.
Conventions
Section titled “Conventions”These apply to the unversioned API. /v1 has its own conventions.
- All dimensions are meters. The plan origin is top-left (x → right, y → down).
- Reads and writes resolve against the caller’s active workspace. Another workspace’s id answers
404, indistinguishable on purpose from an id that doesn’t exist. - Unbounded lists are
{ items, nextCursor }.nextCursoris opaque: pass it back as thecursorquery parameter. It isnullon the last page. There is no total count. - Bounded lists are
{ items }, or a named plural (embeds,endpoints,typicals) where the route already ships that way. - Errors are a JSON body
{ error, detail? }. - Mutations that return no resource answer
{ ok: true }. - Catalog ids are opaque strings. Never derive a manufacturer from a prefix.
- Layout payloads use the sunny-layout format.
Streaming
Section titled “Streaming”The arrangement paths, POST /arrange and POST /plan-floor, return application/x-ndjson: one
JSON object per line, progress lines first, then a terminal done line (with the generation id
and final placements) or an error line. The HTTP status is 200 either way, so read the stream
and inspect the last line. Runs can take up to about 160 seconds.
Idempotency
Section titled “Idempotency”POST /quotes and POST /quotes/{id}/lines accept an Idempotency-Key header, as does every
POST and PATCH under /v1. Send a key (1–255 printable ASCII characters; a random UUID is
typical) on the first attempt and the same key on each retry. A retry of a finished request
replays the stored response with Idempotent-Replayed: true; a retry while the first is still
running gets 409 with Retry-After: 1; reusing a key for a different request gets 422. Keys
are remembered for 24 hours.
/v1 (beta)
Section titled “/v1 (beta)”https://api.sunnysdk.com/v1 is the versioned API for integrations. It is curated: it grows one
resource at a time, and each /v1 resource is a stable contract over the same data the
unversioned API serves. The unversioned API is what the Sunny web app uses and it keeps evolving;
build new integrations against /v1 where it covers what you need.
Every /v1 operation is currently beta (x-sunny-stability: beta in the document) and may
still change shape. Once an operation leaves beta, changes to it are additive only, and a removal
is announced with Deprecation and Sunset headers at least six months ahead.
What it covers
Section titled “What it covers”| Area | Operations |
|---|---|
| Quotes | List, create, get, update, delete; lines; presented versions; the Present link (get, mint, revoke, email) |
| Orders | A quote’s order as JSON, Order SIF, or OFDA XML; import an order file as a draft quote |
| Customers | List, create, get, update, delete |
| Discount tables | List and get (quote_costs:read) |
| Catalog | List and get catalog items; get a manufacturer |
| Audit events | The workspace audit log (audit:read) |
| Sandbox | Reset a sandbox workspace (org:admin, test key only) |
The /v1 reference lists each operation’s scope.
Authentication on /v1
Section titled “Authentication on /v1”An API key (x-api-key) or an OAuth Bearer token, and nothing else. A request with neither gets
401 with code credentials_missing. /v1 is server-to-server: browsers get no CORS allowance.
/v1 conventions
Section titled “/v1 conventions”- Ids are opaque strings; don’t parse them.
- Fields are snake_case. Timestamps are ISO 8601 in UTC.
- Money is an integer in minor units (cents for USD), with an ISO 4217
currencyon the owning object. - A field with no value is omitted, never
null. - Lists are
{ data, next_cursor? }. Passnext_cursorback ascursor; it is omitted on the last page.limitis 1–100, default 25. - Every response carries an
X-Request-Idheader. Quote it when reporting a problem. - Ignore response fields you don’t recognise, and handle enum values you don’t recognise,
including error
types andcodes. That is what lets new fields and values ship without a new version.
Errors on /v1
Section titled “Errors on /v1”Every /v1 error has one shape:
{ "error": { "type": "invalid_request_error", "code": "parameter_invalid", "message": "limit: Too big: expected number to be <=100", "param": "limit", "request_id": "6f1c2a4e-1b7d-4d0e-9a51-1f0f6d3b8c22" }}typeis the broad class. The current set isinvalid_request_error,authentication_error,permission_error,idempotency_error,rate_limit_error, andapi_error; more may be added.codeis specific (parameter_invalid,insufficient_scope,not_found,rate_limited, …). Codes are added over time but never renamed or repurposed.paramis present only when one request field or header is at fault.messageis for people. Don’t parse it.request_idmatches theX-Request-Idheader.
Sandbox
Section titled “Sandbox”A workspace can create one sandbox companion under Organization → Sandbox, to build an
integration without touching live data. Workspace keys created on the sandbox start with
sunny_wsk_test_. A test key only works against its sandbox, a live key never works against a
sandbox, and personal keys never act on a sandbox. The sandbox includes a fixture catalog and
discount table, its email is logged rather than sent, and POST /v1/sandbox/reset clears its
quotes, customers, orders, and discount tables and re-seeds the fixtures.
Rate limits
Section titled “Rate limits”Every request made with an API key or an OAuth Bearer token is limited per owner over a 60-second window:
| Bucket | Limit per 60 s |
|---|---|
Reads (GET, HEAD) |
600 |
| Writes (everything else) | 120 |
| Reads, sandbox workspace | 120 |
| Writes, sandbox workspace | 30 |
Personal keys and Bearer tokens are counted per user (Bearer tokens also per OAuth client).
Workspace keys are counted per workspace, so creating more keys does not raise the limit. /v1
and the unversioned API share the same buckets.
The cost endpoints — POST /arrange, POST /plan-floor, and minting a single-item embed — also
allow 5 requests per 60 seconds per caller, on top of the limits above. The
MCP server’s write and render tools draw from the same 5-request budget.
Limited responses carry these headers:
| Header | Value |
|---|---|
RateLimit-Policy |
The bucket, quota, and window, e.g. "api-read";q=600;w=60. |
RateLimit-Limit |
The bucket’s quota (read and write buckets). |
RateLimit-Reset |
The window length in seconds — an upper bound on when the window resets. |
RateLimit-Remaining |
Sent only on a 429, where it is 0. |
RateLimit |
On a 429, e.g. "api-read";r=0;t=60. |
Retry-After |
On a 429: 60. |
A rejection is 429 with {"error":"rate_limited"} on the unversioned API, or the error envelope
with code rate_limited on /v1. Counters are approximate, so back off on a 429 rather than
pacing to the exact quota.