> ## Documentation Index
> Fetch the complete documentation index at: https://docs.open.cx/llms.txt
> Use this file to discover all available pages before exploring further.

# Connect Twilio Flex

> Save Flex credentials, copy the org-scoped webhook URL, and register it on both TaskRouter and the Conversations service.

export const TwilioFlexFlow = () => {
  const BEAM_COLOR = "#22c55e";
  const renderBeam = ({id, d, duration = 2.8, delay = 0, reverse = false}) => {
    const values = reverse ? {
      x1: "110%;-10%",
      x2: "120%;0%"
    } : {
      x1: "-10%;110%",
      x2: "0%;120%"
    };
    return <g key={id}>
        <path d={d} stroke="currentColor" strokeOpacity="0.18" strokeWidth="1.5" fill="none" strokeLinecap="round" />
        <path d={d} stroke={`url(#${id})`} strokeWidth="2" fill="none" strokeLinecap="round" />
        <defs>
          <linearGradient id={id} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="0">
            <stop offset="0%" stopColor={BEAM_COLOR} stopOpacity="0" />
            <stop offset="45%" stopColor={BEAM_COLOR} stopOpacity="1" />
            <stop offset="55%" stopColor={BEAM_COLOR} stopOpacity="1" />
            <stop offset="100%" stopColor={BEAM_COLOR} stopOpacity="0" />
            <animate attributeName="x1" values={values.x1} dur={`${duration}s`} begin={`${delay}s`} repeatCount="indefinite" />
            <animate attributeName="x2" values={values.x2} dur={`${duration}s`} begin={`${delay}s`} repeatCount="indefinite" />
          </linearGradient>
        </defs>
      </g>;
  };
  const CH_X = 70, FLEX_X = 330, FORK_X = 540, OCX_X = 790, HUMAN_X = 790;
  const MID = 190, R = 40, CHR = 20;
  const AI_Y = 120, HUMAN_Y = 260;
  const channels = [{
    key: "voice",
    label: "Voice",
    y: 90
  }, {
    key: "sms",
    label: "SMS",
    y: 140
  }, {
    key: "whatsapp",
    label: "WhatsApp",
    y: 190
  }, {
    key: "widget",
    label: "Web chat",
    y: 240
  }, {
    key: "messenger",
    label: "Messenger",
    y: 290
  }];
  const chBeams = channels.map((ch, i) => ({
    id: `flex-ch-${i}`,
    d: `M ${CH_X + CHR},${ch.y} C ${CH_X + CHR + 70},${ch.y} ${FLEX_X - R - 40},${MID} ${FLEX_X - R},${MID}`,
    delay: i * 0.3
  }));
  const bFlexFork = `M ${FLEX_X + R},${MID} L ${FORK_X - 10},${MID}`;
  const bForkAI = `M ${FORK_X + 10},${MID} C ${FORK_X + 80},${MID} ${OCX_X - R - 80},${AI_Y} ${OCX_X - R},${AI_Y}`;
  const bForkAIr = `M ${OCX_X - R},${AI_Y} C ${OCX_X - R - 80},${AI_Y + 30} ${FORK_X + 80},${MID + 30} ${FORK_X + 10},${MID}`;
  const bForkHuman = `M ${FORK_X + 10},${MID} C ${FORK_X + 80},${MID} ${HUMAN_X - 50 - 60},${HUMAN_Y} ${HUMAN_X - 50},${HUMAN_Y}`;
  return <div className="zendesk-flow not-prose my-8 w-full">
      <div className="zendesk-flow-frame relative w-full overflow-hidden rounded-md border border-black/10 bg-white px-4 py-6 dark:border-white/10 dark:bg-zinc-950">
        <div className="zendesk-flow-grid pointer-events-none absolute inset-0" aria-hidden />
        <svg viewBox="0 0 880 380" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" className="relative z-[1] block w-full text-zinc-500 dark:text-zinc-400" role="img" aria-label="Twilio Flex flow: channels hit TaskRouter; two workflow filters split tasks between the OpenCX AI Worker and human Flex workers">
          {chBeams.map(b => renderBeam({
    id: b.id,
    d: b.d,
    duration: 3.0,
    delay: b.delay
  }))}
          {renderBeam({
    id: "flex-ff",
    d: bFlexFork,
    duration: 2.2,
    delay: 0
  })}
          {renderBeam({
    id: "flex-ai",
    d: bForkAI,
    duration: 2.6,
    delay: 0.3
  })}
          {renderBeam({
    id: "flex-air",
    d: bForkAIr,
    duration: 2.6,
    delay: 1.6,
    reverse: true
  })}
          {renderBeam({
    id: "flex-hu",
    d: bForkHuman,
    duration: 2.6,
    delay: 0.9
  })}

          {}
          <g className="fill-zinc-500 dark:fill-zinc-400" style={{
    font: "500 10.5px ui-sans-serif, system-ui, sans-serif"
  }}>
            <text x={(FORK_X + OCX_X) / 2} y={AI_Y - 12} textAnchor="middle">Filter: ai-skilled</text>
            <text x={(FORK_X + HUMAN_X) / 2} y={HUMAN_Y + 28} textAnchor="middle">Filter: handedOff==true</text>
          </g>

          {}
          {channels.map(ch => <g key={ch.key}>
              <circle cx={CH_X} cy={ch.y} r={CHR} className="fill-white stroke-black/10 dark:fill-zinc-900 dark:stroke-white/10" strokeWidth="1" />
              <g transform={`translate(${CH_X - 7} ${ch.y - 7})`} className="text-zinc-700 dark:text-zinc-200">
                {ch.key === "voice" && <svg x="0" y="0" width="14" height="14" viewBox="0 0 20 20" fill="currentColor">
                    <path d="M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.5 11.5 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 14.352V15.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 7.326 13.019 13.019 0 0 1 2 4V3.5Z" />
                  </svg>}
                {ch.key === "sms" && <g fill="currentColor"><path d="M3 1.5A1.5 1.5 0 0 0 1.5 3v8A1.5 1.5 0 0 0 3 12.5h2V15l2.8-2.5H13A1.5 1.5 0 0 0 14.5 11V3A1.5 1.5 0 0 0 13 1.5H3Z" /></g>}
                {ch.key === "whatsapp" && <g fill="currentColor"><path d="M8 1a7 7 0 0 0-6.03 10.55L1 15l3.55-.93A7 7 0 1 0 8 1Zm3.78 9.6c-.16.45-.94.86-1.31.89-.33.03-.76.04-1.22-.08a11 11 0 0 1-1.11-.4 8.6 8.6 0 0 1-3.3-2.88c-.25-.32-.65-.86-.65-1.64 0-.78.4-1.16.55-1.32.14-.16.3-.2.4-.2h.3c.1 0 .24 0 .37.28l.55 1.31c.05.1.08.21.02.33a1.23 1.23 0 0 1-.19.3l-.28.32c-.1.1-.19.22-.08.41.1.2.45.75.98 1.22.66.58 1.22.77 1.42.86.2.1.3.08.42-.05.12-.13.48-.56.61-.76.13-.2.26-.17.44-.1.18.06 1.15.55 1.35.65.2.1.33.15.38.24.05.1.05.5-.11.95Z" /></g>}
                {ch.key === "widget" && <g fill="currentColor"><path d="M1 2.5A1.5 1.5 0 0 1 2.5 1h11A1.5 1.5 0 0 1 15 2.5v7A1.5 1.5 0 0 1 13.5 11H9l-3 3v-3H2.5A1.5 1.5 0 0 1 1 9.5v-7z" /></g>}
                {ch.key === "messenger" && <g fill="currentColor"><path d="M8 1C4.13 1 1 3.9 1 7.48c0 2.02 1 3.8 2.6 5v2.52l2.37-1.3c.64.17 1.32.27 2.03.27 3.87 0 7-2.9 7-6.49C15 3.9 11.87 1 8 1Zm.72 8.7-1.82-1.93-3.55 1.94 3.9-4.14 1.87 1.94 3.5-1.94-3.9 4.13Z" /></g>}
              </g>
              <text x={CH_X + CHR + 6} y={ch.y + 4} className="fill-zinc-700 dark:fill-zinc-200" style={{
    font: "500 11px ui-sans-serif, system-ui, sans-serif"
  }}>{ch.label}</text>
            </g>)}

          {}
          <g>
            <circle cx={FLEX_X} cy={MID} r={R} className="fill-white stroke-black/10 dark:fill-zinc-900 dark:stroke-white/10" strokeWidth="1" />
            <foreignObject x={FLEX_X - 22} y={MID - 22} width="44" height="44">
              <div className="flex h-full w-full items-center justify-center">
                <img src="/images/integrations/twilio-flex.svg" alt="" className="h-8 w-8 object-contain" />
              </div>
            </foreignObject>
            <text x={FLEX_X} y={MID + R + 22} textAnchor="middle" className="fill-zinc-700 dark:fill-zinc-200" style={{
    font: "600 12px ui-sans-serif, system-ui, sans-serif"
  }}>Twilio Flex</text>
            <text x={FLEX_X} y={MID + R + 36} textAnchor="middle" className="fill-zinc-500 dark:fill-zinc-400" style={{
    font: "400 10.5px ui-sans-serif, system-ui, sans-serif"
  }}>TaskRouter</text>
          </g>

          {}
          <g>
            <polygon points={`${FORK_X - 18},${MID} ${FORK_X},${MID - 22} ${FORK_X + 18},${MID} ${FORK_X},${MID + 22}`} className="fill-white stroke-black/10 dark:fill-zinc-900 dark:stroke-white/10" strokeWidth="1" />
            <text x={FORK_X} y={MID + 3} textAnchor="middle" className="fill-zinc-700 dark:fill-zinc-200" style={{
    font: "700 10px ui-sans-serif, system-ui, sans-serif"
  }}>filters</text>
          </g>

          {}
          <g>
            <circle cx={OCX_X} cy={AI_Y} r="44" className="fill-none" stroke={BEAM_COLOR} strokeOpacity="0.25" strokeWidth="1.5">
              <animate attributeName="r" values="42;52;42" dur="3.6s" repeatCount="indefinite" />
              <animate attributeName="stroke-opacity" values="0.35;0;0.35" dur="3.6s" repeatCount="indefinite" />
            </circle>
            <circle cx={OCX_X} cy={AI_Y} r="40" className="fill-white stroke-black/10 dark:fill-zinc-900 dark:stroke-white/10" strokeWidth="1" />
            <foreignObject x={OCX_X - 30} y={AI_Y - 16} width="60" height="32">
              <div className="flex h-full w-full items-center justify-center">
                <img src="/logo-light.svg" alt="" className="block h-full w-full object-contain dark:hidden" />
                <img src="/logo.svg" alt="" className="hidden h-full w-full object-contain dark:block" />
              </div>
            </foreignObject>
            <text x={OCX_X} y={AI_Y + 58} textAnchor="middle" className="fill-zinc-700 dark:fill-zinc-200" style={{
    font: "600 12px ui-sans-serif, system-ui, sans-serif"
  }}>OpenCX</text>
            <text x={OCX_X} y={AI_Y + 72} textAnchor="middle" className="fill-zinc-500 dark:fill-zinc-400" style={{
    font: "400 10.5px ui-sans-serif, system-ui, sans-serif"
  }}>AI Flex Worker</text>
          </g>

          {}
          <g>
            <rect x={HUMAN_X - 50} y={HUMAN_Y - 20} width="100" height="40" rx="10" className="fill-white stroke-black/10 dark:fill-zinc-900 dark:stroke-white/10" strokeWidth="1" strokeDasharray="3 3" />
            <g>
              <circle cx={HUMAN_X - 20} cy={HUMAN_Y} r="7" className="fill-zinc-600 dark:fill-zinc-300" />
              <circle cx={HUMAN_X} cy={HUMAN_Y} r="7" className="fill-zinc-500 dark:fill-zinc-400" />
              <circle cx={HUMAN_X + 20} cy={HUMAN_Y} r="7" className="fill-zinc-600 dark:fill-zinc-300" />
            </g>
            <text x={HUMAN_X} y={HUMAN_Y + 36} textAnchor="middle" className="fill-zinc-700 dark:fill-zinc-200" style={{
    font: "600 12px ui-sans-serif, system-ui, sans-serif"
  }}>Flex Workers</text>
            <text x={HUMAN_X} y={HUMAN_Y + 50} textAnchor="middle" className="fill-zinc-500 dark:fill-zinc-400" style={{
    font: "400 10.5px ui-sans-serif, system-ui, sans-serif"
  }}>human queue</text>
          </g>
        </svg>
      </div>
    </div>;
};

