Durable messages

Send named messages to a running durable function from a webhook, a backend, a browser, or another function, and read or subscribe to the messages it publishes.

A durable function can wait for something outside it to happen, and tell the outside world how it is getting on. ctx.waitForMessage suspends the execution until a message with that name arrives; ctx.publish records a message that applications list or receive live. A webhook receiver, a backend, a browser, or another function sends the message, with the credential it already holds.

const { durable } = require('@volcano.dev/sdk/durable');

exports.handler = durable(async (input, ctx) => {
  await ctx.publish('progress', { stage: 'waiting-for-upload' });

  const upload = await ctx.waitForMessage('uploaded', { timeout: '1d' });
  if (upload === null) {
    return { status: 'abandoned' };
  }

  const report = await ctx.step('render', () => render(upload.data.url));
  await ctx.publish('progress', { stage: 'done', report_url: report.url });
  return { status: 'done', report_url: report.url };
});

Send the message from wherever the upload finishes:

curl -X POST "https://api.volcano.dev/durable-functions/report-builder/executions/$EXECUTION_ID/messages" \
  -H "Authorization: Bearer $SERVICE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: upload-7f3a" \
  -d '{"name": "uploaded", "data": {"url": "https://files.example.com/7f3a.csv"}}'
{
  "id": "4c8e2d1a-6b7f-4e3a-9d2c-1f5e8a7b6c4d",
  "execution_id": "9a8b7c6d-5e4f-4a3b-8c1d-2e3f4a5b6c7d",
  "direction": "in",
  "seq": 2,
  "name": "uploaded",
  "data": { "url": "https://files.example.com/7f3a.csv" },
  "sender": { "type": "service_key", "user_id": null },
  "status": "delivered",
  "delivered_at": "2026-10-12T09:14:03Z",
  "sent_at": "2026-10-12T09:14:03Z"
}

The execution resumes from the line after waitForMessage, holding the message. It runs no code and costs no compute while it waits.

The same handler in Python:

from volcano_sdk.durable_authoring import durable


@durable
def handler(event, ctx):
    ctx.publish("progress", {"stage": "waiting-for-upload"})

    upload = ctx.wait_for_message("uploaded", timeout="1d")
    if upload is None:
        return {"status": "abandoned"}

    report = ctx.step("render", lambda scope: render(upload.data["url"]))
    ctx.publish("progress", {"stage": "done", "report_url": report["url"]})
    return {"status": "done", "report_url": report["url"]}

waitForMessage and publish need @volcano.dev/sdk 2.1 or later in JavaScript, or volcano-sdk-python 0.15 or later in Python. Ruby has no durable authoring API, so a Ruby application can send, list, and subscribe but not wait or publish.

Messages or approvals

Both suspend an execution until something outside it answers. They differ in who may answer.

UseWhen
ctx.waitForApprovalA person has to decide. Only a signed-in person can approve or deny; a project access token, a service key, or the workflow itself cannot.
ctx.waitForMessageAnything else: a webhook, a payment provider, a user clicking a button in your app, another function finishing. Whoever the function's visibility admits can send.

A message never decides an approval, whatever it carries, so moving a step from an approval to a message changes who may answer it. Keep an approval wherever an answer from code would defeat the point.

Wait for a message

const message = await ctx.waitForMessage('payment-confirmed', { timeout: '30m' });
ArgumentDescription
nameRequired. The message name to wait for, and the operation's name in the execution's history. Up to 237 printable ASCII characters.
timeoutHow long to wait, in the format ctx.wait takes ('30m', '3d', a number of seconds), from 1 second to 366 days. Without it, the wait lasts as long as the execution.

It resolves with the message, or with null (None in Python) when the timeout passes first. A timeout is an outcome to branch on, not an error.

JavaScriptPythonValue
ididThe message's id
namenameThe name it was sent with
datadataThe JSON value sent, or null
sender.typesender.typeservice_key, user, anon, or project
sender.userIdsender.user_idThe user who sent it, when sender.type is user
sentAtsent_atISO 8601 timestamp

