REST API - 2DAICommunity/2dai-cloud-sdk GitHub Wiki
Everything the SDK does is plain HTTP β use it from Python, Go, Rust, curl or anything that speaks JSON. This page is the raw contract; if you're on JavaScript or TypeScript, the SDK wraps all of it (polling, retries, typed errors) and you can skip this.
-
Base URL β
https://dapp.2dai.io:444/v1 -
Auth β
Authorization: Bearer 2dai_sk_<keyId>_<secret>on every request. Header only; there is no query-param or cookie fallback. -
Content type β
application/jsonexceptPOST /v1/uploads(multipart/form-data). - User-Agent β optional, but it decides how your creations are labelled. See below.
Get a key in the dashboard: Integrations β API keys. See Keys & Scopes for the scope matrix and spend caps.
Every creation carries a source, and the dashboard uses it to file your work:
Cloud Drive β Activities has an SDK collection and an MCP collection, and
the lightbox shows a matching origin pill. The server derives source from your
User-Agent.
| You send | source |
Where it lands |
|---|---|---|
2dai-cloud-sdk/2.1.0 |
sdk |
SDK collection |
2dai-cloud-sdk/2.1.0 2dai-mcp-server/1.0.0 |
mcp |
MCP collection |
| anything else, or nothing | api |
neither β plain API history |
curl -X POST https://dapp.2dai.io:444/v1/generate/image \
-H "Authorization: Bearer $TWODAI_API_KEY" \
-H "User-Agent: 2dai-cloud-sdk/2.1.0" \
-H "Content-Type: application/json" \
-d '{"prompt":"a lighthouse at dusk"}'Omitting it is fine β nothing is gated on the header. The request succeeds,
the generation runs, you are charged the same. You simply land as api instead
of sdk, which is the honest label for a raw REST caller anyway. Send the SDK
token only if you genuinely want your traffic grouped with the SDK's.
The token must be a whole name/version word with a full x.y.z version.
These do NOT match, and fall back to api:
2dai-cloud-sdk β no version
2dai-cloud-sdk/2.0 β not x.y.z
2dai-cloud-sdk-fork/2.0.2 β different name
my-app (uses 2dai-cloud-sdk)
Matching used to be a loose substring search, which mislabelled anything merely mentioning the name; it is strict as of the 2026-07-29 API build. This is attribution, never authorisation β no scope, quota or price depends on it, and forging a token only mislabels your own work in your own drive.
A generate call does not return an image. It returns a queueId; you then
poll GET /v1/queue/:queueId until status is completed, at which point the
row carries creationId, outputCdnId and downloadUrl.
# 1 Β· submit
curl -X POST https://dapp.2dai.io:444/v1/generate/image \
-H "Authorization: Bearer $TWODAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt":"a lighthouse at dusk","width":1456,"height":816}'
# β {"queueId":"a1b2β¦","status":"pending","costUsd":0.07}
# 2 Β· poll until completed
curl "https://dapp.2dai.io:444/v1/queue/a1b2β¦" \
-H "Authorization: Bearer $TWODAI_API_KEY"
# β {"queueId":"a1b2β¦","status":"completed","creationId":"β¦","downloadUrl":"/cdn/file/β¦"}
# 3 Β· fetch the bytes (same bearer)
curl -L "https://dapp.2dai.io:444/cdn/file/<outputCdnId>" \
-H "Authorization: Bearer $TWODAI_API_KEY" -o out.jpgPoll every 2 s or so. Terminal statuses: completed, failed, cancelled,
timeout, expired, dismissed. Non-terminal: pendingCharge, pending,
processing. A job waiting for render capacity stays pending (it can also go
from processing back to pending) β keep polling.
A failed, timeout or expired job also returns errorCode (a stable code to
branch on) and errorMessage (a readable sentence to show), next to the raw
error:
{ "queueId": "a1b2β¦", "status": "failed", "error": "GENERATION_FAILED: β¦",
"errorCode": "CAPACITY_FULL",
"errorMessage": "No capacity left right now. You weren't charged β try again soon." }The codes are listed in Errors β Why a generation failed.
Every error is JSON with an error code, sometimes plus context fields:
{ "error": "KEY_SPEND_LIMIT_REACHED", "costUsd": 0.07, "spentUsd": 5, "spendLimitUsd": 5 }Three can come back from any endpoint:
| HTTP | Code | Meaning |
|---|---|---|
| 401 | INVALID_API_KEY |
Bad, malformed or revoked key |
| 401 | ACCOUNT_UNAVAILABLE |
Account can't use the app (banned, terms pendingβ¦) |
| 403 | MISSING_SCOPE |
Key lacks the scope; body carries requiredScope + scopes
|
Per-endpoint codes are listed below. Error Handling maps them to the SDK's typed classes.
JSON responses omit keys whose value is undefined β treat every optional field as "may be absent" rather than "may be null".
No parameters.
{
"userId": "0029e8aeβ¦",
"username": "2DAI",
"creditUsd": 99855.65,
"tier": "tier5",
"canDisableWatermark": true,
"quality": { "ultraImage": true, "ultraVideo": true, "ultimateImage": true, "ultimateVideo": true },
"qualities": {
"image": [ { "id": "max", "name": "Max", "description": "Recommended Β· β¦", "allowed": true, "recommended": true }, β¦ ],
"video": [ { "id": "ultra", "name": "Ultra", "description": "Recommended Β· 800p Β· β¦", "allowed": true, "recommended": true }, β¦ ],
"videoNext": [ { "id": "fast", "name": "Fast", "allowed": true, "recommended": false }, { "id": "high", "name": "High", "allowed": true, "recommended": true }, β¦ ],
"imageNext": [ { "id": "max", "name": "Max", "allowed": true, "recommended": true }, β¦ ]
},
"videoDurations": [ { "value": 5, "label": "5s", "locked": false, "recommended": true },
{ "value": 6.5, "label": "6s", "locked": false }, { "value": 7.5, "label": "7s", "locked": true } ],
"defaultVideoDuration": 5,
"canUseVideo": true,
"videoDurationsNext": [ { "value": 1, "label": "1s", "locked": false }, { "value": 5, "label": "5s", "locked": false, "recommended": true },
{ "value": 8, "label": "8s", "locked": false }, { "value": 10, "label": "10s", "locked": false }, β¦ ],
"defaultVideoDurationNext": 5,
"canUseImageNext": true,
"modelChannels": { "video": { "default": "gen7", "next": "gen8" }, "image": { "default": "gen7", "next": "gen8" } },
"promptMaxChars": 4000,
"promptMaxCharsNext": { "tier": "Believer", "max": 6000 },
"key": { "keyId": "5zmvg7rj", "label": "E2E Tests",
"scopes": ["read","generate","manage","publish"],
"spendLimitUsd": null, "spentUsd": 1.36,
"modelChannels": ["default", "next"] }
}spendLimitUsd: null means uncapped. Your account credit is always the real
limit. qualities carries every preset per generation type with the tier
lock resolved (allowed) and the platform recommendation (recommended:
image max, video ultra); pick from it instead of discovering locks with a
403 at submit. videoDurations[].locked and defaultVideoDuration follow the
same idea. Gen8 Preview video β videoModel: "next" on the video
endpoint β adds qualities.videoNext, videoDurationsNext and
defaultVideoDurationNext when the engine is open to the account (video from
Holder); Gen8 Preview images β imageModel: "next" on the image endpoint β
is announced by canUseImageNext (the engine is on, the tier opens it β Holder β
and the key reaches next) and qualities.imageNext (always listed, every preset
allowed: false while the channel is off or locked); modelChannels names the
engine behind each channel.
All three submit endpoints return the same shape:
{ "queueId": "β¦", "status": "pending", "costUsd": 0.07 }costUsd is present only when the call was charged.
| Field | Req | Type | Default | Notes |
|---|---|---|---|---|
prompt |
β | string | β | non-empty, β€ promptMaxChars of the account (tier ladder 1,500 β 20,000) β PROMPT_TOO_LONG otherwise |
imageModel |
string | default |
default = Gen 7.2 Β· next = Gen8 Preview images: up to 6 references kept natively, legible text, long literal prompts; opens at Holder (canUseImageNext on GET /v1/me), priced on render time at roughly 4β7Γ the Gen 7.2 preset |
|
style |
string | auto |
auto lets the server pick; raw disables styling |
|
quality |
string | auto |
fast, normal, high, max, ultra, ultimate (top two are tier-gated) β auto picks by tier: a weighted draw over max / ultra / ultimate limited to what your tier allows (on imageModel: next: high or max). Recommended: max (best balance), ultimate for the highest resolution |
|
width / height
|
number | 1024 | clamped 1β8192 | |
negativePrompt |
string | β | β€ promptMaxChars of the account; optional β applied by Gen8 Preview images (imageModel: "next"), the Gen7 image engine renders without it |
|
enhanced |
boolean | false |
rewrite the prompt with TIXI (the 2DAI prompt agent) before generating; ~2 s, no extra credit. Your text stays in prompt, the rewrite lands in finalPrompt
|
|
allowNSFW |
boolean | false |
requires the tier that permits it | |
refCreationIds |
string[] | β | up to 3, up to 6 with imageModel: next (strict β see below); each reference adds a surcharge |
|
clientToken |
string | derived | idempotency; [0-9a-f]{1,64}
|
Endpoint-specific errors: 400 IMAGE_MODEL_UNAVAILABLE (the next engine is off),
403 TOOL_NOT_ALLOWED_FOR_TIER (tool: "image-next" below Holder), 403 KEY_POLICY_CHANNEL
(the key's policy stops at default), 403 QUALITY_NOT_ALLOWED_FOR_TIER.
Every tool caps its reference list, and the caps are enforced strictly:
exceeding one is a 400 { "error": "TOO_MANY_REFS", "max": β¦, "got": β¦ }
before any charge, rather than a silent trim.
| Endpoint / tool | Max refs |
|---|---|
generate/image (refCreationIds) |
3 Β· 6 with imageModel: next
|
character-ref |
6 |
style-transfer |
6 |
smart-edit |
6 (refs[0] = the image to edit) |
generate/wallpaper (refCreationIds) |
3 |
| Field | Req | Type | Notes |
|---|---|---|---|
tool |
β | enum |
character-ref | style-transfer | smart-edit β face-ref was merged into character-ref (400 INVALID_TOOL since 2026-10) |
refCreationIds |
β | string[] | β₯ 1; per-tool caps above. smart-edit: refs[0] is the image to edit, further refs are visual support |
prompt |
β | string | required in practice for all tools; for smart-edit it is the edit instruction |
quality, width, height, allowNSFW, clientToken
|
as above | ||
extractionDirective |
enum | style-transfer only: style (default) | composition | styleAndComposition
|
There is no
enhancedfield here: character-ref always runs TIXI server-side (their directives are built around it), so expect the same ~2 s of prompt-rewriting latency whether you ask for it or not. Style-transfer runs a two-step TIXI pass of its own.
Endpoint-specific errors: 400 INVALID_TOOL, 400 REFS_REQUIRED,
400 TOO_MANY_REFS (with max / got), 403 TOOL_NOT_ALLOWED_FOR_TIER,
400 INVALID_EXTRACTION_DIRECTIVE.
Wallpaper-resize: expand a stored creation into a wallpaper dimension.
| Field | Req | Type | Notes |
|---|---|---|---|
inputCreationId |
β | string | the creation to expand/resize |
dimension |
β | string |
standard | photo | widescreen | ultrawide β drives the price; unknown values are rejected (fail-closed) |
refCreationIds |
string[] | up to 3 extra soft-conditioning refs | |
prompt |
string | optional guidance for the expansion | |
allowNSFW, clientToken
|
as above |
Quality is forced to Ultra server-side (there is no quality field), same
as the studio's tool. Endpoint-specific errors: 400 INPUT_CREATION_REQUIRED,
400 MISSING_DIMENSION, 400 TOO_MANY_REFS.
| Field | Req | Type | Default | Notes |
|---|---|---|---|---|
inputCreationId |
β | string | β | the still to animate (Gen8 Preview video: the opening frame) |
videoModel |
string | default |
default = Video (silent clips) Β· next = Gen8 Preview video: clips with sound, a first frame plus up to 6 references |
|
prompt |
string | auto | empty falls back to a motion default (Gen8 Preview video: the scenario β opening frame, shots in order, the sound you expect) | |
enhanced |
boolean | false |
with style: "raw", TIXI writes the motion scenario (Video) or the full Gen8 Preview video brief (shots, pictures, sound) from the prompt; a prompt starting with tixi adds a thinking pass. Every other style (and auto) runs TIXI anyway and cannot switch it off. No extra credit |
|
style |
string | auto |
motion styles | |
quality |
string | auto |
ultra / ultimate are tier-gated. auto draws max / ultra only β it never lands on ultimate (that preset needs a GPU class that is not always online; ask for it explicitly). Recommended: ultra at 5 s (best coherence for the price); ultimate = 1080p, longest wait |
|
duration |
number | 5 |
Video: 5, 6 or 7 (the studio lengths; 6.5 / 7.5 still accepted) β 7 needs Supporter+; 5 s is recommended. Gen8 Preview video: one of videoDurationsNext from GET /v1/me (1, 5, 8 or 10 from Holder; 12, 15, 17 or 20 at Founder) |
|
refCreationIds |
string[] | β | Gen8 Preview video only: up to 6 references after the first frame (characters, props, places); each adds a surcharge; 400 TOO_MANY_REFS above the cap |
|
aspectRatio |
string | auto |
Gen8 Preview video only: auto keeps the first frame's ratio, or 1:1, 3:2, 4:3, 16:9, 21:9, 2:3, 3:4, 9:16
|
|
audioCreationId |
string | β | Gen8 Preview video only: one of your uploaded MP3s (POST /v1/uploads) as the audio reference; counts as one more reference for the price. 400 AUDIO_REF_NEXT_ONLY on the default model, 400 INVALID_AUDIO_REF when the creation is not an audio file, 404 CREATION_NOT_FOUND when it is not yours |
|
audioUse |
string | music |
music = soundtrack / sound design Β· voice = a voice sample the speaking character follows |
|
frameInterpolation |
boolean | false |
Video only | |
allowNSFW, clientToken
|
as above |
Endpoint-specific errors: 400 INPUT_CREATION_REQUIRED,
400 INVALID_INPUT_CREATION_ID, 400 INVALID_DURATION, 400 TOO_MANY_REFS,
400 VIDEO_MODEL_UNAVAILABLE (the next engine is off), 403 INSUFFICIENT_TIER
(a length above the tier), 403 QUALITY_NOT_ALLOWED_FOR_TIER (a preset above the tier),
400 AUDIO_REF_NEXT_ONLY / 400 INVALID_AUDIO_REF / 400 AUDIO_TOO_LONG (the audio reference).
Body: { "creationId": "β¦" }. The server re-derives the original tool's
submit from the stored row (prompt, style, quality, dimensions, references)
and queues it like any other generation β same ticket response, same pricing,
same gates, and the per-key spend cap applies. The new creation records its
parent in retriedFromCreationId.
Endpoint-specific errors: 400 MISSING_CREATION_ID,
404 CREATION_NOT_FOUND (unknown, not yours, or deleted),
400 CREATION_UNAVAILABLE (in trash),
400 REGEN_UNSUPPORTED_TOOL (raw upload or wallpaper-resize row),
400 REGEN_MISSING_SOURCE / 400 REGEN_MISSING_PROMPT (the stored row lacks
what the tool needs), 422 REGEN_REFS_UNAVAILABLE (a character-ref row
whose identity references are all in the trash, deleted, or no longer visible).
Every submit answers { queueId, status: "pending", quality, costUsd? } β quality is the preset the
server resolved (what auto chose, and what costUsd was priced on).
-
404 CREATION_NOT_FOUND { missingCreationIds }β aninputCreationId,refCreationIdsentry or extraction ref names a creation that does not exist, or a picture reference that is in the trash, deleted, or not visible to the account (neither yours nor public). Refused before any charge.
| HTTP | Code | Extra fields |
|---|---|---|
| 400 |
INVALID_PROMPT, INVALID_STYLE, INVALID_QUALITY
|
β |
| 402 | INSUFFICIENT_CREDIT |
costUsd, creditUsd, deficitUsd
|
| 402 | KEY_SPEND_LIMIT_REACHED |
costUsd, spentUsd, spendLimitUsd
|
| 403 | QUALITY_NOT_ALLOWED_FOR_TIER |
quality, requiredTier, requiredTierName, message
|
| 403 | TOOL_NOT_ALLOWED_FOR_TIER |
tool (video, video-next, image-next, style-transfer, artist-painter), requiredTier, requiredTierName, message
|
| 403 | KEY_POLICY_CHANNEL |
channel β the key's modelChannels policy does not include it |
| 403 | NSFW_REFS_BLOCKED |
blockedCreationIds |
| 409 | QUEUE_LIMIT_REACHED |
maxPending, current
|
| 409 | IDEMPOTENT_RETRY |
β (your identical retry was de-duplicated) |
| 413 | STORAGE_QUOTA_EXCEEDED |
bytesUsed, quotaBytes
|
| 503 | PRICING_UNAVAILABLE |
β |
Both credit errors fire before any charge.
{
"queueId": "β¦", "status": "completed", "costUsd": 0.07,
"creationId": "β¦", "outputCdnId": "β¦", "downloadUrl": "/cdn/file/β¦",
"createdAt": "β¦", "startedAt": "β¦", "completedAt": "β¦"
}startedAt appears once a worker picked the job up (absent while it waits); imageModel / videoModel echo the channel when the job ran on one.
error carries the failure reason on failed rows; videoModel / imageModel
name the channel ("next" = Gen8 Preview video / Gen8 Preview images) when the job was submitted on one. 404 QUEUE_ITEM_NOT_FOUND
if the id is unknown or belongs to someone else.
Settled rows past your newest ~200 are eventually moved to an archive
(never deleted); this endpoint reads the archive transparently, so a stored
queueId keeps resolving with its terminal status and creationId.
Stop a generation that is still pending β the charge is refunded and the queue slot released:
{ "queueId": "β¦", "status": "cancelled" }Once a worker has picked the job up there is no abort hook:
409 { "error": "NOT_PENDING", "status": "processing" } with the current
status. The race is benign β either you cancelled in time and were refunded,
or the generation completes and you keep it. 404 QUEUE_ITEM_NOT_FOUND for
unknown ids, 403 when the item isn't yours.
The full listing engine β the same lenses and filters the studio's cloud
drive uses. POST /v1/creations mirrors it exactly with the params in a JSON
body, for when you'd rather keep search text out of URLs and proxy logs.
Modes β pick at most one; combining two is a
400 { "error": "CONFLICTING_FILTERS" }:
| Param | Values | Lens |
|---|---|---|
| (none) |
folderId (__root__ = ungrouped) Β· trashed (1/true/yes) |
the default flat listing |
activity |
all | history | uploaded | likes | public | trash | sdk | mcp | portfolio
|
the studio's fixed collections |
smart |
faces | videos | crop | alpha | favorites
|
smart collections (favorites = creations inside starred folders) |
sharedFolderId |
a folder id | a folder shared with you (view access) |
groupId |
a folder-group id | every folder filed in that group, one listing (your own groups β see GET /v1/folder-groups); 404 GROUP_NOT_FOUND / 403 NOT_AUTHORIZED
|
Cross-cutting params β compose with any mode:
| Param | Notes |
|---|---|
search |
whole-word AND over descriptions + tags; a trailing * makes a token a prefix (cur*); β€ 128 chars / 8 tokens |
sort |
newest (default) | oldest | updated | size-desc | size-asc | type-asc | type-desc
|
ratioFilter |
square | landscape | portrait, or a comma set (square,portrait); unknown ids are dropped |
from / to
|
date range β ISO string or epoch ms |
hideFiled |
1 β exclude creations already filed into a folder (history/uploaded/likes/sdk/mcp lenses only) |
usedRef |
a creation id β only creations BUILT FROM it: any reference slot or lineage parent counts, clone families expanded; 400 INVALID_USED_REF on a malformed id |
blockNSFW |
1 β exclude NSFW-flagged rows (your account-level preference applies regardless) |
random |
1 β return { "creation": Row | null } instead of a page: ONE uniformly-sampled creation from the whole filtered collection. Not available on the trash (400 RANDOM_NOT_SUPPORTED) |
limit / page / beforeDate
|
limit 1..100 default 20 Β· page 1-based Β· beforeDate cursor |
{
"creations": [{
"creationId": "β¦", "prompt": "β¦", "description": "β¦",
"toolKind": "image-gen", "style": "β¦", "quality": "high", "aiModel": "gen7.2",
"outputCdnId": "β¦", "downloadUrl": "/cdn/file/β¦",
"width": 2048, "height": 2048,
"mediaKind": "image", "hasAudio": false,
"isUploaded": false, "source": "sdk", "creationDate": "β¦",
"folderId": "β¦", "inTrash": false, "isPublicShared": false,
"likes": 3, "isLiked": true, "isOwner": true,
"nsfwFlagged": false, "nsfwRate": 0.1, "username": "β¦"
}],
"page": 1, "limit": 20, "count": 20,
"nextBeforeDate": "β¦"
}A row rendered on a next channel carries videoModel / imageModel: "next" and aiModel: "gen8.0".
count is the rows in this page, not the collection total.
nextBeforeDate is present only while a further page may exist and the
rows are in newest-first date order (the default sort, no relevance-ranked
search) β feed it back as beforeDate; for any other ordering, page with
page. prompt, nsfwRate and folderId are owner-only; likes is the
total count and isLiked is whether your account liked the row. source
is web (studio), sdk, mcp or api.
The cross-user public feed, newest-published first. Query: limit (1..48,
default 24) Β· page (1-based, β€ 1000) Β· includeNsfw (1 to include
flagged rows).
{ "creations": [ β¦ ], "page": 1, "limit": 24, "hasMore": true }Rows are the same shape as above; rows you don't own hide their prompts.
Returns { "creation": { β¦ } } β the full owner view: prompts, dimensions,
references, NSFW rating, lineage, folder, timestamps, plus downloadUrl.
Note the key is creation.id here, not creationId.
404 CREATION_NOT_FOUND when unknown, not yours, or deleted.
| Endpoint | Body | Returns |
|---|---|---|
POST /v1/creations/:id/move |
{ folderId } β null/__root__ detaches |
{ creationId, folderId } |
POST /v1/creations/:id/trash |
β | { creationId, trashed: true } |
POST /v1/creations/:id/restore |
β | { creationId, trashed: false } |
DELETE /v1/creations/:id |
β | { creationId, deleted: true } |
POST /v1/creations/:id/clone |
β |
{ creation } (full owner view of the copy) |
Permanent delete requires the creation to be in trash first, else
409 NOT_IN_TRASH. Other codes: 404 CREATION_NOT_FOUND, 403 NOT_AUTHORIZED,
410 ALREADY_DELETED, 403 INVALID_FOLDER.
Clone makes a byte-identical duplicate (images only) with its own CDN file,
inheriting the original's description/tags/moderation verdict; no credit
cost, but the upload tier + storage-quota gates apply. Clone-specific codes:
403 CLONE_FORBIDDEN, 400 CLONE_UNSUPPORTED_MEDIA (video/gif),
409 CREATION_HAS_NO_MEDIA, 403 UPLOAD_NOT_ALLOWED_FOR_TIER,
413 STORAGE_QUOTA_EXCEEDED.
| Endpoint | Body | Returns |
|---|---|---|
POST /v1/creations/:id/like |
{ "liked": true | false } |
{ liked, likeCount, changed } |
POST /v1/creations/reorder |
{ "creationIds": [ β¦ ] } (β€ 1000) |
{ reordered } |
POST /v1/creations/batch |
{ "action": "trash" | "untrash" | "delete", "ids": [ β¦ ] } (β€ 1000) |
{ action, requested, processed, skipped } |
like is an idempotent SET, not a toggle β retries are safe, and
changed: false means the state already matched. Any live creation is
likeable; trashed/deleted rows answer 410. reorder stamps
creationIds[i] with sort index i (ids you don't own are silently
skipped). batch is owner-only; rows whose state doesn't fit the action are
counted in skipped β delete requires rows already in trash.
| Endpoint | Scope | Returns |
|---|---|---|
POST /v1/creations/:id/flag-nsfw |
manage |
{ nsfwFlagged: true, nsfwManual: "nsfw", nsfwRate } |
POST /v1/creations/:id/explain-nsfw |
generate |
{ explanation, cached, nsfwRate, nsfwThreshold } |
POST /v1/creations/:id/score-face-ref |
generate |
{ score, scoredAt, cached } |
POST /v1/creations/:id/score-character-ref |
generate |
{ score, scoredAt, cached } |
All four are owner-only. flag-nsfw is a manual safe β NSFW escalation (the
reverse direction isn't exposed here) and flips the CDN gate immediately.
The other three are cached on the row: the first call burns a vision-LLM
pass (hence the generate scope), repeats answer from the stored result with
cached: true. Codes: 400 NOT_FLAGGED (explain on a clean row),
422 SCORE_PARSE_FAILED, 503 = the LLM pass didn't land β retry later.
| Endpoint | Returns |
|---|---|
POST /v1/creations/:id/publish |
{ creationId, isPublicShared: true } |
POST /v1/creations/:id/unpublish |
{ creationId, isPublicShared: false } |
409 CREATION_IN_TRASH β restore it first.
409 UPLOADED_NOT_PUBLISHABLE β uploaded source files can't be published, only
your own generations.
multipart/form-data with a single file field named file, plus optional
text fields:
| Field | Meaning |
|---|---|
targetFolderId |
file the upload straight into this folder (instead of the drive root). Must be a folder you can write to β yours, or shared with you with editor rights; system folders excluded. Otherwise 403 INVALID_FOLDER and the upload is discarded. The duplicate check below is scoped to this folder |
croppedFromCreationId |
record the upload as a crop of a creation you own (images only) |
erasedFromCreationId |
record the upload as an erase edit β mutually exclusive with croppedFromCreationId
|
curl -X POST https://dapp.2dai.io:444/v1/uploads \
-H "Authorization: Bearer $TWODAI_API_KEY" \
-F "file=@./ref.jpg" \
-F "targetFolderId=$FOLDER_ID"Accepted types: JPEG, PNG, WebP, GIF, MP4 (video uploads are tier-gated), and
MP3 β the audio reference of a Gen8 Preview video clip: 5 minutes max, Holder tier and
up, no vision caption (the file name and length label it); the row comes back
with mediaKind: "audio" and duration in seconds. Per-account size cap
applies (10 MB on the base tier).
Returns the created creation object directly (not wrapped).
Notable errors: 400 NO_FILE, 400 INVALID_MIME, 403 UPLOAD_NOT_ALLOWED_FOR_TIER,
403 VIDEOS_NOT_ALLOWED, 403 AUDIO_NOT_ALLOWED, 400 AUDIO_TOO_LONG { maxSeconds },
403 NSFW_REJECTED, 403 INVALID_FOLDER,
413 FILE_TOO_LARGE, 413 STORAGE_QUOTA_EXCEEDED, 409 DUPLICATE_IN_FOLDER
(identical bytes already in that folder), 429 UPLOAD_IN_PROGRESS.
Advisory pre-flight dedup: does a creation with this content hash already
exist? Query: md5 (32 hex chars β the md5 of the file bytes) Β· folderId
(optional; omit to check the drive root).
{ "duplicate": true, "existingCreationId": "β¦", "folderId": "β¦" }Saves the full upload round-trip, but the upload itself still re-checks β a
concurrent upload can race a false, so treat it as a hint, not a lock.
| Endpoint | Scope | Body / query | Returns |
|---|---|---|---|
GET /v1/folders |
read |
limit (default 20, max 100) Β· beforeDate (cursor) Β· sort
|
{ folders: [...], nextBeforeDate } |
POST /v1/folders |
manage |
{ title, description?, groupId? } β title β€ 40, description β€ 500; groupId must be one of your groups |
{ folder } |
PATCH /v1/folders/:id |
manage |
{ title?, description?, groupId?, posterCreationId?, isFavorite? } β at least one. groupId: string sets the group, null detaches. posterCreationId: string pins the poster, null clears. isFavorite: boolean |
{ folder } |
DELETE /v1/folders/:id |
manage |
?trashContents=1 also trashes the contents |
{ folderId, deleted: true } |
An account holds up to 500 folders β 409 FOLDER_LIMIT_REACHED past the
cap. Folder shape:
{ "folderId": "β¦", "title": "β¦", "description": "β¦", "icon": "β¦",
"kind": "private", "isPublicShared": false, "isShared": false,
"posterCreationId": "β¦", "groupId": "β¦", "isFavorite": false,
"createdAt": "β¦", "updatedAt": "β¦" }groupId is absent when the folder is ungrouped; isFavorite marks the
sidebar star (starred folders feed the smart=favorites lens).
The list is newest-first by createdAt by default, and nextBeforeDate is
present only when a further page may exist β feed it back as beforeDate, same
as GET /v1/creations.
sort accepts the same vocabulary as the dashboard: index, date-desc,
date-asc, name-asc, name-desc, update-desc. Note the interaction with the
cursor: beforeDate filters on the creation date, so it can only page a
date-descending sequence without skipping or repeating rows. Ask for
name-asc (or any non-date order) and you get your ordering, but
nextBeforeDate is omitted β raise limit instead, which at 100 covers any
realistic number of collections. The alternative, accepting sort and handing
back a cursor that silently corrupts the sequence, would be the worse contract.
Errors: 400 INVALID_TITLE (with maxLength), 400 NO_UPDATES,
400 FIXED_KIND_PROTECTED (system folders can't be renamed or deleted),
404 NOT_FOUND, 403 NOT_AUTHORIZED.
Groups are the collapsible sections the studio's cloud-drive sidebar organises collections into. They hold folders, never creations directly:
| Endpoint | Scope | Body | Returns |
|---|---|---|---|
GET /v1/folder-groups |
read |
β |
{ groups: [...] } β sidebar order |
POST /v1/folder-groups |
manage |
{ title } |
{ group } |
PATCH /v1/folder-groups/:id |
manage |
{ title } |
{ group } |
DELETE /v1/folder-groups/:id |
manage |
β | { groupId, deleted: true } |
Group shape: { groupId, title, sortIndex, collapsed, createdAt, updatedAt }.
An account holds up to 100 groups (409 GROUP_LIMIT_REACHED). Deleting a
group only detaches its member folders back to the ungrouped pane β
folders and creations are never deleted through this route. Move folders in
and out via PATCH /v1/folders/:id with groupId.
Read-only account analytics, all scope read. overview and top are
served from a server-side cache; their cache block
({ computedAt, ageSeconds, stale }) says how fresh the numbers are β
stale: true means a background refresh was kicked, re-fetch shortly.
{
"storage": { "usedBytes": 0, "quotaBytes": 0, "imageBytes": 0,
"videoBytes": 0, "uploadBytes": 0, "trashBytes": 0 },
"counters": { "totalCreations": 0, "weekCreations": 0, "weekDelta": 0,
"privateFolders": 0, "sharedFolders": 0,
"favoriteFolders": 0, "trashCreations": 0 },
"streak": { "days": 0 },
"smartSummaries": [{ "id": "faces", "count": 0 }],
"toolsRecent": { "image-gen": { "total": 0, "recentIds": ["β¦"] } },
"cache": { "computedAt": "β¦", "ageSeconds": 12, "stale": false }
}Generation volume + spend over the window (any other days is a 400); a live
aggregate memoized ~60 s per window:
{
"days": 30,
"totals": { "count": 0, "costUsd": 0 },
"byDay": [{ "day": "2026-08-01", "count": 0, "costUsd": 0 }],
"byTool": [{ "toolKind": "image-gen", "count": 0, "costUsd": 0 }],
"bySource": { "machine": { "count": 0, "costUsd": 0 },
"studio": { "count": 0, "costUsd": 0 } }
}machine is work made through keys (SDK / MCP / raw REST); studio is the
web app.
Most-used references (with the config the studio would suggest for each), styles and keywords:
{
"topRefs": [{ "referenceCreationId": "β¦", "useCount": 0,
"suggestedTool": "character-ref", "latestUsedAt": "β¦",
"suggestedConfig": { "quality": "high", "style": null,
"motionStyle": null, "requestedDuration": null,
"isEnhanced": false, "frameInterpolation": false } }],
"topStyles": [{ "style": "β¦", "count": 0 }],
"topKeywords": [{ "tag": "β¦", "count": 0 }],
"cache": { "computedAt": "β¦", "ageSeconds": 12, "stale": false }
}The money family is read-only by construction: no deposit, withdraw, swap
or lock operation exists anywhere on the API-key surface β those live in the
dashboard. The wallet reads require the opt-in finance scope (never in the
mint defaults); the two catalogue reads only need read.
| Endpoint | Returns |
|---|---|
GET /v1/tiers |
{ tiers: [{ key, name, valueUSD, icon?, color?, settings? }, β¦] } β the public tier catalogue |
GET /v1/token/price |
{ usdPrice, asOf, staleMs } β the cached $2DAI quote (machine calls never trigger a live refresh). 503 PRICE_UNAVAILABLE when no cache exists yet |
| Endpoint | Query | Returns |
|---|---|---|
GET /v1/wallet |
β | { tokens, creditUsd, tier, pendingWithdrawal, pendingSwap, lockActive } |
GET /v1/account/tier |
β |
{ effective, legs: { db, watermark, holdWatermark, dynamic } } β the effective tier is the max of the 4 legs |
GET /v1/wallet/lock |
β |
{ kind: "none" }, or { kind: "active" | "expiring-soon", lock, remainingMs }
|
GET /v1/wallet/transactions |
limit (5..50, default 20) Β· page (0-based) |
{ transactions: [{ id, type, status, asset, amount, hash, date, processedDate, counterparty, reason, needsAdminReview, secondaryUsd }], page, pageSize, hasMore } |
GET /v1/wallet/balance-history |
limit = days (7..365, default 90) |
{ currentBalance, airdropUsdCredit, points: [{ date, balance, delta, type }] } |
GET /v1/wallet/credit-history |
limit = days (7..365, default 90) |
{ currentCredit, tokenBalance, points: [{ date, balance, delta, type }] } |
GET /v1/wallet/credit/sources |
days (1..365, default 30) |
{ days, accruedUsd, bonusesUsd, swappedUsd, spentUsd } |
amount is a magnitude β the direction comes from type. secondaryUsd is
set on swap rows only. A key without the scope gets
403 { "error": "MISSING_SCOPE", "requiredScope": "finance", "scopes": [ β¦ ] }.
Send the same bearer; the response is the raw bytes (not JSON), with
content-type, content-length and etag passed through.
curl -L "https://dapp.2dai.io:444/cdn/file/<outputCdnId>" \
-H "Authorization: Bearer $TWODAI_API_KEY" -o out.jpgYou can append an extension (/cdn/file/<id>.jpg) and pass CDN transform
params: ?s=<px> scales the longest edge keeping the aspect ratio (what you
want for previews), ?w=<px> / ?h=<px> target one axis and may crop. ?s
takes precedence over ?w / ?h when both are sent.
curl -L "https://dapp.2dai.io:444/cdn/file/<id>?s=512" \
-H "Authorization: Bearer $TWODAI_API_KEY" -o preview.jpgPublicly-shared creations serve without auth; everything else requires the key
that owns them. 404 for unknown ids, 403 when the key may not see that file.
-
Pending generations are capped per tier (4 on the base tier) β
409 QUEUE_LIMIT_REACHEDwhen you exceed it. Poll and retry. -
Pagination β
limitmaxes out at 100 (48 on the feed; 50 on wallet transactions). -
References are capped per tool (strict
400 TOO_MANY_REFSβ see the caps table above). -
Bulk β
reorderandbatchtake at most 1000 ids per call. -
Drive β 500 folders and 100 folder groups per account
(
409 FOLDER_LIMIT_REACHED/GROUP_LIMIT_REACHED). -
JSON bodies are capped at 5 MB; prompts at the account's
promptMaxChars(GET /v1/me; tier ladder 1,500 β 20,000). -
Idempotency β submits carry a
clientToken. Re-sending an identical call within a few seconds returns409 IDEMPOTENT_RETRYinstead of charging twice. Pass your own token to control the window. Without a valid token the server derives one from the call itself (prompt, settings, source and reference ids, audio reference, 5 s bucket), so two submits that differ by a reference are never mistaken for a retry. -
Rate/abuse β repeated auth failures and 404s count against your IP; a
blocked IP gets
429with an empty body on every route.