Skip to content

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:

bash
npm ls @civitai/sdk
npm install @civitai/sdk@^0.14.0

To 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 storeapp.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 / getQuotauseAppStorage/api/v1/blocks/app-storage/*
Legacy shared store (deprecated; shut down 2026-11-10)app.sharedStorageuseSharedStorage/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.

json
{
  "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 as plays.
  • marks — shared only. A per-viewer flag on a document, at most one of each per viewer per document, such as vote. A reader sees each mark's count and whether they set it, never who else did. report is built in on every shared collection and must not be declared.
  • indexes — the fields of a document's data you can filter and sort by, as a map of field path to type (string, number, boolean or time). A path such as status or meta.round names a field inside data.
  • showAuthor — shared only. When true, 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 with write: "own" only. Fields of data that, 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's data must match. See A schema for each document.
  • exportable — optional, true when omitted. Whether another app may reach this collection, for a viewer who allows it, through an import. Set false on a collection no other app should reach: every call to it through an import is then refused not_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 refused write_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.

json
{
  "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 write is "own", the only policy under which the viewer making a write is always the document's author. It is refused on private collections and on write"anyone", "owner" and "none".
  • Pointers. Each entry names one field of nested objects: no array indexes, no ~0 / ~1 escapes, 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, null or 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" and null included) 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 to null. 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 write data and 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 put by a viewer who is not the author, whose data carries a version that viewer does not own (such as the author's own id, unchanged), is refused resource_not_owned, not not_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 ownedModelVersions to 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):

json
{
  "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:

  • civitai CLI 0.1.128 or later validates it in civitai app validate; older CLIs reject it as an unknown property.
  • @civitai/sdk 0.16.0 or later names resource_not_owned in StorageErrorCode, and its createFakeStorage enforces the rule.
  • @civitai/app-sdk 0.73.0 or later types ownedModelVersions on 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.

json
{
  "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 of object, array, string, number, integer, boolean, null, or a list of them.
  • Object keywords: properties, required, and additionalProperties (true, false or 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: minLength says nothing about a number.
  • integer is a number with no fraction, and number includes integers.
  • null is its own type, and an array is not an object.
  • String lengths count code points, so an emoji is one character.
  • pattern is unanchored (write ^…$ to match the whole string) and is a JavaScript regular expression with the u flag.
  • enum and const compare JSON values: 1 does not equal "1", and object key order does not matter.
  • required and additionalProperties look 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:

  1. type
  2. const, then enum
  3. the string, number and array keywords, with maxLength before pattern
  4. items, element by element
  5. required, in its declared order
  6. the object's own keys, in ascending order, each against its properties entry or additionalProperties

When it is checked.

  • On every create (POST /{collection}, create) and every replace (PUT /{collection}/{key}, put).
  • A put replaces 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 data and are not checked.
  • The check runs before the ownedModelVersions check, 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 migrations for 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.pointer is the first failing JSON pointer (RFC 6901) into data. "" is the root. ~ and / in a key are escaped as ~0 and ~1.
  • details.keyword is the keyword it failed.
  • details.collection is the collection.
  • The message names the pointer too. A pointer longer than 200 characters is cut short in the message, but details.pointer always carries it whole.
json
{
  "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/sdk 0.23.0 or later names schema_violation in StorageErrorCode, and its createFakeStorage and the local preview enforce the schema. 0.22.0 and older do not check it.
  • @civitai/app-sdk 0.81.0 or later types schema on a collection and bundles a manifest schema that knows the key.
  • civitai CLI 0.1.135 or later checks it in civitai app validate. 0.1.134 and older refuse a manifest with schema as an unknown property, in both civitai app validate and civitai app submit, so upgrade the CLI.

Checking offline, with those versions:

  • createFakeStorage in @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 JavaScript u-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 validate checks the subset and the bounds: the size, depth, pattern count, min above max, and no schema on write: "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, \z and \pL compile 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 additionalProperties is false;
  • additionalProperties newly false, or newly a schema;
  • a type removed (number to integer counts);
  • enum or const narrowed, or a const added;
  • minimum, minLength or minItems added or raised; maximum, maxLength or maxItems added or lowered; pattern added 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:

json
{
  "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" }
        ]
      }
    }
  }
}
OpCoversWhat it does to a stored document
defaulta 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.
renamea declared property removed under additionalProperties: false, at fromMoves the value when from is present and to is absent. The new schema must allow to.
removeevery breaking change at or under path, as long as the new schema does not require that fieldDeletes the value only where the new schema refuses it. A value that is valid under the new schema is kept.
acknowledgeexactly 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, rename and remove take 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 a migrations list left in a later version does nothing. Remove it.
  • No op can fill a newly required field inside an array element or an additionalProperties schema (…/*/…): declare it optional, or remove the whole optional array or object. A newly required object with a newly required field inside it takes two versions: first require the object, with a default that already holds the field, then require the field with its own default.

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.

Three scopes:

