Durable functions

Durable functions checkpoint their progress and resume where they left off, so a single execution can run for up to a year across many invocations.

A durable function checkpoints its progress as it runs. If it is interrupted — because it suspended on a wait, or because an attempt crashed — it resumes from the last completed step instead of starting over. That lets one execution run for up to a year, far longer than the 300 or 900 seconds a single attempt is allowed.

Reach for a durable function when the work has steps you do not want to repeat, or waits you cannot hold a request open for: a multi-step order pipeline, a nightly reconciliation, an approval that arrives hours later, a batch job that calls a flaky third-party API.

Durable functions are a separate resource from standard functions. They live under /projects/{id}/durable-functions, are started asynchronously rather than invoked, and never appear in the standard functions collection. A function cannot change kind after it is created.

Write one

A durable function is written against the authoring API in the Volcano SDK. Wrap the handler with durable() and do the work through the context it hands you:

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

exports.handler = durable(async (input, ctx) => {
  const charge = await ctx.step('charge', () => chargeCard(input.order_id));

  await ctx.wait('settle', '30s');

  const packed = await ctx.map('pack', input.items, (item, itemCtx) => itemCtx.step('pack-item', () => pack(item)));

  return { charged: charge.id, packed: packed.results };
});
npm install @volcano.dev/sdk

Python is the same API, with the same operations and the same checkpointing:

from volcano_sdk.durable_authoring import durable


@durable
def handler(event, ctx):
    charge = ctx.step("charge", lambda scope: charge_card(event["order_id"]))

    ctx.wait("settle", "30s")

    packed = ctx.map(
        event["items"],
        lambda item, item_ctx, index: item_ctx.step("pack-item", lambda s: pack(item)),
        "pack",
    )

    return {"charged": charge["id"], "packed": packed.results}
pip install volcano-sdk

The SDK is the only dependency a durable function declares. Checkpointing needs a runtime the handler never imports, and Volcano installs it into the function when it builds one deployed as durable.

Python durable operations are synchronous — there is no await, and a step's own function is handed a scope rather than called with no arguments.

Every context operation is checkpointed: what finished is recorded, and a resumed execution replays the recorded outcome instead of doing the work again. That is the one rule the handler has to respect — the code between operations runs again on every resume, so it has to reach the same operations in the same order. Keep decisions that must not change inside a step, and do not branch on the clock or a random value.

OperationWhat it does
ctx.step(name?, fn, options?)Runs work once and records its result. options.retry sets the retry policy, options.atMostOnce marks work that must not repeat.
ctx.wait(name?, duration)Suspends the execution. '30s', '2h', a whole number of seconds, or { hours, minutes }. Anything shorter than a second is rejected.
ctx.waitUntil(name?, check, options)Polls your own state until options.until holds, suspending between checks. options.initialState is required.
ctx.waitForApproval(name, options)Suspends until a person approves or denies, or options.timeout passes, and resumes with the decision. See Durable approvals.
ctx.waitForMessage(name, options?)Suspends until a message with that name is sent to the execution, or options.timeout passes, and resumes with the message or null. See Durable messages.
ctx.waitForCallback(name, submitter, options?)Hands a token to another system and suspends until it succeeds or fails the wait, or options.timeout or options.heartbeatTimeout passes. Resumes with the result or null; a failure throws CallbackFailedError. See Durable callbacks.
ctx.publish(name, data?)Records a message that applications list or receive live. See Durable messages.
ctx.map(name?, items, fn, options?)Runs one child context per item, with options.concurrency.
ctx.parallel(name?, branches, options?)Runs independent branches concurrently.
ctx.child(name?, fn)Groups operations under one checkpointed context.
ctx.logThe execution's logger, with the execution's identifiers attached.

Waits and polls are held by the platform rather than by your code, so a function suspended for an hour costs nothing while it waits — but the execution timeout still applies. To wait for a person's sign-off, use ctx.waitForApproval. To wait for a webhook, your application, or another function, have it send a message and wait with ctx.waitForMessage. To wait for a system that holds no Volcano credential, hand it a token with ctx.waitForCallback; for state only you can check, poll it with ctx.waitUntil. Full reference and worked examples are in the SDK's durable functions guide.

Deploy one

zip function.zip index.js

curl -X POST "https://api.volcano.dev/projects/$PROJECT_ID/durable-functions" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -F "name=order-pipeline" \
  -F "runtime=nodejs24.x" \
  -F "handler=handler" \
  -F "code=@function.zip"
{
  "id": "6f1c0f6e-6b0e-4a1d-9f8a-2c3d4e5f6a7b",
  "project_id": "b1d3f4a2-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
  "name": "order-pipeline",
  "status": "provisioning",
  "visibility": "private",
  "is_public": false,
  "durable": {
    "execution_timeout_seconds": 31622400,
    "retention_days": 30
  },
  "deployed_regions": [],
  "runtime": "nodejs24.x",
  "handler": "handler",
  "created_at": "2026-09-04T18:22:41Z",
  "updated_at": "2026-09-04T18:22:41Z"
}

A bundle holding nothing but the entry file works. Volcano installs what a durable function needs to checkpoint, so the runtime is not something the bundle has to carry. Include package.json and its lockfile, or requirements.txt, when your handler has dependencies of its own; they are installed during the build as they are for a standard function.

Deployment is asynchronous, exactly as it is for a standard function: status is provisioning until the build and rollout finish, then active or failed. Poll GET /projects/{id}/durable-functions/{functionId} to follow it.

