# External API integrations

A *Call external API* step sends the contact to your API, stores what it finds as [contact variables](https://founderdb.co/docs/contact-variables.md), and continues the sequence. Your API can answer right away or hours later.

## How it works

1. A contact reaches the step. We POST a JSON request to your URL.
2. Your API answers either **right away** (sync) with the result in the HTTP response, or **later** (async): it accepts the request, then POSTs the result to the `callbackUrl` we sent.
3. We store the returned `variables` on the contact and continue on the step's **Done** path, or on **Failed** if your API reports failure, errors, or (async) doesn't call back in time.

Protocol version: `founderdb.integration.v1`. It's in every request and callback response.

## Example: research a contact, then email

You want to know which platforms each contact already uses before writing to them, and your research service takes a few hours.

```text
Contact enrolled
  → Set variables        has_figma = unknown, platforms = unknown
  → Call external API    https://research.example.com/founderdb  (async, give up after 24h)
       Done   → Branch on variable   has_figma is yes
                    Yes → Email (template): "Saw you're on Figma — {{var.pitch_line | …}}"
                    No  → Email (AI): "Pitch the tools they'd benefit from"
       Failed → Email (template): generic intro
```

Setting the variables to unknown first is optional, but it makes the contact show "Unknown" in the inbox while research runs, and it lets a later branch tell "no answer" apart from "no".

## The request we send

`POST` to the step's URL with `content-type: application/json`:

```json
{
  "protocol": "founderdb.integration.v1",
  "callId": "8c1f7a0e-2d4b-4a51-9d0a-6f3a1c2b9e77",
  "mode": "async",
  "callbackUrl": "https://founderdb.co/api/integrations/callback/Qm9n…",
  "expiresAt": "2026-09-25T10:00:00.000Z",
  "contact": {
    "id": "3f1c6c5c-ab32-4711-acf4-c92686c05e87",
    "email": "jane@acme.com",
    "name": "Jane Doe",
    "product": { "name": "Acme", "websiteUrl": "https://acme.com" },
    "variables": { "has_figma": null, "platforms": null }
  },
  "sequence": { "id": "…", "name": "Main sequence" },
  "step": { "id": "node_4" },
  "sentAt": "2026-09-24T10:00:00.000Z"
}
```

| Header | Value |
| --- | --- |
| `x-founderdb-call-id` | Same as callId. Use it to deduplicate. |
| `x-founderdb-signature` | `sha256=<hex>`: HMAC-SHA256 of the raw body with the step's signing secret. Only sent when a secret is set. |
| `user-agent` | `FounderDB-Integrations/1` |
| Your headers | Any headers added on the step, e.g. Authorization. |

`expiresAt` is `null` for sync steps. The `callbackUrl` belongs to this one contact at this one step.

### Verifying the signature

```js
import crypto from "node:crypto";

function isFromFounderDB(rawBody, signatureHeader, secret) {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Sign-check the raw bytes you received, before parsing the JSON.

## The result

Whether it comes back in the response (sync) or in a callback (async), a result looks like this:

```json
{
  "status": "completed",
  "variables": {
    "has_figma": true,
    "platforms": ["figma", "stripe", "notion"],
    "team_size": 12,
    "pitch_line": "Your Figma files could ship straight to Acme."
  },
  "message": "optional, shown on the contact's timeline if it failed"
}
```

| Field | Meaning |
| --- | --- |
| `status` | `completed` (the default) → Done path. `failed` → Failed path. `pending` → still working (async only; nothing changes). |
| `variables` | Key → value. Text, a number, true/false, a list of strings, or `null` for unknown. New keys are created automatically. See [contact variables](https://founderdb.co/docs/contact-variables.md). |
| `message` | Optional. Up to 1,000 characters. |

## Sync: answer in the response

- We wait up to 60 seconds for your response.
- A `2xx` with a result body continues right away. A `2xx` with an empty or non-JSON body counts as `completed` with no variables.
- A non-2xx status, a timeout or a network error takes the Failed path.

```http
HTTP/1.1 200 OK
content-type: application/json

{ "variables": { "has_figma": true } }
```

## Async: call back later

1. Accept the request within 20 seconds with any `2xx`, e.g. `202 Accepted`. Only a body with an explicit `"status": "completed"` or `"failed"` is treated as the final result. Anything else means "working on it".
2. The contact waits at this step. Nothing else is sent to them meanwhile.
3. When you're done, `POST` the result to `callbackUrl`. The sequence continues within seconds.
4. If no result arrives before `expiresAt` (the step's "give up after", 24 hours by default), the step takes the Failed path.

```bash
curl -X POST "$CALLBACK_URL" \
  -H "content-type: application/json" \
  -d '{"status":"completed","variables":{"has_figma":true,"platforms":["figma","stripe"]}}'
```

### Callback responses

| Status | Body | Meaning |
| --- | --- | --- |
| 200 | `{"ok":true,"alreadyProcessed":false,"status":"completed"}` | Stored; the sequence continues. |
| 200 | `{"ok":true,"alreadyProcessed":true,"status":"timed_out"}` | This call was already settled (an earlier callback, or it timed out). Nothing changed. |
| 200 | `{"ok":true,"status":"pending"}` | You sent status pending. Still waiting. |
| 400 | `{"ok":false,"error":"…"}` | The body isn't valid JSON or doesn't match the result shape. |
| 404 | `{"ok":false,"error":"Unknown callback"}` | No such callback URL. |

## Rules worth knowing

- **Single use.** Each callback URL settles its call once. Retrying is safe: repeats get `alreadyProcessed: true`, so retry on network errors or `5xx` until you get a `200`.
- **One call per contact per step.** If a sequence loops back through the step, that's a new call with a new `callId` and `callbackUrl`.
- **The callback URL is the credential.** It's long and random. Keep it private. Callbacks aren't signed.
- **Variables are merged.** Keys you don't return are left as they are. Return `null` to mark one unknown.
- **Everything is logged.** Variable changes, failures and timeouts show on the contact's timeline; each call shows in the sequence's run log.

> **Note:** Pausing a contact's sequence while it waits is fine. Their results are still stored on the contact. If you resume the sequence, the step runs again and makes a new call; the old callback URL stops working.
