Invoking an app

Call an endpoint synchronously or asynchronously, read the task back, and know which failures are worth retrying.

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.

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.

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.

{
  "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

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'
  }
})
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 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"
    }
  }'
echo '{"prompt":"a red bicycle"}' | runware serverless apps invoke image-tools generate -f -
{
  "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.

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.

{
  "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

RouteReturnsUse it when
invoke-sync200 with the finished task, or 202 with a pending one if the work outlives the wait windowThe work is short and you want the result inline
invoke-async202 with a pending taskThe work is long, or you are submitting many and collecting later

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.

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'
})
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 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"
    }
  }'
echo '{"prompt":"a red bicycle"}' | runware serverless apps invoke image-tools generate --sync -f -
{
  "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

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.

StateResult
initializing, active, stoppingAccepted
initializing with no version yet409, and it clears once the first build finishes
active with nothing able to serve503, retryable, no task created
stopped, deleting, failed409. Resume a stopped app before invoking it

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.