Posting to the same name again redeploys that function and returns 200 instead of 201. Executions already running continue on the runtime they started on. For a short time after a redeploy becomes active, a new execution can still start on the previous code.

The durable block is derived from your plan rather than supplied in the request, and is fixed for the life of the function. Changing those limits means creating a new function.

Durable execution runs on nodejs22.x, nodejs24.x, python3.13 and python3.14, because checkpointing a function needs the durable authoring API and that ships for JavaScript, TypeScript and Python. Any other runtime is rejected with 400, and the response names the ones that work. Ruby stays available for standard functions.

Note that a durable Python function needs a newer runtime than a standard one defaults to: python3.12 is the default for Python and cannot host a durable function, so name python3.14 explicitly when you deploy through the API.

Rather than hardcoding that list, read it from GET /functions/runtimes and keep the runtimes whose durable_capable is true:

{
  "runtimes": [
    {
      "name": "nodejs24.x",
      "label": "Node.js 24",
      "language": "nodejs",
      "default": true,
      "durable_capable": true,
      "deployment": {
        "file_extensions": [".js", ".mjs"],
        "entrypoint": "index.js",
        "handler": "handler",
        "dependency_manifests": [
          "package.json",
          "package-lock.json",
          "npm-shrinkwrap.json",
          "pnpm-lock.yaml",
          "yarn.lock",
          ".yarnrc.yml"
        ]
      }
    },
    {
      "name": "python3.12",
      "label": "Python 3.12",
      "language": "python",
      "default": true,
      "durable_capable": false,
      "deployment": {
        "file_extensions": [".py"],
        "entrypoint": "main.py",
        "handler": "handler",
        "dependency_manifests": ["requirements.txt"]
      }
    },
    {
      "name": "python3.14",
      "label": "Python 3.14",
      "language": "python",
      "default": false,
      "durable_capable": true,
      "deployment": {
        "file_extensions": [".py"],
        "entrypoint": "main.py",
        "handler": "handler",
        "dependency_manifests": ["requirements.txt"]
      }
    }
  ]
}

Pass name to the deploy. label is the runtime's display name, so show it in a picker instead of formatting name yourself.

A durable function deploys to every region its project deploys to, and every one of those regions has to offer durable execution. Selecting a region that does not is rejected with 400, on the deploy and on the project's own region change, and the message names the region.

Volcano can also pause durable deploys platform-wide, which answers 503 on any deploy carrying a durable function. Executions already running are unaffected, and the same request succeeds once deploys are re-enabled, so treat it as temporary rather than as a rejected request.

From a repository

Set kind: durable on the function in volcano-config.yaml and deploy the project as usual:

functions:
  - name: order-pipeline
    kind: durable

kind is asserted, never rewritten: a manifest that changes the kind of an existing function is rejected. Omitting it means standard, so a durable function has to name it. The invocation settings in that section — invocation_mode, http_auth_mode, openapi_spec — do not apply to a durable function and are rejected on one. See Project configuration and Git deploy.

A repository has no runtime to declare, so the runtime comes from the source file's extension. A durable function gets the durable runtime for its language — nodejs24.x for .js, python3.14 for .py — rather than the one standard functions default to, so declaring kind: durable is all a Python durable function needs.

From the CLI

The Volcano CLI covers the whole lifecycle under volcano cloud durable, reading the same volcano-config.yaml and the same volcano/functions/ layout standard functions use:

volcano cloud durable deploy --all
volcano cloud durable start order-pipeline --input '{"order_id":4417}' --name order-4417
volcano cloud durable executions list order-pipeline --status running
volcano cloud durable executions get order-pipeline $EXECUTION_ID
volcano cloud durable executions stop order-pipeline $EXECUTION_ID
volcano cloud durable schedulers create order-pipeline --cron "0 * * * *"
volcano cloud durable approvals approve $APPROVAL_ID --comment "Checked stock"

Logs come from the same command in both shapes a durable function has them: volcano cloud durable logs order-pipeline --type build is the deploy's own output, which is where a deploy that ended in failed says why, and --type runtime is what the function logged while its executions ran. Add --follow to tail either one.

The two collections refuse each other's names, so a function declared kind: durable is skipped by volcano cloud functions deploy and picked up by volcano cloud durable deploy — a wrong kind fails before anything is uploaded rather than creating a function that can never change. volcano cloud deploy runs both as part of the whole project: variables and migrations first, then functions, durable functions, frontends, and the manifest. See the CLI deploy reference.

Start an execution

A durable execution can outlive any request you could hold open, so starting one never returns a result. It returns a handle:

curl -X POST "https://api.volcano.dev/durable-functions/$FUNCTION_ID/executions" \
  -H "Authorization: Bearer $SERVICE_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Volcano-Execution-Name: order-4417" \
  -d '{"order_id": 4417}'
{
  "id": "9a8b7c6d-5e4f-4a3b-8c1d-2e3f4a5b6c7d",
  "function_id": "6f1c0f6e-6b0e-4a1d-9f8a-2c3d4e5f6a7b",
  "name": "order-4417",
  "status": "running",
  "region": "us-east-1",
  "created_at": "2026-09-04T18:31:02Z",
  "client_token": "vdct_..."
}

Keep client_token with the caller that will follow the execution: it is what lets an anon key read and stop this execution and reach its messages. Only the start that creates the execution returns it.

The request body is the function's input and must be valid JSON if present, up to 256 KiB. An empty body starts the execution with no input.

