KV Store: Batch Operations
Section titled “KV Store: Batch Operations”The batch endpoints read, write, and delete up to 100 keys in a single request. Use them to amortize round-trip costs, apply atomic updates across many keys, or run per-item compare-and-swap conditions. The write endpoints (/batch/set and /batch/delete) accept an Idempotency-Key header that replays the stored response for a repeat of the same key (24 hours).
Batch get multiple keys
Section titled “Batch get multiple keys”Retrieve values for multiple keys in a single request (max 100 keys). Each requested key appears in the response: keys that exist return their parsed value (or base64 if not JSON) and ETag; missing or expired keys return null.
POST /api/v1/sqlite/kv/batch/get
Section titled “POST /api/v1/sqlite/kv/batch/get”Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
db | query | string | Yes | Database file path |
table | query | string | No | Custom table name. Default: kv_store |
timeout | query | integer | No | Server-side deadline in whole seconds, clamped to [1, 300]. Default: 30 |
Request body
Section titled “Request body”| Field | Type | Required | Description |
|---|---|---|---|
keys | array of strings | Yes | Keys to retrieve. 1-100 entries; no entry may be empty or whitespace-only. |
Request example
Section titled “Request example”curl -X POST 'https://{projectId}-{containerId}-sqlite-1.{server}.containers.hoody.com/api/v1/sqlite/kv/batch/get?db=app.db' \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json' \ -d '{"keys": ["user:1", "user:2", "user:3"]}'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-sqlite-1.{server}.containers.hoody.com', token: process.env.HOODY_TOKEN });
await client.sqlite.kv.getMany( { keys: ['user:1', 'user:2', 'user:3'] }, { db: 'app.db' });Response
Section titled “Response”Values retrieved successfully.
{ "success": true, "results": { "user:1": { "content_type": "application/json", "etag": "a1b2c3d4e5f6", "value": {"name": "Alice", "age": 30} }, "user:2": { "content_type": "text/plain", "etag": "f6e5d4c3b2a1", "value_base64": "SGVsbG8gV29ybGQ=" }, "user:3": null }}| Field | Type | Required | Description |
|---|---|---|---|
results | object | Yes | One entry per requested key, keyed by the key. Each value is a KVBatchGetResult object or null when the key does not exist or has expired. |
success | boolean | Yes | Always true on a 200. |
Invalid request parameters.
{ "code": "INVALID_PARAMETERS", "error": "keys must be a non-empty array"}| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Machine-readable error code. |
error | string | Yes | Human-readable description of the failure. Not a contract: match on code. |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
INVALID_PARAMETERS | Invalid request parameters | A parameter or the request body is missing, malformed or out of range. This is the default code for a 400 that has no more specific one. | Check parameter types and values against the API specification; the error text names the problem |
INVALID_DB_PATH | Invalid database path | The db value is empty, relative, outside /hoody/databases (unless the server allows any absolute path), contains ? or #, escapes via a symlink, or names something that is not a regular file | Provide an absolute path under /hoody/databases, or a bare name / ./name shorthand (resolved to /hoody/databases/*.db) |
DATABASE_NOT_FOUND | Database file does not exist | The database file does not exist and this request does not create it | Check the path, create the database first, or pass create_db_if_missing=true where the operation supports it |
INVALID_SQLITE_HEADER | Not a valid SQLite database | The file exists but is empty or does not start with the SQLite header | Point db at a SQLite database file |
PATH_IS_DIRECTORY | Path is a directory | Expected a database file but the path is a directory. Only reading, writing, deleting and checking a single key and listing keys (without at_timestamp) accept a directory, given as an absolute path (directory mode) | Use a .db file path |
Internal server error.
{ "code": "INTERNAL_ERROR", "error": "An unexpected error occurred"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INTERNAL_ERROR | Internal server error | An unexpected server-side failure. Default code for a 500 that has no more specific one; details are in the server log | Retry later; report it if it persists |
DATABASE_ERROR | Database fault | SQLite failed for a reason that is not the request: I/O error, corruption, disk full, a file that cannot be opened. The message is sanitised; details are in the server log | Check the server log, disk space and the database file; the database may need repair |
Database busy, request deadline exceeded, or server shutting down.
{ "code": "DATABASE_BUSY", "error": "Database is locked by another writer"}The response includes a Retry-After header indicating the number of seconds to wait before retrying (always 1).
| Error Code | Title | Description | Resolution |
|---|---|---|---|
SERVICE_UNAVAILABLE | Service unavailable | The service could not complete the request right now. Default code for a 503 that has no more specific one | Retry after a short delay |
REQUEST_TIMEOUT | Request deadline exceeded | The request did not finish before its deadline (the timeout parameter, default 30 s); nothing was committed | Retry, or raise timeout (max 300) |
DATABASE_BUSY | Database busy | The database was locked by another writer or in use for longer than the request could wait; nothing was changed | Retry after a short delay |
Batch set multiple keys
Section titled “Batch set multiple keys”Store values for multiple keys in a single transaction (max 100 items). Each item can carry an if_match or if_none_match condition for compare-and-swap semantics. A 412 response rolls back the entire batch and lists every key whose condition failed.
POST /api/v1/sqlite/kv/batch/set
Section titled “POST /api/v1/sqlite/kv/batch/set”Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
db | query | string | Yes | Database file path |
table | query | string | No | Custom table name. Default: kv_store |
history | query | boolean | No | Store this write’s value in history; false records only that the write happened, not what it wrote. Default: true |
Idempotency-Key | header | string | No | Replays the stored response for a repeat of the same key (24 h). |
create_db_if_missing | query | boolean | No | Create database file if it is missing. The legacy alias auto_create is still accepted and means the same thing; the two must agree when both are sent. Default: false |
timeout | query | integer | No | Server-side deadline in whole seconds, clamped to [1, 300]. Default: 30 |
Request body
Section titled “Request body”| Field | Type | Required | Description |
|---|---|---|---|
items | array of objects | Yes | Items to write in a single transaction. 1-100 entries. Each item has the fields below. |
Each item in items has the following fields:
| Field | Type | Required | Description |
|---|---|---|---|
key | string | Yes | Key to write. Must not be empty or whitespace-only. |
value | string | No | Value to store. |
content_type | string | No | Content type recorded alongside the value. Defaults to application/octet-stream. |
if_match | string | No | Write this item only if the key exists and its current ETag is one of these (a comma-separated list of strong entity-tags, as in the If-Match header), or * for “the key exists”. Checked for every item before any is written; one failure fails the whole batch with 412. |
if_none_match | string | No | * to write this item only if the key does not exist (or has expired). Any other value is rejected with 400. Checked for every item before any is written; one failure fails the whole batch with 412. |
ttl | integer | No | Lifetime in seconds from now. 0 or omitted stores without expiry; negative is rejected. |
Request example
Section titled “Request example”curl -X POST 'https://{projectId}-{containerId}-sqlite-1.{server}.containers.hoody.com/api/v1/sqlite/kv/batch/set?db=app.db' \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json' \ -d '{"items": [{"key": "user:1", "value": "{\"name\":\"Alice\",\"age\":30}", "content_type": "application/json"}, {"key": "user:2", "value": "SGVsbG8gV29ybGQ=", "content_type": "text/plain"}]}'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-sqlite-1.{server}.containers.hoody.com', token: process.env.HOODY_TOKEN });
await client.sqlite.kv.setMany( { items: [ { key: 'user:1', value: '{"name":"Alice","age":30}', content_type: 'application/json' }, { key: 'user:2', value: 'SGVsbG8gV29ybGQ=', content_type: 'text/plain' } ] }, { db: 'app.db' });Response
Section titled “Response”Values stored successfully.
The response includes an Idempotent-Replayed header that is true when this is the stored response of an earlier request with the same Idempotency-Key.
{ "success": true, "count": 2, "items": [ {"key": "user:1", "etag": "a1b2c3d4e5f6"}, {"key": "user:2", "etag": "f6e5d4c3b2a1"} ]}| Field | Type | Required | Description |
|---|---|---|---|
count | integer | Yes | Number of items stored. |
items | array of objects | Yes | One entry per item, in request order, with the key’s ETag after the batch (for a key written twice, both entries carry the final ETag). |
items[].key | string | Yes | The key. |
items[].etag | string | Yes | The key’s ETag after the batch. |
success | boolean | Yes | Always true on a 200. |
Invalid request parameters, or an If-Match / If-None-Match header (not evaluated by this operation).
{ "code": "INVALID_PARAMETERS", "error": "if_none_match must be \"*\" if provided"}| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Machine-readable error code. |
error | string | Yes | Human-readable description of the failure. Not a contract: match on code. |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
INVALID_PARAMETERS | Invalid request parameters | A parameter or the request body is missing, malformed or out of range. This is the default code for a 400 that has no more specific one. | Check parameter types and values against the API specification; the error text names the problem |
INVALID_DB_PATH | Invalid database path | The db value is empty, relative, outside /hoody/databases (unless the server allows any absolute path), contains ? or #, escapes via a symlink, or names something that is not a regular file | Provide an absolute path under /hoody/databases, or a bare name / ./name shorthand (resolved to /hoody/databases/*.db) |
DATABASE_NOT_FOUND | Database file does not exist | The database file does not exist and this request does not create it | Check the path, create the database first, or pass create_db_if_missing=true where the operation supports it |
INVALID_SQLITE_HEADER | Not a valid SQLite database | The file exists but is empty or does not start with the SQLite header | Point db at a SQLite database file |
PATH_IS_DIRECTORY | Path is a directory | Expected a database file but the path is a directory. Only reading, writing, deleting and checking a single key and listing keys (without at_timestamp) accept a directory, given as an absolute path (directory mode) | Use a .db file path |
Change capture is not available for this table. The server cannot capture changes for tables with triggers that reference the table or the server’s change-capture tables, self-referential foreign keys with CASCADE/SET NULL/SET DEFAULT actions, expression-only UNIQUE indexes, or rowid tables that hide the rowid columns. Nothing was written.
{ "code": "CONFLICT", "error": "Change capture is not available for this table"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
CONFLICT | Conflict | The request conflicts with the current state. Default code for a 409 that has no more specific one | Read the current state and retry with a request that matches it |
Precondition failed: an item’s if_match or if_none_match did not hold. The failed array lists every key whose condition failed, with the current ETag (or null when the key does not exist or has expired). Nothing was written.
{ "code": "PRECONDITION_FAILED", "error": "One or more items failed their precondition", "failed": [ {"key": "user:1", "current_etag": "abc123def456"}, {"key": "user:2", "current_etag": null} ]}| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | PRECONDITION_FAILED. |
error | string | Yes | Human-readable description of the failure. Not a contract: match on code. |
failed | array of objects | Yes | Every item whose condition failed. |
failed[].key | string | Yes | The key whose condition failed. |
failed[].current_etag | string | Yes | Its current ETag; null when the key does not exist or has expired. |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
PRECONDITION_FAILED | Precondition failed | The compare-and-swap value (if_match) did not match the stored value, or an If-Match / If-None-Match condition (or a batch item’s if_match / if_none_match) did not hold; current_etag (failed for a batch) carries the current ETag. Nothing was written | Read the current value and ETag and retry with them |
When an Idempotency-Key is provided and the response would be larger than 1 MiB, it cannot be stored for replay. Nothing was written.
{ "code": "IDEMPOTENT_RESPONSE_TOO_LARGE", "error": "Response is larger than 1 MiB and cannot be stored"}| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Machine-readable error code. |
error | string | Yes | Human-readable description of the failure. Not a contract: match on code. |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
PAYLOAD_TOO_LARGE | Request too large | The request exceeds a size or item-count limit | Split the request |
IDEMPOTENT_RESPONSE_TOO_LARGE | Response too large to store | With Idempotency-Key, the response would be larger than 1 MiB and could not be stored for replay; nothing was written | Send the request without Idempotency-Key, or narrow it |
The Idempotency-Key has already been used, within its lifetime, for a different request (method, key, query, conditions, content type or body differ).
{ "code": "IDEMPOTENCY_KEY_MISMATCH", "error": "Idempotency-Key was already used for a different request"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
IDEMPOTENCY_KEY_MISMATCH | Idempotency-Key reused | The Idempotency-Key was already used, within its lifetime, for a different request (method, key, query, conditions, content type or body differ); nothing was written | Use a new Idempotency-Key for a different request |
Internal server error.
{ "code": "INTERNAL_ERROR", "error": "An unexpected error occurred"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INTERNAL_ERROR | Internal server error | An unexpected server-side failure. Default code for a 500 that has no more specific one; details are in the server log | Retry later; report it if it persists |
DATABASE_ERROR | Database fault | SQLite failed for a reason that is not the request: I/O error, corruption, disk full, a file that cannot be opened. The message is sanitised; details are in the server log | Check the server log, disk space and the database file; the database may need repair |
Database busy, request deadline exceeded, or server shutting down.
{ "code": "DATABASE_BUSY", "error": "Database is locked by another writer"}The response includes a Retry-After header indicating the number of seconds to wait before retrying (always 1).
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Machine-readable error code. |
error | string | Yes | Human-readable description of the failure. Not a contract: match on code. |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
SERVICE_UNAVAILABLE | Service unavailable | The service could not complete the request right now. Default code for a 503 that has no more specific one | Retry after a short delay |
REQUEST_TIMEOUT | Request deadline exceeded | The request did not finish before its deadline (the timeout parameter, default 30 s); nothing was committed | Retry, or raise timeout (max 300) |
DATABASE_BUSY | Database busy | The database was locked by another writer or in use for longer than the request could wait; nothing was changed | Retry after a short delay |
Batch delete multiple keys
Section titled “Batch delete multiple keys”Delete multiple keys in a single transaction (max 100 keys). Each item can carry an if_match condition for compare-and-swap semantics. A 412 response rolls back the entire batch.
POST /api/v1/sqlite/kv/batch/delete
Section titled “POST /api/v1/sqlite/kv/batch/delete”Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
db | query | string | Yes | Database file path |
table | query | string | No | Custom table name. Default: kv_store |
history | query | boolean | No | Store this write’s value in history; false records only that the write happened, not what it wrote. Default: true |
Idempotency-Key | header | string | No | Replays the stored response for a repeat of the same key (24 h). |
create_db_if_missing | query | boolean | No | Create database file if it is missing. The legacy alias auto_create is still accepted and means the same thing; the two must agree when both are sent. Default: false |
timeout | query | integer | No | Server-side deadline in whole seconds, clamped to [1, 300]. Default: 30 |
Request body
Section titled “Request body”Send one of items or keys, not both.
| Field | Type | Required | Description |
|---|---|---|---|
items | array of objects | No | Keys to delete, each with an optional if_match condition. 1-100 entries. |
keys | array of strings | No | Keys to delete. 1-100 entries; no entry may be empty or whitespace-only. |
Each item in items has the following fields:
| Field | Type | Required | Description |
|---|---|---|---|
key | string | Yes | Key to delete. Must not be empty or whitespace-only. |
if_match | string | No | Delete this key only if its current ETag is one of these (a comma-separated list of strong entity-tags, as in the If-Match header), or * for “the key exists”. Checked for every item before any key is deleted; one failure fails the whole batch with 412. |
Request example
Section titled “Request example”curl -X POST 'https://{projectId}-{containerId}-sqlite-1.{server}.containers.hoody.com/api/v1/sqlite/kv/batch/delete?db=app.db' \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json' \ -d '{"keys": ["user:1", "user:2"]}'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-sqlite-1.{server}.containers.hoody.com', token: process.env.HOODY_TOKEN });
await client.sqlite.kv.deleteMany( { keys: ['user:1', 'user:2'] }, { db: 'app.db' });Response
Section titled “Response”Keys deleted successfully.
The response includes an Idempotent-Replayed header that is true when this is the stored response of an earlier request with the same Idempotency-Key.
{ "success": true, "deleted": 2}| Field | Type | Required | Description |
|---|---|---|---|
deleted | integer | Yes | Number of keys that existed and were deleted. |
success | boolean | Yes | Always true on a 200. |
Invalid request parameters, or an If-Match / If-None-Match header (not evaluated by this operation).
{ "code": "INVALID_PARAMETERS", "error": "Send keys or items, not both"}| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Machine-readable error code. |
error | string | Yes | Human-readable description of the failure. Not a contract: match on code. |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
INVALID_PARAMETERS | Invalid request parameters | A parameter or the request body is missing, malformed or out of range. This is the default code for a 400 that has no more specific one. | Check parameter types and values against the API specification; the error text names the problem |
INVALID_DB_PATH | Invalid database path | The db value is empty, relative, outside /hoody/databases (unless the server allows any absolute path), contains ? or #, escapes via a symlink, or names something that is not a regular file | Provide an absolute path under /hoody/databases, or a bare name / ./name shorthand (resolved to /hoody/databases/*.db) |
DATABASE_NOT_FOUND | Database file does not exist | The database file does not exist and this request does not create it | Check the path, create the database first, or pass create_db_if_missing=true where the operation supports it |
INVALID_SQLITE_HEADER | Not a valid SQLite database | The file exists but is empty or does not start with the SQLite header | Point db at a SQLite database file |
PATH_IS_DIRECTORY | Path is a directory | Expected a database file but the path is a directory. Only reading, writing, deleting and checking a single key and listing keys (without at_timestamp) accept a directory, given as an absolute path (directory mode) | Use a .db file path |
Change capture is not available for this table. The server cannot capture changes for tables with triggers that reference the table or the server’s change-capture tables, self-referential foreign keys with CASCADE/SET NULL/SET DEFAULT actions, expression-only UNIQUE indexes, or rowid tables that hide the rowid columns. Nothing was written.
{ "code": "CONFLICT", "error": "Change capture is not available for this table"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
CONFLICT | Conflict | The request conflicts with the current state. Default code for a 409 that has no more specific one | Read the current state and retry with a request that matches it |
Precondition failed: an item’s if_match did not hold. The failed array lists every key whose condition failed, with the current ETag (or null when the key does not exist or has expired). Nothing was written.
{ "code": "PRECONDITION_FAILED", "error": "One or more items failed their precondition", "failed": [ {"key": "user:1", "current_etag": "abc123def456"}, {"key": "user:2", "current_etag": null} ]}| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | PRECONDITION_FAILED. |
error | string | Yes | Human-readable description of the failure. Not a contract: match on code. |
failed | array of objects | Yes | Every item whose condition failed. |
failed[].key | string | Yes | The key whose condition failed. |
failed[].current_etag | string | Yes | Its current ETag; null when the key does not exist or has expired. |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
PRECONDITION_FAILED | Precondition failed | The compare-and-swap value (if_match) did not match the stored value, or an If-Match / If-None-Match condition (or a batch item’s if_match / if_none_match) did not hold; current_etag (failed for a batch) carries the current ETag. Nothing was written | Read the current value and ETag and retry with them |
When an Idempotency-Key is provided and the response would be larger than 1 MiB, it cannot be stored for replay. Nothing was written.
{ "code": "IDEMPOTENT_RESPONSE_TOO_LARGE", "error": "Response is larger than 1 MiB and cannot be stored"}| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Machine-readable error code. |
error | string | Yes | Human-readable description of the failure. Not a contract: match on code. |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
PAYLOAD_TOO_LARGE | Request too large | The request exceeds a size or item-count limit | Split the request |
IDEMPOTENT_RESPONSE_TOO_LARGE | Response too large to store | With Idempotency-Key, the response would be larger than 1 MiB and could not be stored for replay; nothing was written | Send the request without Idempotency-Key, or narrow it |
The Idempotency-Key has already been used, within its lifetime, for a different request (method, key, query, conditions, content type or body differ).
{ "code": "IDEMPOTENCY_KEY_MISMATCH", "error": "Idempotency-Key was already used for a different request"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
IDEMPOTENCY_KEY_MISMATCH | Idempotency-Key reused | The Idempotency-Key was already used, within its lifetime, for a different request (method, key, query, conditions, content type or body differ); nothing was written | Use a new Idempotency-Key for a different request |
Internal server error.
{ "code": "INTERNAL_ERROR", "error": "An unexpected error occurred"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INTERNAL_ERROR | Internal server error | An unexpected server-side failure. Default code for a 500 that has no more specific one; details are in the server log | Retry later; report it if it persists |
DATABASE_ERROR | Database fault | SQLite failed for a reason that is not the request: I/O error, corruption, disk full, a file that cannot be opened. The message is sanitised; details are in the server log | Check the server log, disk space and the database file; the database may need repair |
Database busy, request deadline exceeded, or server shutting down.
{ "code": "DATABASE_BUSY", "error": "Database is locked by another writer"}The response includes a Retry-After header indicating the number of seconds to wait before retrying (always 1).
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Machine-readable error code. |
error | string | Yes | Human-readable description of the failure. Not a contract: match on code. |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
SERVICE_UNAVAILABLE | Service unavailable | The service could not complete the request right now. Default code for a 503 that has no more specific one | Retry after a short delay |
REQUEST_TIMEOUT | Request deadline exceeded | The request did not finish before its deadline (the timeout parameter, default 30 s); nothing was committed | Retry, or raise timeout (max 300) |
DATABASE_BUSY | Database busy | The database was locked by another writer or in use for longer than the request could wait; nothing was changed | Retry after a short delay |