Connecting Flex is three pieces of work: paste credentials, register the webhook on both Twilio surfaces, and add two workflow filters. Miss any one and handoff looks silently broken.

<TwilioFlexFlow />

<Warning>
  **The webhook URL is scoped to a single OpenCX org and environment.** Do not
  reuse a sandbox webhook URL against a production Twilio account — the token in
  the URL carries your `orgId`, so events will silently route to the wrong
  workspace. Copy the URL fresh from every environment you're connecting.
</Warning>

## Before you start

<AccordionGroup>
  <Accordion title="Flex admin access" icon="user-shield">
    You need access to the <Tooltip tip="Twilio's unified admin for Flex. Everything you configure on the Twilio side — workspaces, workflows, webhooks, Conversations — is reachable from here.">Twilio Flex Console</Tooltip> and the parent Twilio Console to copy credentials and register webhooks.
  </Accordion>

  <Accordion title="Workspace and AI queue ready" icon="list-check">
    Know the{" "}

    <Tooltip tip="The TaskRouter workspace your Flex tasks live in. Every queue, workflow, and worker belongs to one workspace. Format: WS followed by 32 hex characters.">
      Workspace SID
    </Tooltip>

    {" "}

    (starts with `WS`) you want OpenCX to join. If you have a dedicated{" "}

    <Tooltip tip="A TaskRouter queue that filters to AI-skilled tasks. Optional — leave blank to let OpenCX's AI worker pick up any task the workflow routes to it.">
      AI queue
    </Tooltip>

    , have its SID handy too.
  </Accordion>

  <Accordion title="A workflow you can edit" icon="diagram-project">
    You need edit access to the <Tooltip tip="The TaskRouter workflow that decides which queue a task goes into. You will add one filter for AI-routed tasks and a second filter for handed-off tasks.">TaskRouter workflow</Tooltip> that routes your inbound tasks — you'll add two filters to it before the setup is complete.
  </Accordion>
