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 looks the address up while the user waits in a holding pattern. Your server then 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, then 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. You can build the flow in the builder, or from code with the Bots API. If your user builds it, hand them 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 the fallback fromPOSTtoPUT, the early200, and the"type": "object"rule, and ship each of those as a bug.
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 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 points to Hold B, and Hold B back to Hold A. Whatever the user types, they bounce between the two and never advance. The flow can't progress on its own, and your assign_bot call is the only way out.
Give them copy that reads as waiting rather than as a question the user is failing to answer, such as "Give me a moment while I look that up…" and "Still working on it, hang tight…". A user who replies sees the other one.
Build the holding loop even if you're only on web today. It costs two blocks and keeps the flow working when you add WhatsApp.
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-… string in a hosted bot URL 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
Build this part in the builder, or create the same blocks through the Bots API. Six blocks:
| # | Block | Configuration |
|---|---|---|
| 1 | Ask a Question (text) | "What's your email address?", saved 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}." This is 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, which 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. 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 early res.sendStatus(200) matters most in this file. 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. However long it takes, the conversation stays parked.
Step 4: Do the work, then land the result in a Field
Get two things right here.
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 hit both: the first run creates the Field, and 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). Unrecognized type strings are 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 waits for the customer to speak first. Use it if you'd rather not interrupt, but it leaves them in the holding loop until they do.)
The code checks the status before parsing the body, and parses JSON only when the content type says so. 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 land in Hold A. Type something and you bounce to Hold B. Type again and you're back in Hold A.
- 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. Copy it again from the published version.
Production checklist
- Rate limits. Every resume costs at least two API calls (the Field write and the
assign_bot), against a limit of 40 requests a second per agent token. That's about 20 resumes a second, fewer if you write several Fields or add asend_text. Queue the callbacks, and on a429wait forRetry-After, or back off when it's missing, before you try again. 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 apologizes. - 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 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, such as enrichment on conversation start or 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, which relays 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 limit that decides how many conversations you can resume at once.