Each wait takes one message. Messages that arrive before anything waits for their name are kept, and each wait takes the oldest one with its name, so a burst of sends is consumed first in, first out by a loop:

const items = [];
for (;;) {
  const message = await ctx.waitForMessage('item', { timeout: '10m' });
  if (message === null || message.data.last) break;
  items.push(message.data);
}

The wait is checkpointed like any other operation. A resumed execution replays the message it received instead of waiting again. Waits inside ctx.parallel, ctx.map, or ctx.child work the same way, so one execution can wait for several names at once.

waitForMessage throws, failing the execution unless you catch it, when its arguments are invalid or Volcano refuses the wait — for example when the execution already has 100 waits open. Brief refusals are retried for about 30 seconds before they surface. See Errors.

Publish a message

const published = await ctx.publish('progress', { pct: 40 });
console.log(published.seq);
published = ctx.publish("progress", {"pct": 40})
print(published.seq)

publish stores the message, adds it to the execution's message list, and delivers it to every client subscribed to the execution. It resolves with the message's id and seq. data is any JSON value up to 64 KiB, or omitted for null.

Publishing is checkpointed, so a replay never publishes twice, and a publish that runs again after a crash returns the message it already published and sends it to subscribers again, in case the crash came before they got it.

The first publish in an execution, and in each ctx.child, ctx.parallel branch, or ctx.map item that publishes, opens a messages session first: Volcano hands the execution a credential to publish with, which only that execution can use. The session costs three durable operations and one resume, once. It appears in the operations trace as a child named volcano:messages-session.

A ctx.map over 500 items that publishes from every item opens 500 sessions. To report progress across a map, publish from the parent instead, after each batch or once the map returns:

const resized = await ctx.map('resize', input.images, (image, imageCtx) =>
  imageCtx.step('resize-image', () => resize(image)),
);
await ctx.publish('progress', { done: resized.succeeded, failed: resized.failed });

Send a message

Send to POST /durable-functions/{functionId}/executions/{executionId}/messages, where functionId is the durable function's id or name.

curl -X POST "https://api.volcano.dev/durable-functions/checkout/executions/$EXECUTION_ID/messages" \
  -H "Authorization: Bearer $ANON_KEY" \
  -H "X-Volcano-Client-Token: $CLIENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "address-confirmed", "data": {"address_id": "addr_19"}}'
FieldDescription
nameRequired. The name a wait takes it by. Up to 237 printable ASCII characters.
dataAny JSON value up to 64 KiB. Omitted means null.

The response is the message, with 201. status is delivered when a wait took it at once, delivering when a wait took it but the handover is still being confirmed, and queued when nothing is waiting for its name yet. A send answers within a few seconds even when the handover takes longer; list the messages to see it land:

StatusMeaning
queuedKept until a wait for its name takes it
deliveringA wait took it and Volcano is handing it over
deliveredThe execution has it
droppedThe execution ended before any wait took it
publishedEvery message the execution published itself

A message the execution cannot take right then — because its wait timed out at that moment, or the execution was stopped — goes back to queued rather than being lost.

Retry safely

Send Idempotency-Key with any string of up to 255 printable ASCII characters. A retry with a key the same sender has already used on the execution returns the original message with 200 and sends nothing, even once the sender is past its send limit. Each sender has its own keys: the service key, each signed-in user, the client token's holders, and the project's own credentials. One sender can never take a key another uses:

curl -X POST "https://api.volcano.dev/durable-functions/report-builder/executions/$EXECUTION_ID/messages" \
  -H "Authorization: Bearer $SERVICE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $WEBHOOK_EVENT_ID" \
  -d '{"name": "uploaded", "data": {"url": "https://files.example.com/7f3a.csv"}}'

Webhook providers retry deliveries they did not see acknowledged, so pass their event id as the key.

Find the execution by name

Messages are addressed by execution id. A sender that only knows the execution name starts the function again under that name: the start returns the existing execution's id without beginning another one, and is not charged.

curl -X POST "https://api.volcano.dev/durable-functions/report-builder/executions" \
  -H "Authorization: Bearer $SERVICE_KEY" \
  -H "X-Volcano-Execution-Name: report-7f3a"

