Key Value Stores
Key Value (KV) Stores provide a simple and efficient way to store and retrieve data at the edge. They can be used to store configuration data, user preferences, or any other data that needs to be quickly accessed by your edge functions.
Getting Started
Section titled “Getting Started”To begin using KV Stores:
- Navigate to the “Compute > K/V Stores” section in the QuantCDN dashboard
- Click “Create New Store” to create a new KV store
- Give your store a name and description
- Once created, you’ll receive a unique identifier for your store
Store IDs and Access
Section titled “Store IDs and Access”The first argument of every kvStore method is the store ID, not the store’s display name. The dashboard shows the ID. Stores that the dashboard and templates create use the form <organization>-<project>-<name>, so a function can build the ID from event.quantOrg and event.quantProject:
const STORE = `${event.quantOrg}-${event.quantProject}-settings`;const theme = await kvStore.get(STORE, 'theme');A function can use only the stores that belong to its project or are linked to it. Any other ID throws Access to KV store <id> is not allowed. To share a store with another project, link it in Compute > K/V Stores, or call POST {dashboard}/api/v2/organizations/{org}/projects/{project}/kv/{store_id}/link with { "project_id": <target project id> }.
Managing Data
Section titled “Managing Data”The QuantCDN dashboard provides a simple interface to:
- Add new key-value pairs
- Edit existing values
- View all stored data
- Search for specific keys
- Delete key-value pairs
- Clear a store, either every key or only the keys that match a prefix or an age
- Delete a store once it is empty
Using KV Stores in Edge Functions
Section titled “Using KV Stores in Edge Functions”KV Stores can be easily integrated into your edge functions using the kvStore object.
Storing Data
Section titled “Storing Data”To store data in a KV store:
await kvStore.set(KV_STORE_ID, key, data);set() takes an optional fourth argument with the options ttl, secret, ifAbsent and ifVersion. Any other option name throws VALIDATION (Unknown set option: <name>) and writes nothing, so a misspelt condition can never become an unconditional write.
Retrieving Data
Section titled “Retrieving Data”To retrieve data from a KV store:
const data = await kvStore.get(KV_STORE_ID, key);Deleting Data
Section titled “Deleting Data”To delete data from a KV store:
await kvStore.delete(KV_STORE_ID, key);delete() takes one option, ifVersion. Any other option name throws VALIDATION and deletes nothing.
Listing and Searching
Section titled “Listing and Searching”Use list() to retrieve a page of keys, optionally restricted to a literal prefix. Set includeValues: true to retrieve values in the same request.
// customerId comes from your verified session, not an unchecked query parameter.const page = await kvStore.list(WORLDS_STORE, { prefix: `customer:${customerId}:world:`, includeValues: true, limit: 25,});// {// items: [{ key, value, encrypted, createdAt?, updatedAt?, expiresAt? }],// cursor: 'opaque-continuation-token' | null,// hasMore: true | false,// scannedCount: 25// }Use search() to combine key selection with exact-match filters on top-level fields of plaintext JSON objects:
const page = await kvStore.search(PROJECTS_STORE, { prefix: `customer:${customerId}:project:`, where: { customerId, status: 'ready' }, includeValues: true, limit: 50, cursor: requestCursor, // Omit on the first request; pass the returned cursor later.});All where conditions must match. Comparisons are case-sensitive and preserve JSON types: 0 differs from "0", and a missing field differs from null. The initial API supports equality only; it does not support nested paths, ranges, sorting, regular expressions, OR expressions or full-text search.
For wildcard key matching, use pattern instead of prefix:
const page = await kvStore.search(JOBS_STORE, { pattern: 'customer:*:job:*', where: { status: 'pending' }, includeValues: true,});Only * is a wildcard, matching zero or more characters. Other characters, including ? and ., are literal. A prefix is entirely literal, including any * it contains. prefix and pattern cannot be combined.
| Option | Methods | Behavior |
|---|---|---|
prefix |
Both | Non-empty literal key prefix, at most 512 characters |
pattern |
search |
Non-empty key pattern, at most 512 characters; only * is special |
where |
search |
1–8 top-level field equalities, combined with AND |
type |
Both | all (default), plaintext, or encrypted |
includeValues |
Both | Boolean, defaults to false |
limit |
Both | Integer 1–100, defaults to 25; bounds evaluated records before filtering |
cursor |
Both | Opaque continuation token from the preceding page, or null/omitted to start |
where field names begin with a letter or underscore and contain only letters, digits and underscores, up to 64 characters. __proto__, constructor and prototype are reserved. Values must be a string of at most 512 characters, a finite number, a boolean or null. Unknown options and invalid filters reject rather than being ignored. search() requires at least one prefix, pattern, field or non-all type filter; use list() for an unfiltered page. KVStore is an alias for kvStore and supports the same methods.
Pagination and Consistency
Section titled “Pagination and Consistency”Each request evaluates one backing-store scan page, at most limit records and at most 1 MiB of backing-store data before filtering. It returns only matching, unexpired records. An empty items array can therefore have hasMore: true; continue with its cursor instead of interpreting it as the end of the results. scannedCount is the number evaluated, not the total number of matches in the store.
Pass the same filters and includeValues setting on subsequent requests. You may change limit. Cursors are signed, bound to the store and query, and expire 24 hours after issuance. Restart without a cursor after expiry. Invalid, altered or mismatched cursors reject.
There is no guaranteed sort order or snapshot across pages. Concurrent writes can change results. On a format 1 store, listing is eventually consistent and is suitable for discovery; authorize individual records again before acting on them. Do not use a search result alone to reserve a credit, claim a job or establish unique ownership.
Key and JSON-field filters currently use bounded scans, not indexed queries. Prefixing customer records is a useful data model, but does not reduce the scan’s read cost. Keep frequently queried directories separate from large usage/event histories. Avoid looping through an entire store in one edge invocation; return the page/cursor to your client or advance a scheduled task in bounded steps.
Values, Permissions and Failures
Section titled “Values, Permissions and Failures”Expired keys are omitted. expiresAt in the edge response is a Unix timestamp in seconds; createdAt and updatedAt use the stored timestamp strings. The public API uses the corresponding snake-case names.
Encrypted values remain "[ENCRYPTED]" with encrypted: true when values are included. Search never decrypts secrets. Field filters exclude encrypted records, and combining where with type: 'encrypted' rejects. Use an authorized individual get() to retrieve a secret.
Both methods enforce the same project/organization store access rules as the existing KV methods. They do not implement per-customer authorization inside a shared store: derive customer IDs from verified sessions and enforce ownership in application code.
Backend failures and invalid queries reject the promise. They are distinct from a successful empty page. Edge queries have an 8-second request timeout and a 4 MiB response limit; oversized responses reject. Catch failures and return an appropriate retryable error rather than rendering “no projects.”
Indexed Stores (Format 2)
Section titled “Indexed Stores (Format 2)”The scan-based listing above is the behaviour of a format 1 store. A format 2 store keeps its keys in an index, so listing the records of one customer reads only those records. Create a store with format: 2 to get this behaviour from the start. Existing stores can be upgraded to format 2 in the dashboard (the Format & upgrade tab of the store page) or with POST …/upgrade in the public API. During an upgrade, writes pause for at least 40 seconds plus the time it takes to copy the data. Reads, including getEntry() and getMany() on a store that is still format 1, continue. An upgrade is limited to stores of at most 100,000 items and 1 GiB. Before it starts, Quant checks the store for keys that format 2 would reject (see the key rules below) and for items that would exceed the 400 KB item size limit in format 2, and reports them to you first, with up to 20 example keys of each kind.
Collections
Section titled “Collections”In a format 2 store, the text of a key before its last : is its collection, and the text after it is the id. The key customer:abc:world:w1 is the item w1 in the collection customer:abc:world. A key with no : belongs to the root collection, which you read with collection: ''. To read one collection, pass collection to list():
const page = await kvStore.list(WORLDS_STORE, { collection: `customer:${customerId}:world`, limit: 50,});// {// items: [{ key, value?, encrypted, version?, createdAt?, updatedAt?, expiresAt? }],// cursor: 'opaque-continuation-token' | null,// hasMore: true | false,// scannedCount: 50,// indexed: true// }value is present only with includeValues: true, and version only on format 2 stores. Items come back in id order, which is the UTF-8 byte order of the id. A collection list contains only the items directly in that collection. The key customer:abc:world:w1:log is in the collection customer:abc:world:w1, so it does not appear when you list customer:abc:world.
| Option | Behavior |
|---|---|
collection |
The collection to read. The text before the last : of the keys you want |
startsWith |
Return only ids that start with this text |
after |
Return only ids greater than this id |
before |
Return only ids less than this id |
reverse |
Boolean. Return the ids in descending order. Needs a format 2 store: on a format 1 store, reverse: true rejects with REQUIRES_FORMAT_2 |
where |
Exact-match field filters, as for search() |
limit |
Integer 1–100, defaults to 25 |
cursor |
Continuation token from the preceding page |
includeValues, type |
As for list() and search() above |
The options follow these rules. startsWith cannot be combined with after or before, and after must sort before before. The id bounds (startsWith, after, before) cannot contain :, because they apply to the id only. collection cannot be combined with prefix or pattern. An option that breaks a rule rejects with a plain Error.
hasMore is exact on a format 2 store. The one exception is a request that uses up its read budget (2,000 rows, 20 backing requests or 4 seconds) before it fills a page, which can happen with where or type filters. That page can hold fewer than limit items while hasMore is true, so keep following cursor until hasMore is false. Cursors from before an upgrade are rejected on a format 2 store with CURSOR_INVALID, and you restart the listing without a cursor.
The indexed field in the response tells you how the call was served. A format 1 store answers a collection call with a bounded scan and reports indexed: false, so it keeps the cost and ordering of a scan. A format 1 store can still return a page with fewer items and hasMore: true for the same budget reason, and the same rule applies.
Key Rules
Section titled “Key Rules”A key in a format 2 store must not start or end with :, and the id (the text after the last :) is at most 1,000 bytes. The whole key is limited to 2,048 bytes as before. A write that breaks these rules throws VALIDATION. The index orders items by id, so use time-ordered ids, such as a ULID, when you need a collection in time order.
Reading Entries
Section titled “Reading Entries”getEntry() returns the value together with its timestamps, and on a format 2 store its version and encrypted flag. It returns null if the key is missing or expired. On a format 2 store the version changes on every write to that key. getMany() reads up to 100 keys in one request.
Secret (encrypted) values are write-only through the public API: a read there returns "[ENCRYPTED]" instead of the value. Edge functions read the decrypted value.
const entry = await kvStore.getEntry(WORLDS_STORE, `customer:${customerId}:world:w1`);// { key, value, encrypted?, version?, createdAt, updatedAt, expiresAt? } | null
const entries = await kvStore.getMany(WORLDS_STORE, keys);// [entry | null, ...] in the same order as keys, each entry shaped like getEntry()'s resultgetMany() returns results in the order of the keys you passed, with null for a missing or expired key. Each entry has the same shape as the result of getEntry(), with the value already decoded. Passing no keys, more than 100 keys, or an empty or non-string key rejects with VALIDATION before any request is made. If the response would exceed 4 MiB, the call rejects with LIMIT_EXCEEDED. The response counts the values together with each key and its metadata, and a key you pass twice counts twice. Encrypted values are decrypted as they are for get(), so apply the same authorisation checks. Both methods also work on format 1 stores, where they return entries without version or encrypted and throw on any failure.
Reads on a format 2 store are strongly consistent by default, so a get() or a collection list sees a write that has already completed.
This example lists a customer’s worlds and then reads the settings stored under each one. The settings key customer:abc:world:w1:settings is in the collection customer:abc:world:w1, so it does not appear in the list of worlds:
const page = await kvStore.list(WORLDS_STORE, { collection: `customer:${customerId}:world`, limit: 50,});
const settings = await kvStore.getMany( WORLDS_STORE, page.items.map((item) => `${item.key}:settings`),);// settings[i] belongs to page.items[i]; use settings[i]?.value, which is undefined if that world has no settings yet.Errors
Section titled “Errors”On a format 2 store, a backend or transport failure in get(), set(), delete(), list(), search(), getEntry() or getMany() throws an Error with name set to 'KVError' and these properties: code, retryable (boolean) and status (the HTTP status of the failure). status is undefined for a timeout and for a RESPONSE_INVALID error, which the runtime raises itself. A failure is never returned as an empty list or false. The only null results are a missing or expired key from get(), getEntry() and getMany(). Invalid options for list() and search() throw a plain Error without these properties.
| Code | Meaning | Retry |
|---|---|---|
VALIDATION |
The key, option or value is invalid | No. Fix the request |
LIMIT_EXCEEDED |
A size limit was exceeded, such as a getMany() response over 4 MiB or a value too large for one item (400 KB) |
No. Reduce the request |
REQUIRES_FORMAT_2 |
The option needs a format 2 store, such as reverse on a format 1 store |
No. Upgrade the store or leave out the option |
CURSOR_INVALID |
The cursor is altered, expired or from another query or store format | No. Restart without a cursor |
KEY_NOT_FOUND |
API only. The edge methods return null for a missing key |
No |
STORE_NOT_FOUND |
The store does not exist | No |
UPGRADE_CONFLICT |
An upgrade is already running, or the store is not in a state that allows it | No. Wait for the upgrade to finish |
CONFLICT |
A conditional write or transaction found the key in a different state (see Conditional Writes and Transactions) | No. Read the key again, then decide |
UNIQUE_VIOLATION |
A write took a value that another key owns in a unique index (HTTP 409). index and existingKey name the index and the owner |
No. Read existingKey and decide |
INDEX_NOT_READY |
The index is still BUILDING or is not ACTIVE (HTTP 409) |
Yes, after a short delay |
INDEX_FIELD_ENCRYPTED |
An encrypted value in an indexed collection (HTTP 400) | No. Do not encrypt in that collection |
COUNT_WITH_TTL |
A ttl in a counted collection (HTTP 400) |
No. Leave out ttl |
CONTENTION |
Concurrent writes to the same key conflicted | Yes, with a short delay |
STORE_MIGRATING |
The store is paused for the final step of an upgrade | Yes, after a few seconds |
UNAVAILABLE |
The backing service did not answer in time. For a transaction or a conditional write, the outcome may be unknown: the write may already have been applied | Check retryable. Retry a transaction only with its idempotencyKey. After UNAVAILABLE on a conditional set() or delete(), call getEntry() and compare versions before you retry |
RESPONSE_INVALID |
The service returned a response the runtime could not use | No |
increment() failures throw KVError on every store format, with the code the service reports (for example STORE_MIGRATING or CONTENTION). Invalid arguments throw VALIDATION before any request is made. A failure whose outcome is unknown (a timeout, a lost connection, or a service error without a code) has code UNAVAILABLE and retryable: false, and its message says not to retry automatically, because the write may already have been applied. See Atomic Counters below.
Format 1 stores keep their existing behaviour. get(), set() and delete() still return null or false on failure, with one exception: while a store is being upgraded, a write throws STORE_MIGRATING so that it cannot be lost without notice. getEntry() and getMany() throw on failure whatever the store format, and return null for a missing key.
Conditional Writes and Transactions (Format 2)
Section titled “Conditional Writes and Transactions (Format 2)”A format 2 store can refuse a write unless the key is in the state you expect. You use this to create a key only once, to change a value without overwriting another writer’s change, and to update several keys together. On a format 1 store, the conditional options and commit() fail with REQUIRES_FORMAT_2.
Compare and Set
Section titled “Compare and Set”Every write to a key on a format 2 store gives it a new version. Read the version with getEntry(), then pass it as ifVersion. The write succeeds only if the key still has that version:
const entry = await kvStore.getEntry(ORDERS_STORE, `order:${id}`);if (entry === null) { // The key is missing or expired. Create it with ifAbsent instead (see Create Once). return;}try { const { version } = await kvStore.set( ORDERS_STORE, `order:${id}`, { ...entry.value, status: 'paid' }, { ifVersion: entry.version }, );} catch (err) { if (err.code === 'CONFLICT') { // Someone else wrote the key after you read it. Read it again and retry. // err.currentVersion is the version now stored, err.reason is 'version'. } else { throw err; }}A plain set() still returns true. A set() with ifAbsent or ifVersion returns { version }, the version of the value you wrote.
A conflict throws a KVError with code: 'CONFLICT' and these properties:
| Property | Meaning |
|---|---|
key |
The key that failed the condition |
currentVersion |
The version now stored. It is present only when the key exists and has not expired |
reason |
Why the condition failed: exists, version, missing, expired, not_numeric or range |
operation |
In a transaction, the index of the operation that failed |
Create Once
Section titled “Create Once”ifAbsent: true writes the key only if it does not exist. Use it as a lock or to create a record once. It replaces the pattern of calling increment() and checking that the result is 1:
try { await kvStore.set(LOCKS_STORE, `import:${jobId}`, { owner: runId }, { ifAbsent: true, ttl: 300 }); // You hold the lock.} catch (err) { if (err.code !== 'CONFLICT') throw err; // Another run holds it (err.reason is 'exists').}An expired key counts as absent, so ifAbsent succeeds on it and the new key gets a new created_at. ifVersion fails on an expired key with reason: 'expired', because an expired key has no current version to match.
Conditional Delete
Section titled “Conditional Delete”delete() takes ifVersion in the same way and returns true when it deletes the key. If the key is missing, it throws CONFLICT with reason: 'missing'. A conditional delete is never retried for you: if its outcome is unknown, it throws UNAVAILABLE with retryable: false. Call getEntry() to find out whether the key is still there.
await kvStore.delete(ORDERS_STORE, `order:${id}`, { ifVersion: entry.version });Increment in One Write
Section titled “Increment in One Write”On a format 2 store, increment() on a live counter that holds a number is one write and returns the new value. The first call on a new, expired or older (string format) counter reads and writes once, and stores the counter as a number, so the calls after it are one write. The behaviour, limits and ttl rules in Atomic Counters below do not change.
const n = await kvStore.increment(ORDERS_STORE, 'stats:orders', 1);Transactions
Section titled “Transactions”atomic() groups 1 to 25 operations on one store. Each operation must use a different key, because a key that appears twice fails with VALIDATION. Either all of them apply or none do. Build the transaction with check(key, version), set(key, value, options), delete(key, options) and increment(key, amount), then call commit():
const { versions, replayed } = await kvStore .atomic(ORDERS_STORE) .set(`order:${id}`, order, { ifAbsent: true }) .set(`customer:${customerId}`, { ...customer, lastOrder: id }, { ifVersion: customerVersion }) .check(`inventory:${sku}`, inventoryVersion) // the key must still have this version .increment('stats:orders', 1) .commit({ idempotencyKey: `order-${id}` });commit() returns versions, the new version of each key it wrote, and replayed. A transaction returns no counter values, so call get() if you need the number after an increment.
If one condition fails, the whole transaction fails with CONFLICT, and operation is the index of the operation that failed. If concurrent writes block the transaction, it throws CONTENTION. This error is retryable, so run the transaction again after a short delay.
An idempotencyKey is 1 to 64 letters, digits, - or _. If you commit the same operations with the same key within 24 hours, the call returns the first result with replayed: true and does not write again. If you use the same key with different operations, the call throws VALIDATION.
If the response to a transaction is lost, for example in a timeout, commit() throws UNAVAILABLE, because the transaction may already have applied. The error has retryable: true only when you passed an idempotencyKey. In that case, call commit() again with the same key. Without a key, read the keys first to find out whether the transaction applied.
An increment inside a transaction cannot take ttl. It needs a live counter that holds a number, or no counter. If the counter is expired or was written in the older string format, the transaction fails with CONFLICT and reason expired or not_numeric. Call kvStore.increment() once outside the transaction to convert it, then use the transaction.
A conditional write costs the same as a plain write. An increment() on a live counter that holds a number is one write; the first call on a new or older counter is one read and one write. A transaction costs 2 write units for each operation in it. An idempotencyKey adds one item to the transaction (2 write units).
HTTP API
Section titled “HTTP API”The public API has the same features:
PUT …/items/{key}/entryacceptsifAbsentorifVersionin the body.DELETE …/items/{key}/entry?if_version=<version>deletes the key only if the version matches.POST …/transactruns a transaction.
Indexes, Unique Constraints and Counts (Format 2)
Section titled “Indexes, Unique Constraints and Counts (Format 2)”A format 2 store can maintain indexes that you declare. A plain index lets you list the items of a collection by the value of a field, a unique index stops two keys from holding the same value, and a count index keeps a running total that you read in one call. You declare them through the public API. The edge runtime cannot create, change or drop an index.
Declaring an Index
Section titled “Declaring an Index”Send the definition to POST …/indexes (scope kv:write). The call returns 202 with the index in the state BUILDING:
curl -X POST https://dashboard.quantcdn.io/api/v2/organizations/<org>/projects/<project>/kv/<store_id>/indexes \ -H "Authorization: Bearer $QUANT_TOKEN" \ -H "Content-Type: application/json" \ -d @index.jsonThe body in index.json is:
{ "name": "byCustomer", "collection": "fly-world", "fields": ["customerId"], "sortBy": "createdAt" }{ "name": "byEmail", "collection": "user", "fields": ["email"], "unique": true }{ "name": "allHostWorlds", "collection": "host-world:*", "fields": [] }{ "name": "allWorlds", "collection": "customer:*:world", "fields": [], "count": true }{ "name": "worldsByCustomer", "collection": "customer:*:world", "fields": ["customerId"], "count": true }| Property | Rule |
|---|---|
name |
Matches ^[A-Za-z][A-Za-z0-9_]{0,63}$ |
collection |
A collection name, where * stands for one whole segment between colons, such as customer:*:world |
fields |
0 to 3 top-level scalar fields of a value that is a plaintext JSON object. A unique index needs at least 1 field. An item that lacks any of the fields is not in the index |
sortBy |
Optional. A top-level field that orders the items inside each group of equal fields values |
unique |
Optional. A key cannot take a value that another key in the index already has |
count |
Optional. Keep a count for each group of fields values. unique and count are exclusive |
A store holds at most 20 indexes, and at most 5 of them can cover one collection. A declaration over either limit throws LIMIT_EXCEEDED. A definition is immutable, so to change an index you drop it and create it again. GET …/indexes returns { indexes }.
The index starts in BUILDING. Writes begin to maintain it about 40 seconds after the declaration, and the backfill of the items that already exist starts about 75 seconds after it. When the backfill finishes, the state becomes ACTIVE. Queries need ACTIVE, and an earlier query throws INDEX_NOT_READY.
Writes do not enforce a unique index during the first 40 seconds or so. If two keys take the same value in that time, the backfill finds the duplicate and the build ends FAILED with UNIQUE_VIOLATION.
If the backfill finds a problem, the state becomes FAILED. The index then has an error and conflicts. error is the code of the first problem found: UNIQUE_VIOLATION (two keys share a value in a unique index), COUNT_WITH_TTL (an item with a ttl in a count index), INDEX_FIELD_ENCRYPTED (an encrypted item in the collection) or VALIDATION (an item whose index keys are too long). conflicts lists up to 20 affected keys. For a unique index it names both keys of each clash. It gives no reason for each key.
A FAILED index keeps its name and its slot until you drop it. Fix the data, drop the index, then declare it again. DELETE …/indexes/{index_name} returns 202 and sets the state to DROPPING, and the index is removed when the job finishes. If a build or a drop stops part way (the job has not advanced, with the same phase and scanned count, for more than 960 seconds), POST …/indexes/{index_name}/resume restarts the job.
Listing by Index
Section titled “Listing by Index”Pass index and equals to list(). equals is an object with a value for each field of the index:
const page = await kvStore.list(WORLDS_STORE, { index: 'byCustomer', equals: { customerId }, after: '2026-10-01T00:00:00Z', reverse: true, limit: 50,});The items come back in the order of the sortBy value, then by key. An index with no sortBy returns the items in key order. An item with no sortBy value sorts first. after and before are both exclusive. They bound the sortBy value, which can be any JSON scalar, or the key when the index has no sortBy. reverse, where, includeValues, limit and cursor work as they do for a collection. equals matches the type as well as the value, so equals: { n: 1 } does not match an item whose n is the string "1". index cannot be combined with collection, prefix, pattern or startsWith.
Unlike a collection list, a list by index is eventually consistent. A write usually appears within 1 second, but a read straight after a write can miss it, so do not use a list by index to check a write you just made. Use getBy() or count(), which are strongly consistent. If the index is not ACTIVE, the call throws INDEX_NOT_READY, which is retryable.
Looking Up One Key
Section titled “Looking Up One Key”getBy() reads a unique index (on any other kind of index it throws VALIDATION) and returns the entry for the value, or null. It is strongly consistent, so it sees your own earlier write:
const user = await kvStore.getBy(USERS_STORE, 'byEmail', { email: 'sam@example.com' });if (user !== null) { // user.key, user.value, user.version and the other entry properties, as for getEntry()}A write that takes a value that another key owns throws UNIQUE_VIOLATION:
try { await kvStore.set(USERS_STORE, `user:${id}`, { email, name });} catch (err) { if (err.code === 'UNIQUE_VIOLATION') { // err.index is 'byEmail'. err.existingKey is the key that owns the email. } else { throw err; }}An owner that has expired no longer holds its value, so a new key can take it.
Counting
Section titled “Counting”count() returns the number of items in a group of a count index (on any other kind of index it throws VALIDATION). It is strongly consistent:
// Index 'worldsByCustomer': fields ['customerId'], count trueconst n = await kvStore.count(WORLDS_STORE, { index: 'worldsByCustomer', equals: { customerId } });
// Index 'allWorlds': fields [], count trueconst total = await kvStore.count(WORLDS_STORE, { index: 'allWorlds' });For an index with no fields, omit equals ({} also works) and the count covers the whole collection pattern. A count with fields: [] changes on every write that adds or removes an item. At about 1,000 writes a second it becomes a hot key, so give the index a field when the collection has a high write rate. Items with a ttl cannot be counted, because an expired item would stay in the total. A write with ttl to a counted collection throws COUNT_WITH_TTL.
What an Index Refuses
Section titled “What an Index Refuses”| Write | Result |
|---|---|
encrypt (a secret value) in an indexed collection |
INDEX_FIELD_ENCRYPTED |
ttl in a counted collection |
COUNT_WITH_TTL |
increment() in an indexed collection |
VALIDATION |
| A string that contains U+0000 in an indexed field | VALIDATION |
| A value that makes an index key too long | VALIDATION, naming the index |
| A purge of a store that has a unique or count index | VALIDATION. Drop those indexes first, or delete the keys one at a time |
| An upgrade rollback of a store that has any index | UPGRADE_CONFLICT. Drop the indexes first |
Index Cost
Section titled “Index Cost”An index adds work to every write in its collection. The Indexes tab shows the cost of a design before you declare it.
| Operation (4 KB read, 1 KB write) | Units |
|---|---|
Plain set() |
1 write unit |
| Each plain index on the collection | +1 write unit, or +2 if the indexed value changes |
set() to a collection with a unique index |
1 read unit and 4 to 6 write units |
set() to a counted collection |
1 read unit and 4 write units |
getBy() |
2 read units |
A write that changes the indexed value of a unique index costs up to 2 more write units.
Index HTTP API
Section titled “Index HTTP API”The public API has the same features:
POST …/indexesdeclares an index and returns202.GET …/indexeslists the indexes with their state.DELETE …/indexes/{index_name}drops an index and returns202.POST …/indexes/{index_name}/resumerestarts a stuck build or drop.GET …/keys?collection=<collection>&subject_tag=<token>lists the entries of one ledger subject token. The token matches^qsub1_[0-7][0-9A-HJKMNP-TV-Z]{25}$, elseVALIDATION. It needs no subject id.GET …/keyswithequalsorwhereon the subject field of a ledger is refused withVALIDATION:Query a ledger subject with POST …/ledgers/{ledger}/subject-entries.GET …/keys?index=<name>&equals=<JSON>lists the items of an index.after,before,reverse,where,include_valuesandlimitwork as for a collection.GET …/indexes/{index_name}/lookup?equals=<JSON>reads one key by a unique index. A missing key returns404 KEY_NOT_FOUND.GET …/indexes/{index_name}/count?equals=<JSON>returns{ count }.equalsis optional for an index with no fields.
Expiring Data (TTL)
Section titled “Expiring Data (TTL)”Pass ttl in seconds to kvStore.set and the key expires on its own. The range is 60 seconds to 31,536,000 seconds (one year). Use this for sessions, OAuth state, nonces, rate-limit counters and any other short-lived value, so the store does not fill with stale keys.
// Expires 10 minutes after it is writtenawait kvStore.set(KV_STORE_ID, `oauth_state:${state}`, { returnTo: '/' }, { ttl: 600 });How expiry behaves:
- An expired key reads as
null, even before the platform removes the row, which can take up to 48 hours. - Writing a key again without
ttlremoves its expiry. ttlcan be combined withsecret: truefor encrypted values.- The API returns the expiry as
expires_at, a Unix timestamp in seconds, on reads and listings.
Atomic Counters
Section titled “Atomic Counters”increment() atomically adds a signed integer to a numeric key and returns the new number. Concurrent calls from different edge executions or locations use the same regional KV service; successful calls do not lose each other’s increments.
const count = await kvStore.increment(COUNTERS_STORE, 'signup-attempts');const updated = await kvStore.increment(COUNTERS_STORE, 'processed-items', 5);const remaining = await kvStore.increment(COUNTERS_STORE, 'available-slots', -1);The amount defaults to 1 and must be a nonzero JavaScript safe integer. The resulting value must also be a safe integer. Negative values are allowed; this method does not enforce a minimum, maximum or quota by itself. A missing or expired key starts at zero. Existing unencrypted integer values written with set() work without migration. Strings (including "1"), objects, fractional values and encrypted live values reject.
Supply ttl to expire a newly created counter:
const count = await kvStore.increment(COUNTERS_STORE, counterKey, 1, { ttl: 60 });For increment(), TTL is set only when creating or restarting an expired key. Incrementing a live counter preserves its existing expiry, even when a different TTL is supplied. Incrementing a live key without TTL preserves its expiry too. This differs from set(), which replaces the expiry. TTL accepts the same 60-second to one-year range. No other increment options are supported.
Counters retain the existing JSON storage format and work with get(), list() and search(). The implementation uses a strongly consistent read followed by a conditional write, retrying only confirmed conflicts (up to eight attempts with jitter). It costs more than a plain write, but does not change the store schema or require migrating existing data. Heavy contention rejects instead of retrying indefinitely. Use the value returned by increment() for the immediate decision; on a format 1 store a separate normal get() may be eventually consistent (format 2 stores read with strong consistency by default). Avoid mixing unconditional set()/delete() calls with a live counter’s increment traffic.
Atomic does not mean exactly once. A timeout or lost response can occur after a successful write. The counter path disables automatic SDK write retries and the edge method makes one request, but a caller retry can still count twice. Treat an ambiguous failure explicitly; do not automatically retry it. Billing, credit settlement and other exact accounting require additional idempotency support. This method is a counter primitive, not a complete rate limiter, distributed lock or credit-reservation API.
If the edge request times out, increment() rejects with an error that says the outcome may be unknown, because the write may already have been applied. Both kvStore and its KVStore alias expose increment(). It uses the same store permissions as other KV methods and rejects unsupported backends or failed requests. The API has a six-second operation deadline; the edge request has an eight-second timeout. No region or replication change is needed.
Encrypting Data
Section titled “Encrypting Data”Pass secret: true to encrypt a value at rest. Reads decrypt automatically.
await kvStore.set(KV_STORE_ID, 'api-key', apiKey, { secret: true });Use secrets for API keys and other credentials, and keep them out of function code. You can also add a secret in the dashboard (the encrypted option), through the API ("secret": true) or with Terraform (quant_kv_item with secret = true). A function reads the plain value with get(). The dashboard, the API, listings and exports show [ENCRYPTED] instead. An API update cannot change whether an existing key is encrypted.
JSON Data Handling
Section titled “JSON Data Handling”When storing JSON data in a KV store, the data is automatically encoded when stored and decoded when retrieved. This means you can directly store and retrieve JavaScript objects without manual JSON parsing.
// Storing an objectconst user = { name: 'John', age: 30 };await kvStore.set(USERS_KV_STORE, 'user123', user);
// Retrieving the objectconst userData = await kvStore.get(USERS_KV_STORE, 'user123');// userData is automatically parsed as { name: 'John', age: 30 }Manage Stores in the Dashboard
Section titled “Manage Stores in the Dashboard”The store list and the store page in the dashboard cover the format, upgrade, index and usage features described above.
Choosing a Format
Section titled “Choosing a Format”The Create KV Store form has a Format choice. Format 2 is recommended and is the default in the dashboard. Choose format 1 only if you need the older behaviour. A store created through the API is still format 1 unless the request asks for format 2.
The store list has a Format column: Format 2, Format 1, Upgrading or Unknown. The column loads after the list appears. Unknown means the lookup failed. It does not mean the store is format 1. A dry run (Check (dry run)) does not change the badge: the store shows Upgrading only during a real upgrade or rollback.
Store Page Tabs
Section titled “Store Page Tabs”A store page has six tabs: Items, Indexes, Format & upgrade, Usage, Export and Ledgers. A tab loads its data only when you open it.
Format & Upgrade
Section titled “Format & Upgrade”On a format 1 store, this tab upgrades the store to format 2.
- Check (dry run) runs a dry run. It looks for keys that are not valid in format 2 and for items that are too large. It changes nothing. If it finds a problem, it lists up to 20 keys. Change the data, then check again.
- Upgrade to format 2 copies the store to a new table. Writes fail with
503 STORE_MIGRATINGfor about 40 seconds, plus the time to copy the items. Reads continue. - Roll back to format 1 returns the store to format 1. It is available for 14 days after the switch. A store with indexes cannot roll back: drop the indexes first.
- Finalize is available after 14 days. It deletes the format 1 copy. You cannot roll back after you finalize.
- Resume a stalled step restarts a step that has stalled. It appears when the step makes no progress for 16 minutes while the page is open.
Indexes
Section titled “Indexes”This tab needs a format 2 store. On a format 1 store, it tells you to upgrade first.
To declare an index, enter a name, a collection, up to 3 fields, an optional sort by field, and choose unique or count if you need them. The list shows the state of each index: BUILDING, ACTIVE, FAILED or DROPPING. A FAILED index shows its error and up to 20 of the conflicting keys. Drop it, fix the data, then declare it again. Resume appears only for a job that has stalled.
The tab shows the cost of each write for the design you enter:
- A plain index adds 1 write unit, or 2 when the indexed value changes.
- One unique or count index makes each write cost 1 read unit and 4 write units.
- Each further unique or count index on the collection adds 2 write units. Each plain index adds 1.
If an upgrade is running, index changes fail with STORE_MIGRATING. Try again in a minute.
This tab shows the items, the size, the reads and writes of the last 30 days, and an estimated cost. The estimate uses list prices for the store’s region for read units, write units, storage and point-in-time recovery. It does not include free allowances, request handling or data transfer. The table size updates about every 6 hours. A new store shows zeros until it has traffic. If the metrics are not available, the tab shows “Usage is not available right now” and not zeros.
Export
Section titled “Export”This tab exports the store to files. Start export asks you to confirm, and the confirmation shows the cost line. The tab then shows the progress of the export. When the export is done, it lists a download link for each part. Resume a stalled step appears when the export makes no progress for 16 minutes while the page is open. See Export a Store.
Ledgers
Section titled “Ledgers”This tab declares and manages ledgers: append-only, hash-chained collections with a locked archive. It needs a format 2 store. See Ledgers in the Dashboard.
Usage HTTP API
Section titled “Usage HTTP API”GET …/usage returns the same data. It never returns item values. A metrics error returns 503 UNAVAILABLE. An unknown store returns 404 STORE_NOT_FOUND.
| Field | Meaning |
|---|---|
storeName, format, state |
The store, its format (1 or 2) and its state (active or migrating) |
table |
name, itemCount, sizeBytes (table data) and indexSizeBytes (all indexes, 0 if none) |
retainedTable |
name and sizeBytes of the format 1 copy. Present only after an upgrade switch and before finalize |
period |
start, end and days. The period is 30 UTC days that end yesterday |
daily |
One row for each day, oldest first: date, readUnits, writeUnits |
totals |
readUnits and writeUnits for the period |
estimate |
readCost, writeCost, requestCost30Days, storageCostPerMonth, pitrCostPerMonth in USD, with the prices used and a note |
Storage cost uses the table, the indexes and the retained table. Point-in-time recovery cost uses the table and the retained table, and not the indexes.
Export a Store
Section titled “Export a Store”You can export every item of a store to files and download them. An export reads a point-in-time snapshot, so it does not use the read capacity of the store and it does not slow your edge functions. You start an export from the Export tab of the store page or through the API.
Export HTTP API
Section titled “Export HTTP API”POST …/exportstarts an export and returns202. The body is optional and has no fields.GET …/exportreturns the latest export of the store. Poll this route.POST …/export/resumequeues a step again. Use it whenupdatedAtin theGETresponse does not change for more than 960 seconds. If the export is in a final phase or its step is still running, the call returns409 CONFLICT.
An export moves through these phases: requested, exporting, transforming, cleaning and done. A failed export has the phase failed, and an export whose files are deleted has the phase expired. One export runs at a time for each store.
| Field | Meaning |
|---|---|
id, phase |
The ULID of the export and its phase |
requestedAt |
When you asked for the export |
updatedAt |
When the export last saved progress. It changes on each step. If it does not change for more than 960 seconds in a phase that is not final, you can resume the export |
exportTime |
The point in time the export reads. It is about 5 seconds before the request |
completedAt |
When the export finished |
itemCount |
The number of items written to the files |
skipped |
internal (internal rows that are left out), expired (items that expired before exportTime) and invalid (rows that could not be read or written, which are left out) |
sizeBytes, parts |
The total size of the compressed files and the number of files |
expiresAt |
The time the files are deleted |
error |
The reason, in phase failed |
downloads |
In phase done only. One entry for each file: part, url and sizeBytes |
Download links stay valid for up to 1 hour (refresh the page or GET again for new links). Quant keeps the files for 7 days after the first file is written. expiresAt is 1 hour before that time. After expiresAt, the phase is expired and there are no downloads.
| Code | Meaning |
|---|---|
EXPORT_IN_PROGRESS |
An export of this store has not reached done or failed (HTTP 409). An upgrade start or an upgrade rollback also fails with this code while an export runs |
CONTENTION |
The store changed during the request (HTTP 409). Retry after a short delay |
LIMIT_EXCEEDED |
The store is larger than 1 GiB (HTTP 400) |
STORE_MIGRATING |
An upgrade is running (HTTP 503). Retry later |
EXPORT_NOT_FOUND |
The store was never exported, or the last export failed more than 7 days ago (HTTP 404) |
UNAVAILABLE |
The service cannot answer now (HTTP 503). Retry later |
If you delete the store during an export, the export stops.
What the Files Contain
Section titled “What the Files Contain”The files are gzip JSON lines. Each line is one JSON object for one key:
{"key":"customer:42","value":{"name":"Ada"},"createdAt":"2026-10-01T02:03:04.000Z","updatedAt":"2026-10-12T09:10:11.000Z","expiresAt":"2026-12-01T00:00:00.000Z","version":"01JA2B3C4D5E6F7G8H9J0KMNPQ"}keyis the key.valueis decoded in the same way as aGET, so a JSON value is a JSON value and not an escaped string.createdAtandupdatedAtare ISO times.expiresAtis an ISO time and appears only when the key has an expiry.versionis present in a format 2 store. It is a ULID string.- A secret value has
"encrypted": trueand the value"[ENCRYPTED]". The export never contains the ciphertext. - Ledger entries are exported as data. Each personal field shows
"[PERSONAL]", and the line addspersonal, the list of those field names. No sealed or decrypted personal data appears in a file. See Personal Fields and Shredding. - The files have no internal rows, such as index rows. The
skipped.internalfield counts them. - A key that expired before
exportTimeis left out. Theskipped.expiredfield counts these keys. - A row that cannot be read or written is left out. The
skipped.invalidfield counts these rows. - A number that the store holds as a raw number (for example in a format 1 store) and that is larger than 2^53 (9007199254740992) cannot be held exactly in a JSON number. The export writes it as a string of decimal digits. Other numbers stay numbers.
Export Cost
Section titled “Export Cost”An export is charged for each GB of table data, plus storage of the files for up to 7 days. An export uses no read capacity. If point-in-time recovery is off for this store, it is turned on and stays on. The Export tab shows the cost before you confirm.
Query an Export
Section titled “Query an Export”Quant does not give you access to its own storage. Download the parts and load them into storage and a query tool that you own. Any tool that reads gzip JSON lines works. Read the value field as a string, because it can hold any JSON, and parse it in your query. Skip lines with "encrypted": true, because their value is "[ENCRYPTED]".
Ledgers (Format 2)
Section titled “Ledgers (Format 2)”A format 2 store can hold ledgers: append-only, hash-chained collections whose entries can never be changed or deleted. Entries are archived to write-once storage with a retention lock, covered by signed digests, optionally anchored outside Quant, and can be verified offline. Personal fields can be crypto-shredded for lawful erasure. Edge functions write entries with kvStore.append() and read them with list() and getEntry(). See KV Ledgers.
Public API
Section titled “Public API”Everything you can do with a store in the dashboard, you can also do over HTTPS with an API token. This includes format 2 features, indexes, export, usage and ledgers. The routes live under your project:
https://dashboard.quantcdn.io/api/v2/organizations/<org>/projects/<project>/kv/<store_id>QuantGov customers use their dashboard host instead: https://dash.quantgov.cloud/api/v2/... with the same path. Every call sends your API token as a bearer token. The examples on this page use … for the base path above, so GET …/usage means GET https://dashboard.quantcdn.io/api/v2/organizations/<org>/projects/<project>/kv/<store_id>/usage.
curl -H "Authorization: Bearer $QUANT_TOKEN" \ https://dashboard.quantcdn.io/api/v2/organizations/<org>/projects/<project>/kv/<store_id>/usagePath segments and query parameters use snake_case (store_id, include_values, if_version). JSON request bodies keep camelCase (ifAbsent, ifVersion, idempotencyKey, retentionDays). Responses of the KV service that the API passes through, such as proofs, signed reports and receipts, are returned byte for byte so that their signatures stay valid.
Create a Store
Section titled “Create a Store”POST to /api/v2/organizations/<org>/projects/<project>/kv creates a store. The optional format field is 1 or 2. The default is 1, so existing automation does not change. Indexes, ledgers, conditional writes and transactions need format 2.
curl -X POST https://dashboard.quantcdn.io/api/v2/organizations/<org>/projects/<project>/kv \ -H "Authorization: Bearer $QUANT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "settings", "format": 2 }'To move an existing format 1 store to format 2, call POST …/upgrade with { "action": "start" }. Add "dryRun": true to check the store first and change nothing. The other actions are rollback, finalize and resume. GET …/upgrade shows the state of the upgrade. See Format & Upgrade for what happens during an upgrade.
Scopes and Permissions
Section titled “Scopes and Permissions”Each call needs a token scope and a role permission. The scope is a property of the token. The permission comes from the role of the user who owns the token. A call that lacks the scope fails with 403 SCOPE_REQUIRED. A call that lacks the permission fails with 403 PERMISSION_DENIED.
| Operations | Token scope | Role permission |
|---|---|---|
| Show an entry, list keys, batch get, index lookup and count, list indexes, show upgrade state, usage, show an export | kv:read |
kv_read |
| Put an entry, increment, transact, create or resume an index, start or resume an export | kv:write |
kv_write |
| Delete an entry, drop an index, purge, delete a store | kv:write |
kv_delete |
| Create a store, start an upgrade, link or unlink a store | kv:write |
kv_admin |
| Export a store that has ledgers | kv:write |
kv_write and ledger_read |
| Append to a ledger | kv:ledger:append |
ledger_append |
| List or show ledgers, ledger status, public key, proof, verify, verification report, archive, list shreds, shred receipt, shred entries | kv:ledger:read |
ledger_read |
Query the entries of a subject (POST …/ledgers/{ledger_name}/subject-entries, a read that works on a read-only store) |
kv:ledger:read |
ledger_read |
| Declare, raise or drop a ledger | kv:ledger:admin (explicit) |
ledger_admin |
| Subject preview and shred | kv:ledger:erase (explicit) |
ledger_erase |
| List retained archives, show an archive, show a purge, purge receipt, purge manifest | kv:ledger:admin (explicit) |
ledger_admin |
| Check (dry run) or start an archive purge | kv:ledger:admin and kv:ledger:purge (both explicit) |
ledger_admin |
The preview and the shred answers do not hold subjectId: it is removed from the erasure responses. The routes GET …/ledgers/{ledger_name}/subjects/{subjectId} and POST …/ledgers/{ledger_name}/subjects/{subjectId}/shred are removed. Send the id in a request body, never in a path or query string. See Query by subject.
The scopes kv:ledger:admin, kv:ledger:erase and kv:ledger:purge are explicit. A token with the full-access preset does not include them, and neither does an older token that has no scopes. Create a separate token that lists the scope, and keep it for the people who run those operations.
Ledger reads in the public API default to personal=sealed: a personal field shows "[PERSONAL]" and the subject field of a pseudonym ledger shows its token. A ledger entry that has personal fields is returned with those fields redacted. To read the clear text, the role needs the ledger_reveal permission and the request must pass reveal=true (public GET) or personal: "decrypted" (the subject-entries POST, field personal). Rows from a scan, prefix or pattern list stay sealed even with reveal. Each reveal is logged. Any call that reads a ledger entry also needs ledger_read.
Pages and Limits
Section titled “Pages and Limits”List calls (GET …/keys, GET …/ledgers/{ledger_name}/shreds and the other lists) take limit from 1 to 1000 and cursor. A list answers with data, next_cursor (null on the last page), has_more and page_end. A cursor is signed, is valid for 24 hours and is bound to the store and the query.
page_end says why the page ended:
page_end |
Meaning |
|---|---|
end |
There are no more items |
limit |
The page holds limit items |
bytes |
The page reached its size budget of 4,000,000 bytes |
budget |
The request used its read budget before the page was full |
A page can hold fewer items than limit while has_more is true. This is normal. Keep following next_cursor until has_more is false. page_end is null if the KV service does not report a reason.
Errors
Section titled “Errors”Every error body has the same shape: error (always true), message, code and retryable, plus detail fields for some codes (for example currentVersion and key on CONFLICT, or retainUntil on LEDGER_RETAINED). If retryable is true, you can send the same request again. Wait for the Retry-After header when it is present. If retryable is false, change the request or the state of the store first.
A write that loses its connection answers 503 UNAVAILABLE with retryable: false, because the write may have been applied. Read the data before you send the write again. A transaction or an append that carries an idempotencyKey is safe to send again, and answers with retryable: true.
| Code | HTTP status | Retryable | Meaning |
|---|---|---|---|
VALIDATION |
400 | No | The request is not valid (body, parameter, key or value). |
LIMIT_EXCEEDED |
400 | No | A size or count limit is exceeded (item over 400 KB, batch, transact, value). |
CURSOR_INVALID |
400 | No | The cursor is not valid for this request. Start the list again. |
CONFIRMATION_REQUIRED |
400 | No | confirm is missing or is not the store name. |
REQUIRES_FORMAT_2 |
400 | No | The operation needs a format 2 store. Upgrade the store first. |
INDEX_FIELD_ENCRYPTED |
400 | No | An indexed field is in a secret (encrypted) value. |
COUNT_WITH_TTL |
400 | No | A count index cannot include items that have a ttl. |
LEDGER_IMMUTABLE |
400 | No | A ledger entry cannot be changed or deleted. Use append. Also a raise that changes subjectMode or subjectCase. |
LEDGER_INDEX_KIND |
400 | No | This index kind is not allowed on a ledger collection. |
LEDGER_SUBJECT_REQUIRED |
400 | No | The ledger needs a subject field in each entry. |
LEDGER_DIRECTION |
400 | No | A ledger setting can only be raised, not lowered. |
SCOPE_REQUIRED |
403 | No | The token does not list the scope that the route needs. |
PERMISSION_DENIED |
403 | No | The role of the token user does not have the permission. |
FORBIDDEN |
403 | No | The name is a reserved system store. |
KEY_NOT_FOUND |
404 | No | The key does not exist or has expired. |
STORE_NOT_FOUND |
404 | No | The store does not exist or is not in this project. |
EXPORT_NOT_FOUND |
404 | No | The store has no export. |
LEDGER_NOT_FOUND |
404 | No | The ledger does not exist. |
SUBJECT_NOT_FOUND |
404 | No | The store has no record of the subject (it was never seen). The message is This ledger holds no personal data for the data subject. |
VERIFICATION_NOT_FOUND |
404 | No | The verification run does not exist. |
SHRED_NOT_FOUND |
404 | No | The erasure record does not exist. |
INDEX_NOT_FOUND |
404 | No | The index does not exist. |
UPGRADE_CONFLICT |
409 | No | The upgrade action is not valid in the current upgrade state. |
CONFLICT |
409 | No | A condition (ifAbsent, ifVersion, check) failed. See reason, key and currentVersion. |
UNIQUE_VIOLATION |
409 | No | A unique index already has the value. See index and existingKey. |
EXPORT_IN_PROGRESS |
409 | No | An export of the store is already running. |
LEDGER_COLLECTION_NOT_EMPTY |
409 | No | The collection already has items, so it cannot become a ledger. |
LEDGER_RETAINED |
409 | No | The ledger is in its retention period. See retainUntil. |
LEDGER_PRESENT |
409 | No | A ledger blocks the action. See ledgers. |
STORE_NOT_DELETED |
409 | No | The store still exists. Delete the store first. |
ARCHIVE_RETAINED |
409 | No | The 24-hour quiet period after the store delete has not ended. See retainedUntil. A lock found later ends the purge in the state RETAINED. |
PURGE_IN_PROGRESS |
409 | No | Another purge of the store is running. See purgeId. |
PURGE_BLOCKED |
409 | No | The last purge stopped and needs Quant. Contact Quant support. |
PURGE_NOT_COMPLETE |
409 | No | The receipt exists only when the purge is COMPLETE. See state. |
DIGEST_OWED |
409 | Yes | A store delete while the last digest is not written yet. Send again in a few minutes. |
STORE_NAME_RETAINED |
409 | No | The store name is held by a retained ledger archive, or by a running archive purge. Purge the archive or choose another name. |
LEDGER_FORKED |
409 | No | The ledger chain does not agree with its archive or digest. See reason. |
LEDGER_DROPPED |
409 | No | The ledger was dropped. |
LEDGER_FORMAT_RETIRED |
409 | No | The ledger uses a retired subject format; declare a new ledger. |
CONTENTION |
409 | Yes | Another write changed the same data at the same time. Send again. |
INDEX_NOT_READY |
409 | Yes | The index is still building. Send again later. |
LEDGER_NOT_READY |
409 | Yes | The ledger is PENDING. Send again after activeAfter. |
RATE_LIMITED |
429 | Yes | Too many requests. Wait for Retry-After seconds. For an archive purge: one start per hour after a RETAINED result. |
INTERNAL |
500 | No | The KV service failed. No detail is given. |
STORE_MIGRATING |
503 | Yes | The store is being upgraded. Wait for Retry-After seconds. |
UNAVAILABLE |
503 | Yes. No when the outcome of a write is unknown | The KV service did not answer. With retryable false, read the data before you send the write again. |
Verify a Ledger Archive
Section titled “Verify a Ledger Archive”The archive route gives an auditor everything needed to check a ledger without trusting Quant. See Archive Download and Verify Offline for the full walkthrough. It needs the kv:ledger:read scope.
Clearing and Deleting Stores
Section titled “Clearing and Deleting Stores”From the dashboard, Clear store deletes keys in place. You can clear every key, or only the keys that start with a prefix, or only the keys older than a chosen age. Small clears finish immediately; large ones continue in the background and the key count falls over the next few minutes. The action asks you to type the store name to confirm. A store with a unique or count index refuses a clear: drop those indexes on the Indexes tab first.
A store can only be deleted once it is empty. Deleting a store that still holds keys returns an error that tells you to clear it first. Through the API, DELETE .../kv/{store_id}?force=true deletes the store together with its data. Deleting a store also deletes any copy that an upgrade keeps, such as the format 1 table that is retained for 14 days after an upgrade. A store cannot be deleted while an upgrade is running.
A store with a ledger that is PENDING or ACTIVE, or DROPPED but not yet finished by the archive job, cannot be deleted (LEDGER_PRESENT), with or without force. The message names the blocking ledgers. See Append and Read for when a store with dropped ledgers can be deleted. A clear never removes ledger entries, and a clear whose prefix starts inside a ledger collection is refused with LEDGER_IMMUTABLE. See KV Ledgers. A store delete never deletes the ledger archive. The delete answer has archive.retained and archive.earliestPurgeAt. Download the archive of every ledger before you delete the store, because after the delete there is no download route. See Deleting a Retained Archive.
Example Use Cases
Section titled “Example Use Cases”KV Stores can be used for various purposes, such as:
- Storing user preferences or settings
- Caching API responses
- Managing feature flags
- Storing configuration data
- Maintaining rate limiting counters
- Storing temporary session data, with a
ttlso it expires
