Skip to main content
Backend scripts have access to a durable key-value store via the storage global. Values are persisted to the database, so they survive restarts and deploys and are readable from every runtime server immediately — there’s no per-server cache to warm and no worker to run. Every key is automatically scoped to the current brand — scripts can never read or write another brand’s data. Values are JSON-serialised, so you can store strings, numbers, booleans, arrays, and plain objects.

Storage vs. Cache

Both are brand-scoped key-value APIs, but they solve different problems:
Reach for cache for high-frequency, disposable data. Reach for storage when losing the value would break something — OAuth refresh tokens, sync cursors, “have I already processed this?” markers.

Options

Every method takes an optional trailing opts object:

storage.get(key, opts?)

Retrieve a value.
Returns: The stored value, or null if the key is missing or expired.

storage.set(key, value, opts?)

Store a value, overwriting any existing one.
Returns: true on success, false if the brand key limit was reached.

storage.del(key, opts?)

Delete a key.
Returns: true.

storage.has(key, opts?)

Check whether a live (non-expired) key exists.
Returns: true if the key exists and has not expired, false otherwise.

storage.setIfAbsent(key, value, opts?)

Write a value only if the key is absent or expired. This is atomic (row-locked), which makes it a reliable short-lived mutex across all runtime servers.
Returns: true if the value was written, false if a live value already existed.

storage.compareAndSet(key, expected, next, opts?)

Atomically set next only if the current stored value equals expected (compared by value). A missing or expired key is treated as null. Use this for lock-free optimistic updates where a stale writer must not clobber newer data.
Returns: true if the swap happened, false if the current value didn’t match expected.

storage.increment(key, amount?, opts?)

Atomically add to a numeric value, creating it (starting at 0) if absent.
Returns: The new numeric value after incrementing.

storage.mutate(key, fn, opts?)

Atomically read a value, transform it, and write the result — the safe way to do read-modify-write without losing a concurrent update. fn receives the current value (undefined if the key is absent) and returns the value to store. Returning undefined aborts the write and leaves the stored value untouched.
Under the hood this is an optimistic loop: read the current value, run fn, then commit with a row-locked compareAndSet. If another execution wrote first, it re-reads and runs fn again (a few times) until it commits cleanly. Returns: The newly stored value (or the unchanged value if fn returned undefined). Throws if it still can’t commit after several retries (extreme contention).
Because fn can run more than once (on retry), it must be a pure function of its input — derive next only from current. Do not put one-time side effects inside fn (spending a single-use token, sending an email, charging a card). For exactly-once side effects, use the claim pattern shown below.

Example: rotating OAuth refresh tokens (exactly-once)

The motivating case. Providers like AWeber hand out a short-lived access token plus a single-use refresh token that rotates on every refresh — using the old refresh token invalidates it. Two guarantees are needed, and storage gives you both:
  1. Durability — the newly rotated refresh token must be persisted for the next run (backend scripts are stateless; there’s nowhere else to keep it).
  2. Exactly-once refresh — if two requests refresh at the same instant, they’d both spend the same single-use refresh token and one call would fail. Only one request may perform the refresh.
The fool-proof approach is refresh-ahead + a claim:
  • Refresh before the access token actually expires (while it’s still valid), so a request that doesn’t win the claim can safely keep using the current token — no waiting.
  • Use setIfAbsent as a non-blocking claim: exactly one request wins and refreshes; the others fall through. Because a claim can never outlive its TTL, a crashed run can’t wedge the integration.
Why not refresh inside a locked callback? Two reasons: holding a database lock across an external network call is an anti-pattern, and the script sandbox can only wait on one async operation at a time — a network call nested inside a locked callback isn’t supported and would fail. Keep the network call in your main script flow, guarded by a claim.
Limits per brand:
  • 10,000 keys maximum per brand
  • 64 KB maximum value size
  • No expiry by default (max TTL when set: 1 year)
  • 50 storage operations per script execution (a mutate retry spends a get + a compareAndSet)
Keys must match the pattern [a-zA-Z0-9_.:-] and be at most 191 characters. Namespaces follow the same pattern up to 64 characters. Values that don’t fit, or keys/namespaces that don’t match, are rejected.