API
Describe a business in one sentence and get back a composed website you can publish. The same orchestration that runs behind the portal, reached over HTTP.
Getting a key
Sign in to the portal and say what you want. Something like create an API key is enough. Dark Orb will ask which permissions the key should carry and then show you the key once, on screen, at the moment it is made. It is never shown again and it cannot be recovered, so copy it somewhere safe before you move on. If you lose it, ask for a new one and revoke the old.
Keys belong to your account, not to you personally. If you lose access to the account, the key stops working with it.
Making a request
The base URL is https://portal.darkorb.ai. Send the key as a bearer token:
Authorization: Bearer $DARK_ORB_KEY
Every response is JSON and carries Cache-Control: no-store and X-Dark-Orb-Api-Version: v0. Errors always take the same shape:
{ "error": { "code": "scope_insufficient", "message": "This key cannot do that." } }
A worked example
Compose a site, then publish it. Two requests, start to finish:
curl -X POST https://portal.darkorb.ai/api/v0/sites \
-H "Authorization: Bearer $DARK_ORB_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"intent":"A joinery in Leeds making bespoke kitchens and fitted wardrobes."}'
That answers 201 with the site, including its siteId and the url the site will live at. Then publish it:
curl -X POST https://portal.darkorb.ai/api/v0/sites/st_.../publish \
-H "Authorization: Bearer $DARK_ORB_KEY"
Open the url from either response and the site is there, live, for anyone.
Endpoints
| Endpoint | Scope | Limit |
|---|---|---|
GET /api/v0/whoami | any | 120 per hour, per key |
GET /api/v0/sites | sites:read | 120 per hour, per key |
GET /api/v0/sites/{siteId} | sites:read | 120 per hour, per key |
POST /api/v0/sites | sites:compose | 5 per hour, per account |
POST /api/v0/sites/{siteId}/publish | sites:publish | 30 per hour, per key |
GET /api/v0/whoami
What this key is and what it can do right now. Useful for checking a key works before you rely on it.
{
"accountId": "acc_...",
"keyId": "ak_...",
"scopes": ["sites:read", "sites:compose", "sites:publish"],
"expiresAt": null
}
The scopes reported are the ones the key can exercise at this moment, which can be narrower than the ones it was issued with if the person who issued it has since lost the access it was drawn from.
GET /api/v0/sites
The account's sites, one page at a time:
{
"sites": [ ... ],
"nextCursor": "WyJ2MCIsIjIwMjYtMDkt..."
}
Ask for a page size with ?limit= (25 by default, 100 at most), and ask for the next page with ?cursor=, passing back the nextCursor you were given. Keep going until nextCursor is null, which is the end. A limit or a cursor we cannot use is refused with 422 rather than quietly changed to something else.
A page can come back shorter than you asked for and still not be the last one, so read nextCursor, not the number of sites. The cursor marks your place rather than counting from the start, so a site made while you are part-way through cannot push one you have not seen out of view.
Each entry is the same shape:
{
"siteId": "st_...",
"slug": "brae-joinery",
"createdAt": "2026-09-01T09:12:44.019Z",
"updatedAt": "2026-09-01T09:12:44.019Z",
"currentVersionId": "v_...",
"publishedVersionId": null,
"published": false,
"url": "https://portal.darkorb.ai/site/brae-joinery"
}
published is true only when the version that is live is also the newest one. A site composed again after publishing reads as not published until you publish the new version.
GET /api/v0/sites/{siteId}
One site, in the shape above. A site id belonging to another account answers 404, exactly as an id that does not exist does, so the endpoint cannot be used to find out what else is out there.
POST /api/v0/sites
Compose a site. The body carries one field, and an unknown field is refused rather than ignored:
{ "intent": "A joinery in Leeds making bespoke kitchens and fitted wardrobes." }
An Idempotency-Key header is required, between 16 and 128 characters. A composed site costs real money to make, so the key is what stops a retry, a timeout or a dropped connection paying for a second one. Send a fresh value per site, a UUID is ideal, and reuse the same value if you retry.
Answers 201 with the site. Repeating a request with a key that already completed replays the original answer verbatim, with Idempotency-Replayed: true, and composes nothing. Keys are held for 24 hours, and are scoped to your account and to this endpoint.
The site is composed but not live. Publish it when you are happy with it.
POST /api/v0/sites/{siteId}/publish
Make the newest version of a site public. No body. Answers 200 with the site, now carrying "published": true and the url anyone can open.
Scopes
A key carries the permissions you asked for when it was made, and nothing else. Ask for the narrowest set that does the job.
| Scope | What it allows |
|---|---|
sites:read | Listing sites and reading one |
sites:compose | Composing a new site |
sites:publish | Publishing a composed site |
A key can never do more than the person who issued it can do. If they lose access to the account, the key loses it at the same moment, without anyone having to remember to revoke it.
Errors
| Status | Code | What it means |
|---|---|---|
| 400 | malformed_request | The body is not valid JSON, or the Idempotency-Key header is missing or the wrong length |
| 401 | unauthenticated | The key is missing, malformed, revoked, expired or not a key at all. Every one of those answers identically, on purpose |
| 403 | scope_insufficient | The key is valid but does not carry the scope this endpoint needs |
| 404 | not_found | No site with that id on this account |
| 409 | idempotency_conflict | That key is still running, or it was used for a different request. Wait, or send a new key |
| 422 | invalid_request | The body is valid JSON but wrong: a missing intent, an empty one, one that is too long, or an unknown field |
| 429 | rate_limited | Over the limit for this endpoint. Retry-After carries the seconds until it resets |
| 429 | budget_exhausted | The account has used its AI allowance for the day. It resets tomorrow, and nothing was spent on this request |
| 503 | unavailable | Something Dark Orb depends on could not answer. Nothing was half-done: a compose that could not be saved is reported as a failure and its idempotency key is freed, so retrying is safe |
Rate limits
Limits reset on the hour rather than easing back gradually, so a 429 carries a Retry-After that tells you when the next hour begins. Waiting less than that will only be refused again.
Composing counts against your account, because composing spends Dark Orb's own AI credits rather than yours. Reading and publishing count against the individual key. Above both sits a daily AI allowance per account, shared with the portal: one account, one meter, whichever way you reach it.
What is here, and what is not
v0 covers composing a site, publishing it, and reading what you have. Correcting a site, uploading images, custom domains and the rest of the toolkit are reachable in the portal today but not yet over the API. More to come.
Changes
| Date | Change |
|---|---|
| September 2026 | v0 opens: whoami, sites, compose and publish |
Questions
Write to hello@danieltodd.uk. Use of the API is covered by the Terms of Service and the Acceptable Use Policy.
← Back