---
title: Invoking an app | Runware Docs
url: https://runware.ai/docs/serverless/invoking
description: Call an endpoint synchronously or asynchronously, read the task back, and know which failures are worth retrying.
relatedDocuments:
  - https://runware.ai/docs/serverless/errors
  - https://runware.ai/docs/tools/typescript/serverless
  - https://runware.ai/docs/tools/python/serverless
---
## Introduction

Every call to an app is a **task**, whether you wait for it or come back later. The route you choose decides only whether the connection stays open.

```text
POST /v1/apps/{appId}/invoke-sync/{endpointPath}
POST /v1/apps/{appId}/invoke-async/{endpointPath}
```

Everything the platform needs is **in the URL and the method**: the app, the endpoint, and whether you are waiting. The method itself is fixed: **every invocation is a POST**.

## Reaching an endpoint

`endpointPath` is one or more lowercase segments joined by slashes, so `generate` and `v1/chat/completions` are both endpoints. **Send those slashes unencoded.** A generated client that escapes them as `%2F` is refused with a `422`.

An app can mark **one endpoint as its default**, with `@endpoint(default=True)` or `default: true` in its `container.yaml`, and a default is addressed by dropping the path entirely. **An app with exactly one endpoint is its own default**, with no mark needed.

```text
POST /v1/apps/{appId}/invoke-sync
POST /v1/apps/{appId}/invoke-async
```

The body, the responses and the task semantics are the same either way, so the short form is only a shorter address. **An app with several endpoints and no marked default answers `404`**, with a detail naming the requirement so you can tell it apart from an app id that does not exist.

## The envelope

The body is an envelope with exactly two members, and **both are required**.

```json
{
  "taskId": "8f14e45f-ceea-467a-9a2f-6d2b1f2c0b3e",
  "payload": { "prompt": "a red bicycle" }
}
```

Your own fields go inside `payload`, where they **can never collide with a platform field**. The endpoint's schema describes the payload alone, so the envelope can grow a member later and your app keeps working.

The envelope is **closed**. A stray member at the top level is rejected rather than ignored, which is what keeps that promise true.

**Most clients build it for you.** The CLI's `-f` file holds the payload alone and it generates the `taskId`, unless you pass `--task-id` yourself. The dashboard's Request tab works the same way, so its body is the payload rather than a whole envelope. You assemble the envelope yourself when you call the route directly.

## Making the call

**TypeScript**:

```typescript
import { createClient } from '@runware/sdk'

const client = await createClient({ apiKey: process.env.RUNWARE_API_KEY })

const task = await client.invoke({
  appId: 'image-tools',
  endpointPath: 'generate',
  payload: {
    prompt: 'a red bicycle'
  }
})
```

**Python**:

```python
import asyncio
import os

from runware import Runware

async def main():
    client = Runware(api_key=os.environ["RUNWARE_API_KEY"])
    try:
        task = await client.invoke(
            "image-tools",
            "generate",
            {
                "prompt": "a red bicycle"
            },
        )
    finally:
        await client.close()

asyncio.run(main())
```

**cURL**:

```bash
curl https://api.serverless.runware.ai/v1/apps/image-tools/invoke-async/generate \
  -H "Authorization: Bearer $RUNWARE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "payload": {
      "prompt": "a red bicycle"
    }
  }'
```

**CLI**:

```bash
echo '{"prompt":"a red bicycle"}' | runware serverless apps invoke image-tools generate -f -
```

**JSON**:

```json
{
  "taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "payload": {
    "prompt": "a red bicycle"
  }
}
```

**Both SDKs generate the task id and poll a `202` through to the result**, so the call above covers everything on this page. The CLI prints the id instead, and takes `--wait` when you want it to poll for you.

## The task id is yours

You generate `taskId` yourself, as a canonical lowercase UUID, and **you know it before the call returns**. Two things follow.

**A lost response costs you nothing.** Send the same id again and you get back the task it already names rather than a second run, so a retry after a dropped connection **cannot be charged twice**.

**Reusing an id for different work returns the first task**, not the new one. Keeping each id unique is your job.

> [!WARNING]
> A resubmitted task id is answered with the task it already names **even when that task belongs to a different app**. The response carries the owning `appId`, so poll under that one.

## What comes back

Both routes return a `Task`.