From an application, the SDKs do the same thing with the credential the client already holds, when the function's visibility admits it:

const { data, error } = await volcano.durable.start(
  'order-pipeline',
  { order_id: 4417 },
  { executionName: 'order-4417' },
);

console.log(data.id, data.status); // 'running'
execution = client.durable.start(
    "order-pipeline", {"order_id": 4417}, execution_name="order-4417"
)

print(execution.id, execution.status)  # 'running'
execution = client.durable.start(
  "order-pipeline", { order_id: 4417 }, execution_name: "order-4417"
)

puts [execution.id, execution.status] # 'running'

Every SDK can start an execution and follow it. Writing the handler takes a durable authoring API, which the JavaScript and Python SDKs have and Ruby does not, so a Ruby application starts and reads executions of functions written in JavaScript or Python.

X-Volcano-Execution-Name is the idempotency key. Repeating a start with the same name returns the execution that already exists instead of beginning a second one, which makes a retried client request safe, and the retry is not charged again. Omit it and Volcano generates one.

A name is made of letters, digits, -, _ and ., up to 255 characters. Anything else is rejected with 400, because the name is used as sent rather than rewritten.

It names one execution, not a process made of several. A name is taken for as long as its execution is readable, so work that continues past one execution — anything past the execution ceiling, or picked up again after it — starts a new execution under a new name, and whatever ties them together is your own identifier carried in the payload.

Starting before the function has finished provisioning answers 409 with durable function is not deployed yet. Wait for its status to reach active rather than retrying straight away. Two starts racing for the same name can both give it up, which answers 409 as well; that one is fine to retry unchanged. A start that failed before it began can be retried by the service key, by the signed-in user who made it, or, if an anon key made it, by an anon key. Any other retry answers 409 with the code execution_name_held, and needs a name of its own. See Durable messages. A start refused because the project is already at its concurrency cap answers 429 instead — see Limits and billing.

An execution is pinned to one region for its whole life, because its checkpoints live there.

Which endpoint to use

Starting has two endpoints, because starting and managing are done by different callers with different credentials.

EndpointCredentialsUse it for
POST /durable-functions/{functionId}/executionsService key; auth user token or anon key as the function's visibility allowsApplications starting work
POST /projects/{id}/durable-functions/{functionId}/executionsProject owner token, or a full project access tokenScripts and tooling already working against a project

They behave identically otherwise: the same body, the same idempotency header, the same 202 and handle. volcano.durable.start calls the first one.

An application reads and stops executions on the same data-plane path, with the same credentials. An anon key reaches the executions whose client token it sends, and a signed-in user also the ones they started. A service key holding *, the default, reaches every execution in the project. A service key scoped to a list without *, such as ["functions.invoke"], reads, stops, and messages an execution only with its client_token, which the start response returns. See Wait for the result and Stop an execution. Listing executions is on the project-scoped collection and takes the owner's token or a project access token. Either token scope can read and list; stopping needs full. The SDK has volcano.durable.get, .list and .stop for those, but they carry one of these credentials, so call them from a backend rather than a browser.

Who can start executions

A durable function has the same visibility levels as a standard one, and they decide who may start executions on the application endpoint. New durable functions are private.

Set visibility when you deploy the function. To let an anon key start executions:

curl -X POST "https://api.volcano.dev/projects/$PROJECT_ID/durable-functions" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -F "name=checkout" \
  -F "runtime=nodejs24.x" \
  -F "handler=handler" \
  -F "visibility=public" \
  -F "code=@function.zip"
{
  "id": "3d2e1f0a-9b8c-4d7e-8f6a-5b4c3d2e1f0a",
  "project_id": "b1d3f4a2-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
  "name": "checkout",
  "status": "provisioning",
  "visibility": "public",
  "is_public": true,
  "durable": {
    "execution_timeout_seconds": 31622400,
    "retention_days": 30
  },
  "deployed_regions": [],
  "runtime": "nodejs24.x",
  "handler": "handler",
  "created_at": "2026-09-04T18:52:07Z",
  "updated_at": "2026-09-04T18:52:07Z"
}

The anon key also needs the functions.invoke permission; without it the key gets 403 whatever the visibility. On an authenticated function an anon key gets 403. A private function answers every caller but a service key with 404, the same answer as a missing one. volcano.durable.start calls this endpoint directly, so it gets the same answers.

Durable functions have no update endpoint, so visibility travels with a deploy. Redeploy with visibility to change it, or omit the field to keep what the function already has. You can also set it in volcano-config.yaml. is_public still works as a deprecated alias: true means public and false means authenticated, not private.

Three things are worth being precise about:

  • Public means startable, not readable by everyone. An anon key ships inside your pages, so everyone who loads one holds it, and an execution id alone reaches nothing with it. Reading an execution, stopping it, and its messages take the client_token its start returned, so a visitor reaches only the run they started. A signed-in user also reaches the executions they started without it.
  • Public means startable, not invocable. A durable function is never reachable through POST /functions/{functionId}/invoke or a function URL, whatever its visibility; both answer 404. A synchronous call would run it with no execution record, no idempotency, no concurrency accounting and no pinned version, which is not a durable execution however much it looks like one.
  • Anyone can start it. A public durable function is startable by anyone who reads your anon key out of a page. So is an authenticated one when your project allows anonymous sign-ins, because anyone can get one of those tokens. Every start counts against your execution and operation allowances and your concurrency cap — a caller who cannot see the result can still spend both. Validate the input inside the function and keep the payload small.

