- Storage, Config & Secrets
- Key Operations
Key Operations
All key operations are performed through a bucket handle. Get a handle by calling kv.bucket("name") with the name of an existing bucket.
Key naming rules
A key may contain only letters, digits, /, -, _, = and .. It cannot be
empty, start or end with ., or contain ... Anything else — a colon, a space,
*, >, ! — is rejected by the API with 400 invalid key, so user:123 is
not a usable key. Use . as the separator (user.123); it is also the token
separator wildcard filters match on.
Writing Values
Use put to unconditionally write a value. If the key already exists, its value is overwritten. The call returns the new revision number.
import { createClient } from "@ironflow/node";
const client = createClient({ serverUrl: "http://localhost:9123" });const kv = client.kv();const bucket = kv.bucket("my-bucket");
const { revision } = await bucket.put("user.123", { name: "Alice", role: "admin" });console.log("Revision:", revision); // 1import { ironflow } from "@ironflow/browser";
ironflow.configure({ serverUrl: "http://localhost:9123" });const bucket = ironflow.kv().bucket("my-bucket");
const { revision } = await bucket.put("user.123", { name: "Alice", role: "admin" });console.log("Revision:", revision); // 1kv := client.KV()bucket := kv.Bucket("my-bucket")
revision, err := bucket.Put(ctx, "user.123", []byte(`{"name":"Alice","role":"admin"}`))if err != nil { log.Fatal(err)}fmt.Println("Revision:", revision) // 1from ironflow import IronflowClient
client = IronflowClient(server_url="http://localhost:9123")
res = client.kv_update_buckets_keys( "my-bucket", "user.123", body={"name": "Alice", "role": "admin"},)print("Revision:", res["revision"]) # 1The Python SDK has no bucket handle — bucket and key are positional arguments
on each call. body is serialized with json.dumps and stored as the value
verbatim, so pass the document you want stored, not a {"value": ...}
wrapper.
Reading Values
Use get to read a value by key. The returned entry includes the key, value, revision number, creation timestamp, and the last operation type.
Values are stored as raw bytes on the server. The SDK returns entry.value as unknown — the wire encoding is base64 for binary content and a JSON string for JSON content. The SDK does not auto-decode; if you stored JSON, decode it on read — in Node JSON.parse(Buffer.from(entry.value as string, "base64").toString("utf-8")), in the browser JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(entry.value as string), (c) => c.charCodeAt(0)))) (plain atob corrupts multi-byte UTF-8) — or store as a string explicitly.
const entry = await bucket.get("user.123");
console.log(entry.key); // "user.123"console.log(entry.value); // base64 string of stored bytes (decode + JSON.parse to recover an object)console.log(entry.revision); // 1console.log(entry.created_at); // "2026-02-22T10:00:00Z"console.log(entry.operation); // "put"const entry = await bucket.get("user.123");
console.log(entry.key); // "user.123"console.log(entry.value); // base64 string of stored bytes (decode + JSON.parse to recover an object)console.log(entry.revision); // 1console.log(entry.created_at); // "2026-02-22T10:00:00Z"console.log(entry.operation); // "put"entry, err := bucket.Get(ctx, "user.123")if err != nil { log.Fatal(err)}
fmt.Println(entry.Key) // "user.123"fmt.Println(entry.Value) // []byte(`{"name":"Alice","role":"admin"}`)fmt.Println(entry.Revision) // 1fmt.Println(entry.CreatedAt) // "2026-02-22T10:00:00Z"fmt.Println(entry.Operation) // "put"import base64, json
entry = client.kv_get_buckets_keys("my-bucket", "user.123")
print(entry["key"]) # "user.123"print(entry["revision"]) # 1print(entry["created_at"]) # "2026-02-22T10:00:00Z"print(entry["operation"]) # "put"
# value is base64-encoded stored bytes — decode it yourselfprint(json.loads(base64.b64decode(entry["value"]))) # {'name': 'Alice', 'role': 'admin'}Deleting Values
Soft Delete
Use delete to place a tombstone marker on the key. The key no longer appears in reads or listings, but its history is preserved according to the bucket’s history setting.
await bucket.delete("user.123");// Key is tombstoned — get() now throws IronflowError with code "HTTP_404"await bucket.delete("user.123");// Key is tombstoned — get() now throws IronflowError with code "HTTP_404"err := bucket.Delete(ctx, "user.123")if err != nil { log.Fatal(err)}// Key is tombstoned — Get() will return an errorclient.kv_delete_buckets_keys("my-bucket", "user.123")# Key is tombstoned — kv_get_buckets_keys() now raises IronflowError (404)Hard Delete (Purge)
Use purge to permanently remove a key and all of its revision history. This is irreversible.
await bucket.purge("user.123");// Key and all history permanently removedawait bucket.purge("user.123");// Key and all history permanently removederr := bucket.Purge(ctx, "user.123")if err != nil { log.Fatal(err)}// Key and all history permanently removedclient.kv_delete_buckets_keys("my-bucket", "user.123", purge=True)# Key and all history permanently removedAtomic Operations
Ironflow supports revision-based concurrency control for safe concurrent writes. Every write to a key increments its revision number. Atomic operations use this revision to detect conflicts and prevent lost updates.
Create (If-Not-Exists)
Use create to write a value only if the key does not already exist. If the key exists, the operation fails with HTTP 412. This is useful for safe initialization of keys that should only be set once.
try { const { revision } = await bucket.create("config.feature-flags", { darkMode: true, betaAccess: false, }); console.log("Created at revision:", revision);} catch (err) { // Key already exists (HTTP 412) console.error("Key already exists:", err.message);}try { const { revision } = await bucket.create("config.feature-flags", { darkMode: true, betaAccess: false, }); console.log("Created at revision:", revision);} catch (err) { // Key already exists (HTTP 412) console.error("Key already exists:", err.message);}revision, err := bucket.Create(ctx, "config.feature-flags", []byte(`{"darkMode":true,"betaAccess":false}`))if err != nil { // Key already exists (HTTP 412) log.Println("Key already exists:", err)} else { fmt.Println("Created at revision:", revision)}The CAS headers are declared on this route, so the generated method takes them as keyword arguments — no escape hatch needed:
from ironflow import IronflowError
try: res = client.kv_update_buckets_keys( "my-bucket", "config.feature-flags", body={"darkMode": True, "betaAccess": False}, if_none_match="*", ) print("Created at revision:", res["revision"])except IronflowError as e: if e.status_code == 412: print("Key already exists") else: raiseUpdate (Compare-and-Swap)
Use update to write a value only if the key’s current revision matches the one you provide. This prevents lost updates when multiple clients modify the same key concurrently. The typical pattern is read-modify-write:
- Read the current entry to get the latest revision.
- Modify the value locally.
- Update with the revision from step 1. If another client wrote in between, the revision will have changed and the update fails with HTTP 412.
// 1. Read the current valueconst entry = await bucket.get("user.123");const current = JSON.parse(Buffer.from(entry.value as string, "base64").toString("utf-8"));
// 2. Modify locallyconst updated = { ...current, role: "superadmin" };
// 3. Write back with the revision guardtry { const { revision } = await bucket.update("user.123", updated, entry.revision); console.log("Updated to revision:", revision);} catch (err) { // Revision mismatch (HTTP 412) — another client wrote first console.error("Conflict — retry with fresh data:", err.message);}// 1. Read the current valueconst entry = await bucket.get("user.123");const bytes = Uint8Array.from(atob(entry.value as string), (c) => c.charCodeAt(0));const current = JSON.parse(new TextDecoder().decode(bytes));
// 2. Modify locallyconst updated = { ...current, role: "superadmin" };
// 3. Write back with the revision guardtry { const { revision } = await bucket.update("user.123", updated, entry.revision); console.log("Updated to revision:", revision);} catch (err) { // Revision mismatch (HTTP 412) — another client wrote first console.error("Conflict — retry with fresh data:", err.message);}// 1. Read the current valueentry, err := bucket.Get(ctx, "user.123")if err != nil { log.Fatal(err)}
// 2. Modify locallynewValue := []byte(`{"name":"Alice","role":"superadmin"}`)
// 3. Write back with the revision guardrevision, err := bucket.Update(ctx, "user.123", newValue, entry.Revision)if err != nil { // Revision mismatch (HTTP 412) — another client wrote first log.Println("Conflict — retry with fresh data:", err)} else { fmt.Println("Updated to revision:", revision)}import base64, jsonfrom ironflow import IronflowError
# 1. Read the current valueentry = client.kv_get_buckets_keys("my-bucket", "user.123")current = json.loads(base64.b64decode(entry["value"]))
# 2. Modify locallyupdated = {**current, "role": "superadmin"}
# 3. Write back with the revision guardtry: res = client.kv_update_buckets_keys( "my-bucket", "user.123", body=updated, if_match=str(entry["revision"]), ) print("Updated to revision:", res["revision"])except IronflowError as e: if e.status_code == 412: print("Conflict — retry with fresh data") else: raiseListing Keys
Use listKeys to retrieve all keys in a bucket, optionally filtered by a wildcard pattern.
Wildcard filters follow NATS subject-matching rules, where . is the token
separator. A wildcard like user.* matches user.123 but not user_123 — the
latter is a single token with no separator. Use . as the separator in your key
names if you want prefix-style filtering.
// List all keysconst allKeys = await bucket.listKeys();console.log(allKeys); // ["user.123", "user.456", "config.feature-flags"]
// Filter with a wildcard pattern (keys must use "." separators for this to match)const userKeys = await bucket.listKeys("user.*");console.log(userKeys); // ["user.123", "user.456"]// List all keysconst allKeys = await bucket.listKeys();console.log(allKeys); // ["user.123", "user.456", "config.feature-flags"]
// Filter with a wildcard pattern (keys must use "." separators for this to match)const userKeys = await bucket.listKeys("user.*");console.log(userKeys); // ["user.123", "user.456"]// List all keysallKeys, err := bucket.ListKeys(ctx, "")if err != nil { log.Fatal(err)}fmt.Println(allKeys) // ["user.123", "user.456", "config.feature-flags"]
// Filter with a wildcard pattern (keys must use "." separators for this to match)userKeys, err := bucket.ListKeys(ctx, "user.*")if err != nil { log.Fatal(err)}fmt.Println(userKeys) // ["user.123", "user.456"]# List all keysall_keys = client.kv_list_buckets_keys("my-bucket")print(all_keys["keys"]) # ['user.123', 'user.456', 'config.feature-flags']
# Filter with a wildcard pattern (keys must use "." separators for this to match)user_keys = client.kv_list_buckets_keys("my-bucket", filter="user.*")print(user_keys["keys"]) # ['user.123', 'user.456']Wildcard patterns follow NATS subject-matching rules. . is the token separator:
| Pattern | Matches | Does not match |
|---|---|---|
* | Any single-token key (user, flag) | Multi-token keys (user.123) |
user.* | user.123, user.456 | user.123.name, user_123 |
> | One or more tokens (matches everything) | — |
user.> | user.123, user.123.name, user.123.name.first | config.flags, user_123 |
Watching for Changes
Watch provides real-time notifications when keys are created, updated, or deleted. Changes are delivered over WebSocket and are available in the Node, browser, and Go SDKs.
import { ironflow } from "@ironflow/browser";
const bucket = ironflow.kv().bucket("my-bucket");
// Watch all keys matching "user.*"const watcher = bucket.watch({ onUpdate: (event) => { console.log(`${event.key} ${event.operation}: rev ${event.revision}`); console.log("New value:", event.value); console.log("Bucket:", event.bucket); }, onError: (err) => console.error(err), onClose: () => console.log("Watch ended"),}, { key: "user.*" });
// Later: stop watchingwatcher.stop();Each KVWatchEvent contains:
| Field | Type | Description |
|---|---|---|
type | "kv_update" | Always "kv_update" |
key | string | The key that changed |
value | string | The new value as base64-encoded bytes (empty for deletes) |
revision | number | New revision number |
operation | "put" | "delete" | What happened |
bucket | string | Bucket name |
event.value is the raw stored bytes encoded as base64 — not a parsed JSON object. If you stored JSON, decode it before use — in Node JSON.parse(Buffer.from(event.value as string, "base64").toString("utf-8")), in the browser via atob + TextDecoder (plain atob corrupts multi-byte UTF-8).
import { createClient } from "@ironflow/node";
const bucket = createClient({ serverUrl: "http://localhost:9123" }) .kv() .bucket("my-bucket");
const watcher = bucket.watch({ onUpdate: (event) => { console.log(`${event.key} ${event.operation}: rev ${event.revision}`); }, onError: (err) => console.error(err), onClose: () => console.log("Watch ended"),}, { key: "user.*" });
// Later: stop watchingwatcher.stop();The event shape is the same KVWatchEvent documented under the browser tab —
event.value is base64-encoded stored bytes, so decode it before use.
// Watch for changes on a keywatcher, err := bucket.Watch(ctx, ironflow.KVWatchCallbacks{ OnUpdate: func(event ironflow.KVWatchEvent) { fmt.Printf("Key %s changed: %s\n", event.Key, string(event.Value)) },}, ironflow.WithWatchKey("user.123"))// Stop watching: watcher.Stop()Not available. The KV watch endpoint is a long-lived WebSocket stream, so
cmd/sdk-gen does not generate a method for it and request() cannot reach
it either. Use the browser or Go SDK to watch a bucket, or poll
kv_list_buckets_keys / kv_get_buckets_keys.
Error Handling
KV operations return standard HTTP error codes for common failure scenarios:
| Error | HTTP Status | When |
|---|---|---|
| Key not found | 404 | Get or delete on a missing key |
| Key already exists | 412 | Create when the key already exists |
| Revision mismatch | 412 | Update with a stale revision |
| Bucket not found | 404 | Operations on a bucket that does not exist |
import { IronflowError, UnauthenticatedError, UnauthorizedError } from "@ironflow/node";
try { await bucket.create("user.123", { name: "Alice" });} catch (err) { if (err instanceof UnauthenticatedError) { console.error("Invalid or missing API key"); } else if (err instanceof UnauthorizedError) { console.error("Insufficient permissions for this operation"); } else if (err instanceof IronflowError) { // IronflowError includes the HTTP status in the message if (err.message.includes("412")) { console.error("Key already exists — use put() to overwrite or update() with a revision"); } else if (err.message.includes("404")) { console.error("Bucket not found — create it first"); } else { console.error("Ironflow error:", err.message); } } else { console.error("Unexpected error:", err); }}Use the global onError handler on createClient() to observe all KV errors centrally without wrapping each call in try/catch.
import { IronflowError } from "@ironflow/core";
try { await bucket.create("user.123", { name: "Alice" });} catch (err) { if (err instanceof IronflowError) { if (err.code === "HTTP_412") { console.error("Key already exists — use put() to overwrite or update() with a revision"); } else if (err.code === "HTTP_404") { console.error("Bucket not found — create it first"); } else { console.error("Ironflow error:", err.message, "Code:", err.code); } } else { console.error("Unexpected error:", err); }}_, err := bucket.Create(ctx, "user.123", []byte(`{"name":"Alice"}`))if err != nil { var ironErr *ironflow.IronflowError if errors.As(err, &ironErr) { switch ironErr.Code { case "HTTP_412": fmt.Println("Key already exists — use Put() to overwrite or Update() with a revision") case "HTTP_404": fmt.Println("Bucket not found — create it first") default: fmt.Printf("Ironflow error: %s (code: %s)\n", ironErr.Message, ironErr.Code) } } else { fmt.Println("Unexpected error:", err) }}from ironflow import IronflowError
try: client.kv_update_buckets_keys( "my-bucket", "user.123", body={"name": "Alice"}, if_none_match="*", )except IronflowError as e: if e.status_code == 412: print("Key already exists — put unconditionally, or use If-Match") elif e.status_code == 404: print("Bucket not found — create it first") else: print(f"Ironflow error: {e} (status: {e.status_code}, code: {e.code})")One exception type covers everything, including transport failures (DNS,
refused connection, timeout, TLS) — you never catch urllib errors directly.
e.retryable tells you whether a retry is worth attempting.