```json
{
  "id": "8f14e45f-ceea-467a-9a2f-6d2b1f2c0b3e",
  "appId": "image-tools",
  "status": "completed",
  "endpointPath": "generate",
  "createdAt": "...",
  "output": { "image": "iVBOR..." },
  "error": null
}
```

`id`, `status`, `appId`, `endpointPath` and `createdAt` are always present. **`output` and `error` are both optional and both nullable**, so `status` is the field to branch on.

**`output` is whatever your handler returned**, and it does not have to be an object. An array, a string, a number or a boolean comes back as it was, so a container app that answers with a bare value is read the same way as one that answers with a map.

## Choosing a route

<table><tbody><tr><td>Route</td><td>Returns</td><td>Use it when</td></tr><tr><td><code>invoke-sync</code></td><td><code>200</code> with the finished task, or <code>202</code> with a pending one if the work outlives the wait window</td><td>The work is short and you want the result inline</td></tr><tr><td><code>invoke-async</code></td><td><code>202</code> with a pending task</td><td>The work is long, or you are submitting many and collecting later</td></tr></tbody></table>

> [!NOTE]
> A `202` from `invoke-sync` is **not a failure and not a timeout**. The task is queued or running, and the work continues. You have its id, so poll for the result exactly as you would an asynchronous one. **Do not resubmit**, because that is how you pay for the same GPU work twice.

Asking for the synchronous route looks like this.

**TypeScript**:

```typescript
import { createClient } from '@runware/sdk'

const client = await createClient({ apiKey: process.env.RUNWARE_API_KEY })

const task = await client.invoke({
  appId: 'image-tools',
  endpointPath: 'generate',
  payload: {
    prompt: 'a red bicycle'
  }
}, {
  deliveryMethod: 'sync'
})
```

**Python**:

```python
import asyncio
import os

from runware import InvokeOptions, Runware

async def main():
    client = Runware(api_key=os.environ["RUNWARE_API_KEY"])
    try:
        task = await client.invoke(
            "image-tools",
            "generate",
            {
                "prompt": "a red bicycle"
            },
            options=InvokeOptions(delivery_method="sync"),
        )
    finally:
        await client.close()

asyncio.run(main())
```

**cURL**:

```bash
curl https://api.serverless.runware.ai/v1/apps/image-tools/invoke-sync/generate \
  -H "Authorization: Bearer $RUNWARE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "payload": {
      "prompt": "a red bicycle"
    }
  }'
```

**CLI**:

```bash
echo '{"prompt":"a red bicycle"}' | runware serverless apps invoke image-tools generate --sync -f -
```

**JSON**:

```json
{
  "taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "payload": {
    "prompt": "a red bicycle"
  }
}
```

**Both SDKs and the CLI take the asynchronous route unless told otherwise**, so this is the only call on the page that names one.

Both routes hand back the same shape, so **one code path serves both**.

## Reading a task back

```text
GET /v1/apps/{appId}/tasks/{taskId}
```

Poll until `status` is terminal. `GET /v1/apps/{appId}/tasks` lists recent tasks for an app when you have lost an id or want to see what ran.

A task that reaches `failed` carries the reason in `error` as **a plain string with no code and no structured cause**, so there is nothing to switch on. Surface it to whoever has to debug the handler.

## When an app cannot take work

A call to an undeclared endpoint is **rejected before anything is queued**, so an unknown path creates no task and costs you nothing.

Beyond that, the app's own state decides.

<table><tbody><tr><td>State</td><td>Result</td></tr><tr><td><code>initializing</code>, <code>active</code>, <code>stopping</code></td><td>Accepted</td></tr><tr><td><code>initializing</code> with no version yet</td><td><code>409</code>, and it clears once the first build finishes</td></tr><tr><td><code>active</code> with nothing able to serve</td><td><code>503</code>, retryable, <strong>no task created</strong></td></tr><tr><td><code>stopped</code>, <code>deleting</code>, <code>failed</code></td><td><code>409</code>. Resume a stopped app before invoking it</td></tr></tbody></table>

**`503` is the only one worth retrying blindly.** No task is minted, so a retry is not a duplicate. Everything else needs you to change something first.

Full detail on every failure shape is in [Errors](https://runware.ai/docs/serverless/errors).