A start creates the execution when no execution has that name: after a typo, or once the execution is past retention, it begins new work. On a public or authenticated function, whoever can start it can also claim a name before you do, and your messages then go to their execution. Store the id from your own start, or use names nobody else can guess.

Sending to a finished execution

A send to an execution Volcano knows has ended is refused with 409:

{ "error": "durable execution is not running", "code": "execution_not_running" }

A send that arrives in the moments before Volcano has seen the execution end is queued, then marked dropped.

Who can send and read

The same rule covers sending, listing, and subscribing on the application endpoint. First the function's visibility has to admit the caller. Sends and lists check it on every request, so a function made private stops answering them at once; a subscription is checked when it is made and about once a minute while it is open, and ends when the check fails. Then the caller has to be allowed to reach the execution:

CredentialReaches
Service keyAny execution of the project, unless the key is scoped without functions.invoke
Signed-in user's access tokenExecutions that user started, and any execution whose client token they send
Anon keyOnly executions whose client token it sends

While an execution is still pending, only the service key reaches it, because a retried start can still hand it to another caller; send and read once it is running.

Everything else answers 404, the same as an execution that does not exist, so an execution id cannot be probed. On an authenticated function, an anon key gets 403; a private function answers everyone but the service key with 404.

Client tokens

Every start whose input an execution runs returns a client_token with it, whatever credential started it:

{
  "id": "9a8b7c6d-5e4f-4a3b-8c1d-2e3f4a5b6c7d",
  "function_id": "6f1c0f6e-6b0e-4a1d-9f8a-2c3d4e5f6a7b",
  "name": "checkout-1182",
  "status": "running",
  "region": "us-east-1",
  "created_at": "2026-10-12T09:10:00Z",
  "client_token": "vdct_..."
}

Send it as X-Volcano-Client-Token to reach that execution with an anon key, or as a signed-in user who did not start it. A backend that starts the execution with the service key can hand the token to the browser that should talk to it.

Volcano stores only a hash of the token, and a repeated start under the same execution name never returns it, because execution names are guessable. If the start response is lost, the token is lost too: the service key and the user who started the execution can still reach it. If the start fails with a 5xx, retry it under the same name. A retry that begins the execution returns a token, and its caller becomes the one who started it. A retry that finds the first start did begin returns the execution without one. An anon-key client that then needs the token can start under a new execution name, which may run the work twice.

The service key can retry any start that failed before it began. A signed-in user can retry only their own, and an anon key only one an anon key made. Any other retry under that name answers 409, so pick a name of your own.

The project endpoint

Tooling working against a project uses /projects/{id}/durable-functions/{functionId}/executions/{executionId}/messages, with the same body and responses:

CredentialListSend
Project owner's tokenYesYes
Project access token (pt-), fullYesYes
Project access token (pt-), read_onlyYesNo, 403

Unlike approvals, sending does not need a person, because a message never decides an approval. The dashboard, the CLI, and the MCP server use this endpoint.

List an execution's messages

curl "https://api.volcano.dev/durable-functions/report-builder/executions/$EXECUTION_ID/messages?direction=out&after=3" \
  -H "Authorization: Bearer $SERVICE_KEY"
{
  "data": [
    {
      "id": "7d2a9c4e-1b3f-4e6a-8c5d-9f0e1a2b3c4d",
      "execution_id": "9a8b7c6d-5e4f-4a3b-8c1d-2e3f4a5b6c7d",
      "direction": "out",
      "seq": 4,
      "name": "progress",
      "data": { "stage": "done", "report_url": "https://files.example.com/7f3a.pdf" },
      "sender": { "type": "execution", "user_id": null },
      "status": "published",
      "delivered_at": null,
      "sent_at": "2026-10-12T09:15:41Z"
    }
  ],
  "has_more": false
}

Messages in both directions are listed oldest first, ordered by seq. seq increases with every message but can skip numbers.

ParameterDescription
directionin for messages sent to the execution, out for what it published. Both by default.
nameOnly messages with this name
afterOnly messages with a greater seq. Pass the last seq you read to read what came next.
limit1 to 500, 100 by default. A page whose messages carry over 1 MiB of data between them stops early, so read on while has_more is true rather than until a page is short.

