Skip to main content

Documentation Index

Fetch the complete documentation index at: https://www.plain.com/docs/llms.txt

Use this file to discover all available pages before exploring further.

Using TypeScript? Check out our GraphQL SDK for a fully typed client.
Plain has built-in importers for common providers. If your source system is supported, use those first — they handle the mapping for you. These mutations are for when you need to build a custom import, for example from a less common provider or an internal tool. The importThread and importThreadMessages mutations let you bring across historic threads while preserving original timestamps, authors, and attachments so your team has full context in Plain. Unlike createThread, imported threads do not trigger SLAs or autoresponders and are marked with import provenance tracking.

Overview

Importing a thread is a two-step process:
  1. Create the thread with importThread — this sets up the thread with its metadata (title, status, priority, labels, etc.) and the original creation timestamp.
  2. Add messages with importThreadMessages — this adds the conversation history (inbound messages, outbound replies, and internal notes) to the thread.
You must create the thread before importing its messages. Each mutation is idempotent: if you call it again with the same externalId, the duplicate is skipped (the result will be NOOP).

Permissions

To import threads you need an API key with the following permissions:
  • thread:import
  • attachment:create (if importing messages with attachments)

Import a thread

The importThread mutation creates a thread tied to an existing customer. You can identify the customer by their Plain customer ID, email address, or external ID. The statusDetail.type must match the thread status:
  • TODO allows NEW_REPLY or IN_PROGRESS
  • SNOOZED allows WAITING_FOR_CUSTOMER
  • DONE allows DONE_MANUALLY_SET or IGNORED
If you provide a tenantId, the customer must already be a member of the tenant or the import will fail.
Mutation
mutation importThread($input: ImportThreadInput!) {
  importThread(input: $input) {
    thread {
      id
      externalId
      customer {
        id
      }
      status
      title
      priority
      createdAt {
        iso8601
      }
    }
    result
    error {
      message
      type
      code
      fields {
        field
        message
        type
      }
    }
  }
}
Variables
{
  "input": {
    "customerIdentifier": {
      "emailAddress": "alice@example.com"
    },
    "title": "Issue with billing",
    // Unique ID from the source system. Duplicates are skipped.
    "externalId": "zendesk_ticket_12345",
    // 0 = urgent, 1 = high, 2 = normal, 3 = low
    "priority": 2,
    "status": "DONE",
    // statusDetail.type must match the status (see above for allowed combinations)
    "statusDetail": {
      "type": "DONE_MANUALLY_SET"
    },
    // Original creation timestamp from the source system (ISO 8601)
    "createdAt": "2024-06-15T10:30:00Z",
    "labelTypeIds": ["lt_01HB924PME9C0YWKW1N4AK3BZA"],
    // Optional. A URL back to the thread in the source system.
    "externalUrl": "https://your-helpdesk.example.com/tickets/12345",
    // Optional. The customer must be a member of this tenant.
    "tenantId": "ten_01HB924PME9C0YWKW1N4AK3BZA"
  }
}
The mutation returns a result field which is one of:
  • CREATED — the thread was imported successfully.
  • NOOP — a thread with this externalId already exists, so the import was skipped.

Import thread messages

Once you have a thread, use importThreadMessages to add conversation history. You can import up to 25 messages per call. Each message has a type that determines which author field to set:
  • INBOUND — set author.customerId (the customer who sent the message)
  • OUTBOUND — set author.userId (the support agent who replied)
  • NOTE — set author.userId (the agent who wrote the internal note)
Exactly one of customerId or userId must be provided.
Mutation
mutation importThreadMessages($input: ImportThreadMessagesInput!) {
  importThreadMessages(input: $input) {
    results {
      threadMessage {
        ... on TimelineEntry {
          id
          timestamp {
            iso8601
          }
        }
        ... on Note {
          id
          createdAt {
            iso8601
          }
        }
      }
      result
      error {
        message
        type
        code
        fields {
          field
          message
          type
        }
      }
    }
    error {
      message
      type
      code
    }
  }
}
Variables
{
  "input": {
    "threadId": "th_01HB924PME9C0YWKW1N4AK3BZA",
    "threadMessages": [
      // INBOUND: author must be a customerId
      {
        "author": {
          "customerId": "c_01H14DFQ4PDYBH398J1E99TWSS"
        },
        "text": "Hi, I have an issue with my latest invoice.",
        "createdAt": "2024-06-15T10:30:00Z",
        "type": "INBOUND",
        "externalId": "msg_001"
      },
      // OUTBOUND: author must be a userId
      {
        "author": {
          "userId": "u_01H14DFQ4PDYBH398J1E99TWSS"
        },
        "text": "Let me look into that for you.",
        "createdAt": "2024-06-15T10:35:00Z",
        "type": "OUTBOUND",
        "externalId": "msg_002"
      },
      // NOTE: author must be a userId
      {
        "author": {
          "userId": "u_01H14DFQ4PDYBH398J1E99TWSS"
        },
        "text": "Internal note: checked billing system, invoice was correct.",
        "createdAt": "2024-06-15T10:36:00Z",
        "type": "NOTE",
        "externalId": "msg_003"
      }
    ]
  }
}
The mutation returns a results array with one entry per message in the same order as the input. Each result contains:
  • resultCREATED or NOOP (if a message with that externalId already exists).
  • threadMessage — the created TimelineEntry (for INBOUND/OUTBOUND) or Note (for NOTE). Null if the message failed.
  • error — per-message error details, null on success.
If some messages fail while others succeed, the top-level error will have the code bulk_partial_failure.

Importing messages with attachments

To import messages that have attachments, you need to upload the attachments first and then reference them by ID.
1

Upload the attachment

Use createAttachmentUploadUrl to get an upload URL, then upload the file. See the attachments guide for the full upload flow.When creating the upload URL, use the attachment type CUSTOM_TIMELINE_ENTRY for INBOUND and OUTBOUND messages, or NOTE for NOTE messages.
2

Reference the attachment in the message

Pass the attachment ID (returned by createAttachmentUploadUrl) in the attachmentIds array of the message:
{
  "author": {
    "customerId": "c_01H14DFQ4PDYBH398J1E99TWSS"
  },
  "text": "Here is a screenshot of the error.",
  "createdAt": "2024-06-15T10:32:00Z",
  "type": "INBOUND",
  "externalId": "msg_004",
  "attachmentIds": ["att_01HB924PME9C0YWKW1N4AK3BZA"]
}
Attachments that are uploaded but not referenced by any message are deleted after 24 hours. Make sure to call importThreadMessages promptly after uploading.

Putting it all together

A typical migration script follows this order:
  1. Upsert customers so they exist in Plain.
  2. Call importThread for each ticket in the source system.
  3. Upload any attachments using createAttachmentUploadUrl.
  4. Call importThreadMessages with the thread ID and messages (in batches of up to 25).
  5. Check the result and error fields to confirm each import succeeded.
Since both mutations are idempotent on externalId, you can safely re-run a migration script without creating duplicates.