> ## Documentation Index
> Fetch the complete documentation index at: https://docs.firstflow.app/llms.txt
> Use this file to discover all available pages before exploring further.

# API calls

> Fetch live data from an external HTTP endpoint mid-flow, server-side, and reference the response in downstream nodes.

An **API Call** node runs an HTTP request from FirstFlow's backend while a flow
executes, then drops the response into the run's step bag so later nodes a
decision branch, a message, a guide's widget composition can read live data
like account status, plan limits, or order state. The request, the credentials,
and the response never touch the browser.

The node type is `api_call`. It belongs to the same family of server-side action
nodes as [Slack messages](/integrations/slack) and [webhooks](/integrations/webhooks),
but unlike those it produces an output you consume downstream rather than a
fire-and-forget side effect.

## How it works

A flow run carries a **step bag** a map keyed by node id that accumulates each
node's output as the run advances. When the engine reaches an `api_call` node, it
first **interpolates** the node's config against everything the run knows so far,
then performs the fetch, then writes the result under the node's id. Downstream
nodes interpolate their own config against that same bag, which is how a later
node "sees" what the API returned.

The sequence inside one node execution:

```mermaid theme={null}
flowchart TD
  A["Engine reaches api_call node"] --> B["Interpolate config<br/>{{trigger.*}} · {{steps.*}}"]
  B --> C["SSRF guard<br/>https only · public host only"]
  C -->|blocked| F["status: failed<br/>(no retry)"]
  C -->|ok| D["Decrypt per-node key<br/>→ Authorization: Bearer …"]
  D --> E["fetch() with timeout"]
  E -->|4xx / oversize / no URL| F
  E -->|network error / HTTP 5xx| G["throw → engine retry policy"]
  E -->|2xx| H["Parse JSON body"]
  H --> I["Write to step bag<br/>{ status, headers, body }"]
  I --> J["Downstream nodes read<br/>{{steps.&lt;id&gt;.body…}}"]

  style C fill:#fde68a,stroke:#b45309,color:#000
  style D fill:#bbf7d0,stroke:#15803d,color:#000
  style F fill:#fecaca,stroke:#b91c1c,color:#000
  style G fill:#fed7aa,stroke:#c2410c,color:#000
  style I fill:#bbf7d0,stroke:#15803d,color:#000
```

Everything here happens in `ApiCallExecutor` on the backend. The browser SDK is
never involved it only ever receives the finished widget that downstream
composition produces.

### Config is interpolated before the fetch

Config values may contain `{{...}}` tokens, and the engine resolves them against
the run **before** the request is built. Two roots are available:

* `{{trigger.<path>}}` the payload that started the run. For a message-driven
  guide that includes `{{trigger.content}}` (also aliased as `{{content}}`) and
  `{{userId}}`.
* `{{steps.<nodeId>.<path>}}` the output of any node that already ran,
  including earlier API Call nodes.

A token that is the **entire** string resolves to the raw typed value, so objects,
numbers, and booleans survive untouched. A token embedded in surrounding text is
stringified. That distinction matters: `"{{steps.lookup.body.plan}}"` yields the
string `"pro"`, while `"id={{userId}}"` yields `"id=usr_123"`.

```jsonc theme={null}
// Node config `{{userId}}` is resolved to the live run's user before the fetch
{
  "apiCallUrl": "https://api.example.com/v1/accounts/{{userId}}",
  "apiCallMethod": "GET",
  "apiCallQueryParams": [{ "key": "include", "value": "plan,limits" }]
}
```

### The response shape

On a successful (2xx) call the node writes this object to the step bag:

```jsonc theme={null}
{
  "status": 200,                       // numeric HTTP status
  "headers": { "content-type": "application/json", "...": "..." },
  "body": { "plan": "pro", "seats": 5 } // parsed JSON
}
```

The response is parsed as JSON. If the body is not valid JSON, `body` falls back
to `{ text: "<raw response>" }` so downstream interpolation never breaks. Read
any of it by path from a later node:

```text theme={null}
Your plan is {{steps.account_lookup.body.plan}} with
{{steps.account_lookup.body.seats}} seats.
```

Use the same paths in a [decision or classify node](/concepts/flows-and-nodes) to
branch for example route to one guide when
`{{steps.account_lookup.body.plan}}` equals `trial` and another when it equals
`pro`. Branch evaluation runs server-side, so the data the branch reads is the
data the backend fetched.

## Configure a node

The flow editor writes two things: the request config travels with the flow
graph (`apiCall*` keys on the node), and the **secret API key** is stored
separately and encrypted, never in the graph. Saving the key uses
`PUT /agents/:id/api-calls/:nodeId`.

<ParamField path="apiCallUrl" type="string" required>
  The endpoint to call. Must be `https://` (plain `http://localhost` is allowed
  only in development). The URL may contain `{{...}}` tokens. URL-embedded
  credentials (`https://user:pass@host`) are rejected.
</ParamField>

<ParamField path="apiCallMethod" type="string" default="GET">
  HTTP method, upper-cased. `GET` is typical for reads.
</ParamField>

<ParamField path="apiCallQueryParams" type="{ key, value }[]">
  Query-string parameters appended to the URL. Keys and values are
  URL-encoded; rows with a blank key are dropped. Values may use `{{...}}`.
</ParamField>

