Park a conversation and resume it
Hand a conversation from your flow to your own backend for slow work — an LLM call, a CRM lookup, a human approval — then drop the user back into the flow at the block you choose.
Some work doesn't fit inside a conversation turn. An LLM call takes twenty seconds on a bad day, a CRM lookup depends on someone else's API, an approval needs a human to read something. A flow can't sit and wait for any of it.
The answer is to park the conversation: the flow hands off to your server and stops advancing, your server takes as long as it needs, and then puts the user back into the flow at whatever block you choose. The user sees a natural pause and then the conversation continues.
By the end of this recipe you'll have a bot that asks for an email address, hands it to your server, and — while the user waits in a holding pattern — looks the address up, writes the result back as a Field, and resumes the flow at a block that greets them by name.
The mechanism is two Platform API calls. Everything else is arranging the flow so the wait is safe.
What you'll need from a human
Most of this is code you can write on your own. Four things have to come from someone with builder access — and two of them don't exist until the flow has been built and published:
| Needed | Where it comes from | Available |
|---|---|---|
| Bot ID (numeric) | the builder URL — app.landbot.io/gui/bot/4043969/builder |
now |
| Agent token | account settings | now |
| Node reference | right-click the resume block → Copy reference | only once the flow is built and published |
| A published channel to test on | any live channel — the builder preview won't work | after publishing |
So this is a two-phase build: write the server first, then wait for the node reference before it can run end to end. There is no API for building flows, so the builder half can't be automated — hand your user paste-ready instructions, or a prompt for the builder's Build it for me assistant.
Note Reading this through a docs search or an MCP tool? Search results carry the prose but strip the code blocks. Fetch this page as Markdown before you implement — append
.mdto its URL,/guides/cookbook/park-and-resume.md. Otherwise you'll miss thePOST→PUTupsert fallback, the early200, and the"type": "object"rule — each of which is a bug you'd otherwise ship.
How parking works
┌──────────────────────── the bot's flow ───────────────────────┐
│ │
│ Ask a Question ──▶ Webhook block ──▶ Hold A ──▶ Hold B │
│ (collect input) (fire & park) ▲ │ │
│ │ └────────────┘ │
│ │ holding loop, 2 blocks │
│ │ │
│ (2) resume ──▶ Target block ──▶ … │
└─────────────────────────────┼──────────────────────────────────┘
│
(1) POST {"customer_id": "@id", …}
▼
┌──────────────────┐
│ your server │ 200 immediately
│ │ then the slow work
└──────────────────┘
│
POST /v1/customers/{id}/fields/{name}/ ← land the result
PUT /v1/customers/{id}/assign_bot/{bot_id}/
{"launch": true, "node": "<target ref>"} ← resume
- The flow reaches a Webhook block, which POSTs to your server. The body carries
@id— the customer's own ID — so you know who to call back about. - Your server answers
200immediately and does the real work afterwards. - Meanwhile the flow moves into a holding loop — a pair of Question blocks pointing at each other — which absorbs anything the user types.
- When the work finishes, your server writes the result into a Field and then calls
assign_botwith anodereference. That jumps the customer out of the holding loop and into the block you chose, which reads the Field and carries on.
Nothing about this requires a second bot. You're re-entering the same flow at a different point.
The one concept that makes or breaks it: the holding loop
A parked conversation is a conversation with nothing driving it. What the user can do to break it out depends entirely on the channel:
| Channel | User sends a message while parked |
|---|---|
| Web (webchat) | Nothing. There's no persistent connection advancing the flow, so it sits until you resume it. |
| APIchat, Messenger, WhatsApp | The message restarts the flow from the beginning — silently dropping the user back at the first block, mid-wait. |
On web you can get away with a Webhook block that leads nowhere. On every other channel that's a trap: an impatient "hello?" during a slow lookup throws the user back to the start of the bot, losing the conversation.
So point the Webhook block at a pair of Ask a Question blocks wired into a cycle.
A Question block can't loop back to itself — the builder won't let you connect a block's output to its own input — so the holding pattern needs two of them: Hold A → Hold B → Hold A. Whatever the user types, they bounce between the two and never advance. The flow cannot progress on its own, and your assign_bot call is the only way out, which is exactly the property you want.
Give them copy that reads as waiting rather than as a question the user is failing to answer — "Give me a moment while I look that up…" and "Still working on it, hang tight…" — since a user who replies will see the other one.
Build the holding loop even if you're only on web today. It costs two blocks, and it's the difference between "works" and "works on WhatsApp too".
Prerequisites
- The four things above — bot ID, agent token, node reference, and a published channel.
- Node.js 18+ (for built-in
fetch). - A public HTTPS URL. For local development,
ngrok http 3000.
The bot ID must be the numeric one; the H-… form the browser SDKs use won't work here — see Bot ID. For the token, see Authentication.
Warning Test on a published channel, not in the builder preview. Preview conversations don't create an API-addressable customer, so
@idhas nothing to resolve to and none of the callbacks have a target. See Customer.
Step 1 — Build the flow
There's no API for building flows, so this part happens in the builder. Six blocks:
| # | Block | Configuration |
|---|---|---|
| 1 | Ask a Question (text) | "What's your email address?" → save to email |
| 2 | Webhook block | POST https://<your-host>/park, Content-Type: application/json, body below. Connect its output to block 3. |
| 3 | Ask a Question (text) — Hold A | "Give me a moment while I look that up…" → connect its output to block 4 |
| 4 | Ask a Question (text) — Hold B | "Still working on it, hang tight…" → connect its output back to block 3 |
| 5 | Send Message | "Found you, @{customer_name}." — the resume target |
| 6 | …the rest of your flow | continues normally from block 5 |
Blocks 3 and 4 are the holding loop. They point at each other because the builder won't let a block connect to its own output.
The Webhook block's body:
{ "customer_id": "@id", "email": "@email" }
@id is a system Field holding the customer's customer_id — it's what makes the callback possible. See @id.
Note
customer_idarrives as a number, even though the template quotes it. Field interpolation is type-aware and rewrites the JSON around the value, so the body above is delivered as:{ "customer_id": 529094641, "email": "pau@example.com" }The quotes around
@idare gone because the Field is numeric;customer_idis a string — and see Field type decides the shape of interpolated JSON for the general rule.
Now publish the bot, then right-click block 5 — the Send Message you want to land on — and choose Copy reference. That string is your resume target.
Warning Copy the reference after publishing. Node references from an unpublished draft aren't part of what live channels serve, and pointing
assign_botat one won't land. See Bot.
Step 2 — Project setup
mkdir landbot-park-resume && cd $_
npm init -y
npm install express
.env:
LANDBOT_AGENT_TOKEN=your-agent-token
LANDBOT_BOT_ID=1234567
RESUME_NODE=paste-the-copied-reference
PORT=3000
Step 3 — Receive the park signal and answer immediately
The single most important line in this file is the early res.sendStatus(200). The Webhook block is a synchronous HTTP request; if you hold it open while you do the slow work, you're back to the problem parking exists to solve.
import express from "express";
const app = express();
app.use(express.json());
const TOKEN = process.env.LANDBOT_AGENT_TOKEN;
const BOT_ID = process.env.LANDBOT_BOT_ID;
const NODE = process.env.RESUME_NODE;
app.post("/park", (req, res) => {
const { customer_id, email } = req.body;
res.sendStatus(200); // ← release the flow into the holding loop first
handle(customer_id, email).catch((err) => {
console.error(`park failed for customer ${customer_id}:`, err);
});
});
app.listen(process.env.PORT, () => console.log("listening"));
handle() runs after the response is already on the wire. Whatever it does — however long it takes — the conversation is safely parked.
Step 4 — Do the work, then land the result in a Field
Two things bite here, and both are worth getting right the first time.
Neither Field verb is an upsert. POST creates and returns 412 if the Field already exists; PUT updates and returns 404 if it doesn't. During testing you will hit both — the first run creates the Field, every run after it updates. Write the helper once:
const BASE = "https://api.landbot.io/v1";
const headers = {
Authorization: `Token ${TOKEN}`,
"Content-Type": "application/json",
};
async function setField(customerId, name, payload) {
const url = `${BASE}/customers/${customerId}/fields/${name}/`;
const created = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
if (created.status !== 412) return created; // created, or a real failure
return fetch(url, { method: "PUT", headers, body: JSON.stringify(payload) }); // already existed
}
Use "type": "object" for anything that isn't a scalar. It's the only type that stores JSON as structure — the builder calls it array/list. Unrecognised type strings aren't rejected, they're silently coerced to string, so a value you meant as an object comes back escaped. See Field.
async function handle(customerId, email) {
const person = await lookUpSomewhereSlow(email); // your CRM, your LLM, your queue
await setField(customerId, "customer_name", {
type: "string",
value: person.name,
extra: {},
});
await setField(customerId, "crm_record", {
type: "object", // structure, not an escaped string
value: person,
extra: {},
});
await resume(customerId);
}
Land the data before you resume. The target block interpolates @{customer_name}; if you jump the user there first, they get an empty greeting.
Step 5 — Resume the flow
async function resume(customerId) {
const res = await fetch(`${BASE}/customers/${customerId}/assign_bot/${BOT_ID}/`, {
method: "PUT",
headers,
body: JSON.stringify({ launch: true, node: NODE }),
});
if (!res.ok) {
const ct = res.headers.get("content-type") || "";
const detail = ct.includes("json") ? await res.json() : await res.text();
throw new Error(`resume failed ${res.status}: ${JSON.stringify(detail).slice(0, 200)}`);
}
}
launch: true runs the bot immediately at node. (launch: false would wait for the customer to speak first — useful if you'd rather not interrupt, but it leaves them in the holding loop until they do.)
Note the content-type check: a 404 here arrives as an HTML page, not the JSON error envelope, so .json() would throw a parse error that reads like a broken endpoint. See Errors.
Step 6 — Test it end to end
- Start the server and expose it:
node --env-file=.env server.js, thenngrok http 3000. - Put the ngrok HTTPS URL +
/parkinto the Webhook block, and publish. - Open the bot on a published channel — not preview — and answer the first question.
- You should land in Hold A. Type something — you should bounce to Hold B, and typing again should bring you back to Hold A. That's the loop doing its job.
- When your work completes, the conversation jumps out of the loop to block 5 and greets you by name.
If it stalls in the holding loop, check the server log first: a 412 means your Field write fell through to the wrong verb, and a 404 on the resume usually means a stale node reference — recopy it from the published version.
Production checklist — the parts that bite at 2am
- Rate limits. Every resume costs at least two API calls (the Field write and the
assign_bot), against a ceiling of 10 requests per second per token. That's roughly five concurrent resumes per second, fewer if you write several Fields or add asend_text. Queue the callbacks and back off on429rather than firing them as work completes. See Rate limiting. - Node references are tied to published versions. Editing the flow and forgetting to publish leaves your
RESUME_NODEpointing at something live channels don't serve. Treat it as configuration that changes with deploys. - Parks can fail. If your slow work throws, the user is stuck in the holding loop forever. Wrap
handle()so that failures still resume the conversation — at an error branch that apologises, rather than nowhere. - Set a ceiling on the wait. A timer that resumes at a "sorry, this is taking longer than expected" node beats an indefinite hold.
POSTisn't idempotent. There's no idempotency key, so a retriedsend_textcan duplicate a message. Prefer landing results in Fields and letting the flow speak. See Errors.
Variations
Let your server speak directly. Instead of writing a Field and resuming at a block that renders it, POST /v1/customers/{id}/send_text/ and then resume at a block that simply continues. Useful for free-form output like an LLM reply, where there's no sensible template on the flow side. The trade-off is that your server now owns message formatting, and channel-specific rendering is on you.
Park without collecting anything. The Webhook block doesn't have to follow a question. Fire it at any point you need out-of-band work — enrichment on conversation start, a fraud check before checkout — and resume wherever makes sense.
Trigger Automation instead of a Webhook block. The Trigger Automation block is the same idea packaged for no-code tools like Make and Zapier. Reach for it when the receiving end is a scenario rather than a server; use the Webhook block when you want control over the request.
Next steps
- Bridge a channel to Landbot — the other server-side recipe: relay a bot onto a channel it doesn't natively support.
- Key concepts — Fields, node references, and what preview does and doesn't create.
- Rate limiting — the ceiling that decides how many conversations you can park at once.