Act as the user who started it

When a signed-in user's access token starts the execution, Volcano adds that user's identity to the input as __volcano_auth, the same context a standard function receives:

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

exports.handler = durable(async (input, ctx) => {
  const auth = input.__volcano_auth;
  if (!auth) throw new Error('start this function with a user access token');

  const reply = await ctx.step('answer', () => answer(input.question));
  await ctx.step('save-reply', () => saveReply(auth.user_id, input.turn_id, reply));
  return { turn_id: input.turn_id };
});
FieldValue
user_idThe user's ID
emailThe user's email
roleauthenticated or anonymous
project_idThe project's ID
access_tokenThe token that started the execution

The input has to be a JSON object, or empty, to carry it; an array or scalar input arrives unchanged and without identity. A start with an anon key or a service key gets no __volcano_auth, and Volcano removes one the caller put in the body, so the field cannot be forged.

The identity is fixed when the execution starts and replayed on every resume. access_token stops working once the project's access token lifetime passes, one hour by default, and an execution with a wait can easily outlive it. Use user_id instead: connect to the database with volcano_user_access:<user_id> so row-level security applies to that user however long the execution runs.

Return the result to the user

A browser can poll the execution it started with its client token, as Wait for the result shows. The token lives in the page, though, so when a reload or another device should still show the result, have the function write it to a table the user can read, and have the page listen for it with Postgres changes, which respect row-level security:

CREATE TABLE chat_replies (
  turn_id UUID PRIMARY KEY,
  user_id UUID NOT NULL,
  body TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE chat_replies ENABLE ROW LEVEL SECURITY;

CREATE POLICY "read own replies" ON chat_replies
  FOR SELECT USING (user_id = auth.uid());

CREATE POLICY "write own replies" ON chat_replies
  FOR INSERT WITH CHECK (user_id = auth.uid());
const channel = realtime.channel('public:chat_replies', { type: 'postgres' });
channel.onPostgresChanges('INSERT', 'public', 'chat_replies', (payload) => {
  if ((payload.record?.turn_id ?? payload.id) === turnId) showReply(turnId);
});
await channel.subscribe();

await volcano.durable.start('chat-turn', { turn_id: turnId, question });

Subscribe before starting, so a fast execution cannot finish unseen. Change events do not count against your Realtime message allowance.

Wait for the result

Poll the execution with the credential that started it. With an anon key, send the client_token the start returned as X-Volcano-Client-Token:

curl "https://api.volcano.dev/durable-functions/$FUNCTION_ID/executions/$EXECUTION_ID" \
  -H "Authorization: Bearer $ANON_KEY" \
  -H "X-Volcano-Client-Token: $CLIENT_TOKEN"
{
  "id": "9a8b7c6d-5e4f-4a3b-8c1d-2e3f4a5b6c7d",
  "function_id": "6f1c0f6e-6b0e-4a1d-9f8a-2c3d4e5f6a7b",
  "name": "order-4417",
  "status": "succeeded",
  "region": "us-east-1",
  "result": { "charged": true, "shipment_id": "shp_88213" },
  "created_at": "2026-09-04T18:31:02Z",
  "completed_at": "2026-09-04T18:47:55Z"
}
StatusMeaning
pendingAccepted, not yet under way
runningExecuting, or suspended on a wait
succeededFinished; result holds what the function returned
failedEnded with an error; error holds its type and message
timed_outExceeded the execution timeout
stoppedCancelled through the stop endpoint
unknownThe platform lost track of the outcome; no result or error is available

Who can read an execution on this endpoint:

CredentialReaches
Service key holding *, the defaultAny execution of the project
Service key scoped to a list without *Only executions whose client token it sends; the list needs 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

An execution's messages follow the same rule. The function's visibility also has to admit the caller. While an execution is still pending, only a service key holding * reaches it. Everything else answers 404, the same as an execution that does not exist, so an execution id cannot be probed. The response never carries client_token.

Tooling working against a project reads the same execution on the project-scoped collection, with the owner's token or a project access token instead:

curl "https://api.volcano.dev/projects/$PROJECT_ID/durable-functions/$FUNCTION_ID/executions/$EXECUTION_ID" \
  -H "Authorization: Bearer $PLATFORM_TOKEN"

The two answer with the same body. The last five statuses are terminal. result is whatever the function returned, verbatim. It is absent while the execution is still running, absent when the result was too large to return and was checkpointed instead, and absent once the function's retention period lapses. Only the last of those sets result_expired to true, which distinguishes a discarded result from an empty one; a checkpointed result reads exactly like a function that returned nothing, so keep what you need to read back small. Volcano keeps a finished execution's result and error in memory for up to five minutes after reading them, so a read can still return them for up to five minutes after the retention period lapses. A finished execution is dropped entirely a day or so after its retention lapses, and reads and listings stop returning it — record anything you need to keep for longer while the execution is still readable.

A failed or timed_out execution carries error when one was recorded: the type and message of what ended it, such as an error your handler did not catch. Like result, it is gone once the function's retention period lapses, or up to five minutes later if Volcano has just read it. Whoever can read the execution reads its error too, including a browser holding the client token, so keep secrets out of the errors your handler throws.

{
  "id": "4c2e8f1a-7b3d-4e9a-a6c5-1d2e3f4a5b6c",
  "function_id": "6f1c0f6e-6b0e-4a1d-9f8a-2c3d4e5f6a7b",
  "name": "order-4418",
  "status": "failed",
  "region": "us-east-1",
  "error": { "type": "CardDeclined", "message": "card declined by issuer: insufficient_funds" },
  "created_at": "2026-09-04T19:02:11Z",
  "completed_at": "2026-09-04T19:02:19Z"
}

A listing leaves error out, so read the execution itself to see why it ended. volcano cloud durable executions get prints the same type and message, and the dashboard shows them under the execution's name in Observability → Durable Tracing. To find the step that raised it, inspect the execution's operations.

unknown is rare and means what it says: the outcome cannot be established, so there is no outcome to report and no later poll will produce one. It is terminal for that reason, and it releases the concurrency slot the execution held. Its completed_at is when the platform gave up, not when the work ended. Treat it the way you would a lost response — check whatever the function was supposed to write, and start again under a new name if it did not.

Two things reach it. An execution that was under way and was never seen to finish, and a start that failed with a 500 without the platform establishing whether the execution began — the second is why a name whose start returned an error can later read as unknown rather than not being found. Retrying that start under the same name is still the safe move while it is the error you are holding: the retry converges on the original execution instead of running the work twice, and only settles as unknown if nothing ever retries it.

Retrying is worth doing even after the status reads unknown. A retry under the same name picks the same execution back up rather than starting a second one, and if the work was in fact running it becomes readable again. That retry needs a free concurrency slot, since an unknown execution gave its own up, and is refused with 429 if the project has none.

A start whose outcome could not be established is charged one durable execution. The work may be running, and the platform cannot ask — so the charge follows the claim the platform keeps rather than the response you received. Retrying it is not charged again, however many attempts it takes: the execution is charged once.

A running execution that is suspended on a wait costs nothing while it waits, so polling on a long interval is fine.

The SDKs read an execution through the project-scoped endpoint, from a backend holding the platform token:

const { data } = await volcano.durable.get(projectId, 'order-pipeline', executionId);

if (data.status === 'succeeded') {
  console.log(data.result);
}

List executions

curl "https://api.volcano.dev/projects/$PROJECT_ID/durable-functions/$FUNCTION_ID/executions?status=running" \
  -H "Authorization: Bearer $PLATFORM_TOKEN"
{
  "data": [
    {
      "id": "9a8b7c6d-5e4f-4a3b-8c1d-2e3f4a5b6c7d",
      "function_id": "6f1c0f6e-6b0e-4a1d-9f8a-2c3d4e5f6a7b",
      "name": "order-4417",
      "status": "running",
      "region": "us-east-1",
      "created_at": "2026-09-04T18:31:02Z"
    }
  ],
  "page": 1,
  "limit": 10,
  "total": 1,
  "has_more": false
}

A listing returns the last status Volcano observed for each execution rather than polling every one, so fetch a single execution when you need its live state.

Inspect an execution's operations

Every operation an execution begins is recorded: each step and every attempt of it, each wait, each waitUntil check, and each child context, map item and parallel branch. Read them as a trace to see where an execution is, what it retried, and where the time went:

curl "https://api.volcano.dev/projects/$PROJECT_ID/durable-functions/$FUNCTION_ID/executions/$EXECUTION_ID/operations" \
  -H "Authorization: Bearer $PLATFORM_TOKEN"
{
  "data": [
    {
      "id": "c3f1e0a2b4d6f8a1",
      "type": "execution",
      "kind": "execution",
      "status": "succeeded",
      "started_at": "2026-09-04T18:31:02Z",
      "ended_at": "2026-09-04T18:31:40Z"
    },
    {
      "id": "5d2c9b8a7f6e1d0c",
      "parent_id": "c3f1e0a2b4d6f8a1",
      "name": "charge",
      "type": "step",
      "kind": "step",
      "status": "succeeded",
      "started_at": "2026-09-04T18:31:02Z",
      "ended_at": "2026-09-04T18:31:05Z",
      "attempts": [
        {
          "attempt": 1,
          "status": "failed",
          "started_at": "2026-09-04T18:31:02Z",
          "ended_at": "2026-09-04T18:31:03Z",
          "error": { "type": "GatewayTimeout", "message": "payment gateway did not answer within 5s" }
        },
        {
          "attempt": 2,
          "status": "succeeded",
          "started_at": "2026-09-04T18:31:04Z",
          "ended_at": "2026-09-04T18:31:05Z"
        }
      ]
    },
    {
      "id": "8e7d6c5b4a3f2e1d",
      "parent_id": "c3f1e0a2b4d6f8a1",
      "name": "settle",
      "type": "wait",
      "kind": "wait",
      "status": "succeeded",
      "started_at": "2026-09-04T18:31:05Z",
      "ended_at": "2026-09-04T18:31:35Z",
      "scheduled_end_at": "2026-09-04T18:31:35Z"
    },
    {
      "id": "1a2b3c4d5e6f7a8b",
      "parent_id": "c3f1e0a2b4d6f8a1",
      "name": "pack",
      "type": "context",
      "kind": "map",
      "status": "succeeded",
      "started_at": "2026-09-04T18:31:35Z",
      "ended_at": "2026-09-04T18:31:40Z"
    },
    {
      "id": "9f8e7d6c5b4a3f2e",
      "parent_id": "1a2b3c4d5e6f7a8b",
      "name": "map-item-0",
      "type": "context",
      "kind": "map_item",
      "status": "succeeded",
      "started_at": "2026-09-04T18:31:35Z",
      "ended_at": "2026-09-04T18:31:39Z"
    }
  ],
  "invocations": [
    { "started_at": "2026-09-04T18:31:02Z", "ended_at": "2026-09-04T18:31:05Z" },
    { "started_at": "2026-09-04T18:31:35Z", "ended_at": "2026-09-04T18:31:40Z" }
  ],
  "complete": true,
  "synced_at": "2026-09-04T18:31:52Z"
}

The execution comes first and is the only operation without a parent_id. Everything else follows in the order it started, with parent_id naming what it ran inside, so the list is a tree. kind is the operation as you wrote it:

Your codekindChildren
ctx.stepstepEach run of the body is an entry in attempts
ctx.waitwaitscheduled_end_at is when it elapses
ctx.waitUntilwait_untilEach check is an attempt; next_attempt_at is the next one
ctx.waitForApprovalchildA callback, waiting until the decision arrives, and the step that registered the request
ctx.waitForMessagechildA callback, waiting until a message arrives, and the step that registered the wait
ctx.waitForCallbackchildA callback, waiting until the token is answered, and the step that ran your submitter
ctx.publishstep, or child in JavaScriptNothing inside a JavaScript publish. The first publish in a context also opens a child named volcano:messages-session
ctx.childchildThe operations inside it
ctx.mapmapOne map_item per item
ctx.parallelparallelOne parallel_branch per branch

A map item or parallel branch you did not name carries the SDK's positional name, such as map-item-0 or parallel-branch-1.

status is running, waiting (a wait, or a waitUntil between checks), retrying (the last attempt failed and next_attempt_at is the next one), or how the operation ended. Work still open when the execution ended — a wait cut short by a stop — ends as cancelled at that moment. An execution whose outcome was lost ends as unknown, matching the execution's own status. invocations are the windows your code was actually running. The gaps between them are time the execution spent suspended, which is why a long wait adds no compute.

A finished execution's trace is final: complete is true, and its operations no longer change. The one late addition is its last invocation window, which can land shortly after. A running one is refreshed when you read it, usually to within about ten seconds and later when many executions are being watched at once; synced_at says when it was last refreshed, and is absent until the first refresh, when data may still be empty. Poll it the way you poll the execution.

The trace is metadata, never data. Inputs, step results and return values are not included. An operation that failed or timed out, and each failed attempt of a step, carries error with its type and a bounded message when one was recorded. A trace lives as long as its execution does: for the function's retention_days, and not past the function's deletion.

The dashboard draws the same trace as a timeline when you open an execution. Observability → Durable Tracing shows a durable function's recent executions beside the trace of the one you pick, and its address links to that execution. Pick an operation in the timeline to see its own error and each attempt's, when the trace has them.

Stop an execution

From the application that started it, with the same credentials as a read:

curl -X POST "https://api.volcano.dev/durable-functions/$FUNCTION_ID/executions/$EXECUTION_ID/stop" \
  -H "Authorization: Bearer $ANON_KEY" \
  -H "X-Volcano-Client-Token: $CLIENT_TOKEN"

From tooling, on the project-scoped collection, with the owner's token or a full project access token:

curl -X POST "https://api.volcano.dev/projects/$PROJECT_ID/durable-functions/$FUNCTION_ID/executions/$EXECUTION_ID/stop" \
  -H "Authorization: Bearer $PLATFORM_TOKEN"

Both answer the same way:

{
  "id": "9a8b7c6d-5e4f-4a3b-8c1d-2e3f4a5b6c7d",
  "function_id": "6f1c0f6e-6b0e-4a1d-9f8a-2c3d4e5f6a7b",
  "name": "order-4417",
  "status": "running",
  "region": "us-east-1",
  "created_at": "2026-09-04T18:31:02Z"
}

The call is accepted rather than awaited. Cancellation happens behind it, so the response reports the execution as it was read back and often still says running; do not branch on that status. The execution settles into stopped shortly after, and polling is how you see it get there.

Stopping does not undo completed steps. Stopping one that already finished is not an error: the response carries the state it settled in, so a stop racing a succeeded reads back succeeded. Stopping one that is still pending answers 409; only a service key holding * and the project endpoint reach an execution in that state.

Run one on a schedule

Durable functions take schedulers, on their own collection:

curl -X POST "https://api.volcano.dev/projects/$PROJECT_ID/durable-functions/$FUNCTION_ID/schedulers" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "nightly-reconcile",
    "schedule": { "kind": "cron", "cron_expression": "0 3 * * *" },
    "payload": { "reason": "nightly" }
  }'
{
  "id": "2c9d1e0f-3a4b-4c5d-8e9f-0a1b2c3d4e5f",
  "project_id": "b1d3f4a2-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
  "function_id": "6f1c0f6e-6b0e-4a1d-9f8a-2c3d4e5f6a7b",
  "function_kind": "durable",
  "name": "nightly-reconcile",
  "enabled": true,
  "schedule_kind": "cron",
  "cron_expression": "0 3 * * *",
  "payload": { "reason": "nightly" },
  "regions": ["us-east-1"],
  "regions_explicit": false,
  "next_run_at": "2026-09-05T03:00:00Z",
  "run_count": 0,
  "health": "healthy",
  "consecutive_failures": 0,
  "created_at": "2026-09-04T19:02:18Z",
  "updated_at": "2026-09-04T19:02:18Z"
}

