# 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 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](/api-reference/graphql/authentication/) with the following permission: * `attachment:create` * `fileName` is the name under which the attachment will appear in the timeline, you can use whichever you want * `fileSizeBytes` is the exact size of the attachment in bytes (specifically, `32318` bytes is the size of Bruce's picture above) * `c_XXXXXXXXXXXXXXXXXXXXXXXXXX` is the customer id you are uploading the attachment for. **Remember to replace this!** Which would console log something like this: 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 * On emails and custom timeline entries: * A maximum of **25 attachments** can be added * All attachments **combined** can not exceed **6MB** in size * 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 Custom Timeline Entry or email, will be **deleted after 24 hours**. * Upload URLs are only **valid for 2 hours** after which a new URL needs to be created. # 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](/api-reference/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](/api-reference/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`. * 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](/api-reference/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 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 # Playground # 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](/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](/api-reference/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](/api-reference/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/api-reference/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](/api-reference/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](/api-reference/request-signing) and [mTLS](/api-reference/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 # 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 with our SDKs or within any API call as a header: ```text Authorization: Bearer plainApiKey_xxx ``` # 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](/tiers/) can be associated with a company. # Get companies You can get all companies you've interacted with in your workspace using the `companies` query. This endpoint supports [Pagination](/api-reference/graphql/pagination). For this query you need the following permissions: * `company:read` # 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` # 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**](/api-reference/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**](/api-reference/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**](/api-reference/graphql/customers/upsert) for use with [**customer cards**](/customer-cards/). # 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` Running the above would console.log: 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. Which if successful will console.log `null` If you prefer you can also use the customer group id instead of the key. You can do this like so: # Delete customers You can delete customers with the `deleteCustomer` API, you will find this name in both our API and our SDKs. 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](/api-reference/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 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) All of these endpoints require the following permissions: * `customer:read` ## Get customers Our API allows you to fetch customers as a collection using `getCustomers` in our SDKs or the `customers` query in GraphQL. In both cases this endpoint supports [Pagination](/api-reference/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/) or [Typescript SDK](https://github.com/team-plain/typescript-sdk/). ## 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 `getCustomerById` in our SDKs or the `customer` query in GraphQL. ## Get customer by email To fetch a customer by email you can use `getCustomerByEmail` in our SDKs or the `customerByEmail` query in GraphQL. # Upserting customers Learn how to create and update customers programmatically. Creating and updating customers is handled via a single API called `upsertCustomer`. You will find this name in both our API and our SDKs. 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` * `customerGroup:read` (Typescript SDK only) * `customerGroupMembership:read` (Typescript SDK only) 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) Running the above would console.log: 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** # 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 can 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 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 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](/api-reference/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. ## Typescript SDK If you are using the Typescript SDK, errors are handled and parsed for you and you don't need to worry about the distinction between queries and mutations. You can see the [full error types in the code of the Typescript SDK](https://github.com/team-plain/typescript-sdk/blob/main/src/error.ts) (It's open source). This is how you can access the error when using the SDK: ```tsx import { PlainClient } from '@team-plain/typescript-sdk'; export async function createCustomer() { const client = new PlainClient({ apiKey: 'XXX' }); const res = await client.upsertCustomer({ identifier: { emailAddress: 'jane@gmail.com', }, onCreate: { fullName: 'Jane Fargate', email: { email: 'jane@gmail.com', isVerified: true, }, }, onUpdate: {}, }); if (res.error) { console.error(res.error); } else { console.log(`Created customer with id=${res.data.customer.id}`); } } ``` # 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](/api-reference/graphql/events/create-customer-event)**: these are created in every existing thread for a customer. When a a new thread is created (e.g. by an inbound communication, or by calling the [createThread](/api-reference/graphql/threads/create) endpoint) the **25** most recent events are shown. * **[Thread events](/api-reference/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](/api-reference/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 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](/api-reference/graphql/events/create-thread-event). To create an event you need a customer ID. You can get this by [upserting a customer](/api-reference/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](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/events-api-key-example.png) For this you'll need an API key with the following permissions: * `customerEvent:create` Which would console.log: For this you'll need an API key with the following permissions: * `customerEvent:create` # Create a 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](/api-reference/graphql/events/create-customer-event). To create a thread event you need a thread ID. You can get this by [creating a thread](/api-reference/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](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/events-api-key-example.png) For this you'll need an API key with the following permissions: * `threadEvent:create` * `threadEvent:read` * `thread:read` * `customer:read` Which would console.log: For this you'll need an API key with the following permissions: * `threadEvent:create` * `threadEvent:read` # 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](/api-reference/graphql/schema) on your own! ## 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](/api-reference/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](/api-reference/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 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"}' ``` This assumes you've installed our Typescript SDK. `npm install @team-plain/typescript-sdk ` Make sure to replace the api key and email in the code: `node script.js ` # 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](/api-reference/webhooks/thread-labels-changed). This lets you build workflows triggered by the addition of a label. # Add labels 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` This will output: # Remove labels 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` Which if successful will console log `null`. # 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 You can reply to an inbound email with the `replyToEmail` API. This operation requires the following permissions: * `email:create` * `email:read` * `attachment:download` This operation requires the following permissions: * `email:create` * `email:read` # Reply to threads You can reply to a thread using the `replyToThread` mutation, as long as the thread's communication channel is either `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`](/api-reference/graphql/error-codes#cannot_reply_to_thread) and a message indicating why. This operation requires the following permission: * `thread:reply` Where `res.data` is: ## Impersonation Impersonation is exclusively available in our `Scale` 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](/headless-support-portal). 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 { "impersonation": { "asCustomer": { "customerIdentifier": { "emailAddress": "blanca@example.com" } } } } ``` The customer message will be processed differently based on the thread's channel: * `SLACK`: a new Slack message will be sent to the thread, using the customer's details * `EMAIL`: an email will be sent to the thread, where the sender will be your customer When replying to an `EMAIL` thread, you can optionally add 'CC' and 'BCC' recipients by using the `channelSpecificOptions` parameter: ```graphql { "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 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. # 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: Notice how we use the cursor information from the first page to fetch the second page. The returned `pageInfo` looks as follows: This will fetch a subsequent page of 50 entries by passing in the `endCursor` from an initial query. # 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/) # 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](/tiers/) can be associated with a tenant. # Add customers to tenants 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` # Get tenants We provide two 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) For all of these queries you need the following permissions: * `tenant:read` ### Get tenants Our API allows you to fetch teanants as a collection using `getTenants` in our SDKs or the `tenants` query in GraphQL. In both cases this endpoint supports [Pagination](/api-reference/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. # Remove customers to tenants 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 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` # Upserting tenants 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 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 (when a customer submits a contact form for example). Threads have a status which can be in either `Todo`, `Snoozed` or `Done` and can only be assigned to one person. 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 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` Where `res.data` is the full thread: ## Unassigning threads To unassign threads you need an API key with the following permissions: * `thread:unassign` * `thread:read` Where `res.data` is the full thread like with assignment. # null Plain provides native [workspace level auto-responses](/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](/api-reference/webhooks) from Plain. We would typically recommend listening for the [thread created](/api-reference/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](/api-reference/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/) or [Typescript SDK](https://github.com/team-plain/typescript-sdk/) for more details. # Create threads 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](/api-reference/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](/api-reference/graphql/customers/upsert) in Plain first. Since the Typescript SDK expands a lot of fields you will need an API key with the following permissions: * `label:create` * `label:read` * `labelType:read` * `machineUser:read` * `customer:read` * `user:read` * `thread:create` * `thread:edit` * `thread:read` * `threadField:create` * `threadField:update` * `threadField:read` Where `result.data` is: To create a thread, you need an API key with the following permissions: * `thread:create` * `thread:read` # Changing status 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 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`. ### 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` # Tiers & SLAs Within Plain you can organise [companies](/company-support/) and [tenants](/tenant-support/) 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](/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 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 For all of these queries you need the following permission: * `tier:read` ## Get tiers Our API allows you to fetch tiers as a collection using `getTiers` in our SDKs or the `tiers` query in GraphQL. In both cases this endpoint supports [Pagination](/api-reference/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 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` # 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 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` # Typescript SDK # 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 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 SHA-256 hash of the request body, encoded as a hexadecimal string.** ### Node example ```javascript 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 UI components are a way of describing some UI when creating threads or [events](/events) or building [customer cards](/customer-cards). For example - this is a button that links to Stripe. ```json { "componentLinkButton": { "linkButtonUrl": "http://stripe.com/", "linkButtonLabel": "View in Stripe" } } ``` and it looks like this: ![Example button linking to stripe](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/ui-component-link-button-stripe.png) 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. 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 Useful for statuses or when you need to attract attention to something. ![Example badges](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/ui-component-badge.png) 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 Useful when you need to create a bit of structure. ![Example container](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/ui-component-container.png) A container has the following properties: * `containerContent` (min 1): an array of components. Allowed components within a Container are: * [Badge](/api-reference/ui-components/badge) * [CopyButton](/api-reference/ui-components/copy-button) * [Divider](/api-reference/ui-components/divider) * [LinkButton](/api-reference/ui-components/link-button) * [Row](/api-reference/ui-components/row) * [Spacer](/api-reference/ui-components/spacer) * [Text](/api-reference/ui-components/text) * [PlainText](/api-reference/ui-components/plain-text) For example: # CopyButton Useful if you have any IDs or other details you want to copy for use in messages or outside of Plain. ![Example copy button](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/ui-component-copy-button.png) 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 Useful when you need a bit of structure. ![Example divider](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/ui-component-divider.png) 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 Useful when you want to link somewhere external (e.g. your own admin tool or payment provider) ![Example link button](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/ui-component-link-button.png) 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 Useful when you want to show any text that should not have any formatting (is not Markdown). If you want markdown please use [Text](/api-reference/ui-components/text). ![Example link button](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/ui-component-plain-text.png) 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 Useful when you need to show two things next to each-other. ![Example row](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/ui-component-row.png) 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](/api-reference/ui-components/badge) * [CopyButton](/api-reference/ui-components/copy-button) * [Divider](/api-reference/ui-components/divider) * [LinkButton](/api-reference/ui-components/link-button) * [Spacer](/api-reference/ui-components/spacer) * [Text](/api-reference/ui-components/text) * [PlainText](/api-reference/ui-components/plain-text) For example: # Spacer ![Example spacer](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/ui-component-spacer.png) 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 ![Example text](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/ui-component-text.png) 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: # 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 ## 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 Authorization: Basic cGxhaW46cm9ja3M= ``` Plain also supports [request signing](/api-reference/request-signing) and [mTLS](/api-reference/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](/api-reference/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)](/api-reference/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](/api-reference/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 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 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 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 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: # 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 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 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](/api-reference/graphql/threads/autoresponders). ## Schema [**View JSON Schema →**](https://core-api.uk.plain.com/webhooks/schema/latest.json) Example: # 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 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 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 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 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 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 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 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 { "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 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 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 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 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: # Webhooks and Typescript Our [TypeScript SDK](https://github.com/team-plain/typescript-sdk) provide utilities to [verify the webhook signature](https://www.plain.com/docs/api-reference/request-signing#request-signing) and parse the webhook body into a typed object. ```typescript import express from 'express'; import { PlainWebhookSignatureVerificationError, PlainWebhookVersionMismatchError, verifyPlainWebhook, } from '@team-plain/typescript-sdk'; // Plain HMAC Secret. You can find this in Plain's settings. const PLAIN_SIGNATURE_SECRET = process.env.PLAIN_SIGNATURE_SECRET; if (!PLAIN_SIGNATURE_SECRET) { throw new Error('PLAIN_SIGNATURE_SECRET environment variable is required'); } const app = express(); app.use(express.text()); app.post('/handler', function (req: Express.Request, res: Express.Response) { // Please note that you must pass the raw request body, exactly as received from Plain, const payload = req.body; // Plain's computed signature for the request. const signature = req.get('Plain-Request-Signature'); const webhookResult = verifyPlainWebhook(payload, signature, secret); if (webhookResult.error instanceof PlainWebhookSignatureVerificationError) { res.status(401).send('Failed to verify the webhook signature'); return; } if (webhookResult.error instanceof PlainWebhookVersionMismatchError) { // The SDK is not compatible with the received webhook version. // This can happen if you upgrade the SDK but not the webhook target, or vice versa. // We recommend setting up alerts to notify you when this happens. // Consult https://plain.com/docs/api-reference/webhooks/versions for more information. console.error('Webhook version mismatch:', webhookResult.error.message); // Respond with a non-2XX status code to trigger a retry from Plain. res.status(400).send('Webhook version mismatch'); return; } if (webhookResult.error) { // Unexpected error. Most likely due to an error in Plain's webhook server or a bug in the SDK. // Treat this as a 500 response from Plain. console.error('Unexpected error:', webhookResult.error.message); res.status(500).send('Unexpected error'); return; } // webhookResult.data is now a typed object. const webhookBody = webhookResult.data; // You can use the eventType to filter down to a specific event type if (webhookBody.payload.eventType === 'thread.thread_created') { console.log(`Thread created: ${webhookBody.payload.thread.id}`); } // Respond with a 200 status code. res.status(200).send('Webhook received'); }); ``` We strongly recommend verifying the webhook signature before processing the webhook body. This ensures that the webhook was sent by Plain and not a malicious third party. However, if you want to skip the verification, you can use the [`parsePlainWebhook` function](https://plain-typescript-sdk-docs.vercel.app/functions/parsePlainWebhook.html) instead. # Webhook Versions Every [webhook target](https://www.plain.com/docs/api-reference/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: ### `2024-09-18` (Latest) 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) * **TypeScript SDK Compatibility**: Version `>= 5.0.0` ### `unversioned` The legacy webhook payload schema before versioning was implemented. * [View JSON Schema](https://core-api.uk.plain.com/webhooks/schema/unversioned.json) * **TypeScript SDK Compatibility**: Versions `>= 4.8.0` and `< 5.0.0` ## 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. **Handle Version Mismatch (TypeScript SDK Users):** If you're using our TypeScript SDK, you can update your code to throw an error when receiving an old version. This will cause Plain to retry the request later, ideally after you've updated the webhook target version in the Plain dashboard. For more details, refer to our [retry policy](https://www.plain.com/docs/api-reference/webhooks#retry-policy). See the [TypeScript SDK Example](#typescript-sdk-example) for implementation details. 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/api-reference/webhooks#delivery-semantics) for more information. ## TypeScript SDK Example Below is an example of how to handle webhook version mismatches using our TypeScript SDK: ```typescript import { verifyPlainWebhook, PlainWebhookVersionMismatchError } from '@team-plain/typescript-sdk'; // The same approach works for `parsePlainWebhook` const webhookResult = verifyPlainWebhook(payload, signature, secret); if (webhookResult.error instanceof PlainWebhookVersionMismatchError) { // Received a webhook with an old version // Return a non-2XX response to trigger a retry throw new Error('Skipping older version'); } // proceed with the rest of your error handling logic ``` **Explanation**: * **Version Mismatch Handling**: By checking if the error is an instance of `PlainWebhookVersionMismatchError`, you can determine if the payload is on the old version. This could happen if an event is sent during the time between your code deployment and the webhook target version update in Plain. * **Triggering a Retry**: Throwing an error or returning a non-2XX HTTP response tells Plain to retry the webhook delivery later. After you update the webhook target version in Plain, the failed event will be resent with the new schema. ## 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/api-reference/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)**. # Assignment ![Assignment within Plain](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/assignment-introduction.png) Threads can be assigned to any user in your workspace. When you start typing a message, Plain will automatically assign you, so you typically don't have to think about assigning yourself manually. If you want to assign a thread manually, you can use the shortcut **A** or use **⌘ + K**. By default in Plain, once you are assigned to a thread, you will never be automatically unassigned. This ensures that if you pick up a support request, you can see it through. We call this behavior "sticky assignment". If you'd like to change this behavior, you can do so in **Settings** → **Workflow**. Here you can choose to be automatically unassigned when a thread is marked as `Done` or `Snoozed`. We recommend turning sticky assignment off if your team has a shared support rota. That way, whoever is on-call can pick up any threads you didn't finish. If you have sticky assignment on but are going on holiday you can turn on Away Mode in the top left menu within Plain. This will unassign you from any threads that come back to Todo. If you are interested in building fully automated bots using Plain, you can also programmatically assign threads to machine users as a starting point. If this sounds like an interesting use-case to you, please reach out to us via [help@plain.com](mailto:help@plain.com). **Multiple Assignees** You can add multiple assignees (lead and co-assignees) to threads. Having the ability to add multiple assignees improves your ability to collaborate internally on customer requests, and speeds up your team's response times. # Auto-responses To set up an auto-response go to **Settings** → **Auto-responses** An auto-response can be useful in situations of acute support load, such as during incidents, or to confirm you received a support request and manage expectations with customers. Auto-response messages are best kept short and personable without too many links or formatting. Messages which are too long and scripted will often just be ignored as they are too obviously automated. When you configure an auto-responses, you can choose which channels it will be used in: * `Slack`: threads started from Slack * `Api`: threads that you create programmatically * `Email`: threads created from an inbound email An auto-response will trigger when a new thread is created. Plain adds a little delay (approximately one minute) before sending the message to give your users a chance to reply. Auto-responses can also be configured to only trigger for specific **tiers** and inside or outside **business hours**. If multiple auto-responses are configured for the same channel, tier and business hours, we will pick the **first one** according to the order they show up under settings. You can also leverage Plain's powerful API and webhooks to build custom interactive auto-responses, see [Thread autoresponders](/api-reference/graphql/threads/autoresponders) for more details. # Broadcasts Plain broadcasts is a free standalone app provided by Plain which lets you send updates to differentiated groups of customers in Slack. ![Plain broadcasts](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/broadcasts.png) ## Getting started ### Define an audience Audiences let you group your customers so that you can control who messages get sent to. In order to add a channel to an audience you need to add the Plain app to it in Slack first. If you can't see a channel you expect to, try hitting the refresh icon next to the search box to refresh the available channels. ### Create a broadcast Broadcasts are the messages and updates which you send out to customers. You can construct your broadcast out of all the blocks you would expect such as: * text * headings * lists * images * code blocks * and more! To insert emojis try using your system emoji picker e.g `ctrl + cmd + space` on Mac or `Windows key + ;` on Windows. # Chat *Chat is currently in Beta. This will not effect the reliability of this feature but we are heavily iterating and improving on the featureset.* Our Chat widget lets you embed a live chat interface on your website or app, allowing your customers to reach out to you without leaving your site. You can then respond to these messages directly from Plain. You and the customer can communicate in real-time and provide high fidelity support including syntax highlighting and rich-text formatting. Here’s how to get started: *At the moment you can only create a single Chat app configuration per workspace. In future we will allow multiple configurations.* - In Plain, navigate to **`Settings`** > **`Chat`**. - Press **`Create Chat App`**. After creating your Chat app, you will be provided with a snippet of code that you can embed in your website or app. This will add the chat widget to your site. We recommend adding this to all pages of your site. You can customize some aspects of the Chat widget by providing additional information to the `Plain.init` function. These are the available options: You can add conditional auto-responses to chat by heading to the "auto-responses" section of your workplace settings. Set conditions based on tier or business hours, and let your customers know when they'll receive a response. Each new conversation in the Chat widget creates a new thread in Plain. You can view and respond to these threads in the Plain app. ## Content Security Policy (CSP) If you are using a Content Security Policy on your website, you will need to add the following to your CSP: ```text script-src https://chat.cdn-plain.com; connect-src https://chat.uk.plain.com; style-src https://fonts.googleapis.com; ``` # Authentication ## Providing customer details By default, customers chatting with you will be anonymous. You can pass customer details, if you know them, in the `Plain.init` function call: ```typescript Plain.init({ // ... Other options customerDetails: { fullName: 'John Doe', // Optional shortName: 'John', // Optional chatAvatarUrl: 'https://picsum.photos/32/32', // Optional }, }); ``` These details will be shown to you in the Plain app when you are chatting with the customer but they will not be matched to an existing customer even if they have the same details. ## Matching chat users to existing customers If you want to match the customer to an existing customer in your workspace, you will need to pass their email. To avoid security issues around impersonation you will also need to provide the email address hashed using a shared secret. You can generate this secret in the Chat settings page in the Plain app. Once you have this secret, you can calculate the hash and pass it to the `Plain.init` function: ```typescript import * as crypto from 'node:crypto'; const secret = ''; const email = 'johndoe@example.com'; const hmac = crypto.createHmac('sha256', secret); hmac.update(email); const hash = hmac.digest('hex'); Plain.init({ customerDetails: { email: email, emailHash: hash, // If you pass other customer details (e.g. fullName), this will also update their customer details within Plain // If you don't pass any other customer details, we will use their existing details within Plain }, }); ``` # Companies ![Company support within Plain](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/companies-introduction.png) In Plain you can see what company a customer belongs to, so you have more context when providing support. The company is automatically set based on the email domain of the customer and is visible throughout the support app. For example if a customer has the email with the domain **@nike.com** then their company will be **Nike**. You can browse threads by company in Plain as well as associate a company with a tier to manage their SLAs. Companies can also be listed and updated [via the API](/api-reference/graphql/companies/). # Contact forms The best way to offer support at scale. With contact forms, you can pre-triage every support request so that you know exactly what to prioritize in Plain. Plain **does not** provide any UI components or a drop-in script tag. Instead, you use your own UI components and then use Plain's API. When a contact form is submitted, you first [create the customer in Plain](/api-reference/graphql/customers/upsert) and then [create a thread](/api-reference/graphql/threads/create) in Plain. Depending on your desired behavior, you can also do other things as part of the form submission, such as [add the customer to a customer group](/api-reference/graphql/customers/customer-groups), [set labels](/labels), and add a priority to a thread. Contact forms can take any shape and be very specific to your product and customers. Here are just a few examples as starting points for you to customize: ### Example floating contact form This shows how you can build a very simple floating contact form (bottom right) in NextJS. [**View demo ↗**](https://example-nextjs-floating-form.vercel.app/) | [View source on Github↗](https://github.com/team-plain/example-nextjs-floating-form) ![Screenshot of the floating contact form](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/contact-form-floating.png) ### Example advanced contact form This shows a more advanced contact form in NextJS with structured inputs depending on the topic you are getting in touch about and categorization. [**View demo ↗**](https://example-nextjs-advanced-contact-form.vercel.app/) | [View source on Github↗](https://github.com/team-plain/example-nextjs-advanced-contact-form) ![Screenshot of the advanced contact form](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/contact-form-advanced.png) # Customer cards Live context straight from your own systems when helping customers. Customer cards are a powerful feature in Plain that allows you to show information from your own systems while looking at a customer or thread in Plain. This ensures that you always have important context at your fingertips when helping customers, without having to jump through different tabs and admin tools. ![Customer cards within Plain](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/customer-cards-introduction.png) Customer cards are: * Pulled from your backend, so you do not have to sync your customer data to Plain. * Short-lived, so no data is permanently stored in Plain beyond the time frame you set. * Defined using a simple JSON schema, so you don't have to worry about styling them. * Automatically reloaded if a user is viewing a customer and the data expires. [**Jump to documentation →**](/api-reference/customer-cards) # Customer groups Organize and segment your customers. ![Customer groups within Plain](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/groups-introduction.png) Groups let you organise customers when company, tenant or tiers don't make sense. A good example of this is when someone is beta tester or an investor trying your tool. You can configure the groups in your workspace by going to **Settings** → **Customer Groups**, where you can set a name and color per group. You can then either manually add a customer to a group from any thread, or you can programmatically [via the API](/api-reference/graphql/customers/customer-groups). You can also filter threads by customer groups, so you can, for example, prioritize threads belonging to beta testers. # Data model A quick overview of how Plain is structured. Whether you are using Plain's API or not, this page is a quick way to understand how Plain works. Within Plain, all data belongs to a **Workspace**. Within a workspace, you have Users, Customers, Threads, Companies and more. ## Workspace Everything within Plain happens in a workspace. Typically, you will have one workspace like you would in Slack or Discord. You can create multiple workspaces if you want to match different environments, e.g. "Acme Staging" and "Acme Production". ## Users When you use Plain, you are a user. As a user, you have one or more roles that define what you are allowed to do within Plain. ## Customers Within Plain, you help customers. Each customer has a name ("Grace Hopper"), short name ("Grace"), and one email address. Email addresses are unique across all customers. Customers can also have an `externalId` which lets you correlate them to customers in your own systems, you can provide this when creating or updating them via the API. Customers are created automatically by Plain when receiving a new support request or can be created programmatically using our API. Customers can belong to a company and multiple tenants (more on that below). ## Threads Threads are the core of Plain's data model and are equivalent to tickets or conversations in other support platforms. Each thread belongs to one customer and has a status which is one of `Todo`, `Done`, or `Snoozed`. 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 created automatically by inbound communications or programmatically via the API, for example, when a contact form is submitted. Each thread has a timeline. The timeline contains all relevant communication, events and other updates such as assignment changes. Threads can be extended with custom attributes called thread fields. ## Labels Labels are a lightweight way of categorizing threads by topic (e.g., bugs, feature requests, demo request, etc.). A thread can have one or more labels, and each label has a name and an icon that you choose in your settings. You can filter threads by label in any queue. ## Events Events belong to a customer or thread and allow you to log important actions that happen outside of Plain within Plain. Events provide you with additional context of the customer's actions when you are helping them. For example, if you log an event when a customer deletes an API key in your systems, then if they reach out reporting 401 errors - you immediately know why. ## Companies Each customer can belong to one company. The company is automatically set by Plain based on the customer's email. For example if a customer gets in touch with the email [jeff@nike.com](mailto:jeff@nike.com),then their company will be set to Nike. The company of a customer can be changed manually and via the API. You can filter and organise your support by company. ## Tenants Tenants allow you to organise your customers in the same way they are organised in your product. For example, if your product's users are organised into teams, then every team would be one tenant within Plain. Tenants can only be created programmatically and are useful for advanced integrations or larger support teams. ## Tiers Tiers allow you to organise companies and tenants into groups that match your product's pricing. For example "Enterprise", "Pro", "Free" etc. Within tiers you can also define SLAs so that you can stay on top of your queue and prioritise. # Digests ![Digests daily update](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/digests-update.png) Digests are a way to get a summary of activity in your Plain workspace. They are currently only available via [Slack Workspace Notifications](./notifications#workspace-notifications) Digests are configured in settings, and you can specify a time (in UTC) when you would like to receive them. They are **only sent on weekdays**. ## Available Digests ### Daily Standup The Daily Standup Digest aims to give you a brief, cohesive overview of your outstanding threads in “Todo”, and tags the members of your team that have threads assigned to them. This Digest is tailored towards the members of your team who are actively responding to support requests – we recommend using it in the mornings so you get a quick overview first thing about the threads that need to be actioned. ### Daily Summary The Daily Summary Digest is aimed at giving you a high-level view into the health of your support queue. Threads that need high priority actions will be listed here – e.g. threads that have breached or are about to breach their SLAs, and threads that have been waiting for action for a long time. This Digest will be useful for Heads of Support, or team members who mightn’t be in the support weeds every day, but need to know about the high-level state of your support queue. We recommend turning it on at the end of the day so you get an idea of how your support team has performed throughout the day. ## Setup * Navigate to **Settings → Notifications → Slack** * Setup or choose an existing Slack integration * Choose the Digest you want to turn on and specify the time (in UTC) that you’d like it to be delivered * Turn on the toggle ## What if I want to send different Digests to different Slack channels? To set up a digest to send to a different Slack channel (e.g. if you want the Daily Summary to be sent to #support-leads, but the Daily Standup Digest to send to #support) – just repeat the steps above and add a new Slack channel integration in your Notifications settings. # Email Setting up email requires you to complete two steps: If you follow this guide, bear in mind that: * All emails sent to that email address will be received by Plain (not your email provider's inbox) * You can only use one domain per Plain workspace but can use multiple emails for the same domain. If you need to receive a copy of each email sent to the support email address (for instance, to keep a copy in your company's email inbox), there are ways to achieve this. Please get in touch with us by email at [help@plain.com](mailto:help@plain.com), and we will be happy to help. ## What you will need * Your Plain workspace's inbound email address (which ends with `@inbound.postmark.app`). We will provide this during the email setup process. * Admin access to your domain's DNS settings. * Admin access to your company's email provider. * If you use Google for email, you will need admin access to the Google Workspace Admin console. ## What makes a good support email address? The only requirement is that your support email address uses your company's domain. Email addresses using `@gmail.com` , `@yahoo.com`, `@icloud.com`, `@hotmail.com` or any other public email provider **are not supported**. We recommend you choose an email address that is easy to remember and clear that it represents a company. Good examples could be `help@`, `support@`, `contact@` or `hello@`. Generally, we'd advise against a personal one like `sonia@` or `peter@` but they might also be fine, depending on your needs. # Alternate addresses Within Plain you can configure up to 10 alternate email addresses. Alternate email addresses allow you to receive and send emails from Plain just like your main support email address. You can configure these at the bottom of **Settings** → **Email** page in Plain. Alternate email addresses must: * ...have the same domain as the main support email. For example, if your main support email is [help@acme.com](mailto:help@acme.com) then you could have [hello@acme.com](mailto:hello@acme.com) as an alternate addresses but not [sales@tnt.com](mailto:sales@tnt.com). * ...be forwarded in the same way as your main help email to the email address Plain provides you when setting up your email. Once alternate emails are set up you can choose to reply from them when writing an email in Plain. By default Plain will always try to reply with the same email used by the customer. # Email avatars When it comes to customizing the avatar or logo that appears alongside the emails you send from Plain, it’s important to note that you don't "send" an avatar with the email itself. Instead, the avatar your customer sees is determined by the recipient's email client, which controls how inboxes are rendered. Here's a breakdown of the options available for displaying custom avatars in your emails: 1. **BIMI**\ BIMI (Brand Indicators for Message Identification) allows you to display a custom logo next to your emails by adding a DNS record that points to an SVG image of your logo. Some [email clients support BIMI](https://support.google.com/a/answer/10911320), but there are a few considerations: * Gmail, amongst others, will only display your logo if it’s verified by a certificate authority. This process involves purchasing a Verified Mark Certificate (VMC), which costs around \~\$1200 per year. * Without the certification, some clients may still show your logo, but support varies across different platforms. 2. **Gravatar**\ Some email clients use [**Gravatar**](https://gravatar.com/) to display avatars. You can create a Gravatar profile **for free** (using your workspace's email address) and if the recipient’s client supports it, your Gravatar's profile picture will show in the emails you send. 3. **Gmail** In the abscense of BIMI, Gmail will pull the avatar from the profile picture you use in your Gmail-managed email. You can do this from your Gmail inbox or follow [these instructions](https://support.google.com/mail/answer/35529) Ultimately, the avatar that appears next to your emails depends entirely on the recipient's email client and its rendering choices. # Receiving emails To receive emails, you need to set up email forwarding from your company's support email address (e.g. `help@yourcompany.com`) to your Plain workspace's inbound email address. Your workspace's inbound email address ends with `@inbound.postmark.app` and can be found in under **Settings** → **Email**. This assumes you use Google Workspace (formerly called Google Apps) to manage your domain's emails. If your email provider is not Google, you can still set up email forwarding in different ways, such as with your domain registrar (e.g. [DNSimple](https://support.dnsimple.com/articles/email-forwarding/)) or your email provider (e.g. [Microsoft 365](https://learn.microsoft.com/en-us/microsoft-365/admin/email/configure-email-forwarding)). You can find this here: [https://admin.google.com/u/0/ac/apps/gmail/defaultrouting](https://admin.google.com/u/0/ac/apps/gmail/defaultrouting) Under "Default routing" click on "CONFIGURE" or "ADD ANOTHER RULE" ![Add new rule](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/email-setup-0.png) In the dropdown, select "Single recipient" and write your **support email address** under "Email address" ![Add your support email address](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/email-setup-1.png) Choose "Replace recipient" and paste the inbound email address (`@inbound.postmarkapp.com`) ![Replace recipient](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/email-setup-2.png) Scroll further down, and choose "Perform this action on non-recognised and recognised addresses" ![Apply to all addresses](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/email-setup-3.png) ...and That's it! 💅 If you have existing email routing rules they can sometimes conflict with ones you add for Plain. For optimal reliability always reorder the Plain ones to apply first. # Sending emails To be able to send emails from Plain using your support email address, you will need to add a couple of DNS records to your domain. While setting up your email, you will see DNS settings that need to be configured for your domain: ![DNS settings](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/email-setup-4.png) The first record is the "DKIM". It is part of the available mechanisms that exist to authenticate emails: verifying that you're the actual sender of the email. The second record sets the return path for emails sent from Plain using your domain. Return paths are used if an email bounces (fails to reach an inbox). Both of them make sure that your emails reach your users' email inboxes and do not end up in the spam folder. Adding these to your DNS records varies slightly among different providers. Here is how to do so for some common hosting providers: 1. Go to [https://dnsimple.com/dashboard](https://dnsimple.com/dashboard). 2. Click on the domain you need to update. If your support address is `help@example.com` you must pick `example.com`. 3. On the left menu, click on "DNS". 4. Under "DNS records" click "Manage". 5. Click "Add record" Choose TXT. Add the following: * **Name**: paste "Hostname" * **Type**: TXT * **Content**: paste "Value" 6. Click "Add record" to save. 7. Click "Add record" again. Choose "CNAME" Add the following: * **Name**: paste "Hostname" * **Type**: CNAME * **Content**: paste "Value" 8. Click "Add record" to save. 1. Go to [https://domains.google.com/registrar/](https://domains.google.com/registrar/). 2. Choose the domain you need to update. If your support address is `help@example.com` you must pick `example.com`. Click on "Manage". 3. On the left menu, click on "DNS". 4. Click on "Manage custom records" Scroll down and click on "Create new record". 5. Add the following: * **Host name**: paste "Hostname" * **Type**: TXT * **Data**: paste "Value" 6. Click on "Create new record" again. 7. Add the following: * **Host name**: paste "Hostname" * **Type**: CNAME * **Data**: paste "Value" 8. Click "Save". 1. Go to [https://ap.www.namecheap.com/domains/list/](https://ap.www.namecheap.com/domains/list/). 2. Pick your domain and click on "Manage". 3. Click on the "Advanced DNS" tab. 4. Under "Host records" click on "Add new record" and set the following: * **Type**: TXT * **Host**: paste "Hostname" * **Value**: paste "Value" 5. Click on "Add new record" again and add the following: * **Type**: CNAME * **Host**: paste "Hostname" * **Value**: paste "Value" 6. Click on "Save all changes" to save. 1. Go to [https://account.godaddy.com/products](https://account.godaddy.com/products) 2. Pick your domain and click on "DNS". 3. Click on "Add" and add the following: * **Name**: paste "Hostname" * **Type**: TXT * **Content**: paste "Value" 4. Click on "Add entry" Confirm details and wait. 5. Click on "Add" again and add the following: * **Name**: paste "Hostname" * **Type**: CNAME * **Content**: paste "Value" 6. Click on "Add entry" to save and wait. Remember that in some cases, changes to DNS records may take some time to propagate. For modern providers this should be less than 10 minutes, but in extreme scenarios it may take 24-48 hours. # Events Get the full picture of what happened and why straight in Plain. ![Screenshot of the Events](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/events-introduction.png) Events are a powerful API feature of Plain that allows you to log key events to Plain, providing more context while you are helping a customer. When you create an event in Plain, you can specify the content of the event using a simple GraphQL request and our [UI Components](/api-reference/ui-components). When a customer gets in touch, you will see the events you created on the thread's timeline, along with all other messages and activity. Some examples of use-cases for events include: * Keeping track of key account activity, such as upgrading plans, inviting team members, or changing sensitive settings in your product. * Logging errors or potential issues that your customers encounter (e.g., someone experiences an unexpected error while using your product). * Logging key events in your own systems, such as changes to feature flags or releases. [**Jump to documentation →**](/api-reference/graphql/events) # Headless Support Portal Give your customers visibility over their support requests. Headless Portal is available on the [Grow pricing plan](https://www.plain.com/pricing) and above. If you are interested in trying this feature, please reach out to us at [sales@plain.com](mailto:sales@plain.com) or via our shared Slack channel. When building a headless support portal, you will have access to our engineering team, who can advise and help you. These docs are primarily intended to give you a high-level overview of how the process works. They do not aim to be exhaustive or self-serve. Customer support portals allow your customers to view, create, and reply to support requests directly from your product. Plain provides a set of powerful APIs to do this, and you build the UI. This means you can build a support portal tailored to your product, look 100% white-label, and not require separate login credentials from your product. ### Security & architecture To build a support portal, you will make API calls to [Plain's GraphQL API using an API key](/api-reference/graphql/authentication). This API key needs fairly broad permissions to be able to read threads and customer details as well as perform other actions such as send emails. For this reason it's **very very important to never make API calls to Plain directly from the client**. Doing so means you will be leaking the API key which is a big security risk. You must always make Plain API calls from an API you control. This allows you to implement access controls so that for example, a customer can only access their support requests etc. If you have leaked an API key by mistake, immediately delete it from your workspace settings and don't hesitate to reach out to us if we can help with any mitigation/investigations. ### How does it work It's very helpful to first read our [data model documentation](/data-model) to get your bearings. Details can vary depending on the experience you'd like to offer but in essence to build a portal you have 4 separate piece of work: 1. Creating a tenant for each of your customers in Plain 2. Fetching threads for a customer's tenant 3. Allow customers to submit new threads 4. Allowing customer to reply within a thread. When a customer signs up to your product you first have to create a tenant for that customer. You can [read more about tenants here](/api-reference/graphql/tenants), but in essence each tenant in Plain should 1:1 map to your own workspace/team/org concept in your product. This is an essential first step so that you can make sure that when someone access the support portal they only see *their* team's support requests. Without this it would be difficult/impossible to know which support requests belong to the customer's workspace/team/org. To create tenants in Plain [you can upsert them](/api-reference/graphql/tenants/upsert). After that you can [add individual customers to that tenant](/api-reference/graphql/tenants/add-customers). You can, if you are prototyping this, skip this step and instead just fetch support requests for the customer that is logged in. This means that if John and Lucy are both part of the same team in your product, John will only see his own tickets vs also being able to see Lucy's latest support requests. Depending on your product this might work fine but for most B2B SaaS this is not the ideal experience. Once you have set tenants & threads for all support requests you can fetch them via our API and show them to the customer To display a list of support requests you will need to fetch the appropriate threads from your workspace. The recommended way to do this is to filter by [tenant](/api-reference/graphql/tenants). ```Javascript const threads = await plainClient.getThreads({ filters: { tenantIdentifiers: [{ externalId: tenantExternalId }], statuses: [ThreadStatus.Todo, ThreadStatus.Snoozed], }, }); ``` If in Step 1 you opted for not using Tenants then you can also filter by customerId here in order to fetch just the threads belonging to a specific customer. This will give you back a list of threads but not give you back the content of a thread. To do so you must use this query. This will fetch all relevant message timeline entries. This example is showing how to do this via GraphQL directly but this can also be done with our SDK using the `rawRequest` method. To submit new support requests from your portal you can create a contact form which, when submitted, create a thread within Plain. What is important is that when you create the thread you pre-fill the customer's tenant id. This ensures that the support request they create is then visible in the support portal. You can read more on [how to create a thread here](/api-reference/graphql/threads/create). One thing to note is that you are not constrained at all here by the data you want to gather. Although a simple form with just a message and a title does work, it's typically much more useful and powerful to ask structured questions such as what the support request is about, how urgent it is and much more. You can [read more on contact forms, as well as see some real examples here](/contact-forms). To reply to threads directly from your support portal is probably the most difficult part of building a good support portal from the UI side of things. In terms of the interaction with Plain, however, it's as simple as calling the [`replyToThread` mutation](/api-reference/graphql/messaging/reply-to-thread). Depending on the channels you support your UI here might vary. For example if you want to not support email communication here you don't need to allow for specifying Cc and Bcc addresses. ### Example implementation To show you how this works we've built an example using NextJS which shows how you might set up a headless support portal. **[Check out the example on Github →](https://github.com/team-plain/example-headless-portal)** *** There is a lot of more nuance and detail that these docs do not cover such as rich formatting, attachments etc. We can help you plan and scope out the necessary work depending on your exact requirements and stack. We can also debug technical issues as well as actually help with the implementation in your code base. If you are interested in building a headless support portal based on this high-level overview, please reach out to us via Plain or on [help@plain.com](mailto:help@plain.com). # Insights ![Insights](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/insights-0.png) **Insights** give you an overview of your support workload allowing you to analyze trends, identify improvement areas and answer questions like: * Are we prioritizing support requests correctly? * What product areas are creating most of our support volume? * What companies are we speaking with most? * Are we becoming more efficient? * And lots more ## Using Insights You can access Insights from the left sidebar of the Plain app or by hovering over a Label, Tier, Group or Company tag on a thread. Insights give you the following metrics and much more: 1. **Support volume**: The number amount of threads in your queue at any given point in time. 2. **First response time**: How quickly your customers receive a first reply from you. 3. **Resolution time**: How long it takes you to resolve support requests. Each metric is broken down to display the median and 90th percentile for either the current day, last 7 days or last 28 days. You will also see a volume breakdown of each metric by Company, Tier, or Label and whether the metric is trending upward or downward. ### Get a birds-eye view At the top of the Insights page you will see the overall health of your support. How is your queue looking? Are you keeping up with your response time targets? Are you resolving your customers requests quickly? ![Insights overall health](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/insights-1.png) ### Get into the details In each section of Insights, you will see a breakdown of your support by Channel, Company, Group, Label, Priority and Tier. This lets you see which attributes of a support request might be contributing to slower response times or increased volume. ![Insights details](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/insights-2.png) ### Check in from anywhere in Plain Hover over any Company, Group, Label or Tier in Plain to instantly see a view of recent support volume. This is a great way to proactively identify trends in your data and improve your workflow. ![Insights around Plain](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/insights-3.png) If you’d like to see additional metrics that aren’t displayed in our Insights page or to receive an export of the data to pipe into your own BI tool, please send us an email at [help@plain.com](mailto:help@plain.com). ## The Metrics Now, the nerdy bit - this is a breakdown of the exact metrics we show in Insights and how they’re calculated. **Note -** All metrics can also be broken down by Channel, Company, Group, Label, Priority and Tier. **Support volume** | Metric | Definition | | ----------------------------- | ---------------------------------------------------------------------------- | | Queue size | A snapshot of the number of threads in todo over time, updating hourly. | | New threads created per day | A count of new threads being created. | | Threads re-opened per day | A count of threads that transition back to Todo from either Snoozed or Done. | | Threads moved to Done per day | A count of threads transitioned to Done. | **First response time** | Metric | Definition | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | First response time - Median | The median time it takes for your team to send the first reply. | | First response time - 90th percentile | The 90th percentile time it takes for your team to send the first reply for support requests that start off slower. | **Resolution time** | Metric | Definition | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | Resolution time - Median | The median time between the first inbound message and the last outbound message for threads marked as Done. | | Resolution time - 90th percentile | The 90th percentile time between the first inbound message and the last outbound message for threads marked as Done. | # Discourse Integration With Plain you can connect your Discourse community and automatically keep track of support requests via Plain. This works by deploying a small node service which uses Plain and Discourse's APIs. You can either self-host this little node service or we can deploy this for you. **[Check out the example on Github →](https://github.com/team-plain/example-discourse-integration)** This integration can be customised depending on the structure of your Discourse community and ideal support workflow. For example you could customise the priority, labels and assignee of support requests from Discourse based on their category, author and content. If you'd like help getting this set up, just reach out to us via Plain or on [help@plain.com](mailto:help@plain.com). # Help Scout Sync To connect your Help Scout account, head to **Settings** → **Help Scout importer** and follow the instructions. You can also sync multiple Help Scout mailboxes to Plain if you need to. When you connect your Help Scout account with Plain, we will import: * all your end users as Plain [**customers**](/api-reference/graphql/customers) * all your conversations as Plain [**threads**](/api-reference/graphql/threads). We will import all conversation threads as messages, and all internal notes. We'll map every HelpScout conversation status to an equivalent Plain thread status. * all the conversation tags as Plain [**labels**](/api-reference/graphql/labels) * all attachments that were previously added to conversations in Help Scout, will be added to threads in Plain After you connect Help Scout for the first time, we will import all your existing data. From that point, any new new end user, conversation, thread, note, or tag created in your Help Scout account, will be automatically synced into Plain and kept up-to-date. To stop syncing data from Help Scout, click on **Settings** → **Help Scout importer** -> **Disconnect**. The data created through the Help Scout importer will not trigger any webhooks or auto-responders. The Help Scout importer is available on [Scale tier](https://www.plain.com/pricing) only – please [reach out for a demo](https://38j36lhg2hq.typeform.com/to/zRvsFJPO) if your interested. We will only create new records in Plain, we will never update existing ones: * once an end user is synced into Plain, further changes to that end user in Help Scout (e.g. changing their name) will not be synced into Plain. * if a customer or label is modified on Plain after syncing them from Help Scout, further syncs from Help Scout will not override your changes on Plain. * the same applies to threads and notes. # HubSpot Sync HubSpot Importer is only available in our [Scale tier](https://plain.com/pricing). If you are interested in using this feature please reach out to use on ([sales@plain.com](mailto:sales@plain.com))\[mailto:[plain@sales.com](mailto:plain@sales.com)]. To connect your HubSpot account, head to **Settings** → **HubSpot importer** and follow the instructions. It will take a couple of clicks. When you connect your HubSpot account with Plain we will import: * all your contacts as Plain **customers** * the companies linked to those contacts as Plain [**tenants**](/tenant-support) After you connect for the first time, we will import all your existing data. From that point, any new connection or account that you create in your HubSpot account, will be automatically synced into Plain. The data created through the HubSpot importer will not trigger any webhooks or auto-responders. To stop syncing data from HubSpot, click on *Disconnect* in **Settings** → **HubSpot importer**. We will only **create** new records in Plain, we will **never update** existing ones: * once a contact is synced into Plain, further changes to that contact in HubSpot (e.g. changing their name) will not be synced into Plain * if a customer or tenant is modified **on Plain** after syncing them from HubSpot, further syncs from HubSpot will not override your changes on Plain # Jira integration To easily keep track of feature requests and bugs reported by your customers in Plain, you can connect your Jira workspace to Plain. This lets you easily link a Jira issue to a thread, and ensures you're always prompted to reply to a customer when their bug is fixed or feature request is shipped. To do this, head to Settings → Jira integration (under the Integrations section) and connect your workspace. Once you've linked your Jira workspace to Plain, you can create a Jira issue (or link an existing Jira issue) directly to a customer request in the sidebar on the right hand side of a thread – or by pressing the shortcut: `i`. Once a Jira issue is linked, the thread will move back to Close the loop when the Jira issue is completed, canceled or deleted. **Coming soon:** you'll be able to get a ranked view of your top customer requests from Jira directly in Plain. You'll also be able to dig into each request, to understand which customers requested each feature, the tier breakdown of these customers and when the request was initially made. Please reach out to us if you'd like a demo of this feature. # Linear integration ![Screenshot of the floating contact form](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/linear-introduction.png) To easily keep track of feature requests and bugs reported in Plain, you can connect your Linear workspace with Plain. This lets you easily link a Linear issue to a thread – and ensures you're always prompted to reply to a customer when their bug is fixed or feature request is shipped. To do this, head to **Settings** → **Linear integration** and connect your workspace. Once you've linked your Linear workspace to Plain, you can create a Linear issue (or link an existing Linear issue) directly to a customer request in the sidebar on the right hand side of a thread – or by pressing the shortcut:`i`. Once a Linear issue is linked, the thread will move back to `Close the loop` when the Linear issue is completed or canceled. Once you connect your Linear account and start linking issues to customer requests, you'll get access to a new Linear issues page in Plain – where you'll be able to get a ranked view of your top customer requests from Linear directly in Plain. You can also dig into each request, to understand which customers requested each feature, the tier breakdown of these customers and when the request was initially made. # Salesforce importer To connect your Salesforce account, head to **Settings** → **Salesforce importer** and follow the instructions. It will take a couple of clicks. When you connect your Salesforce account with Plain, we will import: * all your contacts as Plain **customers** * the accounts linked to those contacts as Plain [**tenants**](/tenant-support) After you connect for the first time, we will import all your existing data. From that point, any new connection or account that you create in your Salesforce account, will be automatically synced into Plain. The data created through the Salesforce importer will not trigger any webhooks or auto-responders. To stop syncing data from Salesforce, click on *Disconnect* in **Settings** → **Salesforce importer**. We will only **create** new records in Plain, we will **never update** existing ones: * once a contact or account is synced into Plain, further changes to that contact in Salesforce (e.g. changing their name) will not be synced into Plain * if a customer or tenant is modified **on Plain** after syncing them from Salesforce, further syncs from Salesforce will not override your changes on Plain # Zendesk importer To connect your Zendesk account, head to **Settings** → **Zendesk importer** and follow the instructions. You will need your *Zendesk subdomain*, e.g. `plain` in `https://plain.zendesk.com/` When you connect your Zendesk account with Plain, we will import: * all your end users as Plain [**customers**](/api-reference/graphql/customers) * all your tickets as Plain [**threads**](/api-reference/graphql/threads). We will import all messages in the tickets, including internal ones, which are imported as Plain **notes**. Tickets in status *closed* or *solved* will be imported as *DONE* threads. All others will be imported as *TODO* threads. * all the ticket tags as Plain [**labels**](/api-reference/graphql/labels) After you connect for the first time, we will import all your existing data. From that point, any new end user, ticket, message or tag created in your Zendesk account, will be automatically synced into Plain. The data created through the Zendesk importer will not trigger any webhooks or auto-responders. To stop syncing data from Zendesk, click on *Disconnect* in **Settings** → **Zendesk importer**. We will only **create** new records in Plain, we will **never update** existing ones: * once an end user is synced into Plain, further changes to that end user in Zendesk (e.g. changing their name) will not be synced into Plain * if a customer or label is modified **on Plain** after syncing them from Zendesk, further syncs from Zendesk will not override your changes on Plain # Can I forward emails to Plain? No. If you receive an email to your personal email address and would like to hand it off to support what you can do is **CC** your support email. For example, if [help@acme.com](mailto:help@acme.com) is your support email, your email exchange could look like this: *** **From**: [jane@gmail.com](mailto:jane@gmail.com)
**To**: You
> Can you help me with A, B & C. *** **From**: You
**To**: [jane@gmail.com](mailto:jane@gmail.com)
**Cc**: [help@acme.com](mailto:help@acme.com) 👈
> Hey Jane, sure thing. Just looping in our support team here (CCd) who can help you out! *** # Can I connect my personal email address to Plain? Technically you can connect any email to Plain if you control the domain (e.g. not @hotmail.com) but we don't recommend using a personal email. The reason for this is that emails are visible to all users of Plain in your workspace. Visibility aside, we are focused on providing support via email and so Plain is not well suited for personal email address workflows. # How long does it take to set up Plain? Do I need to have someone technical do it for me? If you are setting up Plain to handle your Slack support it only takes a few minutes. You have to add a Slack app to your workspace and then invite Plain to the channels you want to handle. For detailed instructions [read the docs](/slack). The Plain App will walk you throught this process or we can help you onboard in a quick 15 minute call. If you want to set-up Plain via email you need a bit more time and need to be able to add DNS records to your domain. For more info on how to set up email, [read the docs](/email). # Are you SOC2 certified? Yes. We are SOC2 Type II compliant. On request we can share our lates SOC2 report as well as our latest pen test and other associated documentation. We are also GDPR compliant. You can read more high-level information on our security approach [here](/security). # Knowledge Base **Beta**: We're just in the process of building out our knowledge base. A lot more coming soon ### Setup * [How long does it take to set up Plain? Do I need to have someone technical do it for me?](./kb-setup-time) ### Email * [Can I connect my personal email address to Plain?](./kb-personal-email) * [Can I forward emails to Plain?](./kb-forwarding-emails) ### Security * [Are you SOC2 certified?](./kb-soc2) # Labels ![Example labels](https://mintlify.s3-us-west-1.amazonaws.com/plain/public/images/labels-introduction.png) Labels are a lightweight and powerful way to categorize threads in Plain. We recommend categorizing threads into topics so you immediately know, at a glance, what people are getting in touch about. For example: * "Bug report" * "Sales" or "Demo request" * "Feedback" or "Feature requests" * "API question" * "Security" * "Hiring" * etc. In combination with setting thread priorities, labels can unlock powerful workflows and let you quickly triage your threads to work on what matters most, first. To configure your labels, press ⌘ + K and search for "Manage labels" or go to **Settings** → **Labels**. Here you can define the icon and name your labels. A thread can have multiple labels. They are displayed throughout the Plain UI wherever threads are visible. In many cases, it's also useful to add labels to threads programmatically. [**Jump to documentation →**](/api-reference/graphql/labels) # Microsoft Teams **Never miss another message from a customer in Microsoft Teams** Microsoft Teams is available on the [Scale pricing plan](https://www.plain.com/pricing). If you are interested in trying this feature, please reach out to us at [sales@plain.com](mailto:sales@plain.com) or via our shared Slack channel. Microsoft Teams support is something we recently shipped. As you'd be among our early adopters, we'd love for you to [book a demo with us](https://38j36lhg2hq.typeform.com/to/zRvsFJPO?typeform-source=www.plain.com) so we can show you around. Our Microsoft Teams integration lets you sync messages from selected Teams channels to Plain and respond directly to customers from the platform. To get started you need to: * Create a team and a channel in Microsoft Teams * Install the Plain app in Microsoft Teams * Invite guests Once this is set up, the integration allows you to: * Respond to teams messages in Plain * Sync messages from selected Teams channels to Plain * Reply as your Teams user from Plain Here's a quick demo of the Plain Microsoft Teams integration in action, to get started, see the [installation guide](./microsoft-teams/installation).