Open waits are listed, for the project endpoint only, at GET .../executions/{executionId}/message-waits. Each has a name, its expires_at, and a status: waiting, or matched while the message it took is handed over.

Messages are kept as long as their execution is: they are deleted with it when its retention lapses.

Subscribe to an execution

Clients receive published messages live on the execution's realtime channel. Subscribing needs a realtime connection, which a signed-in user, anonymous sign-ins included, or the service key opens; an anon key alone does not. Over it, the subscriber needs what listing needs: the service key, the user who started the execution, or the execution's client token, once the execution is running. The SDKs catch up from the list so that nothing published before or during the subscribe is missed:

import { VolcanoRealtime } from '@volcano.dev/sdk/realtime';

const realtime = new VolcanoRealtime({ apiUrl, anonKey, accessToken: volcano.accessToken });
await realtime.connect();

const { data: subscription, error } = await volcano.durable.messages.subscribe(
  { functionName: 'report-builder', executionId: execution.id, clientToken: execution.client_token },
  (message) => {
    if (message.name === 'progress') showProgress(message.data);
  },
  { realtime },
);

// Later
await subscription.unsubscribe();

The handler receives out messages in seq order, once each. Each live message carries the seq of the message published before it, so when one is lost on the way the SDK notices at the next and reads the gap from the list before calling the handler. A subscription stays open until it is closed, its connection drops, or a check finds the subscriber no longer reaches the execution, as when the function is made private. A reconnect is checked again. A publish with subscribers counts against the project's Realtime message allowance as one message plus one for each subscriber it reaches. A publish that runs again after a crash is sent again and counts again, though the handler still receives it once. A publish nobody is subscribed to costs nothing, so neither does publishing on a project with Realtime off. Subscribing needs Realtime enabled for the project; listing works either way. Clients cannot publish on the channel.

A visitor who is not signed in can poll list with after instead of subscribing, or sign in anonymously first.

From an SDK

Each SDK sends, lists, and subscribes with the credential its client holds. Name the execution with the durable function and execution id, and add the client token when the caller did not start it:

const target = {
  functionName: 'checkout',
  executionId: execution.id,
  clientToken: execution.client_token,
};

const { data: message, error } = await volcano.durable.messages.send(
  target,
  'address-confirmed',
  { address_id: 'addr_19' },
  { idempotencyKey: 'confirm-addr_19' },
);

const { data: page } = await volcano.durable.messages.list(target, { direction: 'out' });
message = client.durable.messages.send(
    "checkout",
    execution.id,
    "address-confirmed",
    {"address_id": "addr_19"},
    idempotency_key="confirm-addr_19",
    client_token=execution.client_token,
)

page = client.durable.messages.list(
    "checkout", execution.id, direction="out", client_token=execution.client_token
)
for message in page.messages:
    print(message.seq, message.name, message.data)


async def follow(client, execution):
    subscription = await client.durable.messages.subscribe(
        "checkout",
        execution.id,
        lambda message: print(message.name, message.data),
        client_token=execution.client_token,
    )
    ...
    await subscription.unsubscribe()

Python's subscribe is a coroutine, and Ruby's runs inside an Async reactor. In Python and Ruby, subscribe follows the execution on the client's realtime connection, which opens only for a signed-in user: a client holding just the client token or a service key gets an error. Such a client can poll list with after instead.

require "async"

message = client.durable.messages.send_message(
  "checkout", execution.id, "address-confirmed", { address_id: "addr_19" },
  client_token: execution.client_token, idempotency_key: "confirm-addr_19"
)

page = client.durable.messages.list("checkout", execution.id, direction: "out",
                                                              client_token: execution.client_token)
page.messages.each { |m| puts [m.seq, m.name, m.data].inspect }

Async do
  subscription = client.durable.messages.subscribe("checkout", execution.id,
                                                   client_token: execution.client_token) do |m|
    puts m.name
  end
  # ...
  subscription.unsubscribe
ensure
  client.realtime.disconnect
end.wait

