Published on

Pancake Conversation Workflow API — Tags, Assign Staff, Mark Read/Unread

Published on
  • avatar
    Name
    Định Phan - netFull
    Twitter

Pancake Conversation Workflow API — Tags, Assign Staff, Mark Read/Unread

TL;DR: Pancake provides 4 endpoint groups to manage CRM workflow inside a conversation: POST /conversations/{id}/tags (add/remove tag), POST /conversations/{id}/assign (assign staff to handle), POST /conversations/{id}/read and /unread (mark as read/unread). Plus 2 lookup endpoints: GET /pages/{id}/tags and GET /pages/{id}/users. All on public_api/v1 with page_access_token. Bonus: Pancake has a built-in round-robin feature — configure it once in the dashboard and the system auto-assigns new conversations to staff in rotation, no code needed.

After you know how to list conversations and sync customers from Pancake, the next step in CRM workflow is managing each conversation's lifecycle: classifying with tags, assigning to staff, marking for follow-up. This article covers all 4 workflow endpoints + 2 lookups, with production-grade patterns.

If you're new to the Pancake API, read the introduction and the webhook + reply API guide first.


1. When do you need these endpoints?

Use casePrimary endpoint
Auto-tag conversations by message keyword (e.g. "buy/order" → tag "Orders")POST /tags
Round-robin shift coverage for sales staff automaticallyPOST /assign
Mark unread for follow-up later (e.g. "I promised to call tomorrow")POST /unread
Bulk re-assign when staff take leavePOST /assign
Sync with internal CRM — Pancake tag = Salesforce/HubSpot pipeline stagePOST /tags
Staff KPI reporting by conversations handledPOST /assign + tracking

TIP

Before writing complex logic yourself, check the Pancake dashboard → Settings → Auto-assign conversations for built-in features. Some cases (round-robin, simple keyword auto-tag) have a no-code config UI.


2. Prepare your Page Access Token

All endpoints in this article require page_access_token. See the Webhook & API guide — section 1 for the full setup.


3. List the page's tags

Before tagging a conversation you need the tag_id — you can't pass a tag name directly.

GET https://pages.fm/api/public_api/v1/pages/{page_id}/tags?page_access_token={token}

cURL example

curl -G "https://pages.fm/api/public_api/v1/pages/$PAGE_ID/tags" \
  --data-urlencode "page_access_token=$TOKEN"

Response shape

{
  "tags": [
    { "id": 0, "text": "Quality check", "color": "#4b5577", "lighten_color": "#c9ccd6" },
    { "id": 1, "text": "Orders", "color": "#2ecc71", "lighten_color": "#a3e4be" },
    { "id": 2, "text": "Complaints", "color": "#e74c3c", "lighten_color": "#f5b8b0" }
  ]
}

WARNING

id in the response is an integer (0, 1, 2...), but the POST add/remove endpoint expects it as a string. Pancake accepts both forms in the request body, but use string to be safe.

Pattern: build a name → id map

const BASE = 'https://pages.fm/api/public_api/v1'

async function loadTagMap(pageId, token) {
  const r = await fetch(`${BASE}/pages/${pageId}/tags?page_access_token=${token}`)
  const { tags = [] } = await r.json()
  return Object.fromEntries(tags.map((t) => [t.text.toLowerCase(), String(t.id)]))
}

// Usage:
const tagMap = await loadTagMap(PAGE_ID, TOKEN)
const orderTagId = tagMap['orders']    // '1'

Cache tagMap in memory (TTL 1 hour) — tags rarely change, no need to fetch on every call.


4. Add / Remove a tag on a conversation

POST https://pages.fm/api/public_api/v1/pages/{page_id}/conversations/{conversation_id}/tags?page_access_token={token}

Request body

{
  "action": "add",          // or "remove"
  "tag_id": "1"
}

cURL — add "Orders" tag

curl -X POST "https://pages.fm/api/public_api/v1/pages/$PAGE_ID/conversations/$CONV_ID/tags?page_access_token=$TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "action": "add", "tag_id": "1" }'

Response

