REST API - 2DAICommunity/2dai-cloud-sdk GitHub Wiki

REST API

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/json except POST /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.


User-Agent and the source label

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.


The one thing to understand: generation is asynchronous

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.jpg

Poll 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.


Errors

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".


Account

GET /v1/me β€” scope read

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.


Generation

All three submit endpoints return the same shape:

{ "queueId": "…", "status": "pending", "costUsd": 0.07 }

costUsd is present only when the call was charged.

POST /v1/generate/image β€” scope generate

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.

Reference caps (strict)

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

POST /v1/generate/image-with-refs β€” scope generate

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 enhanced field 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.

POST /v1/generate/wallpaper β€” scope generate

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.

POST /v1/generate/video β€” scope generate

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).

POST /v1/generate/similar β€” scope generate

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).

Errors shared by all submits

  • 404 CREATION_NOT_FOUND { missingCreationIds } β€” an inputCreationId, refCreationIds entry 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.

GET /v1/queue/:queueId β€” scope read

{
  "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.

POST /v1/queue/:queueId/cancel β€” scope generate

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.


Creations

GET /v1/creations β€” scope read

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.

GET /v1/feed β€” scope read

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.

GET /v1/creations/:id β€” scope read

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.

Organising β€” scope manage

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.

Likes, bulk actions, reorder β€” scope manage

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.

NSFW + reference tooling

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.

Publishing β€” scope publish

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.


Uploads

POST /v1/uploads β€” scope generate

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.

GET /v1/uploads/check-duplicate β€” scope read

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.


Folders

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.

Folder groups

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.


Stats

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.

GET /v1/stats/overview

{
  "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 }
}

GET /v1/stats/generations?days=30|90

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.

GET /v1/stats/top

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 }
}

Wallet & tiers (read-only)

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.

Scope 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

Scope finance

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": [ … ] }.


Downloading

GET /cdn/file/:cdnId β€” scope read

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.jpg

You 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.jpg

Publicly-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.


Limits worth knowing

  • Pending generations are capped per tier (4 on the base tier) β€” 409 QUEUE_LIMIT_REACHED when you exceed it. Poll and retry.
  • Pagination β€” limit maxes 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 β€” reorder and batch take 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 returns 409 IDEMPOTENT_RETRY instead 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 429 with an empty body on every route.
⚠️ **GitHub.com Fallback** ⚠️