# Building Agents on Plain Source: https://www.plain.com/docs/agents Plain is designed to be a great home for agents. You can build your own customer support agent using whatever AI stack you prefer and have it work alongside your team in the same threads, with the same tools, and the same audit trail as everyone else. These docs are about the **plumbing** of building an agent on Plain: how it gets an identity, how it receives events, how it decides when to act, and how it replies. The AI part (e.g. model choice, prompts, RAG, tool use) is up to you. If you just want to chat about support data with a local agent, our [MCP server](https://plain.support.site/article/mcp-server) is the fastest way to get a model talking to Plain. This guide is about building autonomous agents within Plain. ## What an agent looks like in Plain An agent in Plain is made of three pieces: The agent's identity in Plain. Has a public name, an avatar, and one or more API keys. A public HTTPS endpoint that receives Plain events like new threads, incoming messages, and assignment changes. Makes calls back to Plain to read threads, reply, change assignment, add labels, create notes, and more. When something happens in Plain (a customer sends an email, a teammate assigns a thread), Plain delivers a webhook to your endpoint. Your code decides whether the agent should act and uses the GraphQL API to do whatever it does: read context, reply, label, summarise, hand off, post a note, anything else. Agents come in many shapes. Some reply to every new thread; some only act when assigned; some never reply at all and just classify, summarise, or post internal notes. The building blocks below are the same regardless of what your agent does. ## The journey A machine user is the agent's identity in Plain. Give it a public name (what customers see) and create an API key with the right permissions. [Set up a machine user →](/docs/agents/machine-users) Plain delivers events as HTTP POST requests. Use the [`@team-plain/webhooks`](/docs/webhooks/sdk) package to verify and parse them with full type safety. [Receive events from Plain →](/docs/agents/receiving-events) You can filter events in code, or use Plain's built-in [workflows](/docs/agents/routing) to assign specific threads to the machine user and only react to those. [Route threads to your agent →](/docs/agents/routing) If your agent needs the conversation context, query the thread and pull its content as LLM-ready text. [Read thread content →](/docs/agents/reading-threads) If your agent answers questions, search your help center articles and indexed documents to ground its replies in real content. [Search knowledge sources →](/docs/agents/searching-knowledge) Use the [`@team-plain/graphql`](/docs/graphql/sdk) SDK to do whatever your agent does: reply, label, change status, create a note, hand off to a human, or anything else. [Act on the thread →](/docs/agents/acting-on-threads) ## A minimal example Here's one possible shape: an agent that replies to every new thread. It uses [Express](https://expressjs.com/), but the same shape works with any HTTP framework. Your own agent will likely do something different in the handler. ```ts theme={null} import express from "express"; import { verifyPlainWebhook } from "@team-plain/webhooks"; import { PlainClient } from "@team-plain/graphql"; const plain = new PlainClient({ apiKey: process.env.PLAIN_API_KEY! }); const app = express(); app.use(express.text({ type: "*/*" })); // we need the raw body for signature verification app.post("/webhooks/plain", async (req, res) => { const result = verifyPlainWebhook( req.body, req.header("plain-request-signature")!, process.env.PLAIN_WEBHOOK_SECRET!, ); if (result.error) { return res.status(400).send(result.error.message); } const event = result.data; if (event.payload.eventType === "thread.thread_created") { const reply = await generateReply(event.payload.thread); // your AI goes here await plain.mutation.replyToThread({ input: { threadId: event.payload.thread.id, textContent: reply, }, }); } res.sendStatus(200); }); app.listen(3000); ``` Replace `generateReply` with whatever AI library you prefer, like the [Vercel AI SDK](https://ai-sdk.dev/), [Anthropic SDK](https://docs.claude.com/en/api/getting-started), [OpenAI SDK](https://platform.openai.com/docs/libraries), or your own. ## What's next Create the agent's identity and API key. Listen for the events that matter to your agent. Use workflows and assignment to control when the agent acts. Fetch thread context as LLM-ready text. Ground replies in your help center and indexed documents. Reply, suggest replies, post notes, add labels, and reassign threads. # Acting on threads Source: https://www.plain.com/docs/agents/acting-on-threads The actions your agent can take on a thread. Once your agent has decided to act on a thread, it uses the GraphQL API to do something. The most common actions are: * **Reply directly** to the customer. * **Suggest a reply** for a teammate to review and send. * **Post a note** that's visible to your team but never the customer. * **Add labels** to classify or flag the thread. * **Change the assignee** to route the thread or hand it off to a human. This page walks through each one. Everything happens through the [GraphQL SDK](/docs/graphql/sdk): ```bash theme={null} npm install @team-plain/graphql ``` ```ts theme={null} import { PlainClient } from "@team-plain/graphql"; const plain = new PlainClient({ apiKey: process.env.PLAIN_API_KEY! }); ``` ## Reply directly The mutation for sending a reply is `replyToThread`. It works on threads whose channel is `API`, `CHAT`, `EMAIL`, `SLACK`, or `MS_TEAMS`, and Plain takes care of delivering the message through the right channel. Replies show up in the thread as messages from the agent's [machine user](/docs/agents/machine-users), the same as any other reply. This operation requires the `thread:reply` permission. ```ts theme={null} const result = await plain.mutation.replyToThread({ input: { threadId: thread.id, textContent: "Thanks for reaching out, let me look into this.", markdownContent: "Thanks for reaching out, let me look into this.", }, }); if (result.error) { console.error(result.error.message); } ``` Always provide both `textContent` and `markdownContent`. `textContent` is shown in clients that don't render markdown (some email clients, plain-text contexts), and `markdownContent` is rendered in the Plain UI, the chat widget, and modern email clients. See the [reply to thread](/docs/graphql/messaging/reply-to-thread) page for the full mutation reference, error codes, and channel-specific options. ## Suggest a reply Instead of sending immediately, your agent can add a reply to the thread as a **suggestion**. The suggestion shows up in Plain attached to a specific customer message, and a teammate can review it, edit it, and send it (or discard it) from the UI. The customer doesn't see anything until the teammate sends. This is a great default while you're tuning an agent. You get all the value of the agent drafting replies without committing to autonomous send. The mutation is `addGeneratedReply`. It takes the thread ID, the `timelineEntryId` of the customer message the reply is for, and the suggested content as markdown: ```ts theme={null} const result = await plain.mutation.addGeneratedReply({ input: { threadId: thread.id, timelineEntryId: customerMessage.id, markdown: "Hi! You can reset your password under **Settings → Security**.", }, }); if (result.error) { console.error(result.error.message); } ``` A few things to know: * The `timelineEntryId` must point at a customer message. You can get one from the webhook payload (for example `payload.email.timelineEntryId` on `thread.email_received`) or by paginating [thread timeline entries](/docs/agents/reading-threads). * `markdown` is capped at 5,000 characters. * This operation requires the `generatedReply:create` permission. See the [suggested replies](/docs/graphql/messaging/suggested-replies) page for the full reference. ## Post a note A note is an internal message that lives on the thread's timeline but is never delivered to the customer. Notes are useful for leaving context for the next teammate who picks up the thread, recording why the agent did (or didn't) act, or attaching extra information you don't want to show the customer. ```ts theme={null} await plain.mutation.createNote({ input: { customerId: thread.customer.id, threadId: thread.id, text: "Customer asked for a refund. Confidence: low. Escalating.", markdown: "Customer asked for a refund. **Confidence: low.** Escalating.", }, }); ``` ## Add labels Labels are useful for classifying threads, flagging them for review, or driving downstream automation (workflows, reporting, or routing rules). A classifier agent that just labels each new thread is one of the simplest agents you can build. You add labels by referencing existing **label types**, which you create in Plain under **Settings → Labels**. ```ts theme={null} await plain.mutation.addLabels({ input: { threadId: thread.id, labelTypeIds: ["lt_01HB8BTNTZ58730MX8H5VMKFD5"], }, }); ``` To remove labels, use `removeLabels` with the IDs of the labels (not label types) you want to remove. See [labels](/docs/graphql/labels/add) for the full reference. ## Change the assignee Reassigning a thread is how your agent hands off to (or accepts handoffs from) the rest of your team. The mutation is `assignThread`, and `AssignThreadInput` accepts either a `userId` or a `machineUserId`: ```ts theme={null} await plain.mutation.assignThread({ input: { threadId: thread.id, userId: "u_01FSVKMHFDHJ3H5XFM20EMCBQN", // a teammate or on-call user }, }); ``` Or unassign the thread and let your workflows or on-call rotation route it from there: ```ts theme={null} await plain.mutation.unassignThread({ input: { threadId: thread.id }, }); ``` A common handoff pattern is to post a note explaining the context, then reassign. See [assignment](/docs/graphql/threads/assignment) for the full reference. ## Other useful operations These mutations cover most agents. The same `PlainClient` exposes everything else available in Plain's API: | Action | Mutation | | ---------------------------- | ------------------------------------------------------------------------------ | | Mark as done or move to todo | [`markThreadAsDone`](/docs/graphql/threads), [`markThreadAsTodo`](/docs/graphql/threads) | | Send a new outbound email | [`sendNewEmail`](/docs/graphql/messaging/send-email) | | Reply to a specific email | [`replyToEmail`](/docs/graphql/messaging/reply-email) | | Set a custom thread field | [`upsertThreadField`](/docs/graphql/threads/thread-fields) | | Create a customer event | [`createCustomerEvent`](/docs/graphql/events/create-customer-event) | The [GraphQL API explorer](https://app.plain.com/developer/api-explorer/) is the fastest way to discover what's available and try it out. If your agent attempts a mutation without sufficient permissions, the response error will tell you which permission is missing. Add it to your machine user's API key and try again. ## Resources * [GraphQL SDK](/docs/graphql/sdk): the typed client your agent calls * [Reply to threads](/docs/graphql/messaging/reply-to-thread): the full `replyToThread` reference * [Suggested replies](/docs/graphql/messaging/suggested-replies): the full `addGeneratedReply` reference * [Assignment](/docs/graphql/threads/assignment): assigning and unassigning threads * [Labels](/docs/graphql/labels/add): adding and removing labels * [API explorer](https://app.plain.com/developer/api-explorer/): browse and test queries interactively # Machine users Source: https://www.plain.com/docs/agents/machine-users Set up your agent's identity in Plain. In Plain, an agent is represented as a **machine user**. This is the same primitive used for any automation or integration, but it's the right home for an agent because it gives your agent a name, an avatar, and an audit trail that's distinct from any human teammate. Machine users can be assigned threads, can reply to customers, can be @-mentioned, and show up in the timeline as the author of any message they send. When a customer sees a reply from your agent, they see the machine user's **public name** and avatar. ## Create a machine user Go to **Settings** → **Machine Users** and click **+ Add Machine User**. Two fields matter: * **Name**: only visible to your team. Use this to describe what the agent does, e.g. *"Support Agent"* or *"Tier 1 Triage Bot"*. * **Public name**: visible to customers when the agent sends a message, e.g. *"Plainey"* or *"Acme Support"*. You can also upload an avatar, which appears next to messages sent by the agent. From the machine user's page, click **+ Add API Key** and pick the permissions your agent needs. Common permissions for an agent that reads threads and replies to customers: * `thread:read`: read threads and timelines * `thread:reply`: send replies via `replyToThread` * `generatedReply:create`: add [suggested replies](/docs/agents/acting-on-threads#suggest-a-reply) for a teammate to review * `thread:assign` and `thread:unassign`: for handoffs between the agent and humans * `customer:read`: look up customer context before replying If your agent does more, like labelling threads, marking them as done, or creating events on customers, add the matching permissions. Copy the API key once it's created. You won't be able to see it again. The API key goes in the `Authorization` header of every GraphQL request: ```text theme={null} Authorization: Bearer plainApiKey_xxx ``` With the [GraphQL SDK](/docs/graphql/sdk): ```ts theme={null} import { PlainClient } from "@team-plain/graphql"; const plain = new PlainClient({ apiKey: process.env.PLAIN_API_KEY! }); ``` ## Why use a machine user Machine users give you a number of benefits: * **A clear author for every message.** Customers and teammates can tell at a glance which messages came from the agent. * **Independent permissions.** You can scope the agent's API key narrowly without touching what your team can do. * **Easy rotation.** You can issue and revoke API keys for the machine user without disrupting access as machine users can have more than one API key. * **Per-agent assignment.** Threads can be assigned to a machine user directly. See [routing](/docs/agents/routing). ## Finding your machine user's ID You'll often want your machine user's ID in code, for example to check whether a thread is assigned to your agent before acting on it (see [routing](/docs/agents/routing)). The ID is visible in the Plain UI on your machine user's page under **Settings → Machine Users**. The easiest approach is to copy it and set it as an environment variable. ## Multiple agents You can create as many machine users as you want, one per agent. Smaller individual agents that do narrow tasks can often be easier to build and manage. # Reading threads Source: https://www.plain.com/docs/agents/reading-threads Fetch thread context for your agent to act on. Most agents need to read the contents of a thread before they decide what to do, whether that's classifying it, adding a label, summarising it into a note, or generating a reply. This page covers how to read thread context via the GraphQL API. ## Get a thread The `thread` query returns a thread by ID along with its metadata. It throws if the thread doesn't exist. This operation requires the `thread:read` permission. ```ts theme={null} const thread = await plain.query.thread({ threadId: "th_01H8H46YPB2S4MAJM382FG9423", }); console.log(thread.title); console.log(thread.status); console.log(thread.priority); ``` Most thread fields are scalars on the model. Related objects (`customer`, `assignee`, `labels`, ...) are lazy-loaded, so accessing them triggers a separate API call. See the [GraphQL SDK](/docs/graphql/sdk) for more on how this works. ## Get the thread content as LLM text Each entry in a thread's timeline (an inbound message, an outbound reply, a note, an assignment change, a label change) exposes an `llmText` field. This is a plain-text rendering of the entry shaped for feeding into a language model. To get the full thread as LLM-ready text, paginate through `timelineEntries` and concatenate `llmText`: ```ts theme={null} async function getThreadAsLlmText(threadId: string): Promise { const thread = await plain.query.thread({ threadId }); const parts: string[] = []; let page = await thread.timelineEntries({ first: 50 }); while (true) { for (const entry of page.nodes) { if (entry.llmText) parts.push(entry.llmText); } const next = await page.fetchNext(); if (!next) break; page = next; } return parts.join("\n\n"); } ``` The result is a single string with every meaningful event in the thread in chronological order, ready to drop into a prompt: ```ts theme={null} const context = await getThreadAsLlmText(thread.id); const reply = await myModel.generate({ system: "You are a customer support agent.", prompt: `Conversation so far:\n\n${context}\n\nWrite the next reply.`, }); ``` `llmText` is `null` for entry types where there's nothing meaningful to render. Skip those entries. ## Reading individual fields If you don't need the whole thread, you can read specific data directly. Some common patterns: * **The customer**: use `thread.customer`, or `plain.query.customer({ customerId })`. Useful for looking up subscription tier, external IDs, or anything else attached to the customer. * **Custom thread fields**: read structured data attached to the thread. See [thread fields](/docs/graphql/threads/thread-fields). * **The triggering message**: for events like [`thread.email_received`](/docs/webhooks/thread-email-received) or [`thread.chat_received`](/docs/webhooks/thread-chat-received), the message itself is on the webhook payload. No extra API call needed if that's all you want. # Receiving events Source: https://www.plain.com/docs/agents/receiving-events Listen for thread and message events with webhooks. Your agent finds out about new threads and customer messages by receiving [webhooks](/docs/webhooks) from Plain. This page covers the setup and the events that are most useful when building an agent. ## Set up a webhook endpoint You need a publicly available HTTPS endpoint that accepts `POST` requests. Once you have one, go to **Settings** → **Webhooks** in Plain and click **+ Add webhook target**. Choose the URL, the events you want to receive, and copy the **signing secret**. You'll need it to verify webhook signatures. See the [webhooks overview](/docs/webhooks) for the full setup, including delivery semantics, retries, and security options like [request signing](/docs/request-signing) and [mTLS](/docs/mtls). ## Verify and parse events with the SDK The [`@team-plain/webhooks`](/docs/webhooks/sdk) package handles signature verification, replay protection, and JSON schema validation, and gives you fully typed event payloads. ```bash theme={null} npm install @team-plain/webhooks ``` ```ts theme={null} import { verifyPlainWebhook } from "@team-plain/webhooks"; app.post("/webhooks/plain", async (req, res) => { const result = verifyPlainWebhook( req.body, // raw body string, see note below req.header("plain-request-signature")!, process.env.PLAIN_WEBHOOK_SECRET!, ); if (result.error) { return res.status(400).send(result.error.message); } const event = result.data; // event.payload is a discriminated union, narrow on eventType switch (event.payload.eventType) { case "thread.thread_created": await onThreadCreated(event.payload); break; case "thread.email_received": await onEmailReceived(event.payload); break; case "thread.thread_assignment_transitioned": await onAssignmentChanged(event.payload); break; } res.sendStatus(200); }); ``` `verifyPlainWebhook` needs the **raw request body**, not the parsed JSON. With Express, use `express.text({ type: "*/*" })`; with other frameworks, disable JSON parsing for the webhook route and read the body as a string. For development, or when verification happens upstream (e.g. an API gateway), you can use `parsePlainWebhook` to skip the signature check and just validate the payload shape. See the [Webhooks SDK reference](/docs/webhooks/sdk) for the full API. ## Events that matter for an agent These are the events agents most commonly subscribe to. | Event | When it fires | | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | [`thread.thread_created`](/docs/webhooks/thread-created) | A new thread is created in your workspace, regardless of which channel it came from. | | [`thread.email_received`](/docs/webhooks/thread-email-received) | A customer email arrives. Fires for the first email and every reply. | | [`thread.chat_received`](/docs/webhooks/thread-chat-received) | A customer sends a chat message via the [chat widget](/docs/ui-components). | | [`thread.slack_message_received`](/docs/webhooks/thread-slack-message-received) | A customer posts in a connected Slack channel. | | [`thread.thread_assignment_transitioned`](/docs/webhooks/thread-assignment-transitioned) | A thread's assignee changes. Use this if your agent should only run when assigned. | | [`thread.thread_status_transitioned`](/docs/webhooks/thread-status-transitioned) | A thread moves between `TODO`, `SNOOZED`, and `DONE`. | If you subscribe to both `thread.thread_created` and `thread.email_received` you'll get two events for the first email in a thread. Check the `isStartOfThread` field on the email payload if you only want to react once. The full list of events lives under [Webhooks → Webhook events](/docs/webhooks/thread-created). # Routing to your agent Source: https://www.plain.com/docs/agents/routing Decide which threads your agent should act on. A given webhook event tells you *something happened*, but most agents only want to act on a subset of threads. This page covers the two patterns for deciding which threads your agent acts on, and how to check whether a given thread belongs to your agent. ## Pattern 1: Listen broadly, filter in code The simplest setup is to subscribe to one of the event types you care about (e.g. `thread.thread_created`, `thread.email_received`) and decide in your handler whether the agent should act. ```ts theme={null} if (event.payload.eventType === "thread.thread_created") { const thread = event.payload.thread; // Example filters, adapt to whatever your agent cares about if (thread.priority !== 0) return; // only urgent threads if (thread.labels.some(l => l.labelType.name === "no-bot")) return; await runAgent(thread); } ``` This is the right shape when the decision is simple and code-driven (e.g. *"only urgent threads"*, *"only this customer tier"*), or when the agent runs on every new thread regardless. This pattern works well for agents that observe and supplement what your team is doing, like classifiers, summarisers, agents that post internal notes, or autoresponders that send a single acknowledgement. It's not well-suited if you want your agent to actually handle support autonomously, because the thread is never assigned to the agent and your reporting won't reflect the agent's involvement. ## Pattern 2: Assign the thread to the agent's machine user (recommended) A cleaner pattern, especially as your routing logic grows, is to **assign threads to your machine user** and have the agent run only on threads it's been assigned. The "should the agent handle this?" decision lives in Plain, not in your code. This is the right pattern when you want your agent to handle support autonomously. Because the thread is actually assigned to the machine user, all of your existing reporting (volumes, resolution times, response times, and so on) attributes work done by the agent to the machine user in exactly the same way it would for a human teammate. Your webhook listens to events whose payload includes a thread, and checks whether that thread is assigned to your machine user. An example implementation could look like this: ```ts theme={null} function isAssignedToMe(thread: { assignee?: { id: string } | null }): boolean { // You can find your machine user's ID on the // machine user settings page in Plain. return thread.assignee?.id === process.env.AGENT_MACHINE_USER_ID; } // Webhhok triggered when a thread is assigned if (event.payload.eventType === "thread.thread_assignment_transitioned") { if (!isAssignedToMe(event.payload.thread)) return; await runAgent(event.payload.thread); } // Webhook triggered when an email is received on a thread if (event.payload.eventType === "thread.email_received") { if (!isAssignedToMe(event.payload.thread)) return; await runAgent(event.payload.thread); } ``` The `thread.thread_assignment_transitioned` payload also includes `previousThread` (the state before the change), so you can inspect who the thread came from if you need to. This pattern has some nice properties: * **No need to maintain filter logic in code** * **Teammates can hand a thread to the agent** by reassigning it, and the agent picks it up automatically. * **The agent can hand back** by reassigning to a teammate (see [change the assignee](/docs/agents/acting-on-threads#change-the-assignee)). * **You can change routing rules without redeploying.** Just update the workflow in Plain. ### Routing threads with a workflow Plain's [workflows](https://plain.support.site/article/workflows) let you set up rules that act on threads automatically. A typical agent setup is a workflow that: 1. Triggers when a thread is **created**. 2. Optionally checks conditions like channel, labels, customer tier, or support hours. 3. Has an action that **assigns the thread** to your machine user. You configure workflows in **Settings** → **Workflows** in Plain. The assignment action lets you pick a machine user directly, so the thread shows up as assigned to your agent the moment the workflow fires. Workflows are configured in the Plain UI, not via the API. Once a workflow assigns a thread to a machine user, your agent's webhook receives a `thread.thread_assignment_transitioned` event just like any other assignment change. ### Assigning programmatically You can also assign threads to your machine user directly from code, for example from a classifier agent that picks which thread should go to which downstream agent. The mutation is `assignThread`, and `AssignThreadInput` accepts a `machineUserId` instead of a `userId`: ```ts theme={null} await plain.mutation.assignThread({ input: { threadId: thread.id, machineUserId: process.env.AGENT_MACHINE_USER_ID, }, }); ``` ## Avoiding accidental loops Whichever pattern you use, if your agent both reacts to and writes to threads, watch out for loops: * Make sure your filters reject events your agent itself caused. The assignee filter above handles assignment changes; for message events, check the message's author isn't your own machine user before reacting. * If the agent reassigns a thread to a human, the resulting `thread.thread_assignment_transitioned` event will have a different assignee on `thread`, so the assignee filter naturally drops it. # Searching knowledge Source: https://www.plain.com/docs/agents/searching-knowledge Give your agent answers from your help center and indexed documents. To answer a customer's question, an agent needs facts. Rather than building and maintaining your own vector store, you can search the knowledge you've already added to Plain with the `searchKnowledgeSources` query. It runs a semantic search and returns the most relevant passages, ready to drop into a prompt. This is the retrieval half of a RAG ("retrieval-augmented generation") agent. ## What knowledge sources are A knowledge source is any content in Plain that can be searched to answer a question. There are two kinds: * **Help center articles** — the self-serve articles you publish in [Plain's help center](https://plain.support.site/article/help-center). * **Indexed documents** — external pages and documents you've indexed for AI, like your public docs or marketing site. You manage both in Plain, and `searchKnowledgeSources` searches across them in a single call. ## Search knowledge sources Pass a natural-language `searchQuery` and Plain returns the passages most semantically relevant to it, ordered most-relevant first. Each result carries the matched `content` (a chunk of text) plus a reference to the source it came from. This operation requires the `knowledgeSource:read` permission. ```ts theme={null} const results = await plain.query.searchKnowledgeSources({ searchQuery: "how do I reset my password?", pageSize: 5, }); for (const result of results) { console.log(result.content); // Narrow on __typename to get a reference back to the source if (result.__typename === "HelpCenterArticleSearchResult") { const article = await result.helpCenterArticle; console.log(article.title); } else if (result.__typename === "IndexedDocumentSearchResult") { const doc = await result.indexedDocument; console.log(doc.url); } } ``` The query returns a union of `HelpCenterArticleSearchResult` and `IndexedDocumentSearchResult`. Narrow with `__typename` (or `instanceof`) the same way as any other [union type](/docs/graphql/sdk#union-types) in the SDK. `pageSize` defaults to 10 and can be between 1 and 50. `searchQuery` must be between 1 and 1000 characters. ## Feeding results into a prompt The `content` field is what you usually want for generation: it's the matched passage, already trimmed to the relevant part of the source. Concatenate the top results into your prompt as context: ```ts theme={null} const results = await plain.query.searchKnowledgeSources({ searchQuery: customerQuestion, pageSize: 5, }); const knowledge = results.map((r) => r.content).join("\n\n---\n\n"); const reply = await myModel.generate({ system: "You are a customer support agent. Answer only from the knowledge below.", prompt: `Knowledge:\n\n${knowledge}\n\nQuestion: ${customerQuestion}`, }); ``` From here you can [reply directly, suggest a reply, or post a note](/docs/agents/acting-on-threads) with whatever the model produces. Keeping the source reference (the article title or document URL) alongside each passage lets your agent cite where an answer came from. ## Narrowing the search The optional `options` argument lets you control what gets searched: | Option | Type | What it does | | -------------------------- | ------------------------------------------- | ------------------------------------------------------------------------ | | `types` | `[INDEXED_DOCUMENT \| HELP_CENTER_ARTICLE]` | Restrict to one kind of source. Omit to search both. | | `labelTypeIds` | `[ID!]` | Only return indexed documents tagged with these label types. | | `includeNonCustomerFacing` | `Boolean` | Include help centers marked as not customer-facing. Defaults to `false`. | For example, to search only your help center articles: ```ts theme={null} const results = await plain.query.searchKnowledgeSources({ searchQuery: customerQuestion, options: { types: ["HELP_CENTER_ARTICLE"] }, }); ``` If you pass `labelTypeIds` without specifying `types`, the search is limited to indexed documents, since labels only apply to them. ## Resources * [GraphQL SDK](/docs/graphql/sdk): the typed client your agent calls * [Reading threads](/docs/agents/reading-threads): get the customer's question as text to search with * [Acting on threads](/docs/agents/acting-on-threads): reply, suggest, or note with the answer * [Help center](/docs/graphql/help-center): manage the articles you're searching over # Customer cards Source: https://www.plain.com/docs/customer-cards Live context straight from your own systems when helping customers. Customer cards are a powerful feature in Plain that let you show information from your own systems while looking at a customer or thread in Plain. This makes sure you always have important context when helping customers. Customer cards are configured on Plain (see how to [how to create one](/docs/customer-cards/create-a-customer-card)) and requested by Plain from your APIs. ## High-level flow For a more detailed description of the protocol, check out the [full spec](/docs/customer-cards/protocol). 1. A thread is viewed in Plain. 2. Plain fires a POST request to your API with: * The thread customer's `email`, `id` and, if set, `externalId` * The thread's `id` and, if set, `externalId`. * If the thread has a tenant, the tenant `id` and, if set, `externalId`. * The configured customer card `key`s 3. Your API responds with the JSON for each card 4. Cards are shown to the user in Plain. Based on your customer card settings, Plain will send a request to your API like the below example: Your API should then reply with a list of cards matching the requested keys where each card contains the components you want to display: ## UI Components To define what each customer card should look like, you use the Plain UI components. All the components are documented in the [Plain UI Components](/docs/ui-components/) section. You can find example customer cards and an example API you can check out [team-plain/example-customer-cards](https://github.com/team-plain/example-customer-cards). Feel free to try these out in your workspace! ## Example cards To demonstrate what you can build with customer cards we've built some examples you can view and which are open source. [**Customer cards Examples →**](https://github.com/team-plain/example-customer-cards) ## Playground The UI components playground lets you build and preview the component JSON needed to create a customer card. Use this to prototype a customer card before starting to build your integration. [**Playground →**](https://app.plain.com/developer/ui-components-playground/) # Create a customer card Source: https://www.plain.com/docs/customer-cards/create-a-customer-card Define the details of the customer card. To create a customer card head to **Settings** → **Customer cards** and enter the following details: * **Title**: this will be displayed as the title of the card so even if the card fails to load users know which card is errored. * **Key**: the link between this config and your API. A key can only contain alphanumeric, hyphen, and underscore characters (regex: `[a-zA-Z0-9_-]+`) * **Default time to live (seconds)**: by default how long Plain should cache customer cards. The minimum is 15 seconds, maximum is 1 year in seconds (31536000 seconds). * **URL**: the URL of your API endpoint that will be built to return customer cards. It must start with `https://`. * **Headers (optional)**: the headers Plain should pass along when making the request. While this is optional it is **highly recommended** to add authorization headers or other tokens that authenticate the request as your API may be returning customer data. To get you started quickly, we've created a few example customer cards that you can configure and see how they look in your application. All example cards are available in our open-source repository: [team-plain/example-customer-cards](https://github.com/team-plain/example-customer-cards) Here is one you can try right now: * Title: e.g. "Usage" * Key: `usage` * Default time to live: `120` * URL: [https://example-customer-cards.plain.com/](https://example-customer-cards.plain.com/) # Examples Source: https://www.plain.com/docs/customer-cards/examples # Playground Source: https://www.plain.com/docs/customer-cards/playground # Protocol Source: https://www.plain.com/docs/customer-cards/protocol Learn how we request customer cards from your API and how to respond to these requests. This page is intended for a technical audience that will be implementing a customer card API. Check out the [customer cards](/docs/customer-cards) page for an overview of customer cards. Customer cards are not proactively loaded. They are just-in-time and pulled when required. This means that if your APIs are slow then users of the Support App will see a loading spinner over the card. The protocol is as follows: 1. When a user in Plain opens up a customer's page the cards are loaded. 2. Plain's backend figures out which cards can be returned from the cache and which cards need to be loaded. On the first load of the customer this would be all cards. 3. It calculates how many requests it needs to make (see [request deduplication](#request-deduplication) for details). 4. Your APIs are then called with the customer's details, so you can look up the customer's data in your systems (see [request](#request) section for details). 5. Your APIs then return customer cards that consist of [Plain UI components](/docs/ui-components) (see [response](#response) section for details). 6. The cards are cached based on either an explicit TTL value in the response or the TTL in the card settings (see [caching](#caching)). 7. Cards are shown to the user in Plain. 8. Users can manually reload the card at any time in which case only that one card will be requested from your API. A **few limits** to be aware of: * Your API must **respond within 15 seconds**, or it will time out. See [retry strategy](#retry-strategy) for details on how timed-out requests are retried. * You can configure a **maximum of 25 customer cards per workspace**. * **Card keys must be unique within a workspace**. A key can only contain **alphanumeric**, **hyphen**, and **underscore** characters (regex: `[a-zA-Z0-9_-]+`). ## Request Plain will make the following request to your backend: * **Method**: `POST` * **URL:** the URL you configured on customer cards settings page. * **Headers:** * All the headers you provided on customer cards settings page. This should typically include authentication headers. * `Content-Type`: `application/json` * `Accept`: `application/json` * `Plain-Workspace-Id`: the ID of the workspace the customer is in. This is useful for logging or request routing. * `User-Agent`: `Plain/1.0 (plain.com; help@plain.com)` * `Plain-Request-Signature`: `XXX` (see [request signing](/docs/request-signing) for details) * **Body:** * `cardKeys`: an array of card keys being requested * `customer`: an object with the customer's core details * `id`: the id of the customer in Plain * `email`: the email of the customer * [`externalId`](https://www.plain.com/docs/graphql/customers/upsert) (optional): string if the customer has an `externalId`, otherwise it is `null`. * `thread` (optional): an object with the thread's details, if this customer card is being requested in the context of a thread * `id`: the id of the thread in Plain * `externalId` (optional): string if the thread has an `externalId`, otherwise it is `null`. Example request body: ### Request deduplication If you configure multiple customer cards that have the same API details then Plain will batch them and make only one request. The request deduplication logic for customer card configs is: * The following config properties are ignored: Title, Card key, Default TTL * **API URL:** Leading and trailing whitespaces are trimmed and then compared. **This is case sensitive**. * For example, these URLs would be considered **different**: * `https://api.example.com/cards` * `https://api.example.com/cards/` * `https://api.example.com/Cards` * **API Headers:** Order of headers does not matter * **Header name:** Leading and trailing whitespaces are trimmed and then compared. **This is case insensitive**. * For example, these header names be considered **the same**: * `Authorization` * `AUTHORIZATION` * `   authorization   ` * **Header value:** No processing done, compared as is (be careful with any extra whitespace characters) * For example, these header values would be considered **as different**: * `Bearer my-token` * `bearer my-token` * `   bearer my-token   ` ## Response For each key requested a corresponding card **MUST** be returned in the response, otherwise an integration error will be returned for that card. Any extra cards in the response will be ignored. Your API must respond with a **`200` status code** or the response body won't be processed and will be treated as an error. The response body must be a JSON object with: * `cards`: an array of cards. Every `cardKey` requested should have a corresponding `key` returned. Any extra returned cards will be ignored. * `key`: the requested key * `timeToLiveSeconds` (optional, nullable): can either be omitted or `null`. If provided it will override the default time to live value. This allows you to control caching on a case-by-case basis. * `components` (nullable): `null` to indicate that the card has no data or an array of [Plain UI Components](/docs/ui-components/). Example response body for a card cached for 1 hour: Example response body for a card that has no data and should not be displayed and TTL omitted: ## Caching We cache the responses we get from your APIs. This cache is controlled via two properties: 1. A time to live value (in seconds) in the customer card's settings. This can be changed under **Settings** → **Customer cards**. Any changes here will only apply to newly loaded customer cards. 2. An explicit time to live value (in seconds) in your API response with the key `timeToLiveSeconds`. This overrides the value from settings and allows your API to dynamically set the TTL using custom logic. Any card that is past its expiry time will usually be deleted within a few minutes but no later than 48 hours after expiry. ## Retry strategy Errors are classified into two categories: 1. **Retriable errors**: these are transient issues where retrying once is appropriate 2. **Integration errors**: these are typically programming or configuration errors. These errors won't be retried and cached for 5 minutes. ## Security Plain supports [request signing](/docs/request-signing) and [mTLS](/docs/mtls) to verify that the request was made by Plain and not a third party. ### Retriable errors The following errors are **retried once** after a **1-second delay**: * HTTP `5xx` response status code * HTTP `429` Too Many Requests response status code * The request times out after 15 seconds. * Plain fails to perform the request for some reason Retriable errors are not cached, therefore if the cards are requested again via the Support App they will be re-requested. ### Integration errors The following errors are **not retried**: * All HTTP 4xx response status codes except for HTTP `429` Too Many Requests response status code * A card key is missing in the response. For example, if `subscription-details` is requested but the `cards` array in the response doesn't have an element with the key `subscription-details`. * The response body does not match the expected schema documented in [response](#response). Integration errors are cached for 5 minutes and usually indicate a programming or configuration error. Users can manually refresh a card in the UI, in which case the card will be requested again. # API Explorer Source: https://www.plain.com/docs/graphql/api-explorer # Attachments Source: https://www.plain.com/docs/graphql/attachments How to upload attachments programmatically for messages and events in Plain. This page outlines how to upload attachments programmatically. At a high level to upload attachments you: * Make an API call to get an upload url and some metadata * You then upload your file, and metadata to that upload url. * Use the ID of the attachment you uploaded in other API calls (e.g. create a thread or send an email). ## Step by step guide To try this, you will need an [API key](/docs/graphql/authentication/) with the following permission: * `attachment:create` - `fileName` is the name under which the attachment will appear in the timeline - `fileSizeBytes` is the exact size of the attachment in bytes - `c_XXXXXXXXXXXXXXXXXXXXXXXXXX` is the customer id you are uploading the attachment for The GraphQL mutation to create an attachment upload URL is the following: In the `AttachmentUploadUrl` we created in the previous step we get back 2 fields which are needed to actually upload our attachment: * `uploadFormUrl`: The URL to which to upload the file to * `uploadFormData`: A list of key, value pairs that have to be included in the data we upload along with the actual file data. With this information we can now upload our actual file to Plain. To do this we need to build a form (`multipart/form-data`) with the data contained in `uploadFormData` and submit it to the `uploadFormUrl`. Here is some example code showing how you would do this in the Browser and from a Node server: ## Limitations * A maximum of **100 attachments** can be added to a message * The **combined** size of all attachments you add to a message cannot exceed the following limits based on attachment type: * **Email attachments**: 6MB * **Chat attachments**: 100MB * **Slack attachments**: 50MB * **Microsoft Teams attachments**: 50MB * **Discord attachments**: 50MB * **Thread discussion attachments**: 50MB * **Note attachments**: 50MB * The following file extensions are not allowed as attachments: ` bat, bin, chm, com, cpl, crt, exe, hlp, hta, inf, ins, isp, jse, lnk, mdb, msc, msi, msp, mst, pcd, pif, reg, scr, sct, shs, vba, vbe, vbs, wsf, wsh, wsl` * Attachments uploaded, but never referenced by a message, will be **deleted after 24 hours**. * Upload URLs are only **valid for 2 hours** after which a new URL needs to be created. # Authentication Source: https://www.plain.com/docs/graphql/authentication Machine Users can have multiple API Keys to make it easy to rotate keys. Every API key also has fine grained permissions. Go to **Settings** → **Machine Users** and click "Add Machine User" A Machine User has two fields: * **Name:** This is just visible to you and could indicate the usage e.g. "Autoresponder" * **Public name:** This is the name visible to customers (if the Machine User interacts with customers) e.g. "Mr Robot" Click "Add API Key" and select the permissions you need. When making API calls, if you have insufficient permissions, the error should tell you which permissions you need. The relevant documentation will tell you which permissions are required for each feature. Once you've made an API key you should copy it and put it somewhere safe, as you will not be able to see it again once you navigate away. That's it! Now that you have an API key you can use this within any API call as a header: ```text theme={null} Authorization: Bearer plainApiKey_xxx ``` # Companies Source: https://www.plain.com/docs/graphql/companies Within Plain every customer can belong to one company. The company is infered automatically using the customer's email address. For example if their email address ends with "@nike.com" then their company will be automatically set to "Nike". Companies allow you to prioritise and filter your support requests. Additionally [tiers and SLAs](https://plain.support.site/article/tiers) can be associated with a company. # Delete a company Source: https://www.plain.com/docs/graphql/companies/delete Deleting a company unlinks it from all of its customers — the customers themselves are not deleted, they just no longer belong to a company. A company is identified by either its Plain `companyId` or its `companyDomainName`. This operation requires the following permissions: * `company:delete` # Fetch companies Source: https://www.plain.com/docs/graphql/companies/get-companies We provide a number of methods for fetching companies: 1. [Get companies](#get-companies) (To fetch more than one company at a time) 2. [Get company by ID](#get-company-by-id) 3. [Search for companies](#search-for-companies) All of these endpoints require the following permissions: * `company:read` ## Get companies You can get all companies you've interacted with in your workspace using the `companies` query. This endpoint supports [Pagination](/docs/graphql/pagination). ## Get company by ID If you already have the ID of a company you can fetch it directly using the `company` query. ## Search for companies The `searchCompanies` query performs a case-insensitive partial match across a company's name and domain. The search term must be at least 2 characters long. # Update customer company Source: https://www.plain.com/docs/graphql/companies/update-customer-company Plain automatically derives a customer's company for you, but you can also update it manually. The customer in question is identified by their id (ie `c_...`). With regards to the company, you can either specify an existing company using the ID we've generated (ie `co_...`), or pass the company domain, which we'll use to derive the rest of the company's info. If you wish to only remove the customer's associated company, then you can pass `null` as the `companyIdentifier`. For this mutation you need the following permissions: * `customer:edit` # Upsert a company Source: https://www.plain.com/docs/graphql/companies/upsert Plain auto-creates companies from customer email domains, but you can also upsert them directly via the API. This is useful when you want to set details like the company name, logo or account owner ahead of any customers being created. `upsertCompany` will create a new company if one with the given identifier doesn't exist, or update it in place if it does. The mutation returns a `result` field of either `CREATED` or `UPDATED` so you can tell which happened. A company is identified by either its Plain `companyId` or its `companyDomainName`. When upserting by domain, you can pass either a bare domain (e.g. `plain.com`) or a full URL (e.g. `https://www.plain.com`) and we'll extract the domain for you. This operation requires the following permissions: * `company:create` * `company:edit` # Customers Source: https://www.plain.com/docs/graphql/customers Customers that reach out to you will automatically be created in Plain without requiring any API integration. However, using our API to manage customers proactively can be helpful when you are optimizing your support workflow. For example: * You can [**put customers into groups**](/docs/graphql/customers/customer-groups/) to better organize your support queue. For example, you could group customers by pricing tier (e.g. Free Tier, Teams, Enterprise) * You can [**create customers**](/docs/graphql/customers/upsert/) in Plain when they sign-up on your own site so that you can reach out to them proactively without waiting for them to get in touch. * You can [**save your own customer's ID**](/docs/graphql/customers/upsert) for use with [**customer cards**](/docs/customer-cards/). # Customer groups Source: https://www.plain.com/docs/graphql/customers/customer-groups Customer groups can be used to group and segment your customers. For example you could organise your customers by their tier "Free", "Growth, "Enterprise" or make use of groups to keep track of customers trialing beta features. Customers can belong to one or many groups. You can filter customer threads by group, allowing you to quickly focus on a subset of them. This guide will show you how to add customers to groups using the API. You can also do this with the UI in Plain if you prefer. This guide assumes you've already created some customer groups in **Settings** → **Customer Groups**. ## Add a customer to groups A customer can be added to a customer group using the `addCustomerToCustomerGroup` mutation. Depending on what your customer groups are you may want to call this API at different times. For example if you are grouping them by their pricing tier you will want to do this every time their tier changes. This operation requires the following permissions: * `customer:create` * `customer:edit` If you prefer you can also use the customer group id instead of the key. You can do this like so: ## Remove a customer from groups A customer can be removed from a customer group by using the `removeCustomerFromGroup` mutation. If you prefer you can also use the customer group id instead of the key. You can do this like so: # Delete customers Source: https://www.plain.com/docs/graphql/customers/delete You can delete customers with the `deleteCustomer` API. To delete a customer you will need the customer's ID from within Plain. You can get this ID in the UI by going to a thread from that customer and pressing the 'Copy ID' button from the customer details panel on the right, or via our [fetch API](/docs/graphql/customers/get). Deleting a customer will trigger an asynchronous process which causes all data (such as threads) associated with that customer to be deleted. This operation requires the following permissions: * `customer:delete` # Fetch customers Source: https://www.plain.com/docs/graphql/customers/get We provide a number of methods for fetching customers: 1. [Get customers](#get-customers) (To fetch more than one customer at a time) 2. [Get customer by ID](#get-customer-by-id) 3. [Get customer by email](#get-customer-by-email) 4. [Get customer by external ID](#get-customer-by-external-id) 5. [Search for customers](#search-for-customers) All of these endpoints require the following permissions: * `customer:read` ## Get customers Our API allows you to fetch customers as a collection using the `customers` query in GraphQL. This endpoint supports [Pagination](/docs/graphql/pagination). This is a very flexible endpoint which supports a variety of options for filtering and sorting, for full details try our [API explorer](https://app.plain.com/developer/api-explorer/). ## Get customer by ID If you already have the ID of a customer from within Plain or one of our other endpoints you can fetch more details about them using the `customer` query in GraphQL. ## Get customer by email To fetch a customer by email you can use the `customerByEmail` query in GraphQL. ## Get customer by external ID If you store a stable identifier from your own system on Plain customers (for example your internal user ID), you can fetch the customer back by that value using the `customerByExternalId` query. External IDs are unique within a workspace. ## Search for customers The `searchCustomers` query lets you do a case-insensitive partial match across a customer's name, email, short name and external ID. This is the same search behaviour as the customer picker in the Plain app and is best suited for human-driven lookups rather than precise programmatic resolution. For exact lookups by email or external ID, prefer [`customerByEmail`](#get-customer-by-email) or [`customerByExternalId`](#get-customer-by-external-id). # Mark customer as spam Source: https://www.plain.com/docs/graphql/customers/spam You can flag a customer as spam to hide their threads from the main inbox and stop them being included in metrics. This is useful for closing the loop on automated handling of throwaway accounts, bot traffic or known abusers. When a customer is marked as spam their `markedAsSpamAt` timestamp is set. The mutation is idempotent — calling it on an already-spam customer leaves the timestamp unchanged. This operation requires the following permissions: * `customer:edit` ## Mark a customer as spam ## Unmark a customer as spam To reverse the above, use `unmarkCustomerAsSpam`. The customer's `markedAsSpamAt` timestamp is cleared and their threads start appearing in the inbox again. # Upserting customers Source: https://www.plain.com/docs/graphql/customers/upsert Learn how to create and update customers programmatically. Creating and updating customers is handled via a single API called `upsertCustomer`. When you upsert a customer, you define: 1. The identifier: This is the field you'd like to use to select the customer and is one of * `emailAddress`: This is the customer's email address. Within Plain email addresses are unique to customers. * `customerId`: This is Plain's customer ID. Implicitly if you use this as an identifier you will only be updating the customer since the customer can't have an id unless it already exists. * `externalId`: This is the customer's id in your systems. If you previously set this it can be a powerful way of syncing customer details from your backend with Plain. 2. The customer details you'd like to use if creating the customer. 3. The customer details you'd like to update if the customer already exists. When upserting a customer you will always get back a customer or an error. ## Upserting a customer This operation requires the following permissions: * `customer:create` * `customer:edit` This will: * Find a customer with the email '[donald@example.com](mailto:donald@example.com)' (the identifier). * If a customer with that email exists will update it (see `onUpdate` below) * Otherwise, it will create the customer (see `onCreate` below) The GraphQL mutation is the following: The value of the `result` type will be: * `CREATED`: if a customer didn't exist and was created * `UPDATED`: if a customer already existed AND the values being updated **were different**. * `NOOP`: if a customer already existed AND the values being updated **were the same** # Discussions Source: https://www.plain.com/docs/graphql/discussions A **discussion** is a side-conversation about a thread — typically a Slack thread or an email chain where teammates pull in expertise from elsewhere in the company. Discussions show up in the thread timeline so context isn't lost. Discussions are mostly created from within the Plain app, but the API is useful if you want to programmatically loop a specific Slack channel into certain types of threads (for example, automatically open a Slack discussion in `#billing-support` for every thread tagged `Billing`). # Start a discussion Source: https://www.plain.com/docs/graphql/discussions/create Opening a discussion attaches a new side-conversation to an existing thread. The `type` determines where messages are exchanged: * `SLACK` — posts a new Slack thread in a [connected Slack channel](https://plain.support.site/article/slack-channels). Requires `slackDetails.connectedSlackChannelId`. * `EMAIL` — sends a new email to one or more recipients. Requires `emailDetails.toAddresses`. The `markdownContent` is the first message of the discussion. For Slack discussions you can also pass `slackBlocks` (a JSON-encoded [Slack Block Kit](https://api.slack.com/block-kit) array) to render rich content in Slack; `markdownContent` remains the fallback rendered in Plain. # Fetch discussions Source: https://www.plain.com/docs/graphql/discussions/get ## List discussions The `discussions` query supports filtering by status, thread, creator, last-activity timestamps and more. ## Get a discussion by ID # Send a discussion message Source: https://www.plain.com/docs/graphql/discussions/send-message Adds a new message to an existing discussion. The message is posted in the original channel (Slack thread reply or email reply) and recorded against the discussion in Plain. # Error codes Source: https://www.plain.com/docs/graphql/error-codes If you receive an error code as part of an API call, this is where you can look up what it means #### `input_validation` The provided input failed validation. See field errors for details. #### `forbidden` Permission denied. #### `internal` An internal server error. The request should be retried. If the error persists, please get in touch at [help@plain.com](mailto:help@plain.com) #### `not_found` An entity referenced in the request is not found. For example trying to create an issue for a customer that doesn't exist. #### `not_yet_implemented` The API is not yet implemented. If you think it should already be implemented please get in touch at [help@plain.com](mailto:help@plain.com) *** #### `action_not_allowed_in_demo_workspace` The performed action is not allowed for a demo workspace. #### `attachment_file_size_too_large` The attachment being uploaded exceeds the limit (6MB) #### `attachment_file_type_not_allowed` The file type is not allowed. Banned file types: `bat`, `bin`, `chm`, `com`, `cpl`, `crt`, `exe`, `hlp`, `hta`, `inf`, `ins`, `isp`, `jse`, `lnk`, `mdb`, `msc`, `msi`, `msp`, `mst`, `pcd`, `pif`, `reg`, `scr`, `sct`, `shs`, `vba`, `vbe`, `vbs`, `wsf`, `wsh`, `wsl` #### `attachment_not_uploaded` The attachment ID being referenced was created, but not uploaded. Upload the attachment and try again. #### `cannot_assign_customer_to_user` The user that the customer is being assigned to doesn't have a role that is capable of helping the customer. Assign the "Help customers" role to the user and try again. #### `cannot_remove_only_admin_user` Can't remove the last user with an admin role. Assign another user the admin role as well and try again. #### `cannot_reply_to_unsent_email` The email being replied to has yet to be sent. Wait until the email is sent and try again. #### `cannot_update_field` Some Custom Timeline Entry fields can't be updated but only created (such as `timestamp`). Delete the Custom Timeline Entry and recreate it if you want to update these fields. #### `customer_already_exists_with_email` A customer with this email already exists in the workspace and can't be created again. #### `customer_already_exists_with_external_id` A customer with this external id already exists in the workspace and can't be created again. #### `customer_already_is_status` An attempt to change a customer to a status that the customer already is was made. #### `customer_already_marked_as_spam` The customer has already been marked as spam and can't be marked as spam again. #### `customer_card_config_key_already_exists` A Customer Card config with this key already exists in the workspace and can't be created again. #### `customer_is_marked_as_spam` An action was attempted but cannot be performed as the customer is marked as spam. #### `customer_is_not_marked_as_spam` An action was attempted but cannot be performed as it requires the customer to be marked as spam. #### `customer_status_cannot_be_changed_to_idle` The customer's status cannot be changed to idle, see error for reason. #### `customer_jwt_expired` The customer's JWT has expired. Recreate the JWT and try again. #### `customer_jwt_invalid` The customer's JWT is in an invalid format. Fix the contents of the JWT and try again. #### `customer_group_has_memberships` The customer group has memberships and can't be deleted. #### `customer_group_key_already_exists` A customer group with this key already exists in the workspace and can't be created again. #### `customer_session_challenge_invalid` The provided customer challenge digits are invalid. #### `domain_already_taken` The domain in the support email address is already taken by a different workspace. Currently only one workspace can use a domain. If this is an issue, please contact [help@plain.com](mailto:help@plain.com). #### `domain_cannot_be_public` The domain in the support email address is considered public and cannot be used. #### `insufficient_permissions` The user doesn't have the required permissions to create an API key that has more permissions than the user itself. #### `issue_already_open` Issue is already in an open state and can't be opened. #### `issue_already_resolved` Issue is already in a resolved state and can't be resolved. #### `issue_already_this_issue_type` Issue is already the provided issue type and can't be changed to it. #### `issue_already_this_priority` Issue is already the provided priority and can't be changed to it. #### `linear_issue_already_linked_to_issue` Issue is already linked to the provided Linear issue and cannot be linked again. #### `linear_organisation_cannot_be_authorised` The Linear OAuth flow failed. Detailed reasoning is included in the error. #### `mark_as_read_user_must_be_assigned_to_customer` The user trying to mark the timeline as read isn't the user that is assigned to the customer. Assign the user to the customer and try again. #### `roles_at_least_one_admin_required` Can't remove the role for the user as it would leave no users with the admin role in the workspace. Assign another user the admin role as well and try again. #### `too_many_customer_card_configs` The maximum number of Customer Card configs has been reached for this workspace. #### `too_many_webhook_targets` The maximum number of webhook targets has been reached for this webhook. #### `user_account_already_exists` A User Account already exists for the current user. #### `user_already_this_status` User is already in this status and can't be changed to it. #### `user_linear_integration_not_found` A User does not have a Linear integration setup. #### `workspace_app_key_not_found` The workspace app key can't be found. #### `workspace_app_public_key_required` A workspace app key must be provided. #### `workspace_app_required` A workspace app must be provided. #### `workspace_chat_not_enabled` Chat is disabled so chat messages can't be sent. #### `workspace_email_domain_not_configured` Email domain settings aren't fully configured yet. Double check the email settings page. #### `workspace_email_domain_not_set` A support email is not configured in the email settings. Double check the email settings page. #### `workspace_email_forwarding_not_configured` Email forwarding settings aren't fully configured yet. Double check the email settings page. #### `workspace_email_not_enabled` Email is not enabled so emails can't be sent. #### `workspace_invite_already_accepted` The invite has already been accepted by the user. #### `workspace_invite_email_already_invited` The email trying to be invited already has an outstanding invite. #### `workspace_invite_email_already_member_of_workspace` The email trying to be invited is already a member of the workspace. #### `workspace_invite_email_doesnt_match` The user trying to accept the invite has a different email than the invite is for. #### `workspace_support_email_address_conflict` The entered support email address is already taken by a different workspace. #### `workspace_user_email_already_used_as_support_email` The provided email is already used as a support email. #### `you_shall_not_pass` 🧙 User account signup is currently blocked. # Error handling Source: https://www.plain.com/docs/graphql/error-handling GraphQL queries and mutations require different error handling. This is because we expect: * … **queries** to generally succeed, as the three most common issues are usually unauthenticated, forbidden, or an internal server error. In the case of unauthenticated and forbidden, the API keys are invalid, while internal server errors should be retried. * … **mutations** to return errors regularly as part of the normal business flow due to invalid inputs. Errors include rich detail which can be used and displayed to an end user. ## Query errors Query errors aren't modeled in the GraphQL schema, but rather use [GraphQL's error extensions](https://www.apollographql.com/docs/apollo-server/data/errors/). If the query returns the value `null`, that typically indicates that the entity is not found (equivalent to an HTTP 404 in a REST API). The list of error extensions that can be returned by queries: * `GRAPHQL_PARSE_FAILED`: The GraphQL operation string contains a syntax error. The request should not be retried. * `GRAPHQL_VALIDATION_FAILED`: The GraphQL operation is not valid against the schema. The request should not be retried. * `BAD_USER_INPUT`: The GraphQL operation includes an invalid value for a field argument. The request should not be retried. * `UNAUTHENTICATED`: The API key is invalid. The request should not be retried. * `FORBIDDEN`: The API key is unauthorized to access the entity being queried. The request should not be retried. * `INTERNAL_SERVER_ERROR`: An internal error occurred. The request should be retried. If this error persists, please get in touch at [help@plain.com](mailto:help@plain.com) and report the issue. ## Mutation errors All mutations return with an `Output` type that follow a consistent pattern of having two optional fields, one for the result and one for the error. If the error is returned then the mutation failed. ```tsx theme={null} type Example { data: String! } type ExampleOutput { # example is the result of the mutation, is only returned if the mutation succeeded example: Example # if error is returned then the mutation failed error: MutationError } ``` Every `MutationError` has the following fields (assuming you included all these fields in your query): * **message:** Usually meant to be read by a developer and not an end user. * **type:** one of `VALIDATION`, `FORBIDDEN`, `INTERNAL`. * Where `VALIDATION` means input validation failed. See the fields for details on why the input was invalid. * Where `FORBIDDEN` means the user is not authorized to do this mutation. See `message` for details on which permissions are missing. * Where `INTERNAL` means an unknown internal server error occurred. Retry in this scenario and contact [help@plain.com](mailto:help@plain.com) if the error persists. * **code:** a unique error code for each type of error returned. This code can be used to provide a localized or user-friendly error message. You can find the [list of error codes](/docs/graphql/error-codes) documented. * **fields:** an array containing all the fields that errored * **field:** the name of the input field the error is for. * **message:** an English technical description of the error. This error is usually meant to be read by a developer and not an end user. * **type:** one of `VALIDATION`, `REQUIRED`, `NOT_FOUND`. * Where `VALIDATION` means the field was provided, but didn't pass the requirements of the field. See the `message` on the field for details on why. * Where `REQUIRED` means the field is required. String inputs may be trimmed and checked for emptiness. * Where `NOT_FOUND` means the input field referenced an entity that wasn't found. For example, you tried to resolve an issue that doesn't exist/was deleted. # Events Source: https://www.plain.com/docs/graphql/events Log important events to have the full picture of what happened in Plain. When helping a customer it can be useful to have context about their recent activity in your product. For example, if someone is getting in touch about a 401 error, it could be important to know that they recently deleted an API key in their settings. Events are created via the Plain API and you have full control of what they look like using Plain's UI components. There are two types of events * **[Customer events](/docs/graphql/events/create-customer-event)**: these are created in every existing thread for a customer. When a new thread is created (e.g. by an inbound communication, or by calling the [createThread](/docs/graphql/threads/create) endpoint) the **25** most recent events are shown. * **[Thread events](/docs/graphql/events/create-thread-event)**: these events belong to a single thread, and only appear in a single thread's timeline. ## UI Components To define what each event should look like, you use the Plain UI components. All the components are documented in the [Plain UI Components](/docs/ui-components/) section. ### Playground The UI Components Playground lets you build and preview the component JSON used to create an event. Use this to prototype an event before starting to build your integration. [**UI Components Playground →**](https://app.plain.com/developer/ui-components-playground/) # Create a customer event Source: https://www.plain.com/docs/graphql/events/create-customer-event A customer event will be created in all threads that belong to the provided customer ID. If you want an event to appear in a specific thread use a [thread event](/docs/graphql/events/create-thread-event). To create an event you need a customer ID. You can get this by [upserting a customer](/docs/graphql/customers/upsert) in Plain, from data in webhooks or other API calls you made. If you want to test this, press **⌘ + K** on any thread and then "Copy customer ID" to get an ID you can experiment with. In this example we'll be creating the following event: Example event For this you'll need an API key with the following permissions: * `customerEvent:create` # Create a thread event Source: https://www.plain.com/docs/graphql/events/create-thread-event A thread event will only be created in the thread ID provided. If you want an event to appear in all threads for a customer please use a [customer event](/docs/graphql/events/create-customer-event). To create a thread event you need a thread ID. You can get this by [creating a thread](/docs/graphql/threads/create) in Plain, from data in webhooks or other API calls you made. If you want to test this, press **⌘ + K** on any thread and then "Copy thread ID" to get an ID you can experiment with. In this example we'll be creating the following event: Example event For this you'll need an API key with the following permissions: * `threadEvent:create` * `threadEvent:read` # Help center Source: https://www.plain.com/docs/graphql/help-center [Plain's help center](https://plain.support.site/article/help-center) lets you publish self-serve articles for your customers, hosted either on a Plain subdomain or on your own custom domain. The help center API covers managing the *content* of a help center programmatically: * **Articles** — individual help pages with HTML content * **Article groups** — folders that organise articles in the navigation * **The navigation index** — the ordered tree of groups and articles that drives the help center sidebar Use the API when you want to keep articles in sync with a docs-as-code workflow in your own git repo, or to bulk-author content from another source. Help centers themselves (name, subdomain, branding, access, custom domains) are created and configured in the Plain app under **Settings → Help center**. The API only covers managing content within an existing help center. The mutations below require the following permissions: * `helpCenter:edit` The read queries require: * `helpCenter:read` # Fetch help centers and articles Source: https://www.plain.com/docs/graphql/help-center/get ## List help centers ## Get a help center by ID ## Get a help center article by ID ## Get a help center article by slug `slug` is unique within a help center. # Manage article groups Source: https://www.plain.com/docs/graphql/help-center/manage-article-groups Article groups are folders that organise articles in the help center navigation. Groups can be nested by setting `parentHelpCenterArticleGroupId` when creating a sub-group. ## Create an article group ## Update an article group ## Delete an article group Deleting an article group leaves its articles intact — they are simply un-grouped. To remove an article entirely use [`deleteHelpCenterArticle`](./manage-articles). # Manage articles Source: https://www.plain.com/docs/graphql/help-center/manage-articles Articles are the individual help pages in a help center. Each article belongs to one help center and optionally to one article group. `contentHtml` is the article body as HTML — Plain renders this directly in the help center. `status` is either `DRAFT` (only visible in the Plain app) or `PUBLISHED` (visible on the help center). ## Create or update an article `upsertHelpCenterArticle` creates a new article when no `helpCenterArticleId` is provided, or updates an existing article in place when one is. `slug` is normalized to lowercase. ## Delete an article # Manage the navigation Source: https://www.plain.com/docs/graphql/help-center/navigation The help center index defines the order in which article groups and articles appear in the help center navigation, as well as the parent-child relationships between them. `updateHelpCenterIndex` replaces the entire navigation tree in a single call. To avoid clobbering concurrent edits, you must include the `hash` returned by the previous `helpCenterIndex` query — if the help center has been re-indexed since you read it, the call will fail and you should re-fetch the latest index and re-apply your changes. Each entry references an existing article or article group by ID, and optionally a parent group ID to nest it within a group. # Introduction Source: https://www.plain.com/docs/graphql/introduction An overview of Plain's GraphQL API. Plain is built with this very GraphQL API we expose to you. This means that there are **no limitations** in what can be done via the API vs the UI. These docs just highlight the most interesting and most used APIs. If you want to do something beyond what is documented here, please [reach out to us](mailto:help@plain.com) or explore our [schema](/docs/graphql/schema) on your own! If you're looking to access our GraphQL API from an Agent, try our [MCP Server](https://plain.support.site/article/mcp-server) instead. ## Key details Our API is compatible with all common GraphQL clients with the following details: * **API URL:** `https://core-api.uk.plain.com/graphql/v1` * **Allowed method**: POST * **Required headers:** * `Content-Type: application/json` * `Authorization: Bearer YOUR_TOKEN` where the token is your API key. See [authentication](/docs/graphql/authentication/) for more details. * **JSON body:** * `query`: the GraphQL query string * `variables`: a JSON object of variables used in the GraphQL query * `operationName`: the name of your GraphQL operation (this is just for tracking and has no impact on the API call or result) If you'd like to use the **GraphQL schema to generate types** for your client code you can fetch the schema from: `https://core-api.uk.plain.com/graphql/v1/schema.graphql` ## Your first API call In this example, we're going to get a customer in your workspace by their email address. You can find a customer's email on the right-hand side when looking at one of their threads in Plain. You will need an API key with the `customer:read` permission. See [authentication](/docs/graphql/authentication/) for details on how to get an API key You'll need to set two shell variables: * `PLAIN_TOKEN`: The API key * `PLAIN_CUSTOMER_EMAIL`: The email of the customer you want to fetch ```shell theme={null} PLAIN_TOKEN=XXX PLAIN_CUSTOMER_EMAIL=XXX curl -X POST https://core-api.uk.plain.com/graphql/v1 \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $PLAIN_TOKEN" \ -d '{"query":"query customerByEmail($email: String!) { customerByEmail(email: $email) { id fullName updatedAt { iso8601 } } }","variables":{"email":"'"$PLAIN_CUSTOMER_EMAIL"'"},"operationName":"customerByEmail"}' ``` # Labels Source: https://www.plain.com/docs/graphql/labels Labels are a light-weight but powerful way to categorise threads, consisting of label text coupled with an icon. Each thread can have multiple labels. They can be added manually or programmatically. For example when a contact form is submitted, you could automatically add a label to the corresponding thread with the issue category they selected, so that you know upfront why they are getting in touch. The available labels you can apply are defined by your label types. Label types can be created and managed in your settings (**⌘ + K** and then search for "Manage labels"). When you want to stop a label being available you can archive a label type. Archived label types are kept on existing threads in order to avoid losing valuable historic data. Label changes can also be a starting point for integrations [via our webhooks](/docs/webhooks/thread-labels-changed). This lets you build workflows triggered by the addition of a label. # Add labels Source: https://www.plain.com/docs/graphql/labels/add You can add multiple labels to a thread with a call to `addLabels`. Label type IDs passed to this endpoint should not be archived, we return a validation error with code `cannot_add_label_using_archived_label_type` for any which are submitted. If a label type you provide is already added to the thread we will return a validation error with code `label_with_given_type_already_added_to_thread`. You can retrieve label type IDs in the Plain UI settings by hovering over a label type and selecting 'Copy label ID' from the overflow menu. This operation requires the following permissions: * `label:create` # Manage label types Source: https://www.plain.com/docs/graphql/labels/label-types Label types define the labels available in your workspace. Each label type has a name, icon, colour, and optionally a description, parent label type and external ID. When you want to stop a label being available, archive its label type rather than deleting it — this preserves the historical record on existing threads. The mutations below all require the following permissions: * `labelType:create` * `labelType:edit` The read queries require: * `labelType:read` ## Get label types Use `labelTypes` to fetch the full list of label types in your workspace. By default this includes archived label types — pass `filters: { isArchived: false }` to exclude them. ## Get a label type by ID ## Get a label type by external ID If you store your own identifier on label types via `externalId`, you can look them up by that value. External IDs are unique within a workspace. ## Create a label type ## Update a label type Updates use field-level wrapper inputs — to change a field pass `{ "value": ... }`; to leave a field untouched omit it entirely. ## Archive a label type Archived label types stay attached to existing threads but cannot be added to new threads. Attempting to call `addLabels` with an archived label type returns the error code `cannot_add_label_using_archived_label_type`. ## Unarchive a label type # Remove labels Source: https://www.plain.com/docs/graphql/labels/remove You can remove labels from a thread with a call to `removeLabels`. Label IDs for this call can be retrieved by fetching a thread with the API. This operation requires the following permissions: * `label:delete` # Messaging Source: https://www.plain.com/docs/graphql/messaging We provide various methods to message your customers with the Plain API. You can use this to reach out proactively, build an autoresponder or even to handle things like waiting list access. Send a new email in a thread ignoring previous communications. Use this to reply to an existing inbound email in a thread. Reply to a thread automatically using the best channel. # Reply to emails Source: https://www.plain.com/docs/graphql/messaging/reply-email You can reply to an inbound email with the `replyToEmail` API. This operation requires the following permissions: * `email:create` * `email:read` # Reply to threads Source: https://www.plain.com/docs/graphql/messaging/reply-to-thread You can reply to a thread using the `replyToThread` mutation, as long as the thread's communication channel is either `API`, `CHAT`, `EMAIL`, `SLACK` or 'MS\_TEAMS'. This information is available in the thread as the `channel` field. If it is not possible to reply to a thread, you will get the mutation error code [`cannot_reply_to_thread`](/docs/graphql/error-codes#cannot_reply_to_thread) and a message indicating why. This operation requires the following permission: * `thread:reply` ## Impersonation Impersonation is exclusively available in our `Grow` plan. You can see all available plans in our [pricing page](https://www.plain.com/pricing). This feature allows you to bring native messaging between your customers and Plain, straight into your own product]\([https://plain.support.site/article/headless-portal-overview](https://plain.support.site/article/headless-portal-overview)). With impersonation, you can reply to a thread on behalf of one of your customers: impersonated messages will show up as if they were sent by the customers themselves. In order to impersonate a customer, provide the `impersonation` parameter in the `replyToThread` mutation, specifying the identifier of the customer you want to impersonate. You can pick any of the available customer identifiers (`emailAddress`, `customerId` or `externalId`) ```graphql theme={null} { "impersonation": { "asCustomer": { "customerIdentifier": { "emailAddress": "blanca@example.com" } } } } ``` Impersonation is only possible for `API`, `CHAT`, `EMAIL` and `SLACK` threads (based on the thread's `channel` field). The customer message will be processed differently based on the thread's channel: * `SLACK`: the message will appear in Slack as a new message from the impersonated customer, including their name and any other customer details * `API` and `EMAIL`: the message will be sent as an email with the impersonated customer's email address as the "From" address, making it appear as if they sent the email directly * `CHAT`: the message will appear in the thread as coming directly from the impersonated customer, with their name and avatar displayed When replying to an `EMAIL` or `API` thread, you can optionally add 'Cc' and 'Bcc' recipients by using the `channelSpecificOptions` parameter: ```graphql theme={null} { "channelSpecificOptions": { "email": { // For CC'd recipients "additionalRecipients": [ { "email": "peter@example.com", "name": "Peter" }, ], // For BCC'd recipients "hiddenRecipients": [ { "email": "finance@example.com" } ] } } } ``` This operation requires the following permissions: * `thread:reply` * `customer:impersonate` # Send new emails Source: https://www.plain.com/docs/graphql/messaging/send-email As well as creating outbound emails in the UI you can also send them with the `sendNewEmail` API. This is useful for proactively reaching out about issues. # Suggested replies Source: https://www.plain.com/docs/graphql/messaging/suggested-replies Suggested replies (also called generated replies) allow you to programmatically add AI-generated or pre-composed reply suggestions to a thread. These suggestions are shown to the user in Plain so they can review and send them to the customer. This is useful if you are building your own AI integration or want to suggest replies based on your own logic, external knowledge bases, or automation workflows. ## Adding a suggested reply To add a suggested reply you need to provide the `threadId`, the `timelineEntryId` of the customer message you are responding to, and the `markdown` content of the reply. The timeline entry must be from a customer — suggested replies cannot be added against non-customer messages. A `timelineEntryId` identifies a single entry on a customer's timeline (e.g. an email or chat message). You can get one by querying a thread's `timelineEntries` connection, where each entry has an `id` you can use here. The `markdown` field supports a maximum of 5,000 characters. To add a suggested reply, you need an API key with the following permissions: * `generatedReply:create` # Notes Source: https://www.plain.com/docs/graphql/notes Notes are internal comments that show up in the thread timeline alongside customer messages but are only visible to your team. They're useful for context — like a heads-up from another teammate, a reminder, or annotations from an automation. Notes can be attached either to a customer (visible across all of that customer's threads) or specifically to a thread (visible only in that thread's timeline). These operations require the following permissions: * `note:create` * `note:edit` * `note:delete` ## Create a note `text` is the plain-text body. If you also pass `markdown`, that version is preferred where rich text is supported. ## Update a note ## Delete a note # Pagination Source: https://www.plain.com/docs/graphql/pagination Our GraphQL API follows the [Relay pagination spec](https://relay.dev/graphql/connections.htm). When fetching collections from our API you can control how much data is returned. We will return 25 records per request by default and the maximum page size is 100 records. We support two forms of page control arguments: 1. Forward pagination with `after` (cursor) & `first` (numeric count) 2. Reverse pagination with `before` (cursor) & `last` (numeric count) Note that these must not be mixed, e.g performing a query with values for first & before will result in a validation error. Endpoints which return paginated results will return a `pageInfo` object along with a `totalCount` field which allows you to make subsequent calls with page controls. Using the `getCustomers` API as an example this would look as follows: This will fetch a subsequent page of 50 entries by passing in the `endCursor` from an initial query. # Schema Source: https://www.plain.com/docs/graphql/schema If you need the schema programmatically for code generation or if you just want to read the schema you can view the [raw GraphQL schema](https://core-api.uk.plain.com/graphql/v1/schema.graphql). You can also use the [API Explorer](https://app.plain.com/developer/api-explorer/) to learn about our API schema. This is the easiest way of discovering everything possible with the GraphQL API. [**View API Explorer →**](https://app.plain.com/developer/api-explorer/) # GraphQL SDK Source: https://www.plain.com/docs/graphql/sdk A typed SDK for Plain's GraphQL API, auto-generated from the schema. The `@team-plain/graphql` package provides a fully typed client for Plain's GraphQL API. It is auto-generated from the [GraphQL schema](/docs/graphql/schema), which means every query and mutation available in the API is available in the SDK. You can use any GraphQL client to interact with Plain's API, but this SDK gives you type safety, automatic pagination, and structured error handling out of the box. ## Installation ```bash theme={null} npm install @team-plain/graphql ``` Supports both ESM and CJS. ## Setup ```ts theme={null} import { PlainClient } from "@team-plain/graphql"; const client = new PlainClient({ apiKey: "plainApiKey_xxx" }); ``` You will need an API key — see [authentication](/docs/graphql/authentication) for how to create one. ## Queries Queries are available under `client.query`. Relations on returned models are lazy-loaded — accessing them triggers a separate API call automatically. ```ts theme={null} const customer = await client.query.customer({ customerId: "c_123" }); console.log(customer.fullName); // Relations are lazy-loaded — accessing them makes a separate API call const company = await customer.company; console.log(company.name); ``` ## Mutations Mutations are available under `client.mutation`. Mutation errors are returned as typed data, not thrown as exceptions. This matches Plain's API where all mutations return `*Output` types with an optional `error` field. ```ts theme={null} const result = await client.mutation.upsertCustomer({ input: { identifier: { emailAddress: "alice@example.com" }, onCreate: { fullName: "Alice", email: { email: "alice@example.com", isVerified: false }, }, onUpdate: {}, }, }); if (result.error) { // Typed MutationError with message, type, code, and field-level errors console.error(result.error.message); result.error.fields?.forEach((f) => { console.error(` ${f.field}: ${f.message}`); }); } else { console.log(result.customer?.id); } ``` ## Pagination ```ts theme={null} const customers = await client.query.customers({ first: 10 }); for (const customer of customers.nodes) { console.log(customer.fullName); } // Fetch the next page const nextPage = await customers.fetchNext(); ``` ## Union types GraphQL union and interface fields are exposed as discriminated unions of model classes. Each union member has a `__typename` property for type narrowing and supports the same lazy-loading as any other model. ```ts theme={null} const thread = await client.query.thread({ threadId: "t_123" }); // Narrow with __typename if (thread.createdBy.__typename === "UserActor") { console.log(thread.createdBy.userId); // Lazy-load a relation on the union member const user = await thread.createdBy.user; console.log(user?.fullName); } // Or narrow with instanceof import { UserActorModel } from "@team-plain/graphql"; if (thread.createdBy instanceof UserActorModel) { const user = await thread.createdBy.user; } ``` Models also support querying sub-connections directly: ```ts theme={null} const thread = await client.query.thread({ threadId: "t_123" }); // Fetch timeline entries directly from the thread model const timelineEntries = await thread.timelineEntries({ first: 25 }); for (const entry of timelineEntries.nodes) { console.log(entry.entry.__typename); } ``` ## Error handling * **Queries**: network, auth (401), forbidden (403), and rate limit (429) errors throw typed exceptions (`AuthenticationError`, `ForbiddenError`, `RateLimitError`, `NetworkError`, `PlainGraphQLError`). * **Mutations**: return the full `*Output` type. Check `result.error` for a typed `MutationError` with `message`, `type`, `code`, and `fields[]`. This is intentional — Plain's API treats mutation errors as data. For more details on error handling patterns, see [error handling](/docs/graphql/error-handling). ## Migrating from `@team-plain/typescript-sdk` If you're upgrading from the old `@team-plain/typescript-sdk` package, see the [migration guide](https://github.com/team-plain/sdk/blob/main/packages/graphql/MIGRATION.md) for a full breakdown of breaking changes. ## Resources * [GraphQL schema](https://core-api.uk.plain.com/graphql/v1/schema.graphql) — the full schema this SDK is generated from * [API explorer](https://app.plain.com/developer/api-explorer/) — browse and test queries interactively * [GitHub repository](https://github.com/team-plain/sdk/tree/main/packages/graphql) # Snippets Source: https://www.plain.com/docs/graphql/snippets Snippets are reusable pieces of text — also known as canned responses — that teammates can drop into a reply with a few keystrokes. They're useful for boilerplate openers, common troubleshooting steps, or any phrasing your team uses often. Each snippet has a name (used to search and identify it), a plain-text body, and an optional markdown body that's preferred when sending into rich-text channels. Snippets can also be grouped under a `path` (an alphanumeric string) — this is what's used to organise snippets in folders in the Plain app. You can create, update and delete snippets in the Plain app, but for teams that maintain a shared library of canned responses in another system, the API is useful for keeping them in sync. # Create a snippet Source: https://www.plain.com/docs/graphql/snippets/create Snippet `name` is used to find the snippet when inserting it during a reply. The `text` field is the plain-text body that will be used in environments that don't render markdown. If you also pass `markdown`, that version is preferred wherever rich text is supported. The optional `path` groups snippets in the Plain app. Only alphanumeric characters are allowed. This operation requires the following permissions: * `snippet:create` # Delete a snippet Source: https://www.plain.com/docs/graphql/snippets/delete Deleted snippets are soft-deleted — they're hidden from the snippet picker but remain queryable by ID with `isDeleted: true`. This preserves the history of any replies that referenced the snippet. This operation requires the following permissions: * `snippet:delete` # Fetch snippets Source: https://www.plain.com/docs/graphql/snippets/get You can fetch snippets either as a paginated collection or by ID. These queries require the following permissions: * `snippet:read` ## Get snippets ## Get a snippet by ID # Update a snippet Source: https://www.plain.com/docs/graphql/snippets/update Updates use field-level wrapper inputs — to change a field pass `{ "value": ... }`; to leave a field untouched omit it entirely. To clear the `path` (un-group a snippet) pass `{ "value": null }`. This operation requires the following permissions: * `snippet:edit` # Tasks Source: https://www.plain.com/docs/graphql/tasks Tasks are reminders or follow-ups that your team needs to action. Each task has a title, description, status, priority and can be assigned to a teammate or a machine user. Tasks can also be linked to a company or a tenant to give them context. Tasks live alongside threads in the Plain app but are not threads themselves — they are a lighter-weight to-do, useful for things like "send onboarding follow-up next Friday" or "check in on this customer after their renewal". Use the API when you want tasks to be created or updated as a side-effect of something happening in your own systems (for example, automatically creating a follow-up task when a deal closes in your CRM). # Create a task Source: https://www.plain.com/docs/graphql/tasks/create `title` is the only required input. You can optionally set a description, status, priority, assignee (a teammate or a machine user) and a parent company or tenant. A task can be linked to either a company or a tenant, not both. This operation requires the following permissions: * `task:create` # Delete a task Source: https://www.plain.com/docs/graphql/tasks/delete Deleting a task soft-deletes it — it is removed from the active task list but remains queryable by ID with `isDeleted: true`. This operation requires the following permissions: * `task:delete` # Fetch tasks Source: https://www.plain.com/docs/graphql/tasks/get We provide three methods for fetching tasks: 1. [Get tasks](#get-tasks) — paginated collection 2. [Get task by ID](#get-task-by-id) 3. [Get task by ref](#get-task-by-ref) — `ref` is the short human-readable identifier shown in the Plain app (e.g. `T-123`) These queries require the following permissions: * `task:read` ## Get tasks ## Get task by ID ## Get task by ref # Update a task Source: https://www.plain.com/docs/graphql/tasks/update Pass only the fields you want to change. Setting `companyId` clears any existing `tenantId` on the task and vice versa — a task can be linked to either, but not both. This operation requires the following permissions: * `task:edit` # Tenants Source: https://www.plain.com/docs/graphql/tenants Tenants allow you to structure your customers in Plain in the same way as they are structured in your product. For example if within your product customers are organised in a 'team' then you would create one tenant per team in your product. A tenant has an `externalId` so that you can map it back to an entity in your database. Customers can belong to multiple tenants. For advanced integrations with Plain you can specify a tenant when creating a thread. This is useful when building a support portal in your product as it allows you to fetch threads specific to a team in your product. Additionally [tiers and SLAs](https://plain.support.site/article/tiers) can be associated with a tenant. # Add customers to tenants Source: https://www.plain.com/docs/graphql/tenants/add-customers You can add a customer to multiple tenants. When selecting the customer you can chose how to identify them. You can use the customer's email, externalId or id. For this mutation you need the following permissions: * `customer:edit` * `customerTenantMembership:create` # Delete a tenant Source: https://www.plain.com/docs/graphql/tenants/delete Deleting a tenant unlinks it from all of its customers and removes any associated tenant fields. Threads previously linked to the tenant retain a reference to the deletion record but are no longer routed via it. A tenant is identified by either its Plain `tenantId` or its `externalId`. This operation requires the following permissions: * `tenant:delete` # Get tenants Source: https://www.plain.com/docs/graphql/tenants/get We provide a number of methods for fetching tenants: 1. [Get tenants](#get-tenants) to fetch more than one tenant at a time. 2. [Get tenant by ID](#get-tenant-by-id) 3. [Search for tenants](#search-for-tenants) For all of these queries you need the following permissions: * `tenant:read` ### Get tenants Our API allows you to fetch tenants as a collection using the `tenants` query in GraphQL. This endpoint supports [Pagination](/docs/graphql/pagination). ### Get tenant by ID If you know the tenant's ID in Plain you can use this method to fetch the tenant. Generally speaking it's preferable to use [upsert](./upsert) when you have the full details of the tenant. ### Search for tenants The `searchTenants` query lets you do a case-insensitive partial match on a tenant's name as well as an exact match on its external ID. The search term must be at least 2 characters long. # Remove customers to tenants Source: https://www.plain.com/docs/graphql/tenants/remove-customers You can remove customers from multiple tenants in one API call. When selecting the customer you can chose how to identify them. You can use the customer's email, externalId or id. For this mutation you need the following permissions: * `customer:edit` * `customerTenantMembership:delete` # Set customer tenants Source: https://www.plain.com/docs/graphql/tenants/set-customer-tenants You can also set all tenants for a customer. Unlike the more specific add or remove mutations this is useful if you are sycing tenants and customers with Plain. For this mutation you need the following permissions: * `customer:edit` * `customerTenantMembership:create` * `customerTenantMembership:delete` # Tenant fields Source: https://www.plain.com/docs/graphql/tenants/tenant-fields Tenant fields are custom fields you can attach to a tenant — for example a plan name, MRR, account owner or any other attribute that is meaningful at the tenant level rather than per-customer. Working with tenant fields involves two layers: 1. **Tenant field schemas** define what fields exist (type, label, options). You create the schema once. 2. **Tenant field values** are the per-tenant values stored against a given schema. This is the same model as thread fields — see [thread fields](/docs/graphql/threads/thread-fields) for the equivalent on threads. These operations require the following permissions: * `tenant:edit` * `tenantFieldSchema:create` / `tenantFieldSchema:edit` / `tenantFieldSchema:delete` (for schema operations) ## Create or update tenant field schemas `upsertTenantFieldSchema` accepts an array of schemas to create or update in one call. Each schema is identified by the combination of `source` and `externalFieldId`. Supported field types are `STRING_TYPE`, `NUMBER_TYPE`, `BOOLEAN_TYPE`, `STRING_ARRAY`, `DATETIME_TYPE` and `ENUM_TYPE` (use `options` to define the allowed values). ## Delete a tenant field schema Deleting a schema also removes any tenant field values stored against it. ## Set a tenant field value Once a schema exists, use `upsertTenantField` to set or update the value for a specific tenant. Pass exactly one of `stringValue`, `numberValue`, `booleanValue`, `arrayValue` or `dateValue` matching the schema's `type`. ## Delete a tenant field value To clear a tenant's value for a specific field without removing the schema itself, call `deleteTenantField`. # Upserting tenants Source: https://www.plain.com/docs/graphql/tenants/upsert When upserting a tenant you need to specify an `externalId` which matches the id of the tenant in your own backend. For example if your product is structured in teams, then when creating a tenant for a team you'd use the team's id as the `externalId`. To upsert a tenant you need the following permissions: * `tenant:read` * `tenant:create` # Threads Source: https://www.plain.com/docs/graphql/threads Threads are the core of Plain's data model and equivalent to tickets or conversations in other support platforms. When you use Plain to help a customer you assign yourself to a thread and then mark the thread as `Done` once you're done helping. Threads are automatically created when a new email is received but can also be [created via the API](/docs/graphql/threads/create) (when a customer submits a contact form for example). If you're migrating from another support provider, you can [import historic threads](/docs/graphql/threads/import) with their original timestamps and conversation history. Threads have [a status](https://plain.support.site/article/statuses) and can be assigned to multiple users. Threads belong to one customer but can contain multiple email threads and customers. An example thread looks like this: The below is only showing a subset fields a thread has. Since our API is a GraphQL API you decide which fields you need when you make API requests. Use our [API explorer](https://app.plain.com/developer/api-explorer) to discover the full schema of threads. # Assignment Source: https://www.plain.com/docs/graphql/threads/assignment Threads can be assigned to users or machine users. The latter is useful if you want a bot to handle or are building a complex automation of some kind. ### Assigning a thread To assign threads you need an API key with the following permissions: * `thread:assign` * `thread:read` ## Unassigning threads To unassign threads you need an API key with the following permissions: * `thread:unassign` * `thread:read` ## Additional assignees In addition to the primary assignee, threads can have **additional assignees** — teammates who are also looped in on the thread but who aren't the main person responsible for it. This is useful for collaborative cases or when escalating to a specialist. You can add or remove either users or machine users in a single call. ### Add additional assignees ### Remove additional assignees # Autoresponders Source: https://www.plain.com/docs/graphql/threads/autoresponders Plain provides native [workspace level auto-responses](https://plain.support.site/article/auto-responses), however for more complex cases you may want to implement your own custom autoresponder. To achieve this you can set up endpoint(s) to be notified of one or more [webhooks](/docs/webhooks) from Plain. We would typically recommend listening for the [thread created](/docs/webhooks/thread-created) webhook as this will allow you the option of responding to any Thread whether it was created via email, Slack or a contact form. If you want to only reply to emails, you can use the [email received](/docs/webhooks/thread-email-received) webhook. This will trigger for all emails, not just the first one in a thread, so you should check the `isStartOfThread` field provided in the webhook payload to ensure you only reply to the first message. Note that if you subscribe to both `thread.thread_created` and `thread.email_received` you may receive two events for the same email, since we create a new thread for emails which don't belong to an existing thread. In order to avoid replying to the same message twice please check the `isStartOfThread` field in the `thread.email_received` payload. Once you have received an event and decided how to respond you can use the `replyToThread` mutation to send a reply back to the customer. See our [API explorer](https://app.plain.com/developer/api-explorer/) for more details. # Create threads Source: https://www.plain.com/docs/graphql/threads/create Creating a thread is useful in scenarios where you want to programmatically start a support interaction. You can do this in many different scenarios but the most common use-cases are when a contact form is submitted or when you want to provide proactive support off the back of some event or error happening in your product. A thread is created with an initial 'message' composed out of [UI components](/docs/ui-components). You have full control over the structure and appearance of the message in Plain. To create a thread you need a `customerId`. You can get a customer id by [creating the customer](/docs/graphql/customers/upsert) in Plain first. If you're migrating historic threads from another support provider, use [`importThread`](/docs/graphql/threads/import) instead. It preserves original timestamps and won't trigger SLAs or autoresponders. To create a thread, you need an API key with the following permissions: * `thread:create` * `thread:read` # Delete a thread Source: https://www.plain.com/docs/graphql/threads/delete Deleting a thread removes it from the inbox. The thread is soft-deleted, so you can still recover it through Plain's support if needed shortly after, but it should be treated as a destructive action. This operation requires the following permissions: * `thread:delete` # Escalating threads Source: https://www.plain.com/docs/graphql/threads/escalation An [escalation path](https://plain.support.site/article/escalation-paths) is a configured sequence of users or label-type owners that a thread escalates through when no-one is responding in time. Escalation paths themselves are configured in the Plain app under **Settings → Escalation paths**. The API lets you trigger an escalation programmatically and change which escalation path a thread is on. These operations require the following permissions: * `thread:edit` ## Escalate a thread `escalateThread` advances the thread to the next step in its current escalation path. If the thread doesn't have an escalation path attached, the call returns an error. ## Change the escalation path Attach a thread to a specific escalation path (or pass `escalationPathId: null` to detach it). # Fetch threads Source: https://www.plain.com/docs/graphql/threads/get We provide a number of methods for fetching threads: 1. [Get a thread by ID](#get-a-thread-by-id) 2. [Get a thread by external ID](#get-a-thread-by-external-id) 3. [List threads](#list-threads) — paginated, with filters 4. [Search threads](#search-threads) — full-text search on title, description and message contents All of these endpoints require the following permissions: * `thread:read` ## Get a thread by ID ## Get a thread by external ID A thread's `externalId` is unique within a customer, which is why the customer ID is required when looking up by external ID. ## List threads The `threads` query supports rich filtering (status, status detail, assignee, customer, labels, priority, date ranges) and sorting. This is the right query for building inbox-style views. ## Search threads `searchThreads` performs a full-text search across thread title, description and message contents. For exact lookups prefer [`thread`](#get-a-thread-by-id) or [`threadByExternalId`](#get-a-thread-by-external-id). # Importing threads Source: https://www.plain.com/docs/graphql/threads/import Plain has [built-in importers](https://help.plain.com/article/migration) for common providers. If your source system is supported, use those first — they handle the mapping for you. These mutations are for when you need to build a custom import, for example from a less common provider or an internal tool. The `importThread` and `importThreadMessages` mutations let you bring across historic threads while preserving original timestamps, authors, and attachments so your team has full context in Plain. Unlike [`createThread`](/docs/graphql/threads/create), imported threads do not trigger SLAs or autoresponders and are marked with import provenance tracking. ## Overview Importing a thread is a two-step process: 1. **Create the thread** with `importThread` — this sets up the thread with its metadata (title, status, priority, labels, etc.) and the original creation timestamp. 2. **Add messages** with `importThreadMessages` — this adds the conversation history (inbound messages, outbound replies, and internal notes) to the thread. You must create the thread before importing its messages. Each mutation is idempotent: if you call it again with the same `externalId`, the duplicate is skipped (the result will be `NOOP`). ## Permissions To import threads you need an API key with the following permissions: * `thread:import` * `attachment:create` (if importing messages with attachments) ## Import a thread The `importThread` mutation creates a thread tied to an existing customer. You can identify the customer by their Plain customer ID, email address, or external ID. The `statusDetail.type` must match the thread `status`: * `TODO` allows `NEW_REPLY` or `IN_PROGRESS` * `SNOOZED` allows `WAITING_FOR_CUSTOMER` * `DONE` allows `DONE_MANUALLY_SET` or `IGNORED` If you provide a `tenantId`, the customer must already be a [member of the tenant](/docs/graphql/tenants/add-customers) or the import will fail. The mutation returns a `result` field which is one of: * `CREATED` — the thread was imported successfully. * `NOOP` — a thread with this `externalId` already exists, so the import was skipped. ## Import thread messages Once you have a thread, use `importThreadMessages` to add conversation history. You can import up to **25 messages per call**. Each message has a `type` that determines which `author` field to set: * `INBOUND` — set `author.customerId` (the customer who sent the message) * `OUTBOUND` — set `author.userId` (the support agent who replied) * `NOTE` — set `author.userId` (the agent who wrote the internal note) Exactly one of `customerId` or `userId` must be provided. The mutation returns a `results` array with one entry per message in the same order as the input. Each result contains: * `result` — `CREATED` or `NOOP` (if a message with that `externalId` already exists). * `threadMessage` — the created `TimelineEntry` (for `INBOUND`/`OUTBOUND`) or `Note` (for `NOTE`). Null if the message failed. * `error` — per-message error details, null on success. If some messages fail while others succeed, the top-level `error` will have the code `bulk_partial_failure`. ## Importing messages with attachments To import messages that have attachments, you need to upload the attachments first and then reference them by ID. Use `createAttachmentUploadUrl` to get an upload URL, then upload the file. See the [attachments guide](/docs/graphql/attachments) for the full upload flow. When creating the upload URL, use the attachment type `CUSTOM_TIMELINE_ENTRY` for `INBOUND` and `OUTBOUND` messages, or `NOTE` for `NOTE` messages. Pass the attachment ID (returned by `createAttachmentUploadUrl`) in the `attachmentIds` array of the message: ```json theme={null} { "author": { "customerId": "c_01H14DFQ4PDYBH398J1E99TWSS" }, "text": "Here is a screenshot of the error.", "createdAt": "2024-06-15T10:32:00Z", "type": "INBOUND", "externalId": "msg_004", "attachmentIds": ["att_01HB924PME9C0YWKW1N4AK3BZA"] } ``` Attachments that are uploaded but not referenced by any message are deleted after 24 hours. Make sure to call `importThreadMessages` promptly after uploading. ## Putting it all together A typical migration script follows this order: 1. [Upsert customers](/docs/graphql/customers/upsert) so they exist in Plain. 2. Call `importThread` for each ticket in the source system. 3. Upload any attachments using [`createAttachmentUploadUrl`](/docs/graphql/attachments). 4. Call `importThreadMessages` with the thread ID and messages (in batches of up to 25). 5. Check the `result` and `error` fields to confirm each import succeeded. Since both mutations are idempotent on `externalId`, you can safely re-run a migration script without creating duplicates. # Changing status Source: https://www.plain.com/docs/graphql/threads/status-changes Threads can be in one of 3 statuses: * `Todo` * `Snoozed` * `Done` When you log into Plain you can filter threads by these statuses. When threads are created they default to `Todo`. To change a threads status you need an API key with the following permissions: * `thread:edit` * `thread:read` ### Mark thread as `Done` When any activity happens in a thread, it will move back to `Todo`. Unlike traditional ticketing software, we expect a ticket to move between `Todo` and `Done` a number of times in the course of helping a customer. This will not break or influence any metrics. `Done` in Plain means "I'm done for now, there is nothing left for me to do". ### Snooze thread You can snooze threads for a duration of time defined in seconds. When any activity happens in a thread, it will be automatically unsnoozed and move to `Todo`. Otherwise threads will be unsnoozed when the timer runs out. ### Mark thread as `Todo` This is useful if you mistakenly marked a thread as `Done` or snoozed a thread and want to unsnooze it. Otherwise just write a message or do what you want to do and the thread will be automatically moved back to do **Todo**. # Thread fields Source: https://www.plain.com/docs/graphql/threads/thread-fields Thread fields allow you to extend Plain's thread data model. The thread fields which you want to support have to conform to a schema configured in **Settings** → **Thread fields**. Thread fields can be nested and be either a boolean, text or a string enum. Thread fields can be required. When they are required, their value must be set in order for the thread to be marked as done. For interacting with thread fields via the API, every field has a `key` defined in its schema. Keys make it possible to quickly refer to a thread field without having to know its ID in the schema. For example if you have a field called "Product Area" the key you might choose for the key to be `product_area`. ## Manage thread field schemas Most teams configure thread field schemas in **Settings** → **Thread fields**, but if you want to provision them programmatically you can do so via the API. ### Get thread field schemas ### Create a thread field schema `key` must be unique within your workspace. `type` is one of `STRING`, `BOOL`, `NUMBER`, `DATETIME`, or `ENUM` (for enum types, populate `enumValues`). This operation requires the following permissions: * `threadFieldSchema:create` ### Update a thread field schema Field-level wrapper inputs apply — pass `{ "value": ... }` for the fields you want to change. This operation requires the following permissions: * `threadFieldSchema:edit` ### Delete a thread field schema Deleting a schema also removes any values stored against it on threads. This operation requires the following permissions: * `threadFieldSchema:delete` ### Reorder thread field schemas `reorderThreadFieldSchemas` updates the `order` of multiple schemas in a single call. You don't need to include every schema — only the ones whose order is changing. ## Manage thread field values ### Upsert a thread field To upsert a thread field you need an API key with the following permissions: * `threadField:create` * `threadField:update` ### Delete a thread field To delete a thread field you need an API key with the following permissions: * `threadField:delete` # Update thread attributes Source: https://www.plain.com/docs/graphql/threads/update The mutations below change individual attributes on an existing thread. Each one operates on a single field — there is no general-purpose `updateThread` mutation, so to change multiple attributes you call multiple mutations. These operations require the following permissions: * `thread:edit` ## Change thread title ## Change thread priority Priority is an integer from `0` (urgent) to `3` (low). ## Change the thread's customer Reassigns the thread to a different customer in your workspace. The original customer keeps any other threads they have. ## Change the thread's tenant Move the thread to a different tenant (or pass `tenantIdentifier: null` to detach the thread from its current tenant). ## Change the thread's tier Move the thread to a different tier (or pass `tierIdentifier: null` to detach). # Tiers & SLAs Source: https://www.plain.com/docs/graphql/tiers Within Plain you can organise [companies](https://plain.support.site/article/companies) and [tenants](https://plain.support.site/article/tenants) into Tiers. Tiers should match your pricing tiers (e.g. "Enterprise", "Pro", "Free", etc.). This allows you to prioritise and filter your support requests by tier. Tiers also add support for defining [SLAs](https://plain.support.site/article/tiers) so you can enforce a first-response time for different support tiers within your product or pricing. Typically, tiers are created via the UI in Plain and then tenants and companies are added and removed via the API when this happens in your product so Plain is in sync. # Add companies and tenants to tiers Source: https://www.plain.com/docs/graphql/tiers/add-members You can add multiple tenants and companies to a tier in a single mutation. Companies and tenants can only be in a single tier. For this mutation you need the following permissions: * `tierMembership:read` * `tierMembership:create` # Get tiers Source: https://www.plain.com/docs/graphql/tiers/get For all of these queries you need the following permission: * `tier:read` ## Get tiers This endpoint supports [Pagination](/docs/graphql/pagination). ### Get tier by ID If you know the tiers's ID in Plain you can use this method to fetch the tier. # Remove companies and tenants to tiers Source: https://www.plain.com/docs/graphql/tiers/remove-members You can remove companies and tenants from the tiers they are part of manually in the UI or via the API. For this mutation you need the following permissions: * `tierMembership:read` * `tierMembership:delete` # Service level agreements Source: https://www.plain.com/docs/graphql/tiers/service-level-agreements [Service level agreements (SLAs)](https://plain.support.site/article/service-level-agreements) commit you to responding to a customer within a certain time. SLAs are attached to a tier, which means every company or tenant belonging to that tier inherits them. Two types of SLA exist: * **First response time** — measured from when a thread is created until the first response is sent. * **Next response time** — measured each time the customer sends a new message, until a teammate responds. SLAs can be scoped to specific thread priorities or labels, and can either be tracked 24/7 or only during your workspace's business hours. These operations require the following permissions: * `serviceLevelAgreement:create` * `serviceLevelAgreement:edit` * `serviceLevelAgreement:delete` ## Create an SLA Provide either `firstResponseTimeMinutes` or `nextResponseTimeMinutes` (but not both — that creates a first-response SLA on one tier and a next-response SLA on the same tier with separate calls). ## Update an SLA Field-level wrapper inputs apply — pass `{ "value": ... }` for the fields you want to update and omit the rest. ## Delete an SLA # Update company tier Source: https://www.plain.com/docs/graphql/tiers/update-company-tier If you want to explicitly set the tier for a company you can do so using this mutation. If instead you want to add many companies to a tier at once, you can use the [add members mutation](./add-members). For this mutation you need the following permissions: * `tierMembership:read` * `tierMembership:create` # Update tenant tier Source: https://www.plain.com/docs/graphql/tiers/update-tenant-tier If you want to explicitly set the tier for a tenant you can do so using this mutation. If instead you want to add many companies to a tier at once, you can use the [add members mutation](./add-members). For this mutation you need the following permissions: * `tierMembership:read` * `tierMembership:create` # Webhook targets Source: https://www.plain.com/docs/graphql/webhook-targets A **webhook target** is an HTTP endpoint that Plain delivers events to. Each target subscribes to one or more event types and is pinned to a specific webhook schema version. This section of the GraphQL API covers the *management* of webhook targets — how to register a new endpoint, change which events it receives, disable it temporarily, or inspect delivery attempts when something goes wrong. If you're looking for the format of the events themselves, see [Webhooks](/docs/webhooks). The mutations below all require the following permissions: * `webhookTarget:create` * `webhookTarget:edit` * `webhookTarget:delete` The read queries require: * `webhookTarget:read` Webhook targets can also be created and managed in the Plain app under **Settings → Webhooks**. Use the API when you want to provision targets as part of an infrastructure-as-code setup or to wire up environments programmatically. # Create a webhook target Source: https://www.plain.com/docs/graphql/webhook-targets/create Creating a webhook target registers a new HTTP endpoint that Plain will deliver events to. You must pass: * `url` — the endpoint Plain should POST to * `description` — a short human-readable label (shown in the Plain app) * `isEnabled` — whether deliveries should start immediately * `eventSubscriptions` — the list of event types this target should receive * `version` — the webhook schema version to pin to (we recommend always pinning to a specific version) The full list of subscribable event types is available via the `subscriptionEventTypes` query, or in the [webhooks reference](/docs/webhooks). # Delete a webhook target Source: https://www.plain.com/docs/graphql/webhook-targets/delete Deleting a webhook target stops deliveries to that endpoint and removes the target from your workspace. Existing delivery attempt history is retained until normal retention expiry. # Inspect delivery attempts Source: https://www.plain.com/docs/graphql/webhook-targets/delivery-attempts Each time Plain attempts to deliver an event to a webhook target, the result is recorded as a delivery attempt. This is useful for debugging failing webhooks programmatically — for example to surface a recent failure rate in your own observability tooling. Each attempt records: * the event ID and event type that was delivered * when the attempt happened and how long it took * the result, which is one of: a successful HTTP response, a failed HTTP response (4xx/5xx), an error (network failure), a rejection, or a schema validation failure You can filter attempts by event types or by result status. # Fetch webhook targets Source: https://www.plain.com/docs/graphql/webhook-targets/get ## List webhook targets ## Get a webhook target by ID # Update a webhook target Source: https://www.plain.com/docs/graphql/webhook-targets/update Update an existing webhook target — for example to change the URL, pause deliveries by setting `isEnabled` to false, change which events are delivered, or move to a newer schema version. Field-level wrapper inputs apply for scalar fields — pass `{ "value": ... }` for the fields you want to change. `eventSubscriptions` is the exception: it's a full replacement of the previous list, so include every event type the target should subscribe to. # mTLS Source: https://www.plain.com/docs/mtls All outbound requests made to your **webhook targets** and **customer card endpoints** include a client TLS certificate which you can verify to achieve mutual authentication. This certificate is self-signed. In order to verify it, we provide our CA's certificate (in PEM format), which you will need to add to your server/truststore: ``` -----BEGIN CERTIFICATE----- MIIDDzCCAfegAwIBAgIUPLCyLvion+WDNw0V8HAZEZL5VjswDQYJKoZIhvcNAQEL BQAwFjEUMBIGA1UEAwwLUGxhaW5NdGxzQ0EwIBcNMjQxMDEwMDkwMzMzWhgPMjEy NDA5MTYwOTAzMzNaMBYxFDASBgNVBAMMC1BsYWluTXRsc0NBMIIBIjANBgkqhkiG 9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvikyF2YpU4zEYUWVYMc5P07CPQgtP6Agoia9 mElydDTReTXW9Rle0apHKNS8OUk8S6qtA5raEh8VT2HOZBUTZb16A1vl54be+LK7 imm7csEsU+FbHbfx9rRbisESu6Mkvf5qklovgcg5UfI4IrmQK3POB6pMBCcmdjyZ udbx6YSrV5LZLth7Gxq9lcPuwzzpv2DWZTr1GGAQ46UNLXNo4+4IQYtgjThRAl4m IBbezmiXqpi9N/7ay+P9kb4TZDQohentJu/1+y6Bj8Mxk86kq0KLlYfrEbm86lGp mJ8s3R5luh98muRT4NdKeoHGf96UAqUq21i00TDJ/PklqardWQIDAQABo1MwUTAd BgNVHQ4EFgQUlYHkn4D7QBvBudbhtq2M+f8CzpAwHwYDVR0jBBgwFoAUlYHkn4D7 QBvBudbhtq2M+f8CzpAwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC AQEAMMLZc8zu7AqP+c2Pms6kRkp9Wr/C6QmXMuhHC98RZL1VcmZhE2P0lg/t644o prYX8yf7Z2SRZgNb2s8oekPpuI2U2WFC4eam1dK5kS4ux7IgaXZkuB8DyZVSo1WO KeIb2IYmXZ6hflnFNsTRjhe/Bkb7uVVw5jMaPfxWqPmeHtgUIIoh7nYj+ZnqV5Jz FQFDb+dZzZDol/Wa3XKm7w96MrX/tanAKTygIkXyjqCrjxTI26latBQV2OPADrRO uagGFG2G0o56wC8LTJdmceZfWYmVBLawSibj75Av8fwHgXK+XAi05m2GuVOQAfLq yuMQLHrNDReQDB1tylx13b6meg== -----END CERTIFICATE----- ``` If you serve your API through AWS API Gateway, you can easily do this by [enabling mTLS and uploading the certificate](https://docs.aws.amazon.com/apigateway/latest/developerguide/rest-api-mutual-tls.html) above as the truststore. # Request signing Source: https://www.plain.com/docs/request-signing We sign outbound requests we make to your target URLs with a HMAC signature using a shared secret key. This allows you to verify that the request was made by Plain and not a third party. ## How to verify Your workspace has a global HMAC secret, this secret can be viewed and (re)generated by workspace admins in **Settings** → **Request signing**. If you have a HMAC secret set up, when you receive a request from Plain you will see a header `Plain-Request-Signature` with the HMAC signature. You can verify this signature by hashing the request body with your HMAC secret and comparing it to the signature in the header. **The signature is a HMAC-SHA256 hash of the request body, encoded as a hexadecimal string.** ### Node example ```javascript theme={null} const crypto = require('crypto'); // You may need to stringify the request body if you are using a library that parses it to a javascript object const requestBody = JSON.stringify(request.body); const incomingSignature = request.headers['Plain-Request-Signature']; const expectedSignature = crypto .createHmac('sha-256', '') .update(requestBody) .digest('hex'); if (incomingSignature !== expectedSignature) { return response.status(403).send('Forbidden'); } ``` # UI Components Source: https://www.plain.com/docs/ui-components UI components are a way of describing some UI when creating threads or [events](https://plain.support.site/article/events) or building [customer cards](https://plain.support.site/article/customer-cards). For example - this is a button that links to Stripe. ```json theme={null} { "componentLinkButton": { "linkButtonUrl": "http://stripe.com/", "linkButtonLabel": "View in Stripe" } } ``` and it looks like this: Example button linking to stripe In the GraphQL API schema, we have two separate unions for Custom Timeline Entry Components and Customer Card Components, but both unions share the same types therefore they can be treated as the same. Using TypeScript? Check out our [UI Components SDK](/docs/ui-components/sdk) for typed helper functions. To see UI components in action you can experiment with them in the [UI components playground](https://app.plain.com/developer/ui-components-playground/) # Badge Source: https://www.plain.com/docs/ui-components/badge Useful for statuses or when you need to attract attention to something. Example badges A badge has the following properties: * `badgeLabel`: the text that should be displayed on the badge * `badgeColor`: one of `GREY`, `GREEN`, `YELLOW`, `RED`, `BLUE` For example: # Container Source: https://www.plain.com/docs/ui-components/container Useful when you need to create a bit of structure. Example container A container has the following properties: * `containerContent` (min 1): an array of components. Allowed components within a Container are: * [Badge](/docs/ui-components/badge) * [CopyButton](/docs/ui-components/copy-button) * [Divider](/docs/ui-components/divider) * [LinkButton](/docs/ui-components/link-button) * [Row](/docs/ui-components/row) * [Spacer](/docs/ui-components/spacer) * [Text](/docs/ui-components/text) * [PlainText](/docs/ui-components/plain-text) For example: # CopyButton Source: https://www.plain.com/docs/ui-components/copy-button Useful if you have any IDs or other details you want to copy for use in messages or outside of Plain. Example copy button A copy button has the following properties: * `copyButtonTooltipLabel` (optional): the text that should be displayed on hover. Defaults to the value if not provided. * `copyButtonValue`: the value that should be copied to the user's clipboard after clicking the button For example: # Divider Source: https://www.plain.com/docs/ui-components/divider Useful when you need a bit of structure. Example divider A divider has the following properties: * `dividerSpacingSize` (optional): the spacing the divider should have before and after the component. One of `XS`, `S`, `M`, `L`, `XL`. Defaults to `S`. For example: # LinkButton Source: https://www.plain.com/docs/ui-components/link-button Useful when you want to link somewhere external (e.g. your own admin tool or payment provider) Example link button A link button has the following properties: * `linkButtonLabel`: the text of the button * `linkButtonUrl`: the URL the button should open in a new tab For example: # PlainText Source: https://www.plain.com/docs/ui-components/plain-text Useful when you want to show any text that should not have any formatting (is not Markdown). If you want markdown please use [Text](/ui-components/text). Example link button The plain text component has the following properties: * `plainText`: the plain text * `plainTextSize` (optional): one of `S`, `M`, `L`, defaults to `M` * `plainTextColor` (optional): one of `NORMAL`, `MUTED`, `SUCCESS`, `WARNING`, `ERROR`, defaults to `NORMAL` For example: # Row Source: https://www.plain.com/docs/ui-components/row Useful when you need to show two things next to each-other. Example row The row component has the following properties: * `rowMainContent` (min 1): an array of row components * `rowAsideContent` (min 1): an array of row components The following components can be used in a row: * [Badge](/docs/ui-components/badge) * [CopyButton](/docs/ui-components/copy-button) * [Divider](/docs/ui-components/divider) * [LinkButton](/docs/ui-components/link-button) * [Spacer](/docs/ui-components/spacer) * [Text](/docs/ui-components/text) * [PlainText](/docs/ui-components/plain-text) For example: # UI Components SDK Source: https://www.plain.com/docs/ui-components/sdk Helper functions for building Plain UI components with full type safety. The `@team-plain/ui-components` package provides typed helper functions for building `ComponentInput` objects. Instead of constructing JSON by hand, you get a concise, type-safe API. ## Installation ```bash theme={null} npm install @team-plain/ui-components @team-plain/graphql ``` Requires `@team-plain/graphql` as a peer dependency. ## Usage ```ts theme={null} import { uiComponent } from "@team-plain/ui-components"; ``` ## Available components | Builder | Description | | --------------------------------------------------- | --------------------------------------- | | `uiComponent.text({ text, size?, color? })` | Rich text with optional size and color | | `uiComponent.plainText({ text })` | Plain unformatted text | | `uiComponent.badge({ label, color? })` | Colored badge | | `uiComponent.divider()` | Horizontal divider | | `uiComponent.spacer({ size })` | Vertical spacing | | `uiComponent.linkButton({ label, url })` | Button that opens a URL | | `uiComponent.copyButton({ value, tooltip? })` | Button that copies a value to clipboard | | `uiComponent.workflowButton({ label, workflowId })` | Button that triggers a workflow | | `uiComponent.container({ content })` | Groups components together | | `uiComponent.row({ mainContent, asideContent })` | Two-column layout | For details on each component's properties and how they render, see the [UI components reference](/docs/ui-components). ## Example ```ts theme={null} import { uiComponent } from "@team-plain/ui-components"; const components = [ uiComponent.row({ mainContent: [uiComponent.text({ text: "Customer Plan", size: "L" })], asideContent: [uiComponent.badge({ label: "Pro", color: "GREEN" })], }), uiComponent.divider(), uiComponent.text({ text: "Signed up 3 days ago", color: "MUTED" }), uiComponent.spacer({ size: "M" }), uiComponent.container({ content: [ uiComponent.linkButton({ label: "View in Stripe", url: "https://dashboard.stripe.com/...", }), uiComponent.copyButton({ value: "cus_abc123", tooltip: "Copy Stripe ID", }), ], }), ]; ``` ## Resources * [UI components reference](/docs/ui-components) — component properties and visual examples * [UI components playground](https://app.plain.com/developer/ui-components-playground/) — build and preview components interactively * [GitHub repository](https://github.com/team-plain/sdk/tree/main/packages/ui-components) # Spacer Source: https://www.plain.com/docs/ui-components/spacer Example spacer A link button has the following properties: * `spacerSize`: the amount of space the component should take up. One of `XS`, `S`, `M`, `L`, `XL`. For example: # Text Source: https://www.plain.com/docs/ui-components/text Example text The text component has the following properties: * `text`: the text. Can include a subset of markdown (bold, italic, and links). * `textSize` (optional): one of `S`, `M`, `L`, defaults to `M` * `textColor` (optional): one of `NORMAL`, `MUTED`, `SUCCESS`, `WARNING`, `ERROR`, defaults to `NORMAL` For example: # WorkflowButton Source: https://www.plain.com/docs/ui-components/workflow-button Useful when you want to let agents trigger a workflow directly from a customer card Example workflow button A workflow button has the following properties: * `workflowButtonLabel`: the text of the button * `workflowButtonWorkflowIdentifier`: an object containing either: * `workflowId`: the ID of the workflow to trigger When clicked, the button will trigger the specified workflow in the context of the current thread. The button shows a loading state while the workflow is being triggered and displays a tooltip with the latest execution status, including when it was run and how long it took. For example: # Webhooks Source: https://www.plain.com/docs/webhooks Webhooks allow you to get notified about events happening in your Plain workspace. You can react to these events in many ways, such as: * Assigning threads to users based on business requirements (urgency, customer value, recurrency, etc.) * Creating an AI-powered auto-responder * Categorising threads by adding labels based on the their content * Triggering internal incidents (by identifying patterns in inbound messages) * Tracking metrics from your customer support team Using TypeScript? Check out our [Webhooks SDK](/docs/webhooks/sdk) for typed parsing and signature verification. ## Receiving events from Plain Events happening in your workspace ('Plain events') are delivered as Webhook requests. In order to receive webhook requests, you need a **publicly available HTTPS** endpoint. Plain will make an `HTTP POST` request to this endpoint whenever an event you are interested in occurs. Once your endpoint is ready, you may create a *webhook target* in Plain. A webhook target tells Plain what events you are interested in and where to send those events. You can create it by going to **Settings** → **Webhooks**, then clicking on '+ Add webhook target' Then, you need to choose a name (e.g. 'Customer notifications'), the URL of your webhook endpoint, the events you want to receive and whether you want to enable it straight away. You can create up to **25 webhook targets** per workspace. Plain events may contain Personally Identifiable Information (PII). If you want to test webhooks with a production workspace, take the necessary precautions to avoid leaking PII to untrusted parties. We have created a repository where you will find instructions on how to create a webhook endpoint using different programming languages. You can find it [here](https://github.com/team-plain/webhooks-resources/tree/main/servers). ## Security Webhook requests are always sent through HTTPS. If you want, you can include basic authentication credentials in your webhook target's URL (`https://username:password@example.com`) which will then be sent along the webhook request in an `Authorization` header: ```text theme={null} Authorization: Basic cGxhaW46cm9ja3M= ``` Plain also supports [request signing](/docs/request-signing) and [mTLS](/docs/mtls) to verify that the request was made by Plain and not a third party. ## Delivery semantics Plain guarantees **at-least-once** delivery of webhook requests. As such, you should make sure your webhook endpoint is idempotent. The `id` field in the [webhook request body](#body) can be used as an idempotency key. ## Handling webhook requests Plain considers a webhook request to be successfully delivered if your endpoint returns a **2xx** HTTP status code. The contents of the response body are ignored. Any other HTTP status code will be considered a failure, **including redirects**, which are explicitly forbidden. ## Retry policy When a webhook request fails, Plain will keep retrying it during the **\~5 days** after the first request. The delay between retries is set by the following table: | Retry # | Delay | Approximate time since first attempt | | ------- | ----- | ------------------------------------ | | 1 | 10s | 10s | | 2 | 30s | 40s | | 3 | 5m | 6m | | 4 | 30m | 36m | | 5 | 1h | 1.5h | | 6 | 3h | 4.5h | | 7 | 6h | 10.5h | | 8 | 12h | 22.5h | | 9 | 1d | 2d | | 10 | 1d | 3d | | 11 | 1d | 4d | | 12 | 1d | 5d | Plain keeps track of all the webhook delivery attempts and their results. Each webhook request includes [some metadata](#webhook-metadata) that you can use in order to know which delivery attempt it is currently being processed. ## The webhook request Webhook requests are sent as an `HTTP POST` request to the webhook target URL. ### Headers * `Accept`: `application/json` * `Content-Type`: `application/json` * `User-Agent`: `Plain-Webhooks/1.0 (plain.com; help@plain.com)` * `Plain-Workspace-Id`: The ID of the workspace where the Plain event originated * `Plain-Webhook-Target-Id`: The ID of the webhook target this webhook request is being sent to * `Plain-Webhook-Target-Version`: The [version](/docs/webhooks/versions.mdx) of the webhook target this webhook request is being sent to * `Plain-Webhook-Delivery-Attempt-Id`: The ID of the delivery attempt. It will be different on every delivery attempt * `Plain-Webhook-Delivery-Attempt-Number`: The current delivery attempt number (starts at 1) * `Plain-Webhook-Delivery-Attempt-Timestamp`: The time at which the delivery attempt was made. In UTC and formatted as ISO8601. E.g. `1989-10-28T17:30:00.000Z` * `Plain-Event-Type`: The Plain event's type * `Plain-Event-Id`: The ID of the Plain event. It remains the same across all of the delivery attempts An additional `Authorization` header is sent if the webhook target URL contains authentication credentials. ### Body The request body is a `JSON` object with the fields below. The JSON schema for Plain the webhook request body can be found [here](https://core-api.uk.plain.com/webhooks/schema/latest.json). | Field | Type | Description | | ----------------- | -------- | -------------------------------------------------------------------------------------------------------- | | `id` | `string` | The ID of the Plain event. It remains the same across all of the delivery attempts | | `type` | `string` | The Plain event's type | | `webhookMetadata` | `object` | Metadata associated with the webhook request. See [Webhook Metadata](#webhook-metadata) for more details | | `timestamp` | `string` | The Plain event's timestamp. In UTC and formatted as ISO8601. E.g. `1989-10-28T17:30:00.000Z` | | `workspaceId` | `string` | The ID of the workspace where the Plain event originated | | `payload` | `object` | The Plain event's payload [(Example)](/docs/webhooks/thread-created); | ### Webhook Metadata All the following fields are also sent as [HTTP headers](#headers). | Field | Type | Description | | --------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `webhookTargetId` | `string` | The ID of the webhook target this webhook request is being sent to. This is the ID that you will find under **Settings -> Webhooks** in the Support App | | `webhookTargetVersion` | `string` | The [version](/docs/webhooks/versions.mdx) of the webhook target this webhook request is being sent to. | | `webhookDeliveryAttemptId` | `string` | The ID of the delivery attempt. It will be different on every delivery attempt | | `webhookDeliveryAttemptNumber` | `string` | The current delivery attempt number (starts at 1) | | `webhookDeliveryAttemptTimestamp` | `string` | The time at which the delivery attempt was made. In UTC and formatted as ISO8601. E.g. `1989-10-28T17:30:00.000Z` | # Customer created Source: https://www.plain.com/docs/webhooks/customer-created This event is fired when a new customer is created in your workspace. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Customer deleted Source: https://www.plain.com/docs/webhooks/customer-deleted This event is fired when a customer is deleted from your workspace. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Customer Group Membership Changed Event Source: https://www.plain.com/docs/webhooks/customer-group-membership-changed This event is fired whenever a customer is added or removed from a customer group. The `changeType` field allows you to know what kind of change has occurred. It can be one of the following: * `ADDED`: a customer group membership was added * `REMOVED`: a customer group membership was removed ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Customer updated Source: https://www.plain.com/docs/webhooks/customer-updated This event is fired when a customer is updated in your workspace. You can expect this event: * when a customer is marked as spam * when a customer is un-marked as spam * when the details of a customer are updated ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Webhooks SDK Source: https://www.plain.com/docs/webhooks/sdk Webhook parsing and signature verification for Plain webhooks. The `@team-plain/webhooks` package provides typed webhook parsing and HMAC-SHA256 signature verification. It is a standalone package with no dependency on `@team-plain/graphql`. ## Installation ```bash theme={null} npm install @team-plain/webhooks ``` ## Verify and parse (recommended) `verifyPlainWebhook` validates the HMAC-SHA256 signature, checks the timestamp to prevent replay attacks, and parses the payload against the webhook JSON schema. ```ts theme={null} import { verifyPlainWebhook } from "@team-plain/webhooks"; const result = verifyPlainWebhook( rawBody, // raw request body string req.headers["plain-request-signature"], // signature header process.env.PLAIN_WEBHOOK_SECRET, // your webhook signing secret ); if (result.error) { console.error(result.error.message); } else { const event = result.data; console.log(event.eventType, event.payload); } ``` The optional fourth argument `tolerance` (default: `300` seconds) controls the maximum allowed age of the webhook timestamp. ## Parse only (no signature verification) `parsePlainWebhook` validates the payload against the webhook JSON schema without checking the signature. Useful for development or when verification is handled elsewhere. ```ts theme={null} import { parsePlainWebhook } from "@team-plain/webhooks"; const result = parsePlainWebhook(rawBody); if (result.error) { console.error(result.error.message); } else { const event = result.data; console.log(event.eventType, event.payload); } ``` ## Error types All functions return a `Result` — either `{ data: T }` or `{ error: Error }`. | Error class | When | | ---------------------------------------- | ------------------------------------------------------------------------ | | `PlainWebhookSignatureVerificationError` | Invalid signature, missing headers, or expired timestamp | | `PlainWebhookPayloadError` | Payload fails JSON schema validation | | `PlainWebhookVersionMismatchError` | Payload version doesn't match the schema version bundled in this package | ## Typed event payloads All webhook payload types are exported for use in your handlers: ```ts theme={null} import type { ThreadCreatedPublicEventPayload, CustomerCreatedPublicEventPayload, // ... } from "@team-plain/webhooks"; ``` See the full list of [webhook events](/docs/webhooks/thread-created) for all available event types. ## Resources * [Webhooks overview](/docs/webhooks) — setup, security, delivery semantics, and retry policy * [Request signing](/docs/request-signing) — how Plain signs webhook requests * [GitHub repository](https://github.com/team-plain/sdk/tree/main/packages/webhooks) # Thread assignment transitioned Source: https://www.plain.com/docs/webhooks/thread-assignment-transitioned This event is fired when the assignee of a thread changes or a thread is unassigned. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Chat received Source: https://www.plain.com/docs/webhooks/thread-chat-received This event is fired when a chat message from a customer is received. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Chat sent Source: https://www.plain.com/docs/webhooks/thread-chat-sent This event is fired when a chat message is sent to a customer in a thread. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Thread created Source: https://www.plain.com/docs/webhooks/thread-created This event is fired when a new thread is created in your workspace. You can subscribe to this event if you want to build an [autoresponder](/docs/graphql/threads/autoresponders). ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Email received Source: https://www.plain.com/docs/webhooks/thread-email-received This event is fired when an email is received in your workspace. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Email sent Source: https://www.plain.com/docs/webhooks/thread-email-sent This event is fired when an email is sent in your workspace. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Thread Field created Source: https://www.plain.com/docs/webhooks/thread-field-created This event is fired when a new thread field is created in your workspace. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Thread Field deleted Source: https://www.plain.com/docs/webhooks/thread-field-deleted This event is fired when a thread field is deleted in your workspace. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Thread Field updated Source: https://www.plain.com/docs/webhooks/thread-field-updated This event is fired when a thread field is updated in your workspace. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Thread labels changed Source: https://www.plain.com/docs/webhooks/thread-labels-changed This event is fired when labels are added to or removed from a thread. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Note created Source: https://www.plain.com/docs/webhooks/thread-note-created This event is fired when a note is created in a thread. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Thread priority changed Source: https://www.plain.com/docs/webhooks/thread-priority-changed This event is fired when the priority of a thread changes. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: ```json theme={null} { "timestamp": "2023-10-19T21:20:07.612Z", "workspaceId": "w_01GST0W989ZNAW53X6XYHAY87P", "payload": { "eventType": "thread.thread_priority_changed", "previousThread": { "id": "th_01HD44FHMCDSSWE38N14FSYV6K", "customer": { "id": "c_01HD44FHDPG82VQ4QNHDR4N2T0", "email": { "email": "peter@example.com", "isVerified": false, "verifiedAt": null }, "externalId": null, "fullName": "Peter Santos", "shortName": "Peter", "markedAsSpamAt": null, "markedAsSpamBy": null, "customerGroupMemberships": [], "createdAt": "2023-10-19T14:12:25.142Z", "createdBy": { "actorType": "system", "system": "email_inbound_handler" }, "updatedAt": "2023-10-19T21:18:12.863Z", "updatedBy": { "actorType": "user", "userId": "u_01H1V4NA10RMHWFBXB6A1ZBYRA" } }, "title": "Unable to tail logs", "previewText": "Hey, I am currently unable to tail the logs of the service svc-8af1e3", "priority": 3, "externalId": null, "status": "TODO", "statusChangedAt": "2023-10-19T21:18:12.862Z", "statusChangedBy": { "actorType": "user", "userId": "u_01H1V4NA10RMHWFBXB6A1ZBYRA" }, "statusDetail": null, "assignee": null, "assignedAt": null, "labels": [], "firstInboundMessageInfo": { "timestamp": "2023-10-19T14:12:25.733Z", "messageSource": "EMAIL" }, "firstOutboundMessageInfo": null, "lastInboundMessageInfo": { "timestamp": "2023-10-19T14:12:25.733Z", "messageSource": "EMAIL" }, "lastOutboundMessageInfo": null, "supportEmailAddresses": ["help@example.com"], "createdAt": "2023-10-19T14:12:25.266Z", "createdBy": { "actorType": "system", "system": "email_inbound_handler" }, "updatedAt": "2023-10-19T21:18:12.862Z", "updatedBy": { "actorType": "user", "userId": "u_01H1V4NA10RMHWFBXB6A1ZBYRA" } }, "thread": { "id": "th_01HD44FHMCDSSWE38N14FSYV6K", "customer": { "id": "c_01HD44FHDPG82VQ4QNHDR4N2T0", "email": { "email": "peter@example.com", "isVerified": false, "verifiedAt": null }, "externalId": null, "fullName": "Peter Santos", "shortName": "Peter", "markedAsSpamAt": null, "markedAsSpamBy": null, "customerGroupMemberships": [], "createdAt": "2023-10-19T14:12:25.142Z", "createdBy": { "actorType": "system", "system": "email_inbound_handler" }, "updatedAt": "2023-10-19T21:18:12.863Z", "updatedBy": { "actorType": "user", "userId": "u_01H1V4NA10RMHWFBXB6A1ZBYRA" } }, "title": "Unable to tail logs", "previewText": "Hey, I am currently unable to tail the logs of the service svc-8af1e3", "priority": 1, "externalId": null, "status": "TODO", "statusChangedAt": "2023-10-19T21:18:12.862Z", "statusChangedBy": { "actorType": "user", "userId": "u_01H1V4NA10RMHWFBXB6A1ZBYRA" }, "statusDetail": null, "assignee": null, "assignedAt": null, "labels": [], "firstInboundMessageInfo": { "timestamp": "2023-10-19T14:12:25.733Z", "messageSource": "EMAIL" }, "firstOutboundMessageInfo": null, "lastInboundMessageInfo": { "timestamp": "2023-10-19T14:12:25.733Z", "messageSource": "EMAIL" }, "lastOutboundMessageInfo": null, "supportEmailAddresses": ["help@example.com"], "createdAt": "2023-10-19T14:12:25.266Z", "createdBy": { "actorType": "system", "system": "email_inbound_handler" }, "updatedAt": "2023-10-19T21:20:07.612Z", "updatedBy": { "actorType": "user", "userId": "u_01H1V4NA10RMHWFBXB6A1ZBYRA" } } }, "id": "pEv_01HD4WYPDWSGNHMETTVVGYHDQY", "webhookMetadata": { "webhookTargetId": "whTarget_01HD4400VTDJQ646V6RY37SR7K", "webhookDeliveryAttemptId": "whAttempt_01HD4XAYFTYXRYMHJ0HGR2FN3D", "webhookDeliveryAttemptNumber": 4, "webhookDeliveryAttemptTimestamp": "2023-10-19T21:26:49.082Z" }, "type": "thread.thread_priority_changed" } ``` # Thread SLA status transitioned Source: https://www.plain.com/docs/webhooks/thread-service-level-agreement-status-transitioned This event is fired when the status of an SLA linked to a thread changes. As part of the `serviceLevelAgreementStatusDetail` field threads can have a status with the following values: | Status | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PENDING` | When the timer on the SLA is counting down but has not met the `IMMINENT_BREACH` threshold | | `IMMINENT_BREACH` | For SLAs where an alert has been set up to notify the team before it breaches. The SLA will be in this status after the alert period and before the SLA breaches | | `BREACHING` | Applies to SLAs while their conditions are not met e.g if a thread with a first response time (FRT) SLA has not been replied to after the time period specified | | `ACHIEVED` | A thread where the SLA conditions were met e.g a thread was replied to within the FRT SLA period | | `BREACHED` | A thread where the SLA conditions were not met (and so entered `BREACHING`) but action has been taken that would have resolved the SLA e.g a thread breached the FRT SLA, but then first reply was sent | | `CANCELLED` | An SLA which no longer applies e.g if a thread is marked as done with no reply the SLA is cancelled since we don't want it to affect metrics | ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Slack message received Source: https://www.plain.com/docs/webhooks/thread-slack-message-received This event is fired when a Slack message is received in your workspace. If the message is edited in Slack, this webhook will not fire again. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Slack message sent Source: https://www.plain.com/docs/webhooks/thread-slack-message-sent This event is fired when a Slack message is sent in your workspace. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Thread status transitioned Source: https://www.plain.com/docs/webhooks/thread-status-transitioned This event is fired when the status of a thread changes. ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # Webhook Versions Source: https://www.plain.com/docs/webhooks/versions Every [webhook target](https://www.plain.com/docs/webhooks#receiving-events-from-plain) in Plain is associated with a specific version. The webhook version defines the schema of the payload that Plain sends to your endpoint. By specifying a version, you ensure that the payload format remains consistent, even as Plain evolves and introduces changes to the webhook schema. **Benefits of Versioning**: * **Consistency**: Your endpoint always receives payloads in the same format. * **Control**: You decide when to adopt new schema changes. * **Stability**: Prevents unexpected breaking changes due to schema updates. ## Available Versions We recommend always using the latest version of the webhook payload schema to benefit from new features and improvements. Below are the currently available versions: ### `2026-05-06` (Latest) * **What's New**: * Added `numberValue` field to `threadField`. * Added `NUMBER` to the `threadField.type` enum. * [View JSON Schema](https://core-api.uk.plain.com/webhooks/schema/2026-05-06.json) ### `2025-08-06` * **What's New**: * Added `additionalAssignees` to `thread`. * Added `externalId` and `isExcludedFromAi` fields to `labelType`. * [View JSON Schema](https://core-api.uk.plain.com/webhooks/schema/2025-08-06.json) ### `2025-07-30` * **What's New**: * `threadField.createdBy` and `threadField.updatedBy` changed from `internalActor` to `actor` (now supports customer and other actor types). * [View JSON Schema](https://core-api.uk.plain.com/webhooks/schema/2025-07-30.json) ### `2024-09-18` Our first official versioned webhook payload schema. * **What's New**: * Introduction of webhook versioning. * Improved forward-compatibility schema definitions for payloads. * Microsoft Teams events. * New thread status details. * [View JSON Schema](https://core-api.uk.plain.com/webhooks/schema/2024-09-18.json) ### `unversioned` The legacy webhook payload schema before versioning was implemented. * [View JSON Schema](https://core-api.uk.plain.com/webhooks/schema/unversioned.json) ## How to Upgrade to the Latest Version Upgrading to the latest webhook version involves updating your code to handle the new schema and changing your webhook target settings in Plain. ### Step 1: Update Your Code Modify your code to handle both the old and new webhook payload versions during the transition period. This ensures uninterrupted processing of events. Deploy this updated code, and fast follow with Step 2. ### Step 2: Update the Webhook Target in Plain After deploying your updated code, change the version of your webhook target in Plain to the new version. This ensures that all future webhook events are sent using the latest schema. ### Step 3: Revert Temporary Code Changes Once you have confirmed that your application is successfully processing events with the new version, you can remove the code that handles both old and new versions. Your code can now exclusively handle the latest webhook payload schema. Ensure that your webhook handling code is **idempotent** and can gracefully handle **duplicate events**. Plain's webhook delivery is **at least once**, meaning the same event might be delivered multiple times. Refer to our [delivery semantics](https://www.plain.com/docs/webhooks#delivery-semantics) for more information. ## Identifying the Webhook Version in Received Payloads If you receive a webhook payload and are unsure which version it is using, you can identify the version by checking: * **Headers**: The `Plain-Webhook-Target-Version` header indicates the version of the webhook target for which this request is intended. * **Payload Metadata**: Within the [webhook metadata](https://www.plain.com/docs/webhooks#webhook-metadata) in the payload body, the `webhookTargetVersion` field specifies the version of the webhook target for this request. This information helps you determine how to parse and handle the webhook payload according to its schema version. ## Best Practices and Recommendations * **Monitor Logs**: After upgrading, monitor your logs and error tracking systems for any issues related to webhook processing. * **Stay Informed**: Keep an eye on our documentation and [change log](https://www.plain.com/changelog) for future updates or changes to the webhook schema. If you have any questions or need assistance, please reach out to us at **[help@plain.com](mailto:help@plain.com)**.