{
  "data": [0, 1],              // tag_id array on the conversation AFTER the update
  "success": true,
  "timestamp": 1717900800
}

IMPORTANT

Each request operates on only one tag. To add multiple tags to a single conversation → loop with throttle, staying under 5 req/s (see rate limit).

Pattern: auto-tag from a webhook by keyword

// Webhook handler for message events
async function onMessageReceived(event, tagMap) {
  const text = event.message?.text?.toLowerCase() || ''

  const keywordToTag = {
    'order': 'orders',
    'buy': 'orders',
    'purchase': 'orders',
    'price': 'pricing inquiry',
    'complaint': 'complaints',
    'issue': 'complaints',
  }

  const tagsToAdd = new Set()
  for (const [kw, tagName] of Object.entries(keywordToTag)) {
    if (text.includes(kw) && tagMap[tagName]) {
      tagsToAdd.add(tagMap[tagName])
    }
  }

  for (const tagId of tagsToAdd) {
    await fetch(
      `${BASE}/pages/${event.page_id}/conversations/${event.conversation_id}/tags?page_access_token=${TOKEN}`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action: 'add', tag_id: tagId }),
      }
    )
    await new Promise((r) => setTimeout(r, 250))  // throttle
  }
}

5. List the page's staff users

You need user.id for the assign endpoint — you cannot use names or Facebook IDs.

GET https://pages.fm/api/public_api/v1/pages/{page_id}/users?page_access_token={token}

Response shape

{
  "success": true,
  "users": [
    {
      "id": "c4bafd84-7b96-4f28-b59a-031f17c32ddf",   // UUID — use this for assign
      "name": "Anh Ngoc Nguyen",
      "fb_id": "116256249766099",
      "status": "available",                            // available | busy | offline
      "is_online": true,
      "status_in_page": "active",
      "page_permissions": { "permissions": [100, 71, 81] }
    }
  ],
  "disabled_users": [
    { "id": "...", "name": "...", "fb_id": "..." }
  ],
  "round_robin_users": {
    "inbox": ["fb5ff8ed-434b-4d4b-a213-b595b242b81a"],
    "comment": ["79d4e769-ac31-4821-8304-d6e251d532e9"]
  }
}

Three important fields:

  • users: currently active staff — use for assign
  • disabled_users: staff that have been disabled (left the company) — don't assign anymore, but still needed for history mapping
  • round_robin_users: list of users currently in the round-robin rotation managed by Pancake itself

TIP

Pancake has a built-in round-robin — configure the user list in the dashboard or via POST /pages/{page_id}/round_robin_users, and Pancake will auto-assign new conversations to the next staff member in rotation. If a simple round-robin is enough → you don't need to code the logic in section 6.4. Only write your own when you need custom rules (assign by skill, language, VIP tier...). See the official Pancake docs on auto-assigning conversations.


6. Assign staff to a conversation

POST https://pages.fm/api/public_api/v1/pages/{page_id}/conversations/{conversation_id}/assign?page_access_token={token}

Request body

{
  "assignee_ids": [
    "c4bafd84-7b96-4f28-b59a-031f17c32ddf",
    "fb5ff8ed-434b-4d4b-a213-b595b242b81a"
  ]
}

cURL — assign to one staff member

curl -X POST "https://pages.fm/api/public_api/v1/pages/$PAGE_ID/conversations/$CONV_ID/assign?page_access_token=$TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "assignee_ids": ["c4bafd84-7b96-4f28-b59a-031f17c32ddf"] }'

WARNING

assignee_ids is a full array replacement, not an append. Passing [] → unassigns everyone. Passing one new user without the existing ones → existing ones get unassigned. To add an assignee, GET the conversation, merge the array, then POST.

Pattern: custom round-robin (when you need it)

Store a cursor in a DB or Redis. Each time a conversation needs to be assigned → fetch the next user by index.

