Storing data (document store)
An app keeps its data on Civitai in the document store: JSON documents in named collections that the app declares in its manifest. A collection is either private, one key space per viewer, or shared, one key space every viewer of the app reads. Shared collections carry authorship, counters and per-viewer marks (a vote, a like, a report) without any code of yours on the server.
The client is app.storage.collection(name) in @civitai/sdk 0.14.0 or later. Underneath it is a plain REST API under /api/v1/blocks/storage, called with the block token, so an app that does not use the SDK can call it directly (REST reference).
A scaffolded app may pin an older SDK, and a caret range on a 0.x version never moves to the next minor, so npm install alone does not get you there. On an older version app.storage.collection does not exist. Check, and raise it if needed:
npm ls @civitai/sdk
npm install @civitai/sdk@^0.14.0To use the store in a civitai app dev-tunnel preview, you need 0.18.0 or later (see Local development).
This store replaces two older ones, useAppStorage and useSharedStorage (and their app-storage / shared-storage routes). Those are shut down on 2026-11-10, after which their routes answer 410 Gone; see Moving from the legacy stores. Which client reaches which store:
| Store | @civitai/sdk | @civitai/blocks-react (bridge) | REST |
|---|---|---|---|
| Document store | app.storage.collection(name), app.storage.info() | none | /api/v1/blocks/storage/* |
| Legacy per-viewer store (deprecated; shut down 2026-11-10) | app.storage.get / set / delete / list / getQuota | useAppStorage | /api/v1/blocks/app-storage/* |
| Legacy shared store (deprecated; shut down 2026-11-10) | app.sharedStorage | useSharedStorage | /api/v1/blocks/shared-storage/* |
Declare your collections
A collection that is not declared in the manifest does not exist: every call to it is refused collection_not_declared. The declaration is reviewed with the rest of the manifest, and the server reads the approved version of it, so a collection you add in a new version exists once that version is approved.
{
"scopes": ["apps:storage", "apps:storage:publish"],
"scopeJustifications": {
"apps:storage:publish": "Viewers post feature requests that every viewer of the app can read and vote on."
},
"storage": {
"collections": {
"prefs": { "visibility": "private" },
"requests": { "visibility": "shared", "write": "own", "marks": ["vote"],
"indexes": { "status": "string" }, "showAuthor": true },
"plays": { "visibility": "shared", "write": "none", "counters": ["plays"] }
}
}
}Each collection declares:
visibility—"private": each viewer has their own key space, and only that viewer reads or writes it."shared": one key space for the whole app, read by every viewer, signed-out viewers included.write— shared collections only, and required on them."own": signed-in viewers create documents, and only a document's author edits or deletes it."none": nobody writes documents; viewers only increment the declared counters and set the declared marks, and the first increment of a key that does not exist creates an empty document. A"none"collection must declare at least one counter or mark.counters— shared only. Atomic tallies the app increments, such asplays.marks— shared only. A per-viewer flag on a document, at most one of each per viewer per document, such asvote. A reader sees each mark's count and whether they set it, never who else did.reportis built in on every shared collection and must not be declared.indexes— the fields of a document'sdatayou can filter and sort by, as a map of field path to type (string,number,booleanortime). A path such asstatusormeta.roundnames a field insidedata.showAuthor— shared only. Whentrue, readers see the author's username. Otherwise they learn only whether they wrote a document themselves. The numeric user id is never exposed either way.ownedModelVersions— shared collections withwrite: "own"only. Fields ofdatathat, when present, must hold a model version the writing viewer owns. See Fields that must name the author's own model version.schema— optional, on any collection that holds documents. A JSON Schema subset that every document'sdatamust match. See A schema for each document.exportable— optional,truewhen omitted. Whether another app may reach this collection, for a viewer who allows it, through an import. Setfalseon a collection no other app should reach: every call to it through an import is then refusednot_exportable.
Renaming a collection or changing its visibility makes it a new collection, and the old one's documents are not moved into it. They are not deleted either: they stay stored, keep counting toward the app's and each viewer's quotas, and can no longer be read or deleted: a call naming a collection that is no longer declared is refused collection_not_declared, and after a visibility change under the same name the old documents sit in a key space the collection no longer reads. Declaring the old collection again brings them back.
Clearing a collection out first is possible only for some of its documents, because there is no app-wide delete:
- In a private collection, each viewer can delete their own documents, with their own token.
- In a shared
"own"collection, each document's author can delete it, and no other viewer can. A document a moderator has hidden cannot be deleted this way. - In a shared
"none"collection, no viewer can delete a document: a delete is refusedwrite_policy. - The app cannot delete documents on viewers' behalf:
write: "owner", which would make the app a writer, is reserved.
Every document not deleted that way stays charged to those quotas, and declaring the old collection again is the only way to reach it again.
Changing an index's type is not supported; remove the index and add one under a new name.
Some of the schema is reserved for later phases, and the server refuses it with a message saying so. The limits table lists which fields are refused today, and the manifest reference has the bounds of each field, including the ones nested inside a collection (its storage.collections.<name>.* rows). ownedModelVersions has its own rules below.
The server's own manifest validation, which applies the reserved-feature refusal and the scope rules, runs when a moderator approves the version (and on a direct git push), not when civitai app submit uploads it. A declaration it refuses therefore waits in review until approval fails, so check it before you submit. From civitai CLI 0.1.131, civitai app validate mirrors those rules locally: the scope rules and a counter/mark name clash are errors, the reserved features are warnings, and it warns when your source calls an undeclared collection or the store without its scope (Storage checks). Older CLIs check only the JSON schema, which admits the reserved fields.
Fields that must name the author's own model version
A shared collection can carry documents that point at a model version, such as a workflow a creator publishes for their own model. Readers then take that id at face value, so the server can enforce that only the version's owner writes it: declare the fields in ownedModelVersions, and every create and replace is checked against the viewer making it.
{
"scopes": ["apps:storage", "apps:storage:publish"],
"scopeJustifications": {
"apps:storage:publish": "Creators publish workflows for their own models, and every viewer of the app can browse them."
},
"storage": {
"collections": {
"workflows": {
"visibility": "shared",
"write": "own",
"indexes": { "workflow.modelVersionId": "number" },
"showAuthor": true,
"ownedModelVersions": ["/workflow/modelVersionId"]
}
}
}
}A document in workflows such as { "workflow": { "modelVersionId": 123456, "steps": [] }, "title": "Portrait pass" } is stored only if model version 123456 belongs to a model the writing viewer owns.
The rules:
- Where it may be declared. Shared collections whose
writeis"own", the only policy under which the viewer making a write is always the document's author. It is refused on private collections and onwrite"anyone","owner"and"none". - Pointers. Each entry names one field of nested objects: no array indexes, no
~0/~1escapes, no hyphens and no non-ASCII characters. The count and the exact pattern are in the limits table. - Absent is allowed. A field that is missing, or a path that steps through an array,
nullor any other non-object on the way, counts as absent and is not checked. To require the field, validate that in your app. - Present must be owned. A present field must be a positive integer naming a model version whose model the writer owns. A version that is someone else's, that does not exist or is deleted, and a value that is not a positive integer (a string
"123456"andnullincluded) are all refused the same way, so the refusal does not reveal whether a version exists. To clear the field, leave the key out rather than setting it tonull. Draft and unpublished versions the writer owns pass. - Which writes. Every create (
POST /{collection},create) and replace (PUT /{collection}/{key},put) of a document. Counters, marks and deletes do not writedataand are not checked. - Before the author check. The check is against the viewer making the write, and it runs before the store looks at who wrote the document. So a
putby a viewer who is not the author, whosedatacarries a version that viewer does not own (such as the author's own id, unchanged), is refusedresource_not_owned, notnot_author. Handle both codes on an edit. - Before moderation and the write budget. A refused write is not scanned and spends no write budget.
- At write time only. Ownership is checked when a document is written. If the model later changes hands or is deleted, documents already stored keep the id; the check applies again the next time the document is written. Adding
ownedModelVersionsto a collection that already holds documents does not re-check them. - A failed lookup is a retryable
503 service_unavailable; the write is never accepted unchecked.
A refusal is 403 with code resource_not_owned, and details.pointer names a declared field that failed (when several fail, only one is reported):
{
"code": "resource_not_owned",
"message": "the model version at this field is not one the author owns",
"error": "the model version at this field is not one the author owns",
"details": { "pointer": "/workflow/modelVersionId" }
}Tooling versions that know the field:
civitaiCLI 0.1.128 or later validates it incivitai app validate; older CLIs reject it as an unknown property.@civitai/sdk0.16.0 or later namesresource_not_ownedinStorageErrorCode, and itscreateFakeStorageenforces the rule.@civitai/app-sdk0.73.0 or later typesownedModelVersionson a shared collection.
A schema for each document
A collection can declare schema, and the server then refuses any document whose data does not match it. Without a schema, a collection's documents are not checked, so existing apps are unaffected.
{
"storage": {
"collections": {
"cards": {
"visibility": "private",
"schema": {
"type": "object",
"required": ["title"],
"additionalProperties": false,
"properties": {
"title": { "type": "string", "minLength": 1, "maxLength": 40 },
"tags": { "type": "array", "maxItems": 3, "items": { "enum": ["red", "green", "blue"] } }
}
}
}
}
}
}Keywords. Only a subset of JSON Schema exists here:
type: one ofobject,array,string,number,integer,boolean,null, or a list of them.- Object keywords:
properties,required, andadditionalProperties(true,falseor a schema). - Array keywords:
items(one schema for every element),minItems,maxItems. - String keywords:
minLength,maxLength,pattern. - Number keywords:
minimum,maximum. - Any type:
enum,const,description.
Any other keyword (format, $ref, oneOf and the rest) is refused, and the refusal names the keyword. A keyword the store would ignore can therefore never look enforced.
Bounds. The limits table has the numbers:
- the schema's size in bytes of JSON;
- how many schemas may nest inside one another (the collection's own schema counts as one);
- how many
patterns it may declare, and how many all of the manifest's collection schemas may declare together (every place a pattern appears counts, even when the same pattern repeats); - each pattern's length.
A schema with a pattern must also declare a maxLength on the same schema, so a pattern only ever runs against a bounded string. A pattern must compile as a JavaScript regular expression with the u flag. A pattern that can backtrack catastrophically (such as ^(a+)+$) is refused. A minLength, minItems or minimum above its maximum is refused. A schema is not allowed on a write: "none" collection, which has no document bodies to check.
What it means. The same rules apply to every evaluator, including the CLI's and the SDK's:
- A keyword for another type is ignored:
minLengthsays nothing about a number. integeris a number with no fraction, andnumberincludes integers.nullis its own type, and an array is not anobject.- String lengths count code points, so an emoji is one character.
patternis unanchored (write^…$to match the whole string) and is a JavaScript regular expression with theuflag.enumandconstcompare JSON values:1does not equal"1", and object key order does not matter.requiredandadditionalPropertieslook at the object's own keys only.
Which failure is reported. When data fails in several places, only the first is reported. Within one schema the order is:
typeconst, thenenum- the string, number and array keywords, with
maxLengthbeforepattern items, element by elementrequired, in its declared order- the object's own keys, in ascending order, each against its
propertiesentry oradditionalProperties
When it is checked.
- On every create (
POST /{collection},create) and every replace (PUT /{collection}/{key},put). - A
putreplaces the whole body, so the check always sees the document that would be stored. Send the whole merged document, not a patch. - Counters, marks and deletes do not write
dataand are not checked. - The check runs before the
ownedModelVersionscheck, the minimum-trust gate, the shared write budget and moderation. A refused write is not stored and spends none of them. - A schema change that could leave stored documents unwritable is refused at submit unless the version declares
migrationsfor it, which run over the stored documents when the version is approved. See Changing a collection's schema.
A refusal is 400 with code schema_violation:
details.pointeris the first failing JSON pointer (RFC 6901) intodata.""is the root.~and/in a key are escaped as~0and~1.details.keywordis the keyword it failed.details.collectionis the collection.- The message names the pointer too. A pointer longer than 200 characters is cut short in the message, but
details.pointeralways carries it whole.
{
"code": "schema_violation",
"message": "document does not match the schema of collection cards at \"/tags/1\": must be one of the schema's enum values",
"error": "document does not match the schema of collection cards at \"/tags/1\": must be one of the schema's enum values",
"details": { "collection": "cards", "pointer": "/tags/1", "keyword": "enum" }
}The server enforces the schema whatever tooling you use. Tooling versions that know the field:
@civitai/sdk0.23.0 or later namesschema_violationinStorageErrorCode, and itscreateFakeStorageand the local preview enforce the schema. 0.22.0 and older do not check it.@civitai/app-sdk0.81.0 or later typesschemaon a collection and bundles a manifest schema that knows the key.civitaiCLI 0.1.135 or later checks it incivitai app validate. 0.1.134 and older refuse a manifest withschemaas an unknown property, in bothcivitai app validateandcivitai app submit, so upgrade the CLI.
Checking offline, with those versions:
createFakeStoragein@civitai/sdk, and the dev-tunnel local preview built on it, enforce the schema the way the server does: the same keywords, order, pointers and messages, and patterns read as JavaScriptu-flag regular expressions. When the fake is created, a schema the server would refuse throws, with the server's message. It does not run the server's check for catastrophic backtracking.civitai app validatechecks the subset and the bounds: the size, depth, pattern count, min above max, and no schema onwrite: "none". The CLI is written in Go, whose regular-expression engine (RE2) is a different language from JavaScript's. A pattern RE2 cannot compile, such as a lookahead, is a warning: the CLI cannot check it, and the server decides at submit. A pattern RE2 does compile is not proven valid either:(?i)x,\zand\pLcompile in RE2 and are refused by the server. The CLI does not check for catastrophic backtracking. See Storage checks.
Changing a collection's schema
When you submit a version, the server compares each collection's schema with the one in your app's previous approved version. It compares the two declarations only and never reads a stored document. A change that could leave a stored document unwritable is breaking, and the submit is refused unless the collection's migrations covers it. These are breaking, at any depth (inside properties, items or an additionalProperties schema):
- a property newly in
required; - a declared property removed while
additionalPropertiesisfalse; additionalPropertiesnewlyfalse, or newly a schema;- a
typeremoved (numbertointegercounts); enumorconstnarrowed, or aconstadded;minimum,minLengthorminItemsadded or raised;maximum,maxLengthormaxItemsadded or lowered;patternadded or changed;- a schema on a collection that had none. It is compared with a schema that accepts anything, so each constraint it adds is a breaking change of its own.
Loosening a bound, adding a type or an enum value, and declaring a new optional property are not breaking. (A new optional property on a schema that left undeclared keys open gets a warning: stored documents may already hold that key with another type, and those are refused on their next write.) A first version has nothing to compare against. A collection removed from the manifest is a warning, not a refusal: its documents are kept, and come back if a later version declares it again, compared with the last approved schema that declared it.
The refusal is a 400 whose message lists each uncovered change as <collection> <pointer> <keyword>: <old> → <new> (the first 20). The REST response of POST /api/v1/blocks/submit-version also carries the whole list as storageBreakingChanges, each entry { collection, pointer, keyword, old, new }. A pointer is a JSON Pointer into data: "" is the document itself, and the segment * stands for every array element (items) or every undeclared property (an additionalProperties schema).
Say the approved version declares cards with title (maxLength: 80), color and a tags array of "red", "green", "blue" or "gold", under additionalProperties: false. This next version breaks it in five places, and four ops cover all of them:
{
"storage": {
"collections": {
"cards": {
"visibility": "private",
"schema": {
"type": "object",
"required": ["title", "status"],
"additionalProperties": false,
"properties": {
"title": { "type": "string", "maxLength": 40 },
"status": { "enum": ["draft", "published"] },
"colour": { "type": "string", "maxLength": 20 },
"tags": { "type": "array", "maxItems": 3, "items": { "enum": ["red", "green", "blue"] } }
}
},
"migrations": [
{ "op": "default", "path": "/status", "value": "draft" },
{ "op": "rename", "from": "/color", "to": "/colour" },
{ "op": "remove", "path": "/tags" },
{ "op": "acknowledge", "path": "/title", "keyword": "maxLength" }
]
}
}
}
}| Op | Covers | What it does to a stored document |
|---|---|---|
default | a newly required property, at exactly path. Nothing else can cover one. | Sets value where the field is missing and its parent object exists. value must match the new schema there, or the submit is refused, and is at most 4096 bytes of JSON. |
rename | a declared property removed under additionalProperties: false, at from | Moves the value when from is present and to is absent. The new schema must allow to. |
remove | every breaking change at or under path, as long as the new schema does not require that field | Deletes the value only where the new schema refuses it. A value that is valid under the new schema is kept. |
acknowledge | exactly one change: the one whose pointer is path and whose keyword is keyword, matched literally, never as a prefix. path may be "" or contain *, as in "/tags/*". | Nothing. Documents that violate it stay stored and readable, and their next write is refused until it matches. keyword cannot be required: a newly required field needs a default. |
In the example, { "title": "a", "color": "teal", "tags": ["red", "gold"] } becomes { "title": "a", "status": "draft", "colour": "teal" }: tags holds "gold", so it is removed. A document already in the new shape is left alone, and a title longer than 40 characters stays as it is.
default,renameandremovetake paths of 1 to 15 segments with no*, and must touch disjoint fields: no path equal to or inside another's. Every op is idempotent, so a run that repeats a page changes nothing.- 1 to 32 ops per collection, and none on a
write: "none"collection. - Only the ops that cover a breaking change of this version run. An op that covers nothing never runs and is listed in the submit response's
storageMigrationWarnings, so amigrationslist left in a later version does nothing. Remove it. - No op can fill a newly required field inside an array element or an
additionalPropertiesschema (…/*/…): declare it optional, orremovethe whole optional array or object. A newly required object with a newly required field inside it takes two versions: first require the object, with adefaultthat already holds the field, then require the field with its owndefault.
When the ops run. At approval. From that moment the new schema applies to every write, including writes from a build that is still serving the old code. The ops then run in the background over the collection's production documents: every viewer's key space, hidden documents, and the documents forks of your app wrote into it. Development and review data are not migrated. Whatever the first pass does not finish is picked up by a job that runs every 10 minutes. A migrated document is written like any other: its version goes up, so a conditional write holding the old version gets version_conflict, and its size counts against the quotas. A document whose rewrite would be refused (over the size cap, for example) is left as it was. A document that still does not match the new schema afterwards, because an op acknowledged the change or the rewrite was refused, stays readable, and its next write must match the new schema.
What you can see. There is no API that reports a run's progress or result to you. What you can see is the submit's refusal or its storageMigrationWarnings, and the documents themselves: read one, and a migrated document has the new shape and a higher version. Until the run reaches every document, your code will read documents in both shapes.
Approving the next version. While an earlier version's run is unfinished, a moderator cannot approve a version that changes that collection's storage: one whose migrations run on it, or one that declares its schema, migrations, visibility, write or exportable differently. A version that leaves the collection alone can be approved, and the run carries on. A version that is rejected or withdrawn has its run closed, so the run blocks nothing.
The server applies all of this whatever tooling you use. @civitai/app-sdk0.82.0 or later types migrations and bundles a manifest schema that knows the key. civitai app validate and civitai app submit accept migrations from CLI v0.1.136 (releases); v0.1.135 and older refuse it as an unknown property. The CLI checks only the shape of the ops. Whether an op covers a change depends on the previous approved version, which only the server has, so that is decided at submit. From v0.1.136, an accepted civitai app submit prints the server's storageMigrationWarnings under the success line; a refused one prints the server's message, which lists each uncovered change.
Scopes and consent
Three scopes:
| Scope | Covers | Consent |
|---|---|---|
apps:storage | everything except authoring shared documents: reads, the viewer's private collections, deleting documents the viewer wrote, marks and counters | consent-exempt |
apps:storage:publish | creating and editing documents in a shared collection whose write is "own" | consent-gated, and needs a scopeJustifications entry |
apps:storage:import | the viewer's documents in another app, through storage.imports (Another app's data) | consent-gated, and needs a scopeJustifications entry |
The rules the server's manifest validation enforces at approval (only the scopeJustifications entry is checked earlier, when civitai app submit uploads the version):
- Declaring any collection requires
apps:storage. - A shared collection with
write: "own"requiresapps:storage:publish.write: "none"does not: no viewer authors a document there. - Declaring any import requires
apps:storage:import, andapps:storagetoo, even when the app declares no collections of its own. - Declaring any of the three scopes with nothing in
storagefor it to reach is refused. auth: "oauth"cannot be declared alongside anyapps:storage*scope.
apps:storage:publish reaches the token only after the viewer agrees in the host's consent dialog, so the first shared write from a viewer who has not been asked is refused consent_required. Ask with app.requestGrants and retry:
import { initialize, StorageError } from '@civitai/sdk';
const app = await initialize();
const requests = app.storage.collection<{ title: string; status: 'open' | 'done' }>('requests');
async function postRequest(title: string) {
try {
return await requests.create({ title, status: 'open' });
} catch (error) {
if (!(error instanceof StorageError) || error.code !== 'consent_required') throw error;
const granted = await app.requestGrants(['apps:storage:publish'], {
signal: AbortSignal.timeout(60_000),
});
if (!granted) return null; // the viewer said no: keep the UI usable without posting
return requests.create({ title, status: 'open' });
}
}A viewer who withdraws the publish scope can still delete the documents they wrote: deleting needs only apps:storage.
Reading and writing
app.storage.collection<T>(name) returns a handle; nothing is sent until you call an operation on it.
import { initialize } from '@civitai/sdk';
interface Request {
title: string;
status: 'open' | 'done';
}
const app = await initialize();
const requests = app.storage.collection<Request>('requests');
const created = await requests.create({ title: 'More lighting presets', status: 'open' });
const doc = await requests.get(created.key); // null when there is none
await requests.put(created.key, { title: 'More lighting presets', status: 'done' }, { ifVersion: doc?.version });
await requests.mark(created.key, 'vote'); // { marked: true, count }
const page = await requests.list({ where: { status: 'open' }, sort: '-mark:vote', limit: 20 });
for (const item of page.items) {
console.log(item.data.title, item.marks.vote, item.author, item.viewer?.marks);
}| Call | Does |
|---|---|
get(key) | the document, or null when there is none (a hidden document reads the same way) |
getMany(keys) | one round trip; the result is in the order of keys, with null for each miss |
put(key, data, { ifVersion? }) | creates or replaces the document at key |
create(data) | creates a document under a key the server chooses, a sortable id |
delete(key, { ifVersion? }) | { deleted: false } when there was nothing to delete; calling it twice is safe |
list(query?) | one page; see Lists |
increment(key, counter, by?) | adds to a declared counter, { value } |
mark(key, name) / unmark(key, name) | sets or clears the viewer's mark, { marked, count } |
app.storage.info() | the declared collections, the limits and the usage so far; signed-in viewers only |
Documents
A document is { key, data, version, createdAt, updatedAt, author, counters, marks, viewer }:
datais a JSON object.versiongoes up by one on every write todata; counter and mark changes do not move it. The SDK gives youcreatedAtandupdatedAtasDates; on the wire they are ISO strings.authoris{ kind: 'user', isViewer, username? }for a viewer's document (usernameonly in ashowAuthorcollection),{ kind: 'counter' }for a document a counter's first increment created, or{ kind: 'app' }. It never carries a numeric user id.countersandmarkshold the declared counters and mark counts of a shared document, and are{}on a private one. Areportcount is never exposed.vieweris{ marks }, the marks the reading viewer set on this document, ornullfor a signed-out viewer.
Reads and writes use the same database connection, so a read made after a write succeeded sees it; there is no replica delay to wait out.
The characters and length a key may have are in the limits table. In a private collection each viewer has their own key space, so two viewers can both have a document theme without colliding.
Conditional writes
put and delete take ifVersion: the write happens only if the stored document is at that version, and 0 means "only if it does not exist yet". A mismatch is refused version_conflict, with the stored version in details.currentVersion. Use it whenever two viewers, or two tabs, can edit the same document: read, change, write with the version you read, and on a conflict read again.
Two writes racing on one key can also be refused version_conflict without ifVersion; retrying is safe.
Lists
list(query) returns { items, nextCursor }. nextCursor is null exactly on the last page; pass it back as cursor with the same query for the next one.
| Query field | Meaning |
|---|---|
where | equality on one declared index: { status: 'open' }. A time index takes an ISO-8601 string, epoch milliseconds or a Date |
sort | key (the default), createdAt, updatedAt, a declared index, counter:<name> or mark:<name>; a leading - sorts descending |
author: 'me' | only documents the viewer wrote |
markedBy: 'me' with mark | only documents the viewer marked with that mark |
keyPrefix | only keys starting with this prefix |
limit | page size; the server applies its own default and maximum |
The filters combine with AND. A filter or sort on an index lists only the documents that have a value for that index; a counter or mark sort lists every document, counting a missing tally as 0. A where or sort on anything undeclared is refused index_not_declared.
Counters and marks
Both exist on shared collections only, and both need a signed-in viewer.
import { initialize } from '@civitai/sdk';
const app = await initialize();
const plays = app.storage.collection('plays'); // write: "none", counters: ["plays"]
const { value } = await plays.increment('model-123', 'plays'); // creates the document on first useincrement(key, counter, by)addsby(1 when omitted) and resolves the new value. In awrite: "none"collection the first increment of a missing key creates the document.markandunmarkare idempotent and resolve the mark's new count. Use a mark for anything one viewer does once per document: a vote, a like, a bookmark.viewer.markson each document tells you which ones this viewer has set, so a toggle can render its state on load.reportis the built-in mark that sends a document to moderators. Its count is never returned, and a report cannot be withdrawn:unmark(key, 'report')is refusedinvalid_requestfor every caller. Reports have their own daily limit on top of the marks-and-counters one.
Who can do what
| Operation | Viewer | Scope | Other gates |
|---|---|---|---|
| Read a shared collection | anyone, signed out included | apps:storage | — |
Read a private collection, info(), author: 'me' / markedBy: 'me' lists | signed in | apps:storage | — |
| Write or delete in a private collection | signed in | apps:storage | a write is checked against the collection's schema when declared |
Create or edit in a shared "own" collection | signed in | apps:storage + apps:storage:publish | the minimum-trust gate, moderation, the shared write budget, and the collection's schema and ownedModelVersions checks when declared (in that order, before the trust gate, moderation and the budget); editing another viewer's document is refused not_author, or schema_violation / resource_not_owned first when its data fails those checks |
Delete in a shared "own" collection | signed in, the author | apps:storage | — |
Write a document in a "none" collection | — | — | refused write_policy for a signed-in viewer (a signed-out one gets sign_in_required first) |
increment, mark, unmark | signed in | apps:storage | the minimum-trust gate |
Signed-out viewers get sign_in_required for everything but shared reads, so gate write affordances on app.viewer, and render shared reads regardless. Every call also needs an approved app, and for a signed-in viewer an account that may run apps; otherwise it is refused not_eligible with details.reasonapp_not_approved or apps_disabled.
The minimum-trust gate applies to a viewer's writes into shared collections (other than deleting their own) and to marks and counters. It is about the viewer's account, not your app: a refusal is not_eligible with details.reason set to ineligible, restricted, onboarding_incomplete, email_unverified or account_too_new. Treat it as a normal outcome and show the viewer why, rather than as an error.
Another app's data: imports
An app can read, and write, a viewer's documents in another app. A fork reaching the data its users already keep in the app it was forked from is the case this exists for (Forking an app). Declare each source app under storage.imports, then call that app's collection with ?from=<its blockId>.
{
"blockId": "my-notes-fork-ab12",
"scopes": ["apps:storage", "apps:storage:import"],
"scopeJustifications": {
"apps:storage:import": "This app is a fork of My Notes. It reads and updates the notes you already keep in My Notes."
},
"storage": {
"imports": [
{ "from": "my-notes", "collections": ["notes", "board"], "access": "readwrite" }
]
}
}Each import declares:
from— the source app'sblockId. One import per source app, and never the app itself.collections— which of the source's collections the import reaches. A name the source does not declare is refused when it is called,collection_not_declaredwithdetails.from.access—"read", or"readwrite"to write as well. Through a"read"import every write and delete is refusedwrite_policywithdetails.reason: "import_read_only".
storage.collections is optional once storage.imports is there: an app that only reaches other apps' data declares no collections of its own. The bounds are in the limits table.
What a call through an import does
Every collection route takes ?from=, with the same method, path, body and reply. info does not: it refuses from as an unknown query parameter, so a source app's limits and usage cannot be read through an import.
- It runs in the source app, as the same viewer, in the source's production data. A private collection holds that viewer's own documents in the source, and a shared one reads as the source shows it to them. A write follows the source collection's
writepolicy andschema, is stored with the viewer as its author, and counts toward the source's quotas. - It is charged twice. Each call is first charged to the calling app's read limit for the viewer. After that it is gated as a call made in the source: the source's rate limits, shared write budget, minimum-trust gate and moderation all apply.
- It needs a signed-in viewer, for reads of shared collections too (
sign_in_required). - It goes one hop. A source app's own imports are never followed.
- It needs an ordinary token of an approved app, in production. A
civitai app dev-tokentoken, a dev-tunnel preview, a hub token and a moderator's review run are refusednot_eligible, withdetails.reasonimport_dev_token,import_hub_token,import_in_revieworimport_namespace. Imports therefore cannot be tried with a dev token: test againstcreateFakeStorage, then on the approved app. - The source must be available. It must be an approved app (
source_not_approvedotherwise). A mature-rated source is refused on a host that cannot show it (import_source_rating). A collection the source declaresexportable: falseis refusednot_exportable.
Two consents
apps:storage:import, in your app. The consent prompt lists each source app and its collections, and says whether your app "reads" them or "reads and changes" them. The grant is recorded per viewer, per your app and per source app, with the collections and access the viewer was shown. When a newer version of your app imports more (another collection, or "read" widened to "readwrite"), the old grant does not cover it, and calls are refused consent_required with details: { "scope": "apps:storage:import", "from": "<source>" } until the viewer allows again:
{
"code": "consent_required",
"message": "apps:storage:import has not been granted",
"error": "apps:storage:import has not been granted",
"details": { "scope": "apps:storage:import", "from": "my-notes" }
}Ask with app.requestGrants(['apps:storage:import']), and pass a signal to bound the wait: without from it has no timeout of its own, and a viewer who presses "Not now" sends nothing. A token minted before the viewer's grant stopped covering the import can still carry the scope, so fetch a fresh token first (app.getToken({ fresh: true })). @civitai/sdk 0.23.0 or later lists apps:storage:import in its Scope type.
apps:storage:publish, in the source app. A create, put, increment, mark or unmark in one of the source's shared collections changes what other viewers of the source see, so it also needs the viewer's own apps:storage:publish grant in the source app. The import grant is not that consent, and your app's own publish grant is never consulted. Without it the write is refused consent_required with details: { "scope": "apps:storage:publish", "from": "<source>" }. Neither a delete nor a write to the viewer's private collections in the source needs it. The source app's own approved manifest must declare apps:storage:publish, or the grant cannot be given, and an app can declare that scope only when it has a shared collection with write: "own". The rule covers counters and marks in a write: "none" collection too, so when the source's only shared collections are write: "none", a vote, like or count through an import is always refused.
To ask for it, the block sends REQUEST_CONSENT { scopes: ['apps:storage:publish'], from: '<source blockId>' }, with exactly that one scope:
- On the app page (
/apps/run/<slug>) the host opens the consent prompt for the source app. The prompt names the source app and tells the viewer that what your app adds there appears publicly under their account. It also tells them that the permission is given to the source app, so the source app can do the same. On Allow the host re-mints the token and pushesTOKEN_REFRESH. Retry the write then; the token's scopes do not change, because the grant is the source's. - For a
fromyour app does not import with"readwrite", the prompt offers nothing to allow. A request that is malformed (another scope alongside, or afromthat is not a blockId) getsCONSENT_UNAVAILABLE. - In a model slot the host answers
CONSENT_UNAVAILABLEand opens nothing.
"Not now" sends nothing
When the viewer presses Not now, the host sends the block no message at all. The block cannot tell a dismissed prompt from one still open, and a TOKEN_REFRESH the host pushes for its own reasons looks the same as one that follows an Allow. So retry the write once after the next TOKEN_REFRESH, treat that retry's answer as the authority (a refusal means no grant), give up after a timeout, and do not ask again until the viewer acts again.
Calling it
Over REST, add from to the query string. This works with any client today:
# The viewer's notes in my-notes, read by my-notes-fork-ab12 with its own block token
curl -s "https://civitai.com/api/v1/blocks/storage/notes?from=my-notes&limit=20" \
-H "Authorization: Bearer $BLOCK_TOKEN"
# Write one, as the viewer, into my-notes
curl -s -X PUT "https://civitai.com/api/v1/blocks/storage/notes/n1?from=my-notes" \
-H "Authorization: Bearer $BLOCK_TOKEN" -H "Content-Type: application/json" \
-d '{"data":{"value":{"text":"Carried over from the original app"}}}'The same call without from reaches your app's own notes collection, a separate key space that starts empty. A worked example, with each reply, is in Forking an app.
Tooling versions that know imports:
@civitai/sdk0.23.0 or later takesapp.storage.collection(name, { from })and sendsfromon every call it makes. When a write through it is refused for want of publish consent in the source,app.storageasks the host once and retries once on the next host-pushedTOKEN_REFRESH, within 60 seconds.storageImportRefusal(error)types the refusals,app.requestGrants(['apps:storage:publish'], { from })asks directly, andcreateFakeStorageserves imports. 0.22.0 and older have nofromoption: use REST.@civitai/app-sdk0.81.0 or later typesstorage.imports,exportableand theapps:storage:importscope, and bundles a manifest schema that knows them.@civitai/blocks-react0.79.0 or later letsuseRequestConsent()passfrom.civitaiCLI 0.1.135 or later checksstorage.importsincivitai app validate, as the server does. 0.1.134 and older refuse a manifest withstorage.imports,exportableorapps:storage:import, in bothcivitai app validateandcivitai app submit, so upgrade the CLI.
With @civitai/sdk 0.23.0 or later:
import { initialize, storageImportRefusal } from '@civitai/sdk';
const app = await initialize();
const notes = app.storage.collection<{ value: { text: string } }>('notes', { from: 'my-notes' });
try {
await notes.put('n1', { value: { text: 'Carried over from the original app' } });
} catch (error) {
const refusal = storageImportRefusal(error);
if (refusal?.kind !== 'import_consent') throw error;
await app.getToken({ fresh: true });
// "Not now" sends nothing, so bound the wait; a timeout rejects, and counts as "no".
const granted = await app
.requestGrants(['apps:storage:import'], { signal: AbortSignal.timeout(60_000) })
.catch(() => false);
if (granted) {
await notes.put('n1', { value: { text: 'Carried over from the original app' } });
}
}Moderation
Every create or edit in a shared collection is scanned before it is stored: every string value and object key in data, and on put the document key. In a "none" collection, every increment scans the document's key, whether or not the document already exists.
- A refusal is
content_rejected(422).details.categoryis coarse (link,unreviewableorblocked) and never the matched text. - When the scan cannot run, the write is refused
moderation_unavailable(503) with aRetry-After; retry after it. In a"none"collection that includes increments to documents that already exist.
Viewers report documents with the built-in report mark. Moderators see reported shared documents across apps and can hide one (it then reads as not found to your app and to every viewer, and a write to its key is refused not_found), unhide it, delete it, or dismiss its reports and keep it. Reports filed after a dismissal reopen it.
Errors
Every refusal has one shape, whichever layer refused:
{ "code": "version_conflict", "message": "version conflict", "error": "version conflict", "details": { "currentVersion": 3 } }Branch on code; message is prose and error repeats it. details carries what the code needs (currentVersion, reason, category, ceiling, bucket, pointer, keyword, collection, scope, from, retryAfter). A refusal that names a wait sends a Retry-After header too, and a 429 also carries rateLimit: { retryAfterSeconds, retryAt }.
In @civitai/sdk a refusal in this envelope is a StorageError, an ApiError with code, status, details and a retryAfter getter. The client also refuses a few things before sending: a collection name that cannot be declared (collection_not_declared) and a key of '', . or .. (invalid_request). A response without the envelope arrives as a plain ApiError, and a request that never got an answer as the platform's TypeError.
import { initialize, StorageError } from '@civitai/sdk';
const app = await initialize();
const requests = app.storage.collection<{ title: string; status: 'open' | 'done' }>('requests');
try {
await requests.put('r1', { title: 'Renamed', status: 'open' }, { ifVersion: 3 });
} catch (error) {
if (!(error instanceof StorageError)) throw error;
if (error.code === 'version_conflict') {
// reload the document and let the viewer redo the edit
} else if (error.code === 'rate_limited') {
console.warn(`busy; retry in ${error.retryAfter ?? 1}s`);
} else {
throw error;
}
}Every code the routes return, generated from the server's own table:
| Code | HTTP | Meaning |
|---|---|---|
invalid_request | 400 | The request is malformed: a bad key, body, query parameter (a from that is not a blockId included), value or cursor, an increment or mark naming an undeclared counter or mark, a counter or mark on a private collection, or withdrawing a report. |
index_not_declared | 400 | A where or sort names a field, counter or mark the collection does not declare. |
schema_violation | 400 | The document's data does not match its collection's declared schema. The message and details.pointer name the first failing JSON pointer into data ("" is the root); details.keyword names the keyword it failed and details.collection the collection. Nothing is stored and nothing is spent. |
unauthenticated | 401 | No usable token. |
sign_in_required | 401 | The operation needs a signed-in viewer: writes, marks, counters, info, any read of a private collection, author=me / markedBy=me lists, and every call through an import (?from=), shared reads included. |
insufficient_scope | 403 | The token does not carry apps:storage. |
consent_required | 403 | The viewer has not granted a consent-gated scope the call needs, or withdrew it: apps:storage:publish for a shared write; through an import, apps:storage:import (or a grant that does not cover what this version imports), or apps:storage:publish in the source app. details.scope, when present, names the scope, and details.from the source app. Ask with requestGrants. |
import_not_declared | 403 | A call through an import (?from=) names a source app or collection that the app's approved manifest does not import, or names the app itself. |
not_exportable | 403 | Through an import: the source app declares the collection exportable: false. |
not_eligible | 403 | The app or the viewer cannot use storage here. details.reason says which, for example app_not_approved or account_too_new; through an import, also import_dev_token, import_hub_token, import_in_review, import_namespace, source_not_approved or import_source_rating. |
not_author | 403 | Only a document's author may change or delete it. |
write_policy | 403 | The collection's write policy does not let viewers write documents, or the import is read-only (details.reason import_read_only). |
resource_not_owned | 403 | A field the collection declares in ownedModelVersions holds a model version the writing viewer does not own (or one that does not exist, is deleted, or is not a positive integer). details.pointer names the field. |
not_found | 404 | No such document (hidden documents read the same way), or no such route. |
collection_not_declared | 404 | The collection is not declared in the app's approved manifest (in a moderator's review run, the pending one). Through an import, the source app does not declare it, and details.from names the source. |
version_conflict | 409 | An ifVersion write found another version. details.currentVersion is the one stored. |
index_building | 409 | Declared for a later phase; not raised today. |
cursor_expired | 410 | Declared for a later phase; not raised today. |
too_large | 413 | The document or the request body is over its limit. |
quota_exceeded | 413 | A quota is full. details.ceiling names which one. |
content_rejected | 422 | Moderation refused a shared write. details.category is coarse and never the matched text. |
rate_limited | 429 | A rate limit or the shared write budget is spent. Wait Retry-After seconds. |
storage_disabled | 503 | The document store is not enabled on this server. |
moderation_unavailable | 503 | A shared write could not be reviewed right now. Retryable after Retry-After. |
consent_unavailable | 503 | The viewer's consent state could not be read. Retryable. |
service_unavailable | 503 | A dependency could not be read. Retryable. |
internal_error | 500 | A server fault. |
method_not_allowed | 405 | The path exists, not for this method. The Allow header lists the methods it takes. |
A 503 storage_disabled answers every data call on a server where the document store is not enabled; it is not something your app caused or can fix.
Limits
Generated from the server's constants. Render app.storage.info().limits, never a number you copied: the server can change any of these without a new SDK, and info() is what the running server enforces.
| Area | Limit | Value |
|---|---|---|
| Documents | One document | 65,536 bytes (64 KiB), measured on the JSON text the server stores, which can be larger than the JSON you sent |
| Documents | One request body | 81,920 bytes (80 KiB) |
| Documents | Nesting depth of data | 16 |
| Documents | Key | ^[A-Za-z0-9._:-]{1,128}$, and not made only of dots |
| Documents | A string index value | 128 bytes |
| Documents | Counter increment (by) | 1 to 100 |
| Lists | Page size (limit) | default 25, at most 100 |
| Lists | Equality filters (where) | 1, on a declared index |
| Lists | Keys in one getMany | 100 |
| Lists | Cursor length | 512 characters |
| Declaration | Collections per app | 16 |
| Declaration | Counters / marks per collection | 8 / 8 |
| Declaration | Indexes per collection | 4, each a field path of up to 3 segments, typed string, number, boolean, time |
| Declaration | Owned model-version fields (ownedModelVersions) | 1 to 4 distinct pointers per collection, each matching ^(/[A-Za-z_][A-Za-z0-9_]{0,63}){1,4}$; shared collections with write: "own" only |
| Declaration | Collection document schema (schema) | the keywords type, properties, required, additionalProperties, items, enum, const, minLength, maxLength, pattern, minimum, maximum, minItems, maxItems, description; at most 16,384 bytes (16 KiB) of JSON, 16 nested schemas and 16 patterns per collection, and 32 patterns across all of the manifest's collection schemas; a pattern of at most 256 characters, on a schema with a maxLength of at most 1,000; not on write: "none" |
| Declaration | Imports (storage.imports) | at most 8, one per source app and never the app itself, each naming 1 to 16 distinct collections, with access "read" or "readwrite" |
| Declaration | An import's source (from) | the source app's blockId, matching ^[a-z][a-z0-9-]{1,38}[a-z0-9]$ |
| Declaration | Built-in marks (never declared) | report |
| Declaration | Refused by the server (not available yet) | write: "anyone", write: "owner", kind: "log" (and maxEntries), ttlSeconds, dailyWritesPerAuthor |
| Quota | Per app | 52,428,800 bytes (50 MiB) and 1,000,000 documents |
| Quota | Per viewer, within one app | 2,097,152 bytes (2 MiB) and 1,000 documents |
| Rate | Reads (info, list, get, getMany) | 300 a minute per viewer, or per IP address for a signed-out viewer |
| Rate | Writes to private collections, and every delete | 120 a minute per viewer |
| Rate | Marks and counters | 30 a minute per viewer |
| Rate | Reports | 20 a day per viewer, on top of the marks-and-counters limit |
| Rate | Creating and editing shared documents | a burst of 10 refilling 1 every 6 minutes, and 50 a day refilling 1 every ~28.8 minutes, per viewer; a write needs a token from both, and only a write that lands spends one |
| Rate | Shared writes the server refused | also charged to a bucket of 10 refilling 1 every 30 minutes; while it is empty, no shared create or edit is accepted |
Three things the table does not say on its own:
- Sizes are in the stored unit. The document limit and both quotas measure the JSON text the server keeps, which can be larger than the JSON you sent.
info().usagereports bytes and document counts in that same unit; size your data against it, not against what you sent. - Every limit is per app. One viewer at a limit in your app is not limited in another app. A shared document counts toward its author's per-viewer quota, and a document a counter created counts toward the viewer whose increment created it.
- The shared write budget is the legacy store's budget. Creating and editing shared documents spend the same per-viewer, per-app budget as the legacy shared store's
appendandupdate, so an app using both spends one budget.
A rate_limited refusal with details.reason: "contention" means the store was busy with other writes to the same documents, not that the viewer is over a limit; it is safe to retry after Retry-After.
Where your data lives
The same app sees different data depending on who is running it:
- Production — every viewer of the approved app shares one namespace: shared collections are shared across all of them, and private collections are per viewer within it.
- Your dev token — a token from
civitai app dev-tokenreads and writes a namespace of your own, separate from production and from every other developer. Nothing you write there reaches a production viewer. - A moderator's review run — a moderator running a pending submission for real gets a namespace per moderator and submission, declared by the pending manifest, and it is deleted once the submission is no longer pending.
Quotas count each namespace separately. When a moderator wipes an account's content, that account's documents and marks are removed from every app's store too.
Your users control their data
Viewers can see and remove what apps keep for them, without your app's help:
- Account settings → Security & Apps → App storage lists every app the viewer keeps production data in, with a bar for their use of that app's per-viewer quota, and a Near limit badge from 80% of either the bytes or the documents.
- On the app page, the host shows the viewer a notice when they are at 80% or more of either, checked once each time the app is opened.
- Empty deletes the viewer's private documents in one app. A checkbox, off by default, also deletes the shared documents they wrote there, which disappear for every viewer along with the marks on them. Counter documents their increments created, documents moderation hid, and the marks they set on other viewers' documents stay.
- Deleting a Civitai account offers "Also delete my data in apps", off by default. Ticked, the account's private documents, the shared documents it wrote, and its marks are removed from every app 7 days after the deletion. Restoring the account in that time cancels the removal, and a legal hold on the account postpones it for as long as the hold stands.
So a document your app wrote for a viewer can be gone the next time they open it, and a shared list can get shorter. Treat a null from get and a missing key as normal states, not errors.
Local development
dev:livedoes not reach the document store. Thepage-moneyharness forwards the block→host bridge, and the document store is not on the bridge:@civitai/sdkcalls it over HTTP, and that template does not use@civitai/sdk. (Thepage-elementstemplate, which does, has nodev:livescript.)- The dev tunnel (
civitai app dev-tunnel) mints a token without either storage scope. With@civitai/sdk0.18.0 or later the preview's document store is local to the tab: the host marks the framestorageMode: 'local-preview', and the SDK servesapp.storage.collection(name)andapp.storage.info()from an in-memory store that enforces your local manifest's declarations, sending nothing to/api/v1/blocks/storage/*(app.storage.modereads'local-preview'). The manifest reaches the SDK only throughblockManifestPlugin()on the Vite dev server; without it every call is refusedlocal_preview_no_manifest.ownedModelVersionsis not checked locally. From 0.19.1,requestGrantsin a preview treatsapps:storageandapps:storage:publishas held instead of asking the host, which refuses them. On an SDK older than 0.18.0 every call is refusedinsufficient_scope. - A
civitai app dev-tokentoken carriesapps:storageandapps:storage:publishwhen your app's manifest declares them (the approved version's, once there is one), without a consent prompt, and the store serves it only once your app has an approved version, in your own namespace (see above) with the collections that version declares. Before approval every call is refusednot_eligiblewithdetails.reason: "app_not_approved". dev:harnessdoes not reach it either. Thepage-moneymock host answers the bridge only (the legacy storage messages included), and thepage-elementsharness hands the app a mock token that no Civitai route accepts.
So test against a fake: createFakeStorage.
Testing with createFakeStorage
@civitai/sdk/testing exports createFakeStorage({ manifest }): an in-memory document store behind the same seam the real client uses, enforcing the server's rules (scopes and consent, write policies, the minimum-trust gate, signed-out readers, keys, limits and quotas, moderation, and the error codes and statuses). The SDK runs the platform's own storage contract against it.
Write your data layer against the DocumentStorage type, pass app.storage in the app and the fake's storage in tests:
import { StorageError, type DocumentStorage } from '@civitai/sdk';
import { createFakeStorage, FAKE_STORAGE_MODERATION } from '@civitai/sdk/testing';
interface Request {
title: string;
status: 'open' | 'done';
}
const requestsIn = (storage: DocumentStorage) => storage.collection<Request>('requests');
/** The refusal's code, or 'ok' — so a test can assert on it. */
const codeOf = (p: Promise<unknown>) =>
p.then(() => 'ok', (e: unknown) => (e instanceof StorageError ? e.code : 'not a StorageError'));
const fake = createFakeStorage({
manifest: {
scopes: ['apps:storage', 'apps:storage:publish'],
storage: { collections: { requests: { visibility: 'shared', write: 'own', marks: ['vote'] } } },
},
viewer: { username: 'alice' },
});
const created = await requestsIn(fake.storage).create({ title: 'Dark mode', status: 'open' });
// The same store, as other callers.
const bob = requestsIn(fake.as({ viewer: { username: 'bob' } }).storage);
await bob.mark(created.key, 'vote'); // { marked: true, count: 1 }
await codeOf(bob.put(created.key, { title: 'Light mode', status: 'open' })); // 'not_author'
const newbie = requestsIn(fake.as({ viewer: { username: 'dan', trusted: false } }).storage);
await codeOf(newbie.create({ title: 'Hi', status: 'open' })); // 'not_eligible' (details.reason 'account_too_new')
const anon = requestsIn(fake.as({ viewer: null }).storage);
(await anon.list()).items.length; // 1: shared reads work signed out
const noConsent = requestsIn(fake.as({ scopes: ['apps:storage'] }).storage);
await codeOf(noConsent.create({ title: 'Hi', status: 'open' })); // 'consent_required'
await codeOf(requestsIn(fake.storage).create({ title: FAKE_STORAGE_MODERATION.blockedText, status: 'open' })); // 'content_rejected'fake.callsrecords every request and answer.- Options:
limits(smaller quotas, to reachquota_exceeded),consent(revoked scopes, or'unavailable'),appStatus,disabled(every callstorage_disabled) andmoderate(your own scanner). By default the scanner refuses the words inFAKE_STORAGE_MODERATION. - Rate limits are off by default, so your tests are never throttled. Pass
enforceRateLimits: true(and drive the clock withnow) to testrate_limited. - A declaration using a reserved feature makes
createFakeStoragethrow, naming it, instead of failing per request. So does a misplaced or malformedownedModelVersions(0.16.0 or later). - From 0.16.0 it enforces a collection's
ownedModelVersionsrule. The fake has no model versions of its own, so it checks writes against the viewer'smodelVersions, the ids that viewer owns; the default is none, so every id is refused, as the server refuses a version that is not the writer's:fake.as({ viewer: { username: 'bob', modelVersions: [4317] } }). - From 0.23.0, it enforces a collection's
schemaas the server does (schema_violation, the same pointers and messages), and throws when it is created on a schema the server would refuse, except for the server's check for catastrophic backtracking, which it does not run. - From 0.23.0, it serves imports: pass
blockIdandsources(the other apps, each with its own manifest,statusandconsent) andimportGrants, and act as a source withas({ app }). ItsrequestGrantsoption stands in for the host's prompt for publish consent in a source. Atoken: 'dev'caller is refused every import, as on the server. - It does not model moderator hides, the review namespace, or a failed ownership lookup (the server's retryable
503 service_unavailable).
REST reference
Base URL https://civitai.com/api/v1/blocks/storage, with Authorization: Bearer <block token>. Every route needs apps:storage on the token. Bodies and responses are JSON; refusals use the envelope. Block origins are allowed by CORS, including the opaque Origin: null of a sandboxed block, with PUT and DELETE in the preflight and Retry-After exposed.
| Method | Path | Body | Response |
|---|---|---|---|
GET | (the base URL) | — | info: { collections, limits, usage } |
GET | /{collection} | — | a page: { items, nextCursor } |
POST | /{collection} | { data } | 201, the created document |
POST | /_get/{collection} | { keys } | { items }, in the order of keys, null for each miss |
GET | /{collection}/{key} | — | the document, or 404 not_found |
PUT | /{collection}/{key} | { data, ifVersion? } | the stored document |
DELETE | /{collection}/{key} | — (?ifVersion= in the query) | { deleted } |
POST | /{collection}/{key}/counters/{name} | { by? } | { value } |
PUT | /{collection}/{key}/marks/{name} | none, or {} | { marked: true, count } (no count for report) |
DELETE | /{collection}/{key}/marks/{name} | — | { marked: false, count } |
A list takes these query parameters, each at most once: where[<index>]=<value>, sort, author=me, markedBy=me with mark, keyPrefix, limit and cursor. Every route but info also takes from, to reach another app's collection through an import. A where value is read by the index's declared type: a number for number, true or false for boolean, and an ISO-8601 string or epoch milliseconds for time. Any other query parameter, on any route, is refused invalid_request rather than ignored. A path that exists answers a method it does not take with 405 method_not_allowed and an Allow header.
curl -s "https://civitai.com/api/v1/blocks/storage/requests?where%5Bstatus%5D=open&sort=-mark:vote&limit=20" \
-H "Authorization: Bearer $BLOCK_TOKEN"Moving from the legacy stores
useAppStorage and useSharedStorage in @civitai/blocks-react, the matching app.storage.get/set/delete/list/getQuota and app.sharedStorage methods in @civitai/sdk, and the routes under /api/v1/blocks/app-storage/* and /api/v1/blocks/shared-storage/* are deprecated and shut down on 2026-11-10. Until then they keep working, with their scopes (apps:storage:read, apps:storage:write, apps:storage:shared:read, apps:storage:shared:write). From 2026-11-10 the legacy REST routes answer HTTP 410 Gone, and the legacy hooks, bridge messages and SDK methods stop working: their calls fail with an error. Move your app to the document store before that date.
Nothing is copied automatically. The document store starts empty; data in the legacy stores stays there. If your app has data viewers care about, you have two options:
- Copy it yourself before 2026-11-10: read it from the legacy store and write it into a collection, as described below. After 2026-11-10 the app can no longer read the legacy store. A self-copy has two limits:
- Per-viewer data can be read only with that viewer's own token, so the copy runs inside the app as each viewer opens it. Only viewers who open the app before 2026-11-10 are moved; everyone else's data is left behind.
- Shared data copied by the app is authored by whoever runs the copy, so that viewer becomes the author of every copied row (see Authorship below). The original authors no longer own their rows, so they cannot edit or withdraw them or publish them as sub-listings, which need an item the viewer authored.
- Ask Civitai to copy it at cutover. On request, Civitai can copy an app's legacy data into its document store when the legacy stores shut down. The cutover copy keeps each row's key, author and hidden state, and runs on Civitai's side, so it does not wait for each viewer to open the app. This is not automatic: contact the Civitai team (or your Civitai contact) before 2026-11-10. For shared data, ask for the cutover copy.
Per-viewer storage (useAppStorage)
Declare a private collection and the apps:storage scope.
| Legacy | Document store |
|---|---|
set(key, value) | put(key, data) — data must be a JSON object, so wrap a scalar: { value } |
get(key) | get(key) — the value is the document's data |
delete(key) | delete(key) |
list({ prefix, cursor }) | list({ keyPrefix, cursor }) — whole documents, not just keys |
getQuota() | app.storage.info() — usage.viewer and limits |
| the legacy byte, row and key-length messages | error codes |
Keys may not carry over as they are. A legacy key is any string of 1 to 200 characters, while a document key is limited to the characters and length in the limits table. A legacy key with a /, a space or any other character outside that set, or one longer than the limit, is refused invalid_request, so map such keys (hash them, for example) when you copy. A viewer's legacy data can be read only with that viewer's own token, so the copy runs per viewer, inside the app, as each viewer opens it — and only viewers who open the app before 2026-11-10 are copied. The cutover copy has no such limit.
Shared storage (useSharedStorage)
Declare a shared collection with write: "own", a vote mark if you had voting, and both scopes; apps:storage:publish replaces apps:storage:shared:write and needs a scopeJustifications entry.
| Legacy | Document store |
|---|---|
append({ title, body?, data? }) | create(data) — put what you stored in title, body and data into the document's data; every string in it is moderated |
list({ limit, cursor }) (newest first) | list({ sort: '-createdAt', limit, cursor }) |
list({ mine: true }) (REST) | list({ author: 'me' }) |
get(key) | get(key) |
update(key, value) | put(key, data), by the author |
withdraw(key) | delete(key), by the author |
vote(key) / unvote(key) | mark(key, 'vote') / unmark(key, 'vote') |
count on an item, getCount(key), getCounts(keys) | marks.vote on the document; getMany(keys) for several |
viewerVoted on an item | viewer.marks.includes('vote') |
authorUserId on an item | author (isViewer, and username when the collection sets showAuthor); no user id |
report(key, reason?) | mark(key, 'report') — no free-text reason, and it cannot be withdrawn |
Two things do not carry over from a legacy board:
- Authorship. A shared document is authored by the viewer whose token created it, and there is no app-owned write yet (
write: "owner"is reserved). Copying a legacy board makes whoever runs the copy the author of every copied document: each one counts toward that viewer's quota and spends their shared write budget, and no other viewer can edit or delete it. To keep the original authors, ask for the cutover copy before 2026-11-10; otherwise read or copy the old rows before that date, or have each author post again. The legacy store cannot be read after 2026-11-10. - Votes. A mark belongs to the viewer who set it, so legacy vote counts cannot become
votemarks. They start at zero.
Two things resolve an item key the app supplies:
- App Store sub-listings:
itemKeynames the item being listed. sharedContentKeyon atextToImagebody: it resolves the item's author.
Which store answers is decided by the app's approved manifest. Once it declares a shared collection that viewers write (write: "own"), the key is looked up in the document store only: it must name one production document in such a collection, a key found in two of them names nothing, and the legacy shared store is no longer read, even for old keys. A write: "none" collection (counters and marks only) is never searched. An app whose approved manifest declares no such collection still resolves keys from the legacy shared store, which shuts down on 2026-11-10, so move listable items into a shared collection before then.
Related
- The
@civitai/sdkclient —initialize(), consent and the rest ofapp. - Forking an app — what a fork starts with, and how it reaches its parent's data.
- Scopes reference —
apps:storage,apps:storage:publishandapps:storage:importbeside every other scope. - Manifest reference — the
storageproperty, each collection field, and their bounds. - Local dev loop — the harness modes.