Each tick starts an execution rather than invoking the function, and gives it an execution name derived from the run, so a retried tick resolves to the execution it already started instead of doing the work twice. Everything else matches Scheduled invocations.

A schedule whose tick is still running when the next one is due starts a second execution; the concurrency cap is what bounds that. Give a long-running schedule an interval comfortably longer than the work.

A run counts as failed when its execution fails, times out, or is stopped. The scheduler's health turns failing and consecutive_failures counts the streak, and by default the project owner is emailed after 3 failed runs in a row. See Scheduler health and Notifications.

Schedulers are a SUPERAGENT capability: on HOBBY the create answers 403. SUPERAGENT allows 100 per project, counted across durable and standard functions together, and a create past the cap answers 403 as well.

To see every durable function's schedules at once, filter the project-wide scheduler list by kind:

curl "https://api.volcano.dev/projects/$PROJECT_ID/schedulers?function_kind=durable" \
  -H "Authorization: Bearer $PLATFORM_TOKEN"

Each item has the shape shown above. See Function Schedulers API for pagination.

Deployments and logs

Each deploy of a durable function is recorded as a deployment. List one function's deployments on its own collection:

curl "https://api.volcano.dev/projects/$PROJECT_ID/durable-functions/$FUNCTION_ID/deployments" \
  -H "Authorization: Bearer $PLATFORM_TOKEN"
{
  "data": [
    {
      "id": "9b1f2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
      "function_id": "6f1c0f6e-6b0e-4a1d-9f8a-2c3d4e5f6a7b",
      "project_id": "b1d3f4a2-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
      "operation": "deploy",
      "status": "active",
      "deploy_source": "cli",
      "created_at": "2026-09-04T18:20:11Z"
    }
  ],
  "page": 1,
  "limit": 20,
  "total": 1,
  "has_more": false
}