<ParamField path="apiCallHeaders" type="{ key, value }[]">
  Extra request headers. FirstFlow always sends `Accept: application/json` and
  `User-Agent: FirstflowBot/1.0`; your headers are merged on top. Values may use
  `{{...}}`.
</ParamField>

<ParamField path="apiCallAuthHeader" type="string" default="Authorization">
  The header the stored secret key is injected into, as `Bearer <key>`. Set this
  to a custom name (e.g. `X-Api-Key`) if the target expects something other than
  `Authorization`.
</ParamField>

<ParamField path="apiCallTimeoutMs" type="number" default="5000">
  Request timeout in milliseconds, clamped to `1000`–`10000`. The request is
  aborted when it elapses.
</ParamField>

<ParamField path="apiCallMaxResponseBytes" type="number" default="51200">
  Maximum response size, clamped to `1024`–`102400` (50 KB by default). A larger
  body fails the node rather than truncating, so downstream paths never read a
  half-parsed object.
</ParamField>

### Saving the secret key

The key never lives in the flow graph. It is encrypted at rest (the same
credential vault used for other [integrations](/features/integrations)) and only
decrypted in memory at call time, server-side, to build the `Bearer` header.

```bash theme={null}
curl -X PUT https://api.firstflow.app/agents/agt_123/api-calls/node_account_lookup \
  -H "Authorization: Bearer <dashboard-session>" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "https://api.example.com/v1/accounts",
        "apiKey": "sk_live_…",
        "authHeaderName": "Authorization",
        "timeoutMs": 5000,
        "maxResponseBytes": 51200
      }'
```

The save endpoint validates `url` (https only, no embedded credentials) and
clamps `timeoutMs` (`1000`–`10000`) and `maxResponseBytes` (`1024`–`102400`). The
matching `GET` returns config metadata plus a boolean `hasApiKey` never the key
itself:

```jsonc theme={null}
{
  "meta": { "statusCode": 200 },
  "data": {
    "url": "https://api.example.com/v1/accounts",
    "hasApiKey": true,
    "authHeaderName": "Authorization",
    "timeoutMs": 5000,
    "maxResponseBytes": 51200
  }
}
```

Send `"apiKey": null` to clear a stored key, or omit `apiKey` on a later `PUT` to
leave the existing key in place while changing other fields.

<Note>
  Most teams configure all of this in the flow editor and never call these
  endpoints directly. The contract is documented here because the key handling —
  encrypted at rest, decrypted only server-side, surfaced to clients only as
  `hasApiKey` is the reason an API Call node is safe to point at an
  authenticated endpoint.
</Note>

## Test before you ship

The editor can run a node's request once, on demand, via
`POST /flow-node-test/api-call`, so you capture the real `{ status, headers, body }`
as the step's sample shape while authoring. The test applies the **same SSRF
guard** as a live run and attaches the stored key only for an agent in your own
organization, so a key can never be exfiltrated by testing someone else's node.

```jsonc theme={null}
// POST /flow-node-test/api-call  →  data
{
  "ok": true,
  "durationMs": 142,
  "request": { "url": "https://api.example.com/v1/accounts", "method": "GET" },
  "response": {
    "status": 200,
    "statusText": "OK",
    "headers": { "content-type": "application/json" },
    "body": { "plan": "pro", "seats": 5 }
  }
}
```

A captured sample lets you reference `{{steps.<id>.body.plan}}` in downstream
nodes with confidence that the path exists.

## Failures, retries, and limits

The node distinguishes **definitive** failures from **transient** ones, because
only transient failures are worth retrying.

| Condition                                  | Outcome                      |
| ------------------------------------------ | ---------------------------- |
| No URL configured                          | `failed` (definitive)        |
| URL blocked by the SSRF guard              | `failed` (definitive)        |
| HTTP `4xx`                                 | `failed` (definitive)        |
| Response exceeds `apiCallMaxResponseBytes` | `failed` (definitive)        |
| Network error / timeout                    | thrown → engine retry policy |
| HTTP `5xx`                                 | thrown → engine retry policy |

A thrown error is handed to the engine's retry policy. The default policy is
`maxAttempts: 1`, which means **no retry** the run fails unless the node is
configured with a higher attempt count, in which case the delay grows
exponentially from a base. A definitive `failed` is never retried; the flow takes
its failure path immediately.

The SSRF guard is not optional. At runtime the URL is re-validated to be
`https://` pointing at a **public** host private, loopback, link-local,
unique-local, and carrier-grade-NAT IP ranges are rejected, as are `localhost`
and `.local` names (except `localhost` in development). Redirects are re-validated
on every hop, so an endpoint cannot 302 you onto an internal address. This is why
you cannot point an API Call node at internal infrastructure; expose a public
HTTPS endpoint instead.

A few smaller constraints worth knowing: the response must fit in
`apiCallMaxResponseBytes` (50 KB default, 100 KB max), the body is interpreted as
JSON with a `{ text }` fallback, and the timeout caps at 10 seconds. If you need
to push data outward rather than pull it in, reach for a
[webhook](/integrations/webhooks) instead it is the outbound counterpart and
runs through a retrying outbox.

## Next

Branch on what you fetched with a [decision or classify node](/concepts/flows-and-nodes),
or wire the value into a guide built from [widgets and blocks](/concepts/widgets-and-blocks).
For pushing data out instead of pulling it in, see [webhooks](/integrations/webhooks).
