Plain
Port's Plain integration allows you to model Plain support resources in your software catalog and ingest data into them.
Prerequisites
- A Plain workspace with permission to create a machine-user API key.
- Your Port user role is set to
Admin. - A Plain API key created on a machine user (Settings → Machine Users → Add API key). The token looks like
plainApiKey_….- Grant at least
company:read,tenant:read,user:read,customer:read,thread:read, andtimeline:read. - Additional permissions may be needed depending on which kinds and nested fields you sync (for example
roles:read,tier:read,machineUser:read,customerTenantMembership:read, and webhook target permissions for live events).
- Grant at least
Setup
Choose one of the following installation methods. Not sure which method is right for your use case? Check the available installation methods.
Configuration
Port integrations use a YAML mapping block to ingest data from the third-party api into Port.
The mapping makes use of the JQ JSON processor to select, modify, concatenate, transform and perform other operations on existing fields and values from the integration API.
Selectors
Every kind supports pageSize (default 100, max 100) to control GraphQL list page size for that mapping block.
Additional selector flags:
excludeDeleted(machine-user only) - Whentrue, skip deleted machine users during resync. Whenfalse(default), sync all machine users including deleted ones. You can filter in Port with a query such as.isDeleted == false.excludeDoneThreads(thread, thread-message, discussion, discussion-message) - Whentrue, sync onlyTODOandSNOOZEDthreads.threadStatuses(thread, thread-message, discussion, discussion-message) - Optional list of thread statuses to sync (for example[TODO, SNOOZED]). Ignored whenexcludeDoneThreadsistrue. Omit it to sync every status.excludeAiDiscussions(discussion, discussion-message) - Whentrue, skip Cursor and agent-session discussions (and their messages) at fetch time.
Default mapping configuration
This is the default mapping configuration you get after installing the Plain integration.
Default mapping configuration (click to expand)
createMissingRelatedEntities: true
deleteDependentEntities: true
resources:
- kind: company
selector:
query: "true"
pageSize: 100
port:
entity:
mappings:
identifier: .id
title: .name
blueprint: '"plainCompany"'
properties:
domainName: .domainName
createdAt: .createdAt.iso8601
updatedAt: .updatedAt.iso8601
- kind: tenant
selector:
query: "true"
pageSize: 100
port:
entity:
mappings:
identifier: .id
title: .name
blueprint: '"plainTenant"'
properties:
externalId: .externalId
url: 'if .url == null or .url == "" then null elif (.url | startswith("http://") or startswith("https://")) then .url else "https://" + .url end'
createdAt: .createdAt.iso8601
updatedAt: .updatedAt.iso8601
- kind: user
selector:
query: "true"
pageSize: 100
port:
entity:
mappings:
identifier: .id
title: .fullName // .publicName // .email
blueprint: '"plainUser"'
properties:
email: .email
publicName: .publicName
status: .status
role: .role.name
- kind: machine-user
selector:
query: "true"
pageSize: 100
excludeDeleted: false
port:
entity:
mappings:
identifier: .id
title: .fullName // .publicName
blueprint: '"plainMachineUser"'
properties:
publicName: .publicName
description: .description
type: .type
isCustomAgent: .isCustomAgent
isAssignableToThreads: .isAssignableToThreads
isDeleted: .isDeleted
createdAt: .createdAt.iso8601
updatedAt: .updatedAt.iso8601
- kind: customer
selector:
query: "true"
pageSize: 100
port:
entity:
mappings:
identifier: .id
title: .fullName // .shortName // .email.email
blueprint: '"plainCustomer"'
properties:
externalId: .externalId
email: .email.email
shortName: .shortName
createdAt: .createdAt.iso8601
updatedAt: .updatedAt.iso8601
relations:
company: .company.id
tenants: "[.tenantMemberships.edges[]?.node.tenant.id]"
- kind: thread
selector:
query: "true"
pageSize: 100
excludeDoneThreads: false
port:
entity:
mappings:
identifier: .id
title: .title // .ref
blueprint: '"plainThread"'
properties:
ref: .ref
externalId: .externalId
description: .description
status: .status
priority: .priority
tier: .tier.name
assigneeType: .assignedTo.__typename
labels: "[.labels[]?.labelType.name]"
productArea: '([.threadFields[]? | select(.key == "product_area") | .stringValue] | first) // ""'
fields: '[.threadFields[]? | .key + "=" + ((.stringValue // .numberValue // .booleanValue) | tostring)] | join(", ")'
createdAt: .createdAt.iso8601
updatedAt: .updatedAt.iso8601
relations:
customer: .customer.id
tenant: .tenant.id
assignee: if .assignedTo.__typename == "User" then .assignedTo.id else null end
machineAssignee: if .assignedTo.__typename == "MachineUser" then .assignedTo.id else null end
- kind: thread-message
selector:
query: "true"
pageSize: 100
excludeDoneThreads: false
port:
entity:
mappings:
identifier: .id
title: '(.entry.__typename // "Message") + (if .llmText then ": " + .llmText[0:80] else "" end)'
blueprint: '"plainThreadMessage"'
properties:
text: .llmText
entryType: .entry.__typename
timestamp: .timestamp.iso8601
actorType: .actor.__typename
actorId: .actor.user.id // .actor.customer.id // .actor.machineUser.id
relations:
thread: .threadId
- kind: discussion
selector:
query: "true"
pageSize: 100
excludeDoneThreads: false
excludeAiDiscussions: false
port:
entity:
mappings:
identifier: .id
title: .title
blueprint: '"plainDiscussion"'
properties:
status: .status
agentStatus: .agentStatus
visibility: .visibility
isUnread: .isUnread
channel: 'if .channelDetails == null then null elif .channelDetails.__typename == "ThreadDiscussionSlackChannelDetails" then "SLACK" elif .channelDetails.__typename == "ThreadDiscussionEmailChannelDetails" then "EMAIL" elif .channelDetails.__typename == "ThreadDiscussionCursorWorkspaceBackgroundAgentChannelDetails" then "CURSOR" elif .channelDetails.__typename == "ThreadDiscussionAgentSessionChannelDetails" then "AGENT_SESSION" else .channelDetails.__typename end'
slackChannelName: .channelDetails.slackChannelName
slackMessageLink: .channelDetails.slackMessageLink
emailRecipients: .channelDetails.emailRecipients
createdAt: .createdAt.iso8601
updatedAt: .updatedAt.iso8601
lastActivityAt: .lastActivityAt.iso8601
resolvedAt: .resolvedAt.iso8601
createdByType: .createdBy.__typename
createdById: .createdBy.userId // .createdBy.customerId // .createdBy.machineUserId // .createdBy.systemId
relations:
thread: .threadId
- kind: discussion-message
selector:
query: "true"
pageSize: 100
excludeDoneThreads: false
excludeAiDiscussions: false
port:
entity:
mappings:
identifier: .id
title: '(.type // "Message") + (if .text then ": " + .text[0:80] else "" end)'
blueprint: '"plainDiscussionMessage"'
properties:
text: .text
messageType: .type
slackMessageLink: .slackMessageLink
timestamp: .createdAt.iso8601
authorType: .createdBy.__typename
authorId: .createdBy.userId // .createdBy.customerId // .createdBy.machineUserId // .createdBy.systemId
relations:
thread: .threadId
discussion: .threadDiscussionId
Monitoring and sync status
To learn more about how to monitor and check the sync status of your integration, see the relevant documentation.
Mapping & selectors per resource
Use the explorer below to view sample payloads and the resulting Port entities for each resource type.
Set up live events
This integration supports live events, allowing real-time updates to your software catalog without waiting for the next scheduled sync.
Note that different events are supported depending on the resource type.
- Hosted by Port
- Self-hosted
Live events base URL
For integrations installed using the Hosted by Port method, you can obtain the live events base URL from the Get an integration API. The response includes liveEventsUuid and liveEventsIngestHostname in the appSpec section.
Call the endpoint with your integration's identifier:
curl -X GET \
'https://api.port.io/v1/integration/<INTEGRATION_IDENTIFIER>' \
-H 'Authorization: Bearer <PORT_ACCESS_TOKEN>'
The port_region, port.baseUrl, portBaseUrl, port_base_url and OCEAN__PORT__BASE_URL parameters select which Port API instance to use:
- EU (app.port.io) →
https://api.port.io - US (app.us.port.io) →
https://api.us.port.io
In the response, look for the following fields under spec.appSpec:
{
"spec": {
"appSpec": {
"liveEventsUuid": "abcdefghijk",
"liveEventsIngestHostname": "ingest.ocean.getport.io"
}
}
}
Combine them to build the live events base URL:
https://<liveEventsIngestHostname>/live-events/<liveEventsUuid>
For example, with the values above, the live events base URL is:
https://ingest.ocean.getport.io/live-events/abcdefghijk
This is the same URL configured for the integration.
BaseUrl & webhook configuration
To enable real-time updates of the data in your software catalog, you need to define the liveEvents.baseUrl parameter.
This parameter should be set to the URL of your Plain integration instance, which needs to have the option to setup webhooks via HTTP requests/receive HTTP requests, so ensure the network is configured accordingly.
- If Plain and the integration are in the same cluster/network: Use an internal URL (e.g., a Kubernetes service DNS name).
For Kubernetes deployments, create a service to expose the integration pod and use the service URL asliveEvents.baseUrl. If both the source system and integration are in the same cluster, an internal ClusterIP service is sufficient. - If Plain is external to the integration's network: The integration must be exposed via an ingress, load balancer, or public URL that Plain can reach.
If you're running high volumes of live events in a self-hosted deployment and want better reliability, event durability, or horizontal scalability, you can optionally set up Port's dedicated live events infrastructure. See live events at scale for details.
If liveEvents.baseUrl is not provided, the integration will continue to function correctly. In such a configuration, to retrieve the latest information from the target system, the scheduledResyncInterval parameter has to be set, or a manual resync will need to be triggered through Port's UI.
To test webhooks or live event delivery to your local environment, expose your local pod or service to the internet using ngrok (e.g. ngrok http http://localhost:8000)
Securing Your Webhooks
The integration.secrets.webhookSecret parameter secures your webhooks. If not provided, the integration will process webhooks without validating the source of the events.
Limitations
- Live events need an event listener that processes webhooks and a reachable
liveEvents.baseUrl(or hosted-by-Port equivalent). - Plain does not emit dedicated company, tenant, user, or machine-user webhooks. Those kinds update when related thread or customer events fire.
- Only the UK GraphQL host (
https://core-api.uk.plain.com/graphql/v1) is known to resolve. UseapiUrlonly if Plain adds another region. - Customer tenants come from the first page of
tenantMemberships(at most 100). That selection needscustomerTenantMembership:readin addition tocustomer:read. - The user query selects
role, which needsroles:readin addition touser:read. - Thread messages come from
thread.timelineEntries, which needstimeline:readin addition tothread:read. - Thread tier is mapped as a string (
.tier.name), not a separate kind. Nested selection may needtier:read. - A human thread assignee maps to
assignee(plainUser). A machine assignee maps tomachineAssignee(plainMachineUser).assigneeTypestores the Plain__typename. - A missing Plain permission fails that kind and logs the permission name. The other kinds still sync.