The project deployment feed lists standard and durable functions together, with resource.kind telling them apart. Pass function_kind=durable to keep only durable functions:

curl "https://api.volcano.dev/projects/$PROJECT_ID/deployments?function_kind=durable&limit=20" \
  -H "Authorization: Bearer $PLATFORM_TOKEN"
{
  "data": [
    {
      "id": "9b1f2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
      "project_id": "b1d3f4a2-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
      "resource": {
        "type": "function",
        "id": "6f1c0f6e-6b0e-4a1d-9f8a-2c3d4e5f6a7b",
        "name": "order-pipeline",
        "kind": "durable"
      },
      "operation": "deploy",
      "status": "active",
      "deploy_source": "cli",
      "created_at": "2026-09-04T18:20:11Z",
      "updated_at": "2026-09-04T18:22:40Z"
    }
  ],
  "limit": 20,
  "total": 1,
  "has_more": false
}

GET /projects/{id}/deployments/summary?resource_type=function&function_kind=durable returns the success rate and median build time for the same set.

A durable function's runtime and build logs are read like any function's, by its ID. To read every durable function's logs at once, set resource.kind to durable:

curl -X POST "https://api.volcano.dev/projects/$PROJECT_ID/logs/search" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"resource":{"type":"function","kind":"durable"}}'

See Logs.

Delete a durable function

curl -X DELETE "https://api.volcano.dev/projects/$PROJECT_ID/durable-functions/$FUNCTION_ID" \
  -H "Authorization: Bearer $PLATFORM_TOKEN"

Returns 202 with an empty body; teardown continues after the response. Deleting takes the function's executions with it: they stop being readable and listable, and they stop counting against the concurrency cap.

The delete stops the executions still in flight, but not at a point you choose: stopping does not interrupt a step already running, so that step continues to its next checkpoint and the execution ends there. If you need to observe an execution ending, stop it yourself and delete once it is terminal. History does not outlive the function either: its execution records go with it, whatever retention_days still had left, so export anything you need to keep before you delete.

Local development

Durable functions run on your machine the same way they run deployed. Your handler and your volcano-config.yaml are identical in both places, so a function you get working locally deploys unchanged.

volcano start
volcano durable deploy --all
volcano durable start orders --input '{"order_id":"ord_123"}'

Two things differ locally, both deliberately:

  • A wait resolves immediately. A function that waits a day is normal to write and unusable to sit through. Your function cannot tell — it still suspends, and still resumes with everything it had finished — but you get the answer in seconds. Set LOCAL_DURABLE_REAL_TIME=true to make waits take their real time. The timeout on an approval, a message wait, or a callback is the exception: it always runs in real time, because something outside the function is answering against it.
  • There is one region. Everything runs on the one local engine, so there is nothing to choose between.

Everything else behaves as it does deployed: steps checkpoint and replay, a failed step retries on its backoff, ctx.map and ctx.parallel fan out, ctx.waitUntil polls, ctx.waitForApproval waits for volcano durable approvals approve, ctx.waitForMessage waits for volcano durable messages send, ctx.waitForCallback waits for its token to be answered against the local API, usage is metered on the same three counters, and an execution suspended when you stop the local server resumes when you start it again.

See Developing durable functions locally for a worked example.

Project scope

Durable functions and their executions are reachable only through the project that owns them. An id belonging to another project answers 404, the same as an id that does not exist — the response never reveals that the id is real elsewhere. That holds for the application start endpoint too: it resolves the function inside the project its credential belongs to, so another project's function id is 404 there as well.

The two function collections are separate in the same way. A standard function's id is 404 under /durable-functions/... and a durable function's id is 404 under /functions/..., including their deployments and schedulers.

Treat 404 on an execution as "not yours or not here" rather than "finished and cleaned up": a completed execution stays readable for its retention_days as long as its function does, so a 404 inside that window means the id is wrong, belongs to another project, or its function has been deleted. An execution id is only ever valid under the durable function that started it, so keep the pair together.

