External API integrations
A Call external API step sends the contact to your API, stores what it finds as contact variables, and continues the sequence. Your API can answer right away or hours later.
How it works
- A contact reaches the step. We POST a JSON request to your URL.
- 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
callbackUrlwe sent. - We store the returned
variableson 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.
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 introSetting 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:
{
"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
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:
{
"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. Values can be text, a number, true/false, a list of strings, or null for unknown. New keys are created automatically. See contact variables. |
message | Optional. Up to 1,000 characters. |
Sync: answer in the response
- We wait up to 60 seconds for your response.
- A
2xxwith a result body continues right away. A2xxwith an empty or non-JSON body counts ascompletedwith no variables. - A non-2xx status, a timeout or a network error takes the Failed path.
HTTP/1.1 200 OK
content-type: application/json
{ "variables": { "has_figma": true } }Async: call back later
- 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”. - The contact waits at this step. Nothing else is sent to them meanwhile.
- When you're done,
POSTthe result tocallbackUrl. The sequence continues within seconds. - If no result arrives before
expiresAt(the step's “give up after”, 24 hours by default), the step takes the Failed path.
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 or5xxuntil you get a200. - One call per contact per step. If a sequence loops back through the step, that's a new call with a new
callIdandcallbackUrl. - 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
nullto 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.