# 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](https://docs.volcano.dev/platform/functions/durable-approvals.md), a batch job that calls a flaky third-party API.

Durable functions are a separate resource from [standard functions](https://docs.volcano.dev/platform/functions/overview.md). 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:

```javascript
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 };
});
```

```bash
npm install @volcano.dev/sdk
```

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

```python
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}
```

```bash
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.

| Operation                                        | What 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](https://docs.volcano.dev/platform/functions/durable-approvals.md).                                                                                                                 |
| `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](https://docs.volcano.dev/platform/functions/durable-messages.md).                                                                                    |
| `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](https://docs.volcano.dev/platform/functions/durable-callbacks.md). |
| `ctx.publish(name, data?)`                       | Records a message that applications list or receive live. See [Durable messages](https://docs.volcano.dev/platform/functions/durable-messages.md#publish-a-message).                                                                                                                                               |
| `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.log`                                        | The 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`](https://docs.volcano.dev/platform/functions/durable-approvals.md). To wait for a webhook, your application, or another function, have it send a message and wait with [`ctx.waitForMessage`](https://docs.volcano.dev/platform/functions/durable-messages.md). To wait for a system that holds no Volcano credential, hand it a token with [`ctx.waitForCallback`](https://docs.volcano.dev/platform/functions/durable-callbacks.md); for state only you can check, poll it with `ctx.waitUntil`. Full reference and worked examples are in the SDK's [durable functions guide](https://docs.volcano.dev/sdk/js/durable-functions.md).

## Deploy one

```bash
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"
```

```json
{
  "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](https://docs.volcano.dev/platform/functions/overview.md).

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`:

```json
{
  "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:

```yaml
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](https://docs.volcano.dev/platform/projects/configuration.md) and [Git deploy](https://docs.volcano.dev/platform/projects/git-deploy.md).

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](https://docs.volcano.dev/cli.md) covers the whole lifecycle under `volcano cloud durable`, reading the same `volcano-config.yaml` and the same `volcano/functions/` layout standard functions use:

```bash
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](https://docs.volcano.dev/cli/deploy.md).

## 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:

```bash
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}'
```

```json
{
  "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](#wait-for-the-result) and [stop](#stop-an-execution) this execution and reach its [messages](https://docs.volcano.dev/platform/functions/durable-messages.md#client-tokens). 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](#who-can-start-executions) admits it:

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

console.log(data.id, data.status); // 'running'
```

```python
execution = client.durable.start(
    "order-pipeline", {"order_id": 4417}, execution_name="order-4417"
)

print(execution.id, execution.status)  # 'running'
```

```ruby
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](https://docs.volcano.dev/platform/functions/durable-messages.md#client-tokens). A start refused because the project is already at its concurrency cap answers `429` instead — see [Limits and billing](#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.

| Endpoint                                                        | Credentials                                                                                               | Use it for                                            |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `POST /durable-functions/{functionId}/executions`               | Service key; auth user token or anon key as the function's [visibility](#who-can-start-executions) allows | Applications starting work                            |
| `POST /projects/{id}/durable-functions/{functionId}/executions` | Project owner token, or a `full` project access token                                                     | Scripts 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](#wait-for-the-result) and [Stop an execution](#stop-an-execution). Listing executions is on the project-scoped collection and takes the owner's token or a [project access token](https://docs.volcano.dev/platform/authentication/security/project-access-tokens.md). 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](https://docs.volcano.dev/platform/functions/creating-functions.md#choose-who-can-invoke-it)
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:

```bash
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"
```

```json
{
  "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`](https://docs.volcano.dev/platform/projects/configuration.md). `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](https://docs.volcano.dev/platform/functions/durable-messages.md#who-can-send-and-read) 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](https://docs.volcano.dev/platform/authentication/anonymous-users.md), because anyone can get one of those tokens. Every start counts against your [execution and operation allowances](#limits-and-billing) 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](https://docs.volcano.dev/platform/functions/overview.md#user-context):

```javascript
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 };
});
```

| Field          | Value                                |
| -------------- | ------------------------------------ |
| `user_id`      | The user's ID                        |
| `email`        | The user's email                     |
| `role`         | `authenticated` or `anonymous`       |
| `project_id`   | The project's ID                     |
| `access_token` | The 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](https://docs.volcano.dev/platform/authentication/configuration/token-lifetimes.md)
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>`](https://docs.volcano.dev/platform/databases/direct-connection.md#from-event__volcano_auth)
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](#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](https://docs.volcano.dev/platform/realtime/postgres-changes.md), which respect
row-level security:

```sql
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());
```

```javascript
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`:

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

```json
{
  "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"
}
```

| Status      | Meaning                                                                 |
| ----------- | ----------------------------------------------------------------------- |
| `pending`   | Accepted, not yet under way                                             |
| `running`   | Executing, or suspended on a wait                                       |
| `succeeded` | Finished; `result` holds what the function returned                     |
| `failed`    | Ended with an error; `error` holds its type and message                 |
| `timed_out` | Exceeded the execution timeout                                          |
| `stopped`   | Cancelled through the stop endpoint                                     |
| `unknown`   | The platform lost track of the outcome; no result or error is available |

Who can read an execution on this endpoint:

| Credential                               | Reaches                                                                        |
| ---------------------------------------- | ------------------------------------------------------------------------------ |
| Service key holding `*`, the default     | Any 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 token            | Executions that user started, and any execution whose client token they send   |
| Anon key                                 | Only executions whose client token it sends                                    |

An execution's [messages](https://docs.volcano.dev/platform/functions/durable-messages.md#who-can-send-and-read) follow the same rule. The function's [visibility](#who-can-start-executions) 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:

```bash
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.

