Shared Data Store
The shared data store holds small JSON payloads under keys you choose, scoped to your organization and bounded by a TTL. An external system writes a payload with a service account token; a running agent session reads it back by key.
Use it when data arrives beside a call rather than on it. The common shape: an upstream system hands you a call carrying only a short correlation id — a SIP header, a query parameter — while the payload that belongs to it is delivered separately to a webhook. Neither half is useful alone. The store is where they meet.
upstream system ──► POST /v1/shared-data (payload, under a key)
upstream system ──► the call, carrying an id (the same key)
your handler ──► context.shared_data.get(key) (payload)
If your data already fits on the call itself, use session inputData instead — it needs no second request and no key.
How it fits together#
| Side | Who | Credential |
|---|---|---|
| Write | An external system | An organization service account token with shared-data:write |
| Read | Your agent handler | Issued automatically per session — you do not manage it |
Both sides are scoped to one organization, and a write can narrow further to a single agent — by default it does not, so any session in the organization that knows the key can read it. See read scope.
The key is opaque to VoiceRun: you compose it on the write side and again in the handler, and the two match because your configuration makes them match.
Storage semantics: entries live in memory with a mandatory TTL and expire on their own. This is not a database — it is a short-lived hand-off. Do not use it as a system of record.
Writing a payload#
POST /v1/shared-data
Permissions#
Requires shared-data:write.
In practice: create an organization service account, grant it only this permission, and hand its token (vrst_…) to the external system. That token can then write into your organization's store and do nothing else with the platform — which is what makes it safe to paste into a third party's configuration. Service account tokens are bound to one organization, so no Organization-Id header is needed.
Request#
curl -X POST https://api.voicerun.com/v1/shared-data \ -H "Authorization: Bearer vrst_YOUR_SERVICE_ACCOUNT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "key": "lead:9f2c41", "data": {"first_name": "Dana", "state": "RI", "plan": "commercial"}, "ttl": 900, "agentId": "agent_abc123" }'
| Field | Required | Notes |
|---|---|---|
key | yes | Your lookup key. Letters, digits, and : . _ @ -, up to 256 characters. |
data | yes | A JSON object, up to 64KB. |
ttl | no | Lifetime in seconds. Defaults to 900 (15 minutes), clamped to 30–3600. |
agentId | no | Narrows the entry to one agent. Omit it and the entry is organization-scoped — see below. |
Response#
{ "data": { "key": "lead:9f2c41", "ttl": 900 } }
ttl is the value actually applied. A request asking for longer than the maximum is clamped, not rejected — losing a payload to an over-eager parameter would be worse than storing it briefly.
Behavior worth knowing#
Writing the same key again replaces the entry and restarts its TTL. A sender that re-fires is safe.
Top-level empty strings are dropped. Systems that interpolate tags into a request body often send "" for a value they could not populate. Storing that would make your handler read an empty string as an answer, so {"first_name": "Dana", "email": ""} is stored as {"first_name": "Dana"}.
Choose keys that carry a namespace. The store is org-scoped, so keys only have to be unique within your organization — but prefixing by source (lead:, crm:, order:) keeps two integrations from colliding as you add more.
Read scope: organization by default, agent when pinned#
agentId decides who can read the entry back:
agentId on write | Who can read it |
|---|---|
| omitted | Any session of any agent in your organization, as long as it knows the key |
| set | Only sessions of that one agent. Every other session gets the same 404 as a key that was never written |
Organization scope is the default. That default is fine when every agent in the organization is entitled to the data.
It stops being fine once you run agents for different purposes — or the payload carries personal data that only one agent needs — an unpinned entry is readable by handler code that has no business seeing it, and the key is the only thing standing in the way. Keys are not secrets: they are composed from correlation ids that travel through third-party systems, SIP headers, and logs.
Pin whenever you know the consumer.
agentIdis the difference between "anything in this organization can read it" and a point-to-point hand-off. It costs one static field in the writer's configuration, and it is the only access control this store has beyond the key itself.
Nothing about pinning changes the read call — a handler still just asks for the key, and a pinned entry it isn't entitled to simply reads as absent.
Reading from a handler#
Reads happen through the SDK. The session's credential is supplied by the runtime — your handler passes only the key.
from primfunctions.logger import logger async def handler(event: Event, context: Context): if isinstance(event, StartEvent): # The upstream system put this id on the call; it also used it to # compose the key it wrote under. correlation_id = context.get_data("transaction_id") if correlation_id: payload = await context.shared_data.get(f"lead:{correlation_id}") if payload: logger.info(f"Loaded {len(payload)} fields for {correlation_id}") for field, value in payload.items(): context.set_data(field, value)
get#
async def get(key: str, default: Any = None) -> Any
Returns the stored payload, or default when there isn't one.
Every kind of miss returns default — never written, expired, pinned to a different agent, or a transport failure. The API answers all of those with 404 deliberately, so that a response cannot reveal whether a key exists to a session that isn't entitled to read it. Your handler cannot distinguish them either, and shouldn't need to.
The same get function is importable directly if you prefer not to go through the context:
from primfunctions.shared_data import get as get_shared_data
Writing a handler that degrades#
Delivery from an external system is typically at-most-once — no retry, no delivery guarantee, and no notification when a payload is dropped. Treat "the payload never arrived" as a normal path, not an error:
- Always have a fallback. If the payload answers three of your questions, the handler must still be able to ask them.
- Retry on later turns. A payload can land after the call starts. Re-reading each turn until it succeeds costs almost nothing and turns a late delivery into a working call.
- Let data that arrived on the call win. A value that came in with the call is first-hand; a stored payload is a copy of what the upstream system believed earlier, and can be staler.
- Don't block the greeting. Any caller input cancels the running handler, which drops everything after an
await. Yield your greeting first, then read — the worst case becomes a lost read that the next turn retries, instead of a lost greeting.
A pattern that satisfies all four:
LOADED_FLAG = "shared_payload_loaded" async def load_payload(context: Context) -> dict: """Merge the stored payload into session data. Any failure is a no-op.""" if context.get_data(LOADED_FLAG): return {} correlation_id = context.get_data("transaction_id") if not correlation_id: return {} payload = await context.shared_data.get(f"lead:{correlation_id}") if not isinstance(payload, dict) or not payload: return {} # nothing yet — a later turn tries again merged = {} for field, value in payload.items(): if value in (None, ""): continue if context.get_data(field) not in (None, ""): continue # what arrived on the call wins context.set_data(field, value) merged[field] = value context.set_data(LOADED_FLAG, True) return merged async def handler(event: Event, context: Context): if isinstance(event, StartEvent): yield TextToSpeechEvent(text="Thanks for calling, how can I help?", voice="nova") await load_payload(context) # after the greeting, never before return if isinstance(event, TextEvent): await load_payload(context) # retry while it's still missing # ... run the turn, using whatever is on the session ...
Because the merge lands in context.data, the rest of your handler reads it exactly as it reads anything that arrived on the call — no branching on where a value came from.
Handling personal data#
These payloads often carry consumer personal data. Two things follow:
- Set
agentId. An unpinned entry is readable by any agent in your organization that knows the key (read scope). Pinning it to the consuming agent is the cheapest control available here. - Keep the TTL short. It is the whole retention policy — the entry expires on its own and there is nothing to clean up. Ask for the shortest window your call flow can live with.
VoiceRun never logs payload contents; writes are logged as organization, key, agent, and size only.
Error Responses#
| Status | Cause |
|---|---|
400 Bad Request | key is missing or uses unsupported characters, data is not a JSON object, data exceeds 64KB, or ttl is not a number |
403 Forbidden | The token lacks shared-data:write, or an Organization-Id header names an organization the token is not bound to |
404 Not Found | On read: no entry for that key in this organization — never written, expired, or pinned to a different agent |