ScopeCoversConsent
apps:storageeverything except authoring shared documents: reads, the viewer's private collections, deleting documents the viewer wrote, marks and countersconsent-exempt
apps:storage:publishcreating and editing documents in a shared collection whose write is "own"consent-gated, and needs a scopeJustifications entry
apps:storage:importthe 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" requires apps:storage:publish. write: "none" does not: no viewer authors a document there.
  • Declaring any import requires apps:storage:import, and apps:storage too, even when the app declares no collections of its own.
  • Declaring any of the three scopes with nothing in storage for it to reach is refused.
  • auth: "oauth" cannot be declared alongside any apps: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:

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

ts
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);
}
CallDoes
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 }:

  • data is a JSON object. version goes up by one on every write to data; counter and mark changes do not move it. The SDK gives you createdAt and updatedAt as Dates; on the wire they are ISO strings.
  • author is { kind: 'user', isViewer, username? } for a viewer's document (username only in a showAuthor collection), { kind: 'counter' } for a document a counter's first increment created, or { kind: 'app' }. It never carries a numeric user id.
  • counters and marks hold the declared counters and mark counts of a shared document, and are {} on a private one. A report count is never exposed.
  • viewer is { marks }, the marks the reading viewer set on this document, or null for 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 fieldMeaning
whereequality on one declared index: { status: 'open' }. A time index takes an ISO-8601 string, epoch milliseconds or a Date
sortkey (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 markonly documents the viewer marked with that mark
keyPrefixonly keys starting with this prefix
limitpage 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.

ts
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 use
  • increment(key, counter, by) adds by (1 when omitted) and resolves the new value. In a write: "none" collection the first increment of a missing key creates the document.
  • mark and unmark are 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.marks on each document tells you which ones this viewer has set, so a toggle can render its state on load.
  • report is 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 refused invalid_request for every caller. Reports have their own daily limit on top of the marks-and-counters one.

Who can do what ​

OperationViewerScopeOther gates
Read a shared collectionanyone, signed out includedapps:storage—
Read a private collection, info(), author: 'me' / markedBy: 'me' listssigned inapps:storage—
Write or delete in a private collectionsigned inapps:storagea write is checked against the collection's schema when declared
Create or edit in a shared "own" collectionsigned inapps:storage + apps:storage:publishthe 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" collectionsigned in, the authorapps: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, unmarksigned inapps:storagethe 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>.

json
{
  "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's blockId. 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_declared with details.from.
  • access — "read", or "readwrite" to write as well. Through a "read" import every write and delete is refused write_policy with details.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 write policy and schema, 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-token token, a dev-tunnel preview, a hub token and a moderator's review run are refused not_eligible, with details.reasonimport_dev_token, import_hub_token, import_in_review or import_namespace. Imports therefore cannot be tried with a dev token: test against createFakeStorage, then on the approved app.
  • The source must be available. It must be an approved app (source_not_approved otherwise). A mature-rated source is refused on a host that cannot show it (import_source_rating). A collection the source declares exportable: false is refused not_exportable.

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:

json
{
  "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 pushes TOKEN_REFRESH. Retry the write then; the token's scopes do not change, because the grant is the source's.
  • For a from your app does not import with "readwrite", the prompt offers nothing to allow. A request that is malformed (another scope alongside, or a from that is not a blockId) gets CONSENT_UNAVAILABLE.
  • In a model slot the host answers CONSENT_UNAVAILABLE and 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:

bash
# 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/sdk 0.23.0 or later takes app.storage.collection(name, { from }) and sends from on every call it makes. When a write through it is refused for want of publish consent in the source, app.storage asks the host once and retries once on the next host-pushed TOKEN_REFRESH, within 60 seconds. storageImportRefusal(error) types the refusals, app.requestGrants(['apps:storage:publish'], { from }) asks directly, and createFakeStorage serves imports. 0.22.0 and older have no from option: use REST.
  • @civitai/app-sdk 0.81.0 or later types storage.imports, exportable and the apps:storage:import scope, and bundles a manifest schema that knows them.
  • @civitai/blocks-react 0.79.0 or later lets useRequestConsent() pass from.
  • civitai CLI 0.1.135 or later checks storage.imports in civitai app validate, as the server does. 0.1.134 and older refuse a manifest with storage.imports, exportable or apps:storage:import, in both civitai app validate and civitai app submit, so upgrade the CLI.

With @civitai/sdk 0.23.0 or later:

ts
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.category is coarse (link, unreviewable or blocked) and never the matched text.
  • When the scan cannot run, the write is refused moderation_unavailable (503) with a Retry-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:

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

ts
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:

CodeHTTPMeaning
invalid_request400The 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_declared400A where or sort names a field, counter or mark the collection does not declare.
schema_violation400The 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.
unauthenticated401No usable token.
sign_in_required401The 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_scope403The token does not carry apps:storage.
consent_required403The 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_declared403A 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_exportable403Through an import: the source app declares the collection exportable: false.
not_eligible403The 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_author403Only a document's author may change or delete it.
write_policy403The collection's write policy does not let viewers write documents, or the import is read-only (details.reason import_read_only).
resource_not_owned403A 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_found404No such document (hidden documents read the same way), or no such route.
collection_not_declared404The 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_conflict409An ifVersion write found another version. details.currentVersion is the one stored.
index_building409Declared for a later phase; not raised today.
cursor_expired410Declared for a later phase; not raised today.
too_large413The document or the request body is over its limit.
quota_exceeded413A quota is full. details.ceiling names which one.
content_rejected422Moderation refused a shared write. details.category is coarse and never the matched text.
rate_limited429A rate limit or the shared write budget is spent. Wait Retry-After seconds.
storage_disabled503The document store is not enabled on this server.
moderation_unavailable503A shared write could not be reviewed right now. Retryable after Retry-After.
consent_unavailable503The viewer's consent state could not be read. Retryable.
service_unavailable503A dependency could not be read. Retryable.
internal_error500A server fault.
method_not_allowed405The 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.

AreaLimitValue
DocumentsOne document65,536 bytes (64 KiB), measured on the JSON text the server stores, which can be larger than the JSON you sent
DocumentsOne request body81,920 bytes (80 KiB)
DocumentsNesting depth of data16
DocumentsKey^[A-Za-z0-9._:-]{1,128}$, and not made only of dots
DocumentsA string index value128 bytes
DocumentsCounter increment (by)1 to 100
ListsPage size (limit)default 25, at most 100
ListsEquality filters (where)1, on a declared index
ListsKeys in one getMany100
ListsCursor length512 characters
DeclarationCollections per app16
DeclarationCounters / marks per collection8 / 8
DeclarationIndexes per collection4, each a field path of up to 3 segments, typed string, number, boolean, time
DeclarationOwned 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
DeclarationCollection 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"
DeclarationImports (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"
DeclarationAn import's source (from)the source app's blockId, matching ^[a-z][a-z0-9-]{1,38}[a-z0-9]$
DeclarationBuilt-in marks (never declared)report
DeclarationRefused by the server (not available yet)write: "anyone", write: "owner", kind: "log" (and maxEntries), ttlSeconds, dailyWritesPerAuthor
QuotaPer app52,428,800 bytes (50 MiB) and 1,000,000 documents
QuotaPer viewer, within one app2,097,152 bytes (2 MiB) and 1,000 documents
RateReads (info, list, get, getMany)300 a minute per viewer, or per IP address for a signed-out viewer
RateWrites to private collections, and every delete120 a minute per viewer
RateMarks and counters30 a minute per viewer
RateReports20 a day per viewer, on top of the marks-and-counters limit
RateCreating and editing shared documentsa 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
RateShared writes the server refusedalso 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().usage reports 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 append and update, 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-token reads 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:live does not reach the document store. The page-money harness forwards the block→host bridge, and the document store is not on the bridge: @civitai/sdk calls it over HTTP, and that template does not use @civitai/sdk. (The page-elements template, which does, has no dev:live script.)
  • The dev tunnel (civitai app dev-tunnel) mints a token without either storage scope. With @civitai/sdk 0.18.0 or later the preview's document store is local to the tab: the host marks the frame storageMode: 'local-preview', and the SDK serves app.storage.collection(name) and app.storage.info() from an in-memory store that enforces your local manifest's declarations, sending nothing to /api/v1/blocks/storage/* (app.storage.mode reads 'local-preview'). The manifest reaches the SDK only through blockManifestPlugin() on the Vite dev server; without it every call is refused local_preview_no_manifest. ownedModelVersions is not checked locally. From 0.19.1, requestGrants in a preview treats apps:storage and apps:storage:publish as held instead of asking the host, which refuses them. On an SDK older than 0.18.0 every call is refused insufficient_scope.
  • A civitai app dev-token token carries apps:storage and apps:storage:publish when 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 refused not_eligible with details.reason: "app_not_approved".
  • dev:harness does not reach it either. The page-money mock host answers the bridge only (the legacy storage messages included), and the page-elements harness 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:

ts
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.calls records every request and answer.
  • Options: limits (smaller quotas, to reach quota_exceeded), consent (revoked scopes, or 'unavailable'), appStatus, disabled (every call storage_disabled) and moderate (your own scanner). By default the scanner refuses the words in FAKE_STORAGE_MODERATION.
  • Rate limits are off by default, so your tests are never throttled. Pass enforceRateLimits: true (and drive the clock with now) to test rate_limited.
  • A declaration using a reserved feature makes createFakeStorage throw, naming it, instead of failing per request. So does a misplaced or malformed ownedModelVersions (0.16.0 or later).
  • From 0.16.0 it enforces a collection's ownedModelVersions rule. The fake has no model versions of its own, so it checks writes against the viewer's modelVersions, 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 schema as 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 blockId and sources (the other apps, each with its own manifest, status and consent) and importGrants, and act as a source with as({ app }). Its requestGrants option stands in for the host's prompt for publish consent in a source. A token: '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.

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

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

LegacyDocument 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 messageserror 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.

LegacyDocument 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 itemviewer.marks.includes('vote')
authorUserId on an itemauthor (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 vote marks. They start at zero.

Two things resolve an item key the app supplies:

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.

Civitai Developer Documentation