```json
{
  "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](#inspect-an-executions-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:

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

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

### List executions

```bash
curl "https://api.volcano.dev/projects/$PROJECT_ID/durable-functions/$FUNCTION_ID/executions?status=running" \
  -H "Authorization: Bearer $PLATFORM_TOKEN"
```

```json
{
  "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:

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

```json
{
  "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 code             | `kind`                           | Children                                                                                                                  |
| --------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `ctx.step`            | `step`                           | Each run of the body is an entry in `attempts`                                                                            |
| `ctx.wait`            | `wait`                           | `scheduled_end_at` is when it elapses                                                                                     |
| `ctx.waitUntil`       | `wait_until`                     | Each check is an attempt; `next_attempt_at` is the next one                                                               |
| `ctx.waitForApproval` | `child`                          | A `callback`, `waiting` until the decision arrives, and the `step` that registered the request                            |
| `ctx.waitForMessage`  | `child`                          | A `callback`, `waiting` until a message arrives, and the `step` that registered the wait                                  |
| `ctx.waitForCallback` | `child`                          | A `callback`, `waiting` until the token is answered, and the `step` that ran your submitter                               |
| `ctx.publish`         | `step`, or `child` in JavaScript | Nothing inside a JavaScript publish. The first publish in a context also opens a `child` named `volcano:messages-session` |
| `ctx.child`           | `child`                          | The operations inside it                                                                                                  |
| `ctx.map`             | `map`                            | One `map_item` per item                                                                                                   |
| `ctx.parallel`        | `parallel`                       | One `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](#wait-for-the-result):

```bash
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:

```bash
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:

```json
{
  "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:

```bash
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" }
  }'
```

```json
{
  "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](https://docs.volcano.dev/platform/functions/scheduled-invocations.md).

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](https://docs.volcano.dev/platform/api-reference/function-schedulers.md#health) and [Notifications](https://docs.volcano.dev/platform/projects/notifications.md).

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:

```bash
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](https://docs.volcano.dev/platform/api-reference/function-schedulers.md) 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:

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

```json
{
  "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:

```bash
curl "https://api.volcano.dev/projects/$PROJECT_ID/deployments?function_kind=durable&limit=20" \
  -H "Authorization: Bearer $PLATFORM_TOKEN"
```

```json
{
  "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`:

```bash
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](https://docs.volcano.dev/platform/functions/logs.md).

## Delete a durable function

```bash
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](#stop-an-execution) 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.

```bash
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](https://docs.volcano.dev/platform/guides/durable-functions-locally.md) 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 operation                               | Notes                                                                                    |
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| The execution                                         | One per execution, whatever it goes on to do                                             |
| Each `ctx.step` attempt                               | A retried step is another attempt, so another operation                                  |
| Each `ctx.wait`                                       | One per wait, however long it lasts                                                      |
| Each `ctx.waitUntil` check                            | Size `maxAttempts` with this in mind: 200 checks is 200 operations                       |
| Each `ctx.child` context                              | Plus whatever the child itself does                                                      |
| Each `ctx.map` item and `ctx.parallel` branch         | Each runs in a child context of its own                                                  |
| Each `ctx.waitForApproval`                            | Three: the approval, the wait for its decision, and the step that registers it           |
| Each `ctx.waitForMessage`                             | Three: the wait, the callback the message completes, and the step that registers it      |
| Each `ctx.waitForCallback`                            | Three: the wait, the callback the token completes, and the step that runs your submitter |
| Each `ctx.publish`                                    | One. The first publish in a context also opens a messages session, which is three more   |
| Each function an execution invokes from inside itself | One 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:

```text
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.

| Limit                             | HOBBY               | SUPERAGENT           |
| --------------------------------- | ------------------- | -------------------- |
| Execution allowance               | 5,000 / month       | 10,000 / month       |
| Operation allowance               | 100,000 / month     | 200,000 / month      |
| Compute allowance                 | 10,000 GB-s / month | 100,000 GB-s / month |
| Memory                            | 128 MB              | 1 GB                 |
| Operations per execution          | 3,000               | 3,000                |
| Step timeout                      | 300 s               | 900 s                |
| Execution timeout                 | 366 days            | 366 days             |
| Result retention                  | 30 days             | 30 days              |
| Concurrent executions per project | 10                  | 100                  |
| Durable functions per project     | 10,000              | 10,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](https://docs.volcano.dev/platform/guides/plans-and-limits.md#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](https://docs.volcano.dev/platform/guides/plans-and-limits.md).

## What's next

| Guide                                             | Description                                                    |
| ------------------------------------------------- | -------------------------------------------------------------- |
| [Durable approvals](https://docs.volcano.dev/platform/functions/durable-approvals.md)         | Pause a workflow until a person approves or denies             |
| [Durable messages](https://docs.volcano.dev/platform/functions/durable-messages.md)           | Send messages to a running workflow and read what it publishes |
| [Durable callbacks](https://docs.volcano.dev/platform/functions/durable-callbacks.md)         | Let another system complete a waiting step with a token        |
| [Functions overview](https://docs.volcano.dev/platform/functions/overview.md)                 | Standard functions, runtimes, and packaging                    |
| [Environment variables](https://docs.volcano.dev/platform/functions/environment-variables.md) | Configure secrets and settings                                 |
| [Logs](https://docs.volcano.dev/platform/functions/logs.md)                                   | View and filter function logs                                  |
| [Plans and limits](https://docs.volcano.dev/platform/guides/plans-and-limits.md) | HOBBY and SUPERAGENT limits in full                            |