Limits and billing

Durable executions are metered on three allowances of their own and spend nothing from the request allowance that function invocations and frontend requests share. An execution that ran for six hours and checkpointed a hundred times costs what it did, rather than one invocation.

Executions are counted per start. A start repeated under the same execution name is the same execution, so a client that retries is charged once.

Operations are what the execution did. One for the execution itself, and one more for everything it begins:

Charged as an operationNotes
The executionOne per execution, whatever it goes on to do
Each ctx.step attemptA retried step is another attempt, so another operation
Each ctx.waitOne per wait, however long it lasts
Each ctx.waitUntil checkSize maxAttempts with this in mind: 200 checks is 200 operations
Each ctx.child contextPlus whatever the child itself does
Each ctx.map item and ctx.parallel branchEach runs in a child context of its own
Each ctx.waitForApprovalThree: the approval, the wait for its decision, and the step that registers it
Each ctx.waitForMessageThree: the wait, the callback the message completes, and the step that registers it
Each ctx.waitForCallbackThree: the wait, the callback the token completes, and the step that runs your submitter
Each ctx.publishOne. The first publish in a context also opens a messages session, which is three more
Each function an execution invokes from inside itselfOne for making the call, on top of whatever the call costs

Only beginning something is charged. How it turned out — a step that succeeded, a wait that elapsed, an execution that failed — is a record of work already counted, not a second operation.

Compute is the time your code is actually running, times the memory it runs at, in gigabyte-seconds. Your plan sets the memory, and every resume adds the time it held the runtime:

compute = memory × (time running, summed over every resume)

Waiting is not running. An execution suspended in a wait or a waitUntil between checks holds no runtime and adds nothing, which is why a waitUntil on a long interval is cheaper than a short one for the same reason it is kinder to whatever it polls. Wall-clock life is not charged either: an execution that spends a day parked and a second working is charged for the second. A resume replays what it already recorded rather than doing it again, and replay is fast, but it does run — the time it takes is part of the resume that carries it.

Operations and compute are both counted once an execution has finished, so a long execution's usage appears when it ends rather than as it runs.

LimitHOBBYSUPERAGENT
Execution allowance5,000 / month10,000 / month
Operation allowance100,000 / month200,000 / month
Compute allowance10,000 GB-s / month100,000 GB-s / month
Memory128 MB1 GB
Operations per execution3,0003,000
Step timeout300 s900 s
Execution timeout366 days366 days
Result retention30 days30 days
Concurrent executions per project10100
Durable functions per project10,00010,000

All three allowances are combined across your projects. Once any of them is reached, HOBBY stops starting new executions within a few seconds and SUPERAGENT bills the excess. See how HOBBY allowances are enforced.

Memory comes from your plan rather than from the function, and a function picks up a plan change on its next deploy. More memory means proportionally more CPU, so a SUPERAGENT execution both has more room and runs faster — and its compute is charged at the larger size, so the same work costs 8× the compute of HOBBY's 128 MB against an allowance 10× larger.

The step timeout bounds one attempt between checkpoints, not the execution: a durable function outlives it by checkpointing and being resumed. The execution timeout bounds the whole execution, including time suspended in a wait.

In practice operations run out before the clock does. An execution gets 3,000 of them, and every step, wait and poll is one — so a workflow that polls hourly exhausts its operations after about four months, and one that waits a few times can sit for the best part of a year. An execution that needs more than 3,000 operations fails, so spread long work over fewer, larger steps and longer waits, or split it across executions.

The other thing a long execution holds is one of your concurrent execution slots, for as long as it runs. That cap, not the timeout, is what limits how much long-lived work a project can have in flight at once; stop is how you get a slot back early.

Every allowance is applied to the next start rather than to work already under way, and operations and compute are only known once an execution has finished. An execution running when one runs out is never interrupted: it finishes, and it is charged for what it did. The start that follows answers 429 with which allowance it hit, and spends no execution.

The number of executions in flight at once is capped per project, and a start beyond that cap answers 429 as well. An execution stops counting against the cap once it finishes, whether or not you ever read it. Fire-and-forget starts are safe: you do not have to poll an execution to release its slot.

Starting through the application endpoint goes through the same rate limit and the same CORS policy as a function invocation: on HOBBY that is 100 starts per 10 seconds for one function and 600 across the project, and a browser start is refused when the project enforces CORS and the page's origin is not allowed. Both refuse before an execution begins — 429 for the rate limit, 403 for the origin — so neither spends an execution. Reads and stops on the application endpoint share the same rate limit and CORS policy, so poll every few seconds rather than in a tight loop. The project-scoped owner endpoint is not rate limited. On either endpoint, a read or stop answers 503 with Retry-After while reads or stops of executions in the region are briefly throttled; wait that many seconds before trying again. A stop whose read back was throttled may already have been sent, and repeating it is safe: a stop of an execution that has already ended answers the state it ended in.

Durable functions are counted against their own per-project cap, so they cannot exhaust the standard function cap or the other way round. See Plans and limits.

What's next

GuideDescription
Durable approvalsPause a workflow until a person approves or denies
Durable messagesSend messages to a running workflow and read what it publishes
Durable callbacksLet another system complete a waiting step with a token
Functions overviewStandard functions, runtimes, and packaging
Environment variablesConfigure secrets and settings
LogsView and filter function logs
Plans and limitsHOBBY and SUPERAGENT limits in full

On this page