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 .md to its URL, /guides/cookbook/park-and-resume.md. Otherwise you'll miss the fallback from POST to PUT, the early 200, 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
  1. 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.
  2. Your server answers 200 immediately and does the real work afterwards.
  3. Meanwhile the flow moves into a holding loop: a pair of Question blocks pointing at each other, which absorbs anything the user types.
  4. When the work finishes, your server writes the result into a Field and then calls assign_bot with a node reference. 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 @id has 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_id arrives 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 @id are gone because the Field is numeric; @email keeps its quotes because that Field is a string. Don't write a receiver that assumes customer_id is 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_bot at 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

  1. Start the server and expose it: node --env-file=.env server.js, then ngrok http 3000.
  2. Put the ngrok HTTPS URL + /park into the Webhook block, and publish.
  3. Open the bot on a published channel (not preview) and answer the first question.
  4. You land in Hold A. Type something and you bounce to Hold B. Type again and you're back in Hold A.
  5. 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 a send_text. Queue the callbacks, and on a 429 wait for Retry-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_NODE pointing 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.
  • POST isn't idempotent. There's no idempotency key, so a retried send_text can 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.