API and MCP
Make pictures and videos in Sable from Claude, ChatGPT, your own app or anything that speaks MCP or HTTP. Version 1.1.0.
Overview
Sable Designs makes pictures and videos. Other software can use it in two ways:
- AI assistants such as Claude, ChatGPT, Codex and Cursor connect to the MCP server at
https://sabledesigns.red/mcp and use Sable as a set of tools. - Your own app or script calls the REST API at
https://sabledesigns.red/api/v1.
Both act as one Sable account, spend that account's credits and follow the same rules as the app. The machine-readable description is at https://sabledesigns.red/api/v1/openapi.json (OpenAPI 3.1).
Connect an assistant
Add a connector (some apps call it an MCP server) with this address. When the assistant asks, sign in to Sable, choose what it may do, set a monthly limit if you want one, type your password and press Allow.
https://sabledesigns.red/mcp
| Assistant | How |
|---|
| Claude (claude.ai and the Claude app) | Settings, Connectors, Add custom connector. Paste the address, then press Connect. |
| ChatGPT | In settings, under Apps and Connectors, turn on developer mode, then create a connector with the address and choose OAuth. |
| Claude Code | claude mcp add --transport http sable https://sabledesigns.red/mcp then type /mcp and choose Authenticate. |
| Codex | codex mcp add sable --url https://sabledesigns.red/mcp then codex mcp login sable. |
| Cursor | Add sable to .cursor/mcp.json with the url. Cursor asks you to sign in. |
| Anything else | Any MCP client that supports Streamable HTTP and OAuth. Clients without OAuth can send a key as a Bearer header instead. |
Each connected app shows on your Account page under API and AI assistants, where you can change its limit or disconnect it. You are emailed whenever one is connected.
Quickstart
1. Make a key on the Account page, under API and AI assistants. It is shown once. 2. Price a request, 3. start it, 4. wait for the result, 5. download it.
export SABLE_API_KEY=sbl_...
curl https://sabledesigns.red/api/v1/models?kind=image -H "Authorization: Bearer $SABLE_API_KEY"
curl https://sabledesigns.red/api/v1/quote -H "Authorization: Bearer $SABLE_API_KEY" -H "Content-Type: application/json" \
-d '{"workflow": "api_image", "params": {"prompt": "a red apple on a wooden table", "model": "bd_45"}}'
curl https://sabledesigns.red/api/v1/generate -H "Authorization: Bearer $SABLE_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: apple-1" \
-d '{"workflow": "api_image", "params": {"prompt": "a red apple on a wooden table", "model": "bd_45"}}'
curl "https://sabledesigns.red/api/v1/jobs/JOB_ID?wait=50" -H "Authorization: Bearer $SABLE_API_KEY"
curl -o apple.jpg https://sabledesigns.red/api/v1/outputs/FILE_NAME -H "Authorization: Bearer $SABLE_API_KEY"
The same in Python and JavaScript:
import httpx
sable = httpx.Client(base_url="https://sabledesigns.red/api/v1", timeout=90, headers={
"Authorization": "Bearer sbl_...", "User-Agent": "my-app/1.0"})
job = sable.post("/generate", headers={"Idempotency-Key": "apple-1"}, json={
"workflow": "api_image",
"params": {"prompt": "a red apple on a wooden table", "model": "bd_45"}}).json()
while job["status"] not in ("done", "failed", "blocked", "cancelled", "stopped"):
job = sable.get(f"/jobs/{job['id']}", params={"wait": 50}).json()
for out in job["outputs"]:
open(out["name"], "wb").write(sable.get(out["download_url"]).content)
const sable = (path, init = {}) => fetch('https://sabledesigns.red/api/v1' + path, { ...init, headers: {
Authorization: 'Bearer ' + process.env.SABLE_API_KEY, 'Content-Type': 'application/json',
...init.headers } }).then((r) => r.json());
let job = await sable('/generate', { method: 'POST', headers: { 'Idempotency-Key': 'apple-1' },
body: JSON.stringify({ workflow: 'api_image',
params: { prompt: 'a red apple on a wooden table', model: 'bd_45' } }) });
while (!['done', 'failed', 'blocked', 'cancelled', 'stopped'].includes(job.status)) {
job = await sable(`/jobs/${job.id}?wait=50`);
}
console.log(job.outputs);
Tool keys, scenes and settings come from GET /tools; model keys for the Image and Video Generator come from GET /models. Those two tools need params.model.
Authentication
API keys
Send Authorization: Bearer sbl_... on every request. Only the account owner can make keys, with the account password; you are emailed about each one. A key is shown once and we keep only a fingerprint of it. You can have 5 at a time, give each a monthly credit limit, make one that can only look, and revoke any at once. A key works on /api/v1 and /mcp only: it cannot change the account, pay, invite people or make more keys.
OAuth 2.1 (sign in)
Assistants connect by signing in. Everything is discovered from the server:
| What | Where |
|---|
| Protected resource metadata (RFC 9728) | https://sabledesigns.red/.well-known/oauth-protected-resource/mcp |
| Authorization server metadata (RFC 8414) | https://sabledesigns.red/.well-known/oauth-authorization-server |
| Client registration | Client ID metadata documents (an https client_id), or dynamic registration at /oauth/register (RFC 7591) |
| Authorization | /oauth/authorize, code flow, PKCE with S256 required, resource (RFC 8707) supported, iss returned (RFC 9207) |
| Tokens | /oauth/token. Access tokens last 60 minutes; refresh tokens 90 days and are replaced at every use. Reusing an old refresh token ends the whole connection. |
| Revocation (RFC 7009) | /oauth/revoke. Revoking a refresh token disconnects the app. |
A token asked for with resource=https://sabledesigns.red/mcp works only there. Scopes: read (see tools, characters, prices, balance and results) and generate (make pictures and videos, which spends credits). The approval page asks for the account password and a monthly limit, and up to 10 apps can be connected at once.
Making requests
- Send and receive JSON. Times are Unix seconds.
- Send a
User-Agent naming your app. Python's built-in client is refused by our network without one. - Every answer carries
X-Request-Id. Quote it if you contact support. - The API answers browsers from any site (CORS), but keys are secrets: keep them on your server.
Jobs and results
POST /generate answers at once with a job. Its status moves from queued to running and ends as one of:
| Status | Meaning |
|---|
done | Finished. outputs lists the results. |
failed | It did not work. error says why, and the credits come back. |
blocked | Refused or held by the content rules. |
cancelled, stopped | Stopped before it finished. |
Poll GET /jobs/{id}?wait=50: it answers as soon as the job finishes, or after 50 seconds. Or pass webhook_url and be called. GET /jobs lists jobs newest first; pass next_cursor as after for the next page. POST /jobs/{id}/cancel stops one.
Each output has a download_url (needs your key) and, unless it is explicit, an open_url that works without a key for one hour. Every file has its metadata removed before you get it.
Uploads
To edit or animate a picture, upload it first and pass the ref you get back as params.image (or params.video).
| Endpoint | Send |
|---|
POST /uploads | multipart form, field file |
POST /uploads/base64 | {"filename", "data_base64"}, up to 30 MB |
POST /uploads/url | {"url"}: a public https address, up to 60 MB |
POST /uploads/chatgpt | {"openaiFileIdRefs": [...]}, as ChatGPT sends files a user attached |
Every upload is checked exactly like one made in the app.
Characters
Pass one of the account's characters by name as character (next to workflow) and her face is kept, the same as tagging her in the app. GET /characters lists them.
Webhooks
Add webhook_url (a public https address) to a generate request and it is called once the job finishes, with the same job object GET /jobs/{id} returns. Events: job.cancelled, job.completed, job.failed.
{
"type": "job.completed",
"timestamp": "2026-09-28T14:03:12Z",
"data": { "id": "...", "object": "job", "status": "done", "outputs": [ ... ] }
}
Deliveries are signed to the Standard Webhooks spec, so their libraries work unchanged. The secret (whsec_...) is shown when you make the key, and under Secret on the Account page. Check it before trusting a delivery:
import base64, hashlib, hmac, time
def verify(secret, headers, body):
msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > 300:
raise ValueError("too old")
key = base64.b64decode(secret.split("_", 1)[1])
mac = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
expected = base64.b64encode(mac).decode()
for sig in headers["webhook-signature"].split():
version, _, value = sig.partition(",")
if version == "v1" and hmac.compare_digest(value, expected):
return
raise ValueError("bad signature")
import crypto from 'node:crypto';
export function verify(secret, headers, rawBody) {
const id = headers['webhook-id'], ts = headers['webhook-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error('too old');
const key = Buffer.from(secret.slice(6), 'base64');
const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64');
const ok = headers['webhook-signature'].split(' ').some((s) => {
const [version, sig] = s.split(',');
return version === 'v1' && sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
});
if (!ok) throw new Error('bad signature');
}
Answer with any 2xx within 10 seconds. Otherwise it is tried again after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, 8 times in all, then given up. Answer 410 Gone to stop at once. webhook-id is the same on every try, so you can ignore repeats.
Idempotency
Send an Idempotency-Key header (any unique string up to 255 characters) on POST /generate and the upload calls. If the network drops and you retry with the same key and the same body, you get the first answer back (marked Idempotent-Replayed: true) and nothing runs or is charged twice. The same key with a different body is refused. Keys are remembered for 24 hours, and only a request that succeeded is remembered, so a failed one can be retried.
Rate limits
Each key or connection may make 120 requests a minute. Every answer says where you stand:
| Header | Meaning |
|---|
RateLimit-Limit | Requests allowed per minute |
RateLimit-Remaining | Requests left in the current minute |
RateLimit-Reset | Seconds until the minute resets |
Retry-After | On a 429, seconds to wait |
Separately, an account runs a few generations at once, the same as in the app, and each key or connection can have a monthly credit limit.
Errors
Errors from /api/v1 look like this, with the HTTP status set to match:
{
"error": {
"type": "billing_error",
"code": "key_limit_reached",
"message": "This key's monthly limit is 500 credits and 480 are used...",
"status": 402,
"request_id": "req_1a2b3c4d5e6f7a8b"
}
}
Act on code; message is written for people.
| code | HTTP | Meaning |
|---|
missing_api_key | 401 | No key or token was sent. Send Authorization: Bearer followed by your key. |
invalid_api_key | 401 | The key or token is wrong, revoked or expired. Make a new key, or sign in again. |
unauthorized | 401 | Not signed in. |
insufficient_scope | 403 | This key or connection may only look. Use one that is allowed to make pictures. |
age_verification_required | 403 | The account has not finished the age check. Finish it in Sable. |
forbidden | 403 | Not allowed for this account. |
invalid_request | 400 | Something in the request is missing or wrong. The message says what. |
invalid_url | 400 | The address is not a public https address. |
fetch_failed | 400 | The address could not be downloaded. |
invalid_webhook_url | 400 | webhook_url must be a public https address. |
insufficient_credits | 402 | Not enough credits for this request. Top up in Sable. |
key_limit_reached | 402 | This key or connection has reached its monthly limit. Raise it on the Account page. |
not_found | 404 | No such job, file or route on this account. |
unknown_tool | 404 | No tool with that key. listTools shows them. |
unknown_character | 404 | No character with that name on this account. listCharacters shows them. |
method_not_allowed | 405 | That HTTP method is not used here. |
tool_unavailable | 409 | The tool is retired, coming soon or switched off right now. |
conflict | 409 | The request conflicts with the current state. |
idempotency_in_progress | 409 | A request with the same Idempotency-Key is still running. Retry shortly. |
too_large | 413 | The file is over the size limit. |
idempotency_key_reused | 422 | That Idempotency-Key was already used for a different request. |
content_policy_violation | 422 | Refused by Sable's content rules. It is recorded. |
unprocessable | 422 | The request could not be processed as sent. |
rate_limited | 429 | Too many requests. Wait for the seconds in Retry-After. |
server_error | 500 | Something failed on our side. Retry; if it keeps happening, contact support. |
On the MCP server, a problem with a tool call comes back as a tool result with isError: true, so the assistant can read it and fix its request. Protocol errors are JSON-RPC errors:
| Code | Name | Meaning |
|---|
| -32700 | Parse error | The body is not valid JSON. |
| -32600 | Invalid request | Not a JSON-RPC 2.0 request. |
| -32601 | Method not found | Unknown method. With protocol 2026-07-28 the HTTP status is 404. |
| -32602 | Invalid params | Unknown tool or prompt, or (from 2026-07-28) a resource that does not exist. |
| -32002 | Resource not found | A resource that does not exist or may not be shared (protocols before 2026-07-28). |
| -32001 | Authentication | No valid key or token. The HTTP status is 401, with WWW-Authenticate. |
| -32020 | Header mismatch | MCP-Protocol-Version, Mcp-Method or Mcp-Name do not match the body (2026-07-28). |
| -32022 | Unsupported protocol version | data.supported lists the versions this server speaks. |
Content rules
Everything made through the API follows the same rules as the app: the same content filter, the same age check on uploads, and requests involving minors are refused and recorded. Explicit results can be made by an account that has passed the age check, but they are never shown inside another company's assistant: no preview, no open link and no resource, only a download_url that needs your key. The assistant you connect has its own rules as well, and may decline some requests itself.
MCP reference
Endpoint https://sabledesigns.red/mcp, Streamable HTTP. Protocol versions: 2026-07-28 (stateless, with server/discover) and, with the initialize handshake, 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05. Answers are JSON, or an SSE stream of progress notifications ending in the result when the request carries a progressToken. There is no GET stream and there are no sessions.
Tools
| Tool | What it does | Access |
|---|
sable_list_tools | What Sable can make (pictures, edits, videos, voice) with each tool's inputs, scenes and settings. Call this first. | looks only |
sable_list_models | The models the Image or Video Generator can use. | looks only |
sable_list_characters | The account's own characters. Pass one by name as `character` to keep her face. | looks only |
sable_get_price | What a request will cost in credits, before anything is spent. | looks only |
sable_generate | Start a generation. SPENDS the account's credits, so price it and confirm with the user first. wait_seconds waits for the result in this call; if it is not done by then, call sable_get_job with the id (and wait_seconds) until status is done. | spends credits |
sable_get_job | A job's status and results, with download links, and a small preview of a finished picture when include_image is true. Explicit results are never previewed. | looks only |
sable_cancel_job | Stop a job that is still running. Unused credits come back as they do in the app. | needs generate |
sable_recent_jobs | The account's jobs, newest first. Pass next_cursor as cursor for the next page. | looks only |
sable_upload | Upload a picture or a video to edit, animate or use as a reference, as base64 or from a public https url. Returns a ref: pass it as params.image for a picture, params.video for a video. Every upload is checked like one made in the app. | needs generate |
sable_get_balance | Credits left on the account, and this key's monthly limit. | looks only |
Every tool declares an input and an output schema, and results carry structuredContent. A tool can wait up to 50 seconds for a result, under the 60 second timeout most clients use; call sable_get_job again to keep waiting.
Resources
Non-explicit results are resources at sable://outputs/{name}: listed by resources/list, read by resources/read, and linked from tool results.
Prompts
| Prompt | About |
|---|
make_picture | Make one picture from a description, optionally of one of your characters. |
character_photoshoot | A small set of pictures of one of your characters in one setting. |
REST reference
Base URL https://sabledesigns.red/api/v1. The same as openapi.json, which you can import into ChatGPT custom GPT Actions, Postman or any OpenAPI tool.
GET /api/v1/me
Credit balance and this key's monthly limit
Answers with object, or an error.
operationId getAccount
GET /api/v1/models
Models for the Image and Video Generator
| Parameter | In | Type | Required | About |
|---|
kind | query | string (image, video) | | |
Answers with object, or an error.
operationId listModels
GET /api/v1/characters
The account's characters
Answers with object, or an error.
operationId listCharacters
POST /api/v1/quote
What a request will cost, in credits. Spends nothing.
Body
| Field | Type | Required | About |
|---|
workflow | string | yes | A tool key from listTools, e.g. api_image |
character | string | | Optional: one of the account's characters, by name |
params | object | | prompt, scene, width, height, batch, image or video (a ref from an upload) and the tool's settings. api_image and api_video need model, a key from listModels. |
webhook_url | string | | Optional https address called when the job finishes, signed to the Standard Webhooks spec |
Answers with Quote, or an error.
operationId getPrice
POST /api/v1/generate
Start a picture or video. Spends credits. Returns the job at once. Poll getJob (with wait) until status is done, or pass webhook_url.
Spends credits or changes a job. Assistants ask you first.
| Parameter | In | Type | Required | About |
|---|
Idempotency-Key | header | string | | Any unique string. Retrying with the same key never runs or charges twice. |
Body
| Field | Type | Required | About |
|---|
workflow | string | yes | A tool key from listTools, e.g. api_image |
character | string | | Optional: one of the account's characters, by name |
params | object | | prompt, scene, width, height, batch, image or video (a ref from an upload) and the tool's settings. api_image and api_video need model, a key from listModels. |
webhook_url | string | | Optional https address called when the job finishes, signed to the Standard Webhooks spec |
Answers with Job, or an error.
operationId generate
GET /api/v1/jobs
Recent jobs, newest first
| Parameter | In | Type | Required | About |
|---|
limit | query | integer | | |
after | query | string | | next_cursor from the previous page |
Answers with JobList, or an error.
operationId listJobs
GET /api/v1/jobs/{jid}
A job's status and results
| Parameter | In | Type | Required | About |
|---|
jid | path | string | yes | |
wait | query | integer | | Seconds to wait for it to finish before answering |
Answers with Job, or an error.
operationId getJob
POST /api/v1/jobs/{jid}/cancel
Stop a job that is still running
Spends credits or changes a job. Assistants ask you first.
| Parameter | In | Type | Required | About |
|---|
jid | path | string | yes | |
Answers with Job, or an error.
operationId cancelJob
POST /api/v1/uploads/base64
Upload a picture or video as base64. Returns a ref for params.image or params.video
| Parameter | In | Type | Required | About |
|---|
Idempotency-Key | header | string | | Any unique string. Retrying with the same key never runs or charges twice. |
Body
| Field | Type | Required | About |
|---|
filename | string | | |
data_base64 | string | yes | |
Answers with Upload, or an error.
operationId uploadFile
POST /api/v1/uploads/url
Upload a picture or video from a public https address
| Parameter | In | Type | Required | About |
|---|
Idempotency-Key | header | string | | Any unique string. Retrying with the same key never runs or charges twice. |
Body
| Field | Type | Required | About |
|---|
url | string | yes | |
filename | string | | |
Answers with Upload, or an error.
operationId uploadFromUrl
POST /api/v1/uploads/chatgpt
Upload pictures the user attached in ChatGPT. Returns refs
Body
| Field | Type | Required | About |
|---|
openaiFileIdRefs | array of string | yes | |
Answers with object, or an error.
operationId uploadChatFiles
Changelog
| Date | Version | What changed |
|---|
| 2026-09-28 | 1.1 | Sign in with OAuth 2.1, so assistants connect by address alone. Webhooks, Idempotency-Key, cursor pagination, cancel, uploads by URL and from ChatGPT, keys that can only look, request ids and rate limit headers. MCP protocol 2026-07-28 alongside the earlier versions, output schemas, resources and prompts. |
| 2026-09-28 | 1.0 | First release: API keys, the REST API and the MCP server. |