---
title: Run Cloud Agent Tasks with the Public API
description: Dispatch cloud agent tasks, read their results, and customize how they run with the public GraphQL API.
---
A [task](/cloud-agents/tasks) is one request to a cloud agent and its result. This page shows how to dispatch tasks, read results, and control how a task runs with the Public API.

<PreviewBanner>Breaking changes may occur.</PreviewBanner>

## Before you start

Task operations use the same [endpoint and authentication](/integrations/api#connecting-to-the-public-api) as the rest of the Public API.

| Token type | Supported |
|------------|-----------|
| Account token | Yes |
| Workspace token | Yes. Tasks run as the member who created the token. |
| OAuth token | Yes |
| Project token | No |

Every task belongs to the user behind the token. You can only read, answer, and cancel tasks you dispatched, and a task can only run on a cloud agent you own.

You need the ID of the project and the environment the task runs in. Find them with the [projects](/integrations/api/manage-projects) and [environments](/integrations/api/manage-environments) queries.

## Dispatch a task

`cloudAgentTaskDispatch` starts a task and returns as soon as Railway accepts it. The task runs in the background.

<GraphQLCodeTabs query={`mutation cloudAgentTaskDispatch($input: CloudAgentTaskDispatchInput!) {
  cloudAgentTaskDispatch(input: $input) {
    taskId
    cloudAgentId
    sessionId
    status
    replayed
    deliveredAsReply
  }
}`} variables={{ input: { projectId: "project-id", environmentId: "environment-id", prompt: "Fix the failing test in api/ and open a pull request", source: { repo: "acme/api" } } }} />

The response is a handle to the task:

| Field | Description |
|-------|-------------|
| `taskId` | The task. Use it to read, answer, or cancel the task. |
| `cloudAgentId` | The cloud agent the task runs on |
| `sessionId` | The session on that agent. Tasks in the same session share context. |
| `status` | The status at dispatch, usually `PENDING` |
| `replayed` | `true` when the `idempotencyKey` matched an earlier dispatch and this is that task |
| `deliveredAsReply` | `true` when the prompt answered a question the agent was waiting on, and no new task was started |

With no target, the dispatch creates a new cloud agent, which is billed. See [Choose where a task runs](#choose-where-a-task-runs) to reuse one.

## Read a task

`cloudAgentTask` returns a task's status and, once it ends, its result. Pass the agent and session from the handle. Reading a task never wakes a sleeping agent.

<GraphQLCodeTabs query={`query cloudAgentTask($cloudAgentId: String!, $sessionId: String!, $taskId: String) {
  cloudAgentTask(cloudAgentId: $cloudAgentId, sessionId: $sessionId, taskId: $taskId) {
    taskId
    status
    text
    structuredOutput
    error
    completedAt
    model
    reasoningEffort
    attachments {
      filename
      url
      contentType
      expiresAt
    }
    pendingInteractions {
      requestId
      kind
      message
      actions
    }
    progress {
      steps
      tools
      updatedAt
    }
  }
}`} variables={{ cloudAgentId: "cloud-agent-id", sessionId: "session-id", taskId: "task-id" }} />

Without `taskId`, the query returns the latest task on the session.

| Field | Description |
|-------|-------------|
| `status` | `PENDING`, `RUNNING`, `AWAITING_INPUT`, `COMPLETED`, `FAILED`, or `CANCELLED` |
| `text` | The agent's closing message, once the task ends |
| `structuredOutput` | The result as JSON, when the dispatch set `outputSchema` |
| `error` | Why the task failed or that it was cancelled |
| `attachments` | Files the agent linked in its result. Each URL stays valid until `expiresAt`. |
| `pendingInteractions` | What the agent is waiting on when the status is `AWAITING_INPUT` |
| `progress` | Steps taken and tools in use while the task runs. `null` once it ends. |
| `model`, `reasoningEffort` | What the task ran on |

`COMPLETED`, `FAILED`, and `CANCELLED` are final. A finished task keeps its `text` and `structuredOutput`, so you can read them after the agent sleeps.

## Dispatch and wait for the result

This example dispatches a task, polls until it settles, and prints the result. Polling every few seconds stays well inside the [rate limits](#limits).

<CodeBlock>
  <CodeTab label="Bash" lang="bash">
{`API="https://backboard.railway.com/graphql/v2"

gql() {
  curl --silent --request POST "$API" \\
    --header "Authorization: Bearer $RAILWAY_API_TOKEN" \\
    --header "Content-Type: application/json" \\
    --data "$1"
}

HANDLE=$(gql '{
  "query": "mutation($input: CloudAgentTaskDispatchInput!) { cloudAgentTaskDispatch(input: $input) { taskId cloudAgentId sessionId } }",
  "variables": { "input": {
    "projectId": "'"$PROJECT_ID"'",
    "environmentId": "'"$ENVIRONMENT_ID"'",
    "prompt": "Summarize the errors in the last hour of logs"
  } }
}' | jq --exit-status '.data.cloudAgentTaskDispatch') || {
  echo "The task was not dispatched" >&2
  exit 1
}

while true; do
  TASK=$(gql '{
    "query": "query($cloudAgentId: String!, $sessionId: String!, $taskId: String) { cloudAgentTask(cloudAgentId: $cloudAgentId, sessionId: $sessionId, taskId: $taskId) { status text error } }",
    "variables": '"$HANDLE"'
  }' | jq '.data.cloudAgentTask')

  case $(echo "$TASK" | jq -r '.status') in
    COMPLETED) echo "$TASK" | jq -r '.text'; break ;;
    FAILED|CANCELLED) echo "$TASK" | jq -r '.error' >&2; exit 1 ;;
    AWAITING_INPUT) echo "The agent is waiting on an answer" >&2; exit 2 ;;
    PENDING|RUNNING) ;;
    *) echo "Could not read the task" >&2; exit 1 ;;
  esac
  sleep 5
done`}
  </CodeTab>
  <CodeTab label="JavaScript" lang="javascript">
{`const API = "https://backboard.railway.com/graphql/v2";

async function gql(query, variables) {
  const response = await fetch(API, {
    method: "POST",
    headers: {
      Authorization: \`Bearer \${process.env.RAILWAY_API_TOKEN}\`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ query, variables }),
  });
  const { data, errors } = await response.json();
  if (errors) throw new Error(errors[0].message);
  return data;
}

const { cloudAgentTaskDispatch: handle } = await gql(
  \`mutation($input: CloudAgentTaskDispatchInput!) {
    cloudAgentTaskDispatch(input: $input) { taskId cloudAgentId sessionId }
  }\`,
  {
    input: {
      projectId: process.env.PROJECT_ID,
      environmentId: process.env.ENVIRONMENT_ID,
      prompt: "Summarize the errors in the last hour of logs",
    },
  },
);

const settled = ["COMPLETED", "FAILED", "CANCELLED", "AWAITING_INPUT"];
let task;
do {
  await new Promise((resolve) => setTimeout(resolve, 5000));
  ({ cloudAgentTask: task } = await gql(
    \`query($cloudAgentId: String!, $sessionId: String!, $taskId: String) {
      cloudAgentTask(cloudAgentId: $cloudAgentId, sessionId: $sessionId, taskId: $taskId) {
        status text error
      }
    }\`,
    handle,
  ));
} while (!settled.includes(task.status));

console.log(task.status, task.text ?? task.error);`}
  </CodeTab>
  <CodeTab label="Python" lang="python">
{`import os
import time

import requests

API = "https://backboard.railway.com/graphql/v2"
HEADERS = {"Authorization": f"Bearer {os.environ['RAILWAY_API_TOKEN']}"}


def gql(query, variables):
    response = requests.post(
        API, json={"query": query, "variables": variables}, headers=HEADERS
    )
    body = response.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"][0]["message"])
    return body["data"]


handle = gql(
    """mutation($input: CloudAgentTaskDispatchInput!) {
      cloudAgentTaskDispatch(input: $input) { taskId cloudAgentId sessionId }
    }""",
    {
        "input": {
            "projectId": os.environ["PROJECT_ID"],
            "environmentId": os.environ["ENVIRONMENT_ID"],
            "prompt": "Summarize the errors in the last hour of logs",
        }
    },
)["cloudAgentTaskDispatch"]

settled = {"COMPLETED", "FAILED", "CANCELLED", "AWAITING_INPUT"}
while True:
    time.sleep(5)
    task = gql(
        """query($cloudAgentId: String!, $sessionId: String!, $taskId: String) {
          cloudAgentTask(cloudAgentId: $cloudAgentId, sessionId: $sessionId, taskId: $taskId) {
            status text error
          }
        }""",
        handle,
    )["cloudAgentTask"]
    if task["status"] in settled:
        break

print(task["status"], task["text"] or task["error"])`}
  </CodeTab>
</CodeBlock>

To be told when a task changes state instead of polling, use [webhooks](#receive-webhooks).

## Choose where a task runs

A dispatch input carries at most one target. The target decides which cloud agent and session the task uses.

| Input | The task runs on | Starts a new agent |
|-------|------------------|--------------------|
| No target | A new cloud agent | Always |
| `cloudAgentId` | That agent, in a new session | Never |
| `cloudAgentId` and `sessionId` | That agent and session | Never |
| `replyToTaskId` | The agent and session of an earlier task | Never |
| `conversationKey` | The agent and session the key started | Only on the key's first task |

`conversationKey`, `replyToTaskId`, and `cloudAgentId` are mutually exclusive.

### Continue an earlier task

Pass `replyToTaskId` to send a follow-up to the same agent and session. The agent keeps the context of the earlier task.

<GraphQLCodeTabs query={`mutation cloudAgentTaskDispatch($input: CloudAgentTaskDispatchInput!) {
  cloudAgentTaskDispatch(input: $input) {
    taskId
    status
  }
}`} variables={{ input: { projectId: "project-id", environmentId: "environment-id", prompt: "Add a changelog entry for that fix", replyToTaskId: "task-id" } }} />

### Use a conversation key

A conversation key is a name you choose for a thread of work, such as the ID of an issue in your tracker. The first task with a key starts a cloud agent. Later tasks with the same key continue its session, each waiting for the one before it to finish.

Conversation keys suit integrations that receive events for the same subject over time, because the caller doesn't have to store an agent or session ID.

<GraphQLCodeTabs query={`mutation cloudAgentTaskDispatch($input: CloudAgentTaskDispatchInput!) {
  cloudAgentTaskDispatch(input: $input) {
    taskId
    cloudAgentId
    sessionId
    status
  }
}`} variables={{ input: { projectId: "project-id", environmentId: "environment-id", prompt: "A new comment on the issue asks for a regression test. Add one.", conversationKey: "linear:ENG-123" } }} />

A key is scoped to the environment and to you. It can be up to 256 characters.

### Ask before creating an agent

A new cloud agent is billed. Set `allowCreate` to `false` to have the dispatch refuse, with error code `AGENT_CREATE_REQUIRED`, when it would have to create one. Nothing is created and nothing is charged. Ask your user, then send the same request again without `allowCreate`.

This is most useful with `conversationKey`, where the caller can't otherwise tell whether a key already has an agent.

<GraphQLCodeTabs query={`mutation cloudAgentTaskDispatch($input: CloudAgentTaskDispatchInput!) {
  cloudAgentTaskDispatch(input: $input) {
    taskId
    status
  }
}`} variables={{ input: { projectId: "project-id", environmentId: "environment-id", prompt: "Investigate the failed deployment", conversationKey: "incident-482", allowCreate: false } }} />

## Customize how a task runs

These dispatch fields control what the agent starts with, what it runs on, and what it returns.

| To | Set |
|----|-----|
| Start a new agent from a repository | `source` |
| Name a new agent | `agentName` |
| Choose the model and effort | `model`, `reasoningEffort` |
| Give the agent background | `context` |
| Show the agent images | `images` |
| Get a JSON result | `outputSchema` |
| Let the agent ask questions | `interactive` |
| Message a run that's in progress | `mode` |
| Make a retry safe | `idempotencyKey` |
| Tag the task with your own identifiers | `externalRef`, `metadata` |
| Be notified of progress and results | `webhookUrl`, `webhookSecret`, `webhookProgress` |

### Start from a repository

`source` sets what a new cloud agent's workspace starts with. It applies only when the dispatch creates an agent.

| Field | Description |
|-------|-------------|
| `repo` | GitHub repository as `owner/repo` |
| `branch` | Branch to check out. Defaults to the repository's default branch. |
| `serviceId` | Use the repository of this service's source instead of naming one |

Set `repo` or `serviceId`, not both.

<GraphQLCodeTabs query={`mutation cloudAgentTaskDispatch($input: CloudAgentTaskDispatchInput!) {
  cloudAgentTaskDispatch(input: $input) {
    taskId
    cloudAgentId
  }
}`} variables={{ input: { projectId: "project-id", environmentId: "environment-id", prompt: "Upgrade the web framework to its latest minor version and open a pull request", agentName: "framework-upgrade", source: { repo: "acme/api", branch: "main" } } }} />

### Choose a model and effort

`cloudAgentTaskModels` lists the models a task in an environment can run on, the reasoning efforts each supports, and the defaults.

<GraphQLCodeTabs query={`query cloudAgentTaskModels($projectId: String!, $environmentId: String!) {
  cloudAgentTaskModels(projectId: $projectId, environmentId: $environmentId) {
    defaultModel
    defaultReasoningEffort
    models {
      id
      displayName
      provider
      isDefault
      reasoningEfforts
    }
  }
}`} variables={{ projectId: "project-id", environmentId: "environment-id" }} />

Pass a model's `id` as `model` and one of its `reasoningEfforts` as `reasoningEffort`.

- `model` is fixed when a conversation starts. A later task in the same session can repeat it but can't change it.
- `reasoningEffort` can change from one task to the next.
- Without `model`, a new conversation runs on the default.

<GraphQLCodeTabs query={`mutation cloudAgentTaskDispatch($input: CloudAgentTaskDispatchInput!) {
  cloudAgentTaskDispatch(input: $input) {
    taskId
    status
  }
}`} variables={{ input: { projectId: "project-id", environmentId: "environment-id", prompt: "Find the cause of the memory growth in the worker service", model: "model-id", reasoningEffort: "high" } }} />

### Give the agent background

`context` is a list of entries the model reads beside the prompt as background, not as instructions. Use it for material the task refers to: earlier messages, the page a user was on, or a log excerpt.

Each entry is an object with a `type`, an optional `text`, and any other attributes with string, number, boolean, or null values.

<GraphQLCodeTabs query={`mutation cloudAgentTaskDispatch($input: CloudAgentTaskDispatchInput!) {
  cloudAgentTaskDispatch(input: $input) {
    taskId
    status
  }
}`} variables={{ input: { projectId: "project-id", environmentId: "environment-id", prompt: "Work out why this request failed and propose a fix", context: [ { type: "issue", text: "Checkout returns 500 for carts with more than 50 items", url: "https://linear.app/acme/issue/ENG-123", priority: 1 }, { type: "log", text: "TypeError: Cannot read properties of undefined (reading 'price')", service: "checkout-api" } ] } }} />

On a continued conversation, send only what is new since the last task. Earlier turns are already in the session.

| Limit | Value |
|-------|-------|
| Entries | 32 |
| `type` | 1 to 64 letters, digits, and `_ . : -` |
| `text` per entry | 8 KiB |
| Attributes per entry | 16, each up to 1 KiB |
| All entries together | 64 KiB |

### Attach images

`images` gives the model up to four images to look at with the prompt, such as screenshots or mockups. Images must be PNG, JPEG, or WebP, and 5 MiB in total.

| Field | Description |
|-------|-------------|
| `url` | A public HTTPS URL. Railway fetches it when the task is dispatched. |
| `data` | The image as base64, or as a `data:` URL. Counts toward the 1 MB request body limit. |
| `mediaType` | Optional. Checked against the image bytes. |
| `filename` | Optional |

Set `url` or `data` on each image, not both. Send larger images by `url`.

<GraphQLCodeTabs query={`mutation cloudAgentTaskDispatch($input: CloudAgentTaskDispatchInput!) {
  cloudAgentTaskDispatch(input: $input) {
    taskId
    status
  }
}`} variables={{ input: { projectId: "project-id", environmentId: "environment-id", prompt: "The settings page renders like the attached screenshot. Fix the overlapping sidebar.", images: [ { url: "https://example.com/screenshots/settings-overlap.png" } ] } }} />

### Get structured output

`outputSchema` is a JSON Schema object. The agent returns a result that matches it in `structuredOutput`, so a program can read the outcome without parsing prose.

<GraphQLCodeTabs query={`mutation cloudAgentTaskDispatch($input: CloudAgentTaskDispatchInput!) {
  cloudAgentTaskDispatch(input: $input) {
    taskId
    cloudAgentId
    sessionId
  }
}`} variables={{ input: { projectId: "project-id", environmentId: "environment-id", prompt: "Check whether the latest deployment of each service is healthy", outputSchema: { type: "object", properties: { healthy: { type: "boolean" }, services: { type: "array", items: { type: "object", properties: { name: { type: "string" }, status: { type: "string" } }, required: ["name", "status"] } } }, required: ["healthy", "services"] } } }} />

The finished task then carries the result:

```json
{
  "status": "COMPLETED",
  "text": "",
  "structuredOutput": {
    "healthy": true,
    "services": [
      { "name": "api", "status": "SUCCESS" },
      { "name": "postgres", "status": "SUCCESS" }
    ]
  }
}
```

`outputSchema` requires the default `PROMPT` mode.

### Let the agent ask questions

`interactive` tells the agent whether someone is there to answer it.

| Value | Behavior |
|-------|----------|
| `true` | The agent can ask a question. The task moves to `AWAITING_INPUT` until it's answered. |
| `false` | Nobody will answer. The agent decides on its own, or gives up and fails with its reason. |
| Omitted | The agent has neither option |

Use `true` when a person is following the task, and `false` for unattended automation that must not stall. See [Answer the agent](#answer-the-agent) for how to reply.

`interactive` requires the default `PROMPT` mode.

### Message a run in progress

`mode` controls how a prompt is delivered to a session.

| Mode | Behavior |
|------|----------|
| `PROMPT` | Default. Starts a new turn. |
| `FOLLOW_UP` | Queues the message into a run that's in progress on the session |
| `STEER` | Sends the message to a run that's in progress, to redirect it |

`FOLLOW_UP` and `STEER` are for a session that already has a run in progress. A message delivered this way joins that run:

- The task finishes when that run finishes, and shares its closing message.
- It can't be cancelled on its own, because cancelling it would stop the run it joined.
- It can't be combined with `outputSchema` or `interactive`.

### Make retries safe

`idempotencyKey` protects against dispatching the same task twice, for example when a webhook is redelivered or a request times out. Sending the same request again with the same key returns the original task with `replayed: true` instead of starting another.

<GraphQLCodeTabs query={`mutation cloudAgentTaskDispatch($input: CloudAgentTaskDispatchInput!) {
  cloudAgentTaskDispatch(input: $input) {
    taskId
    status
    replayed
  }
}`} variables={{ input: { projectId: "project-id", environmentId: "environment-id", prompt: "Triage the new issue and label it", conversationKey: "linear:ENG-123", idempotencyKey: "linear:ENG-123:event-7c1e" } }} />

A key is scoped to the workspace and can be up to 128 characters. Reusing a key with a different request fails with `IDEMPOTENCY_CONFLICT`.

### Tag a task with your own identifiers

`externalRef` and `metadata` attach your identifiers to a task. Railway returns them when you read the task and includes them in every webhook, so you can match a task to a record in your system.

| Field | Limit |
|-------|-------|
| `externalRef` | A string up to 256 characters |
| `metadata` | An object of string values: up to 20 keys, 1,000 characters per value, 4,096 bytes in total |

## Answer the agent

A task in `AWAITING_INPUT` lists what the agent is waiting on in `pendingInteractions`.

| Field | Description |
|-------|-------------|
| `requestId` | Identifies the request when you answer it |
| `kind` | `approval` for a tool the agent wants to run, `elicitation` for a question or form |
| `message` | The question, for an elicitation |
| `tool`, `summary` | The tool and a preview of its arguments, for an approval |
| `server`, `fields` | The MCP server asking and the form fields to fill, for a form |
| `actions` | The answers the request accepts |
| `expiresAt` | When the request stops waiting |

### Answer a question

To answer a question the agent asked, dispatch your answer to the same session with `replyToTaskId`, `conversationKey`, or `cloudAgentId` and `sessionId`. Railway delivers the prompt as the answer and returns `deliveredAsReply: true` with the `taskId` of the task that asked.

<GraphQLCodeTabs query={`mutation cloudAgentTaskDispatch($input: CloudAgentTaskDispatchInput!) {
  cloudAgentTaskDispatch(input: $input) {
    taskId
    deliveredAsReply
  }
}`} variables={{ input: { projectId: "project-id", environmentId: "environment-id", prompt: "Use the staging database", replyToTaskId: "task-id" } }} />

### Answer an approval or a form

`cloudAgentTaskRespond` answers one request by its `requestId`. It wakes a sleeping agent to deliver the answer.

<GraphQLCodeTabs query={`mutation cloudAgentTaskRespond($input: CloudAgentTaskRespondInput!) {
  cloudAgentTaskRespond(input: $input) {
    taskId
    outcome
  }
}`} variables={{ input: { taskId: "task-id", requestId: "request-id", action: "ALLOW" } }}
optionalFields={[
  { name: "input.content", type: "JSON", description: "Form values keyed by field name. Only with ACCEPT." },
  { name: "input.answeredBy", type: "String", description: "Who gave the answer, for the audit log. Not verified." },
]} />

| Action | Use for |
|--------|---------|
| `ALLOW`, `DENY` | Approving or refusing a tool call |
| `ACCEPT` | Submitting a form, with its values in `content` |
| `DECLINE` | Declining to fill in a form |
| `CANCEL` | Dismissing the request |

After you answer, read the task again to follow it to its result.

## Cancel a task

`cloudAgentTaskCancel` stops a task. A task that hasn't started never runs. A running task has its own run stopped. The task ends `CANCELLED`. Cancelling a finished task changes nothing.

<GraphQLCodeTabs query={`mutation cloudAgentTaskCancel($taskId: String!) {
  cloudAgentTaskCancel(taskId: $taskId) {
    taskId
    status
  }
}`} variables={{ taskId: "task-id" }} />

The mutation returns at once with the status the task had when you asked. Read the task to confirm it ended.

## List tasks

`cloudAgentTasks` lists the tasks you dispatched in an environment, newest first.

<GraphQLCodeTabs query={`query cloudAgentTasks($environmentId: String!, $status: CloudAgentTaskStatus, $limit: Int, $cursor: String) {
  cloudAgentTasks(environmentId: $environmentId, status: $status, limit: $limit, cursor: $cursor) {
    nextCursor
    tasks {
      id
      status
      cloudAgentId
      sessionId
      promptPreview
      createdVia
      externalRef
      metadata
      text
      error
      createdAt
      startedAt
      completedAt
    }
  }
}`} variables={{ environmentId: "environment-id", status: "RUNNING", limit: 20 }}
optionalFields={[
  { name: "cloudAgentId", type: "String", description: "Only tasks on this cloud agent" },
  { name: "sessionId", type: "String", description: "Only tasks in this session. Requires cloudAgentId." },
  { name: "status", type: "CloudAgentTaskStatus", description: "Only tasks with this status" },
  { name: "limit", type: "Int", description: "Page size, 1 to 100", apiDefault: "20" },
  { name: "cursor", type: "String", description: "nextCursor from the previous page" },
]} />

To page through results, pass `nextCursor` as `cursor` until it comes back `null`.

Listing a session's tasks, with `cloudAgentId` and `sessionId`, returns the turns of one conversation in order.

## Recover a result

`cloudAgentTaskRecover` reads the latest task result from the agent itself. Use it when `cloudAgentTask` reports a completed task with no `text`. Unlike `cloudAgentTask`, it can wake a sleeping agent.

<GraphQLCodeTabs query={`mutation cloudAgentTaskRecover($cloudAgentId: String!, $sessionId: String!) {
  cloudAgentTaskRecover(cloudAgentId: $cloudAgentId, sessionId: $sessionId) {
    taskId
    status
    text
    structuredOutput
  }
}`} variables={{ cloudAgentId: "cloud-agent-id", sessionId: "session-id" }} />

## Receive webhooks

Set `webhookUrl` on a dispatch and Railway sends an HTTP `POST` with a JSON body for each lifecycle event of that task.

<GraphQLCodeTabs query={`mutation cloudAgentTaskDispatch($input: CloudAgentTaskDispatchInput!) {
  cloudAgentTaskDispatch(input: $input) {
    taskId
    status
  }
}`} variables={{ input: { projectId: "project-id", environmentId: "environment-id", prompt: "Fix the failing test in api/ and open a pull request", externalRef: "ENG-123", webhookUrl: "https://example.com/hooks/railway-tasks", webhookSecret: "a-long-random-secret", webhookProgress: true } }} />

### Events

Every event carries `event`, `status`, `taskId`, `cloudAgentId`, `sessionId`, `projectId`, and `environmentId`, plus `externalRef` and `metadata` when the dispatch set them.

| Event | Sent when | Additional fields |
|-------|-----------|-------------------|
| `task.started` | The agent begins the task | `prompt`, `timestamp` |
| `task.progress` | The agent makes progress. Sent only with `webhookProgress`, at most once every 15 seconds. | `progress`, `timestamp` |
| `task.awaiting_input` | The agent asks a question or needs an approval | `interaction`, `timestamp` |
| `task.completed` | The task finishes | `text`, `structuredOutput`, `attachments`, `error`, `completedAt` |
| `task.failed` | The task fails or is cancelled | `text`, `error`, `completedAt` |

A completed task's event looks like this:

```json
{
  "event": "task.completed",
  "status": "completed",
  "taskId": "6a667040-033b-4319-a24d-3d9fa1cdb1dd",
  "cloudAgentId": "a1574ca3-e81a-4efb-87dc-47bbe0a2a341",
  "sessionId": "task-24250171c35d",
  "projectId": "7ea90d35-6594-429e-8d15-9f3958cfb749",
  "environmentId": "703b95f8-2f0c-4a53-9d0c-6c1b9a2e4f11",
  "externalRef": "ENG-123",
  "text": "Fixed the off-by-one in the pagination test and opened a pull request.",
  "error": null,
  "completedAt": "2026-11-12T21:14:31.636Z"
}
```

A cancelled task sends `task.failed` with `"error": "The task was cancelled"`.

### Verify the signature

When the dispatch sets `webhookSecret`, Railway signs the request body with HMAC-SHA256 and sends the result in the `x-railway-signature` header as `sha256=<hex digest>`. Compute the same digest over the raw body and compare before you trust the payload.

<CodeBlock>
  <CodeTab label="JavaScript" lang="javascript">
{`import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();

app.post(
  "/hooks/railway-tasks",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const expected =
      "sha256=" +
      createHmac("sha256", process.env.TASK_WEBHOOK_SECRET)
        .update(req.body)
        .digest("hex");
    const received = req.get("x-railway-signature") ?? "";

    const valid =
      expected.length === received.length &&
      timingSafeEqual(Buffer.from(expected), Buffer.from(received));
    if (!valid) return res.status(401).end();

    const event = JSON.parse(req.body);
    if (event.event === "task.completed") {
      console.log(event.externalRef, event.text);
    }
    res.status(204).end();
  },
);

app.listen(process.env.PORT ?? 3000);`}
  </CodeTab>
  <CodeTab label="Python" lang="python">
{`import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)


@app.post("/hooks/railway-tasks")
def railway_tasks():
    expected = "sha256=" + hmac.new(
        os.environ["TASK_WEBHOOK_SECRET"].encode(),
        request.get_data(),
        hashlib.sha256,
    ).hexdigest()
    received = request.headers.get("x-railway-signature", "")
    if not hmac.compare_digest(expected, received):
        abort(401)

    event = request.get_json()
    if event["event"] == "task.completed":
        print(event.get("externalRef"), event["text"])
    return "", 204`}
  </CodeTab>
</CodeBlock>

### Delivery

Webhooks are best effort. Treat them as a prompt to act, and keep polling as the fallback.

- Railway waits up to 30 seconds for a response.
- A response with a `5xx` status, or no response, is retried once after a few seconds. Any other response isn't retried.
- A failed delivery never changes the task. The result is stored before the final event is sent, so you can always read it with `cloudAgentTask`.
- The URL must use `http` or `https` and must not contain credentials.

## Errors

A failed dispatch returns an error with a code in `extensions.code`.

| Code | Meaning | What to do |
|------|---------|------------|
| `RATE_LIMITED` | A rate limit was reached | Retry after `extensions.retryAfterSeconds` |
| `CONVERSATION_STARTING` | The conversation's first task is still creating its agent | Retry after `extensions.retryAfterSeconds` |
| `IDEMPOTENCY_CONFLICT` | The idempotency key was used with a different request, or the original is still being accepted | Use a new key, or retry shortly |
| `AGENT_CREATE_REQUIRED` | The task would start a new agent and `allowCreate` was `false` | Send the request again without `allowCreate` |
| `TASK_STARTUP_FAILED` | The task was accepted but couldn't start. `extensions.taskId` names it. | Read the error message. Don't retry the same request. |

Other problems, such as a target that doesn't exist or a model the environment can't use, return an error message without a code.

```json
{
  "errors": [
    {
      "message": "This task would start a new cloud agent; send it again with allowCreate to start one",
      "extensions": { "code": "AGENT_CREATE_REQUIRED" }
    }
  ]
}
```

## Limits

Railway applies these rate limits per user.

| Action | Limit |
|--------|-------|
| Dispatch | 60 every 5 minutes |
| Respond and cancel | 120 every 5 minutes |
| Read and list | 240 every minute |

A dispatch that creates a cloud agent also counts against your workspace's cloud agent creation limit. Dispatches that reuse an agent don't.

A prompt can be up to 32,000 characters.