</AccordionGroup>

## Save credentials in OpenCX

<Steps>
  <Step title="Open the Flex integration">
    Go to [**Settings → Integrations**](https://platform.open.cx/settings/integrations) in your OpenCX dashboard and click **Twilio Flex**.
  </Step>

  <Step title="Paste credentials">
    Fill in the form:

    | Field                                   | Where to find it                                                                                                                                                                                                          |
    | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | **Account SID**                         | Twilio Console dashboard — starts with `AC`.                                                                                                                                                                              |
    | **Auth Token**                          | Twilio Console dashboard — account-level token.                                                                                                                                                                           |
    | **Workspace SID**                       | Flex Console → Configuration → Workspace — starts with `WS`.                                                                                                                                                              |
    | **AI Queue SID** *(optional)*           | Flex Console → TaskRouter → Queues — starts with `WQ`. Leave blank to skip queue filtering.                                                                                                                               |
    | **AI Worker Email Domain** *(optional)* | Your own domain, e.g. `yourcompany.com`. Each AI worker's Twilio attributes then include an email like `opencx1-a1b2c3d4@yourcompany.com`. Set it only if a downstream system reads worker emails; leave empty otherwise. |

    <Note>
      OpenCX automatically provisions an <Tooltip tip="A TaskRouter worker OpenCX creates and maintains automatically. Represents the AI in your workspace. You do not create or manage it manually.">AI worker</Tooltip> in your workspace on first save — you do **not** create this worker yourself. Its SID is persisted inside OpenCX so your workflow filter can target it. Saving again later never creates extra workers.
    </Note>
  </Step>

  <Step title="Optional: scale the AI worker pool">
    Twilio caps each worker at **50 concurrent chats**. If you expect more
    parallel conversations, use the **AI Agents** section in the integration
    settings and click **Add AI Agent** — each worker you add raises total
    capacity by 50 (2 workers = 100 concurrent chats, and so on). Every pool
    worker carries the same `ai` and `support` routing skills, so your workflow
    filter matches all of them without changes.
  </Step>

  <Step title="Copy the webhook URL">
    The **Webhook URL** field at the top of the modal shows the URL OpenCX will
    accept events on. Copy it — you'll paste it into Twilio twice.
  </Step>

  <Step title="Click Test & Save">
    OpenCX validates the credentials against TaskRouter's workspace API. A failure means the Account SID, Auth Token, or Workspace SID is wrong.
  </Step>
</Steps>

## Register the webhook on TaskRouter

In the Twilio Flex Console, open **TaskRouter → Workspaces → \[your workspace] → Event Callbacks**. Paste the OpenCX webhook URL and subscribe to the following events:

<Tabs>
  <Tab title="TaskRouter events">
    Subscribe this URL to **all** of these events:

    * `reservation.created`
    * `reservation.accepted`
    * `reservation.timeout`
    * `task.created`
    * `workflow.target-matched`

    Save. TaskRouter will start delivering events immediately.
  </Tab>
</Tabs>

## Register the webhook on the Conversations service

In the Twilio Console, open **Conversations → Services → \[your Flex Conversations service] → Webhooks**. Paste the **same** OpenCX webhook URL and subscribe to:

<Tabs>
  <Tab title="Conversations filters">
    Subscribe this URL to **all** of these filters:

    * `onMessageAdded`
    * `onConversationAdded`
    * `onParticipantAdded`

    Save.
  </Tab>
</Tabs>

<Warning>
  **Both webhooks are required.** Registering only the TaskRouter webhook is the
  single most common onboarding mistake — customer messages reach OpenCX but no
  task is created, because the Conversations events never arrive. Register both
  before moving on.
</Warning>

## Add the workflow filters

Open your TaskRouter workflow and add two filters, in this order:

<Steps>
  <Step title="Route AI-skilled tasks to the OpenCX AI worker">
    If you set an **AI Queue SID** during credentials, filter on that queue. Otherwise, filter on the routing skills `ai` and `support` — OpenCX adds both to every AI worker it provisions, so one filter covers the whole pool. This is the filter that gets the AI a crack at the task first.
  </Step>

  <Step title="Route `handedOff == true` tasks to your human queue">
    Filter expression: `handedOff == true`. Target your human agent queue (support, tier 2, etc.). This is the filter OpenCX relies on after it rejects the AI reservation — without it, a handed-off task sits in limbo.
  </Step>
</Steps>

## Verify end to end

<Steps>
  <Step title="Send a test message">
    Send a message on the channel you wired up (SMS, WhatsApp, web chat, voice call). OpenCX's AI worker should accept the reservation and reply.
  </Step>

  <Step title="Check the task in Flex">
    Open TaskRouter → Tasks. The task should show the AI worker as the assigned
    worker, with `conversationSid` present in the task attributes.
  </Step>

  <Step title="Trigger a handoff">
    Reply with *"I want to talk to a human"* (or whatever handoff trigger your org uses). The task should re-route — check the attributes for `handedOff: true` and the reason/summary fields. Your human queue should now own the task.
  </Step>
</Steps>

<Tip>
  If the AI never accepts, your workflow filter isn't matching the AI worker. If
  the AI accepts but handoff never re-routes, the `handedOff == true` filter is
  missing. See [Troubleshooting](/integrations/twilio-flex/troubleshooting).
</Tip>

***

## Related Documentation

<CardGroup cols={2}>
  <Card title="Handoff and task attributes" icon="user-group" href="/integrations/twilio-flex/handoff">
    What OpenCX writes to every task on handoff.
  </Card>

  <Card title="Channels" icon="grid-2" href="/integrations/twilio-flex/channels/web-chat">
    Per-channel implementation on top of this shared setup.
  </Card>

  <Card title="Troubleshooting" icon="wrench" href="/integrations/twilio-flex/troubleshooting">
    Missing webhook, wrong queue, events routed to the wrong org.
  </Card>

  <Card title="Human handoff" icon="user-headset" href="/handoff/introduction">
    Global handoff rules and office hours.
  </Card>
</CardGroup>