// Assume availableUsers comes from the /users API, filtered by status='available'
async function roundRobinAssign(pageId, conversationId, availableUsers, db) {
  if (availableUsers.length === 0) return  // no one available

  // Get the latest index from DB
  const row = await db.query(
    `SELECT idx FROM rr_cursor WHERE page_id = $1`, [pageId]
  )
  const lastIdx = row.rows[0]?.idx ?? -1
  const nextIdx = (lastIdx + 1) % availableUsers.length
  const targetUser = availableUsers[nextIdx]

  await fetch(
    `${BASE}/pages/${pageId}/conversations/${conversationId}/assign?page_access_token=${TOKEN}`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ assignee_ids: [targetUser.id] }),
    }
  )

  // Save cursor
  await db.query(
    `INSERT INTO rr_cursor (page_id, idx) VALUES ($1, $2)
     ON CONFLICT (page_id) DO UPDATE SET idx = $2`,
    [pageId, nextIdx]
  )
}

TIP

Reminder: unless you have specific custom requirements (assign by skill / language / VIP tier), use Pancake's built-in round-robin instead of writing your own — saves you one cron job and one DB table.


7. Mark Read / Unread

Two simple endpoints, no request body:

# Mark as read
curl -X POST "https://pages.fm/api/public_api/v1/pages/$PAGE_ID/conversations/$CONV_ID/read?page_access_token=$TOKEN"

# Mark as unread — for later follow-up
curl -X POST "https://pages.fm/api/public_api/v1/pages/$PAGE_ID/conversations/$CONV_ID/unread?page_access_token=$TOKEN"

Mark Unread use cases

  • Schedule a follow-up: customer asks "call me tomorrow morning" → mark unread → next morning the agent opens Pancake, sees the red bubble, knows to reply
  • Shift hand-off: night shift didn't finish → mark unread so the morning shift continues
  • Bulk re-open: cron job marks unread for conversations that have been read for over 24h but haven't been replied to

Mark Read use cases

  • After a bot auto-reply: the bot already replied → mark read so it doesn't show in the agent's inbox
  • Spam filter: spam detected → mark read + tag "Spam" → doesn't disturb agents

8. End-to-end workflow — webhook → auto-tag → auto-assign

Combine the 3 endpoints above into a single production-grade pipeline:

import { Client } from 'pg'

const BASE = 'https://pages.fm/api/public_api/v1'
const PAGE_ID = process.env.PANCAKE_PAGE_ID
const TOKEN = process.env.PANCAKE_PAGE_ACCESS_TOKEN

// Cache tag map + users, refresh every hour
let cache = { tagMap: null, users: [], expiresAt: 0 }

async function refreshCache() {
  if (Date.now() < cache.expiresAt) return cache

  const [tagsRes, usersRes] = await Promise.all([
    fetch(`${BASE}/pages/${PAGE_ID}/tags?page_access_token=${TOKEN}`),
    fetch(`${BASE}/pages/${PAGE_ID}/users?page_access_token=${TOKEN}`),
  ])
  const { tags = [] } = await tagsRes.json()
  const { users = [] } = await usersRes.json()

  cache = {
    tagMap: Object.fromEntries(tags.map((t) => [t.text.toLowerCase(), String(t.id)])),
    users: users.filter((u) => u.status === 'available'),
    expiresAt: Date.now() + 60 * 60 * 1000,
  }
  return cache
}

// Webhook handler — Pancake calls this for each new message
export default async function pancakeWebhook(req, res) {
  res.status(200).json({ ok: true })   // Reply 200 first to prevent Pancake retries — handle async

  const event = req.body
  if (event.type !== 'new_message') return
  if (event.from?.id === event.page_id) return  // skip messages from the page itself (avoid loops)

  const { conversation_id, message, page_id } = event
  const { tagMap, users } = await refreshCache()

  // 1. Auto-tag by keyword
  const text = (message?.text || '').toLowerCase()
  const tagIds = []
  if (text.match(/buy|order|purchase/)) tagIds.push(tagMap['orders'])
  if (text.match(/price|cost/)) tagIds.push(tagMap['pricing inquiry'])
  if (text.match(/complaint|issue|problem/)) tagIds.push(tagMap['complaints'])

  for (const tagId of tagIds.filter(Boolean)) {
    await fetch(
      `${BASE}/pages/${page_id}/conversations/${conversation_id}/tags?page_access_token=${TOKEN}`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action: 'add', tag_id: tagId }),
      }
    )
    await new Promise((r) => setTimeout(r, 250))
  }

  // 2. Auto-assign round-robin (if not already assigned)
  if (!event.assignee_ids || event.assignee_ids.length === 0) {
    const nextUser = users[Math.floor(Math.random() * users.length)]   // simple random
    if (nextUser) {
      await fetch(
        `${BASE}/pages/${page_id}/conversations/${conversation_id}/assign?page_access_token=${TOKEN}`,
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ assignee_ids: [nextUser.id] }),
        }
      )
    }
  }
}

IMPORTANT

Key pattern: reply 200 OK first, process later. Pancake has a webhook timeout (a few seconds) — if you process tag/assign slowly then reply, Pancake will retry → duplicate side effects. Best practice: push the event into a queue (Redis/SQS) and a worker handles it async. The code above is a simplified version for illustration.


9. Common pitfalls

SymptomCauseFix
Tag doesn't show in dashboard after addBrowser cache, or wrong tag_id lookupF5 the dashboard; verify tag_id via GET /tags
400 on assignWrong user.id (used fb_id instead of id)Use the id (UUID) field from GET /users, not fb_id
Assigned but staff get no notificationUser status = offline / disabledFilter users by status='available' && status_in_page='active' before assign
Add tag succeeds but data array is missing the new tagTag was already there, or race conditionRead data to confirm the actual state instead of assuming
Pancake retries the webhook indefinitelySlow handler / timeout / 5xx responseReply 200 immediately, handle async; check rate limit; log errors

10. FAQ

Q: Where does tag_id come from? Can I use the tag name directly?

A: From GET /pages/{page_id}/tags. You cannot use the tag name — you have to look up the ID first. Recommended: cache a name→id map in memory.

Q: Can I create a new tag via the API?

A: No. Tags can only be created from the Pancake dashboard (Settings → Labels). The API only allows adding/removing existing tags.

Q: Can I assign multiple staff members at once?

A: Yes. assignee_ids is an array — pass multiple UUIDs. All of them will see the conversation in their inbox. Use case: a critical conversation that needs two people watching.

Q: How do I remove all tags from a conversation at once?

A: There's no bulk-remove endpoint. You have to GET the conversation → grab the tags array → loop POST remove on each one with throttling.

Q: Does Pancake auto-assign when a new customer messages in?

A: Yes if you configure round-robin users in the dashboard or via POST /round_robin_users. Pancake auto-assigns to the next user in rotation. You don't need to code anything — see section 5 TIP and the official Pancake docs on auto-assigning conversations.

Q: When a staff member is offline, does the conversation get auto-assigned to the next person?

A: Pancake's built-in round-robin does check status — it skips offline users. If you write your own, you have to filter users.filter(u => u.is_online && u.status === 'available').

Q: Integrating with Salesforce/HubSpot — how do I map Pancake tags to pipeline stages?

A: Create a static map in code:

  • Pancake tag "Pricing inquiry" → HubSpot stage "Qualified Lead"
  • Pancake tag "Orders" → HubSpot stage "Closed Won"
  • Pancake tag "Complaints" → Salesforce Case "New"

On every Pancake conversation_tagged webhook → call the HubSpot/Salesforce API to update the stage. See the 3 ways to extract Pancake data post for detailed integration patterns.


11. Summary

  • 4 workflow endpoints: tags (add/remove), assign (assignee_ids array), read, unread. All on public_api/v1, page_access_token.
  • 2 mandatory lookup endpoints: GET /tags (for tag_id), GET /users (for user.id UUID).
  • Pancake has built-in round-robin — use it for simple cases, save yourself the code.
  • Auto-tag pattern: webhook → keyword match → POST add tag. Throttle under 5 req/s.
  • Assign is a full array replace, not append — merge before POST.
  • Reply 200 to the webhook first, process async after — avoid Pancake retries causing duplicates.


Last updated: 2026-06-09. API version: public_api/v1 for all workflow endpoints. Check the OpenAPI spec to verify any changes.