Pass projectId (project_id) to use the project endpoint with an owner's or project access token instead. JavaScript returns its usual { data, status, error } envelope; Python and Ruby raise a not-found error for 404 and a conflict error for 409, whose code says which.

From the CLI

volcano cloud durable messages send report-builder 9a8b7c6d-5e4f-4a3b-8c1d-2e3f4a5b6c7d uploaded \
  --data '{"url": "https://files.example.com/7f3a.csv"}' --idempotency-key upload-7f3a
volcano cloud durable messages list report-builder 9a8b7c6d-5e4f-4a3b-8c1d-2e3f4a5b6c7d --direction out
volcano cloud durable messages waits report-builder 9a8b7c6d-5e4f-4a3b-8c1d-2e3f4a5b6c7d

Every command takes --json. Against volcano start, drop cloud. See Developing durable functions locally.

Limits and billing

LimitValue
Message data64 KiB, counting each number written out in full, so 1e300 takes about 300 bytes
Message name237 printable ASCII characters
Idempotency-Key255 printable ASCII characters
Queued messages per execution100
Published messages per execution1,000 over the execution's life
Open waits per execution100
Messages sessions per execution1,000 over the execution's life
Sends3,600 per hour per execution for each kind of sender on the application endpoint (the service key, signed-in users, and anon key callers), and 3,600 through the project-scoped endpoints
Waits and sessions3,600 per hour per execution
Wait timeout1 second to 366 days

These are the same on both plans. The per-execution counts stop runaway loops rather than meter use: requests that race at a count can each be accepted, so an execution can briefly hold a few more. Sends and lists through the application endpoint also share the function's rate limit with its invocations and starts: on HOBBY, 100 requests per 10 seconds for one function and 600 across the project. Poll list no faster than you need to. The project-scoped endpoints do not share the function's rate limit; their sends count only toward their own send limit.

Messages are not a separate allowance: each waitForMessage is three durable operations, each publish one, and each messages session three, counted against your operation allowance. Waiting costs no compute. Sending costs nothing beyond the request. A publish with subscribers counts as one Realtime message, plus one for each subscriber it reaches; one with none costs nothing.

Errors

Errors on the send and list endpoints:

StatuscodeWhen
400The name, data, filters, or Idempotency-Key are invalid
401The credential is missing or not valid
403An anon key or scoped service key without functions.invoke, an anon key on an authenticated function, an origin the project's CORS policy refuses, or a read_only project access token sending
404No such function or execution, or the caller may not reach it
409execution_not_runningThe execution has ended
409too_many_queued_messagesThe execution already holds 100 messages nothing has taken
413The request body is larger than 72 KiB
429Too many messages sent to this execution, the application endpoint's rate limit for the function or the project, or the project is over its bandwidth cap
503The application endpoint could not read the project's plan terms. Retry with backoff

Errors waitForMessage and publish can raise inside the workflow. Those marked retried are retried for about 30 seconds and raised only if they persist:

StatuscodeWhen
400The name or data is invalid
401publish sent a session token Volcano does not accept. Only a modified SDK sends this
404No running durable execution Volcano started matches the request. Retried
409callback_not_readyVolcano cannot see the execution waiting yet. Retried
409callback_takenThe callback is already registered as an approval or as another wait or session. Only a modified SDK sends this
409execution_endedwaitForMessage, or the session publish opens, after the execution finished
409execution_not_runningpublish after the execution finished
409too_many_message_waitsThe execution already has 100 waits open
409too_many_message_sessionsThe execution already opened 1,000 sessions
409too_many_published_messagesThe execution already published 1,000 messages
429Too many requests from this execution. Retried
5xxThe request could not be confirmed right now. Retried

A 409 with callback_closed is not raised: the wait's timeout already passed, so waitForMessage resolves with null. Like approvals, both need the VOLCANO_PLATFORM_API_URL variable Volcano sets on durable functions; a function last deployed before approvals were available has to be redeployed once.

What's next

GuideDescription
Durable functionsOperations, executions, and billing
Durable approvalsWait for a person's decision
Developing durable functions locallySend messages on your machine
ErrorsEvery API error body

On this page