Partner Support API
Open and track Pluggy support tickets programmatically. Same queue, same SLA, same portal as a ticket filed by a human.
Base URLhttps://docs.pluggy.ai
On this page
Overview#
Open and track Pluggy support tickets from your own systems. A ticket created through this API is identical to one filed by hand in the support portal: same request type, same intake form, same SLA and the same queue. Your team keeps seeing everything in the portal as usual.
Machine-readable contract: /api/partners/docs (OpenAPI 3.0 — point your codegen at it).
Quick start#
Get your API key
Your Pluggy contact issues it. Send it on every request — the details are under Authentication, below.
Open a ticket
curl -X POST https://docs.pluggy.ai/api/partners/tickets \
-H "Authorization: Bearer $PLUGGY_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{
"summary": "Transação do cartão de crédito não sendo retornada",
"description": "Item conectado e atualizado, fatura fechada em 05/08. As 3 últimas transações não aparecem na API. Afeta ~40 usuários desde 04/08.",
"itemId": "8f2a1b3c-0000-4d4e-9a9a-111122223333",
"product": "Credit Card",
"problemType": "Transação do cartão de credito não sendo retornada",
"accountType": "Pessoa Física",
"externalId": "YOUR-1947",
"priority": "P1 — Alto"
}'Response:
{
"ticket": "SUP2-13826",
"portalUrl": "https://pluggy.atlassian.net/servicedesk/customer/portal/1/SUP2-13826",
"status": "Aberto",
"created": true,
"deduplicatedFrom": null,
"attachmentsUploaded": 0,
"derivedFromItem": { "connectorName": "Nubank", "isOpenFinance": true },
"unmappedValues": []
}Check status
curl https://docs.pluggy.ai/api/partners/tickets/SUP2-13826 \
-H "Authorization: Bearer $PLUGGY_PARTNER_KEY"Returns the current status and the SLA clocks. You can only read tickets that belong to your account.
Authentication#
Send the API key your Pluggy contact issued you:
Authorization: Bearer pk_live_…
# or
X-Api-Key: pk_live_…The key identifies your account — you never send a team or customer id, and a key can only open tickets for its own account. Treat it as a secret; if it leaks, ask us to revoke it and we issue a new one immediately.
Tickets#
Open a ticket#
POST /api/partners/tickets opens a ticket. The quick start above has a complete request and its response; every field it takes is in the table below.
Fields#
| Field | Required | Notes |
|---|---|---|
summary | yes | Short title. Our workflow rewrites it as [YourOrg/Institution] problem (externalId), so keep it descriptive but don't rely on it. |
description | yes | What's wrong, since when, how many users are affected, how to reproduce. This is what the engineer reads first. |
itemId | yes | A Pluggy item where the problem reproduces. It must belong to your account, and its connector is what sets the institution — you don't send one. A ticket citing another account's item is refused before it is created. |
product | yes | Transactions, Credit Card, Investments, Consent… |
problemType | yes | The closest match from the enum. Picking well is what routes the ticket correctly. |
externalId | strongly recommended | Your own issue id. Makes the call idempotent — a retry returns the existing ticket instead of a duplicate — and shows in the ticket title so both sides can cross-reference. |
accountType | no | Pessoa Física, Pessoa Jurídica, Corretora or Outra. Tells us which kind of account to reproduce against. |
accountId | no | The Pluggy account the problem is on, when the item has more than one. Saves us a round trip asking which. |
investmentId | no | Same, for investment problems: the specific investment that is wrong or missing. |
priority | no | P0 — Crítico to P3 — Baixo. Your read of the impact — it is a signal for triage, not an SLA commitment. |
Evidence and attachments#
Screenshots, HAR files and logs make the difference between a ticket resolved in a day and one that bounces for a week. Send up to 5 files per ticket, 10 MB each, either as an HTTPS URL we fetch once (preferred — signed URLs with a short TTL are fine) or inline base64 for small files. Tickets already open take evidence too: POST /api/partners/tickets/{key}/comments accepts the same attachments on SUP2 tickets, with or without a message.
"attachments": [
{ "filename": "evidencia.har", "contentType": "application/json",
"url": "https://files.example.com/signed/evidencia.har" },
{ "filename": "log.txt", "contentType": "text/plain",
"base64": "TG9nIGNvbnRlbnQ=" }
]Follow a ticket#
GET /api/partners/tickets lists your tickets with the SLA due date and whether it was breached, paged by cursor (nextCursor in the response, send it back as cursor), and POST /api/partners/tickets/{key}/comments adds a public comment — the same thing your team would write in the portal, so support sees it in the conversation rather than in an internal note. GET /api/partners/tickets/{key}/comments returns the ticket's public messages, and GET /api/partners/tickets/{key}/events its history — status, priority and deadline changes, messages and the resolution, each with its date. Reads do not count against your quota.
| Method | Path | What it does |
|---|---|---|
| GET | /api/partners/tickets | Your tickets, newest first, with the SLA due date and whether it was breached. Paged by cursor. |
| GET | /api/partners/tickets/{key} | One ticket: its current status and the SLA clocks. |
| GET | /api/partners/tickets/{key}/comments | The ticket's public messages. |
| GET | /api/partners/tickets/{key}/events | The ticket's history: status, priority and deadline changes, messages and the resolution. |
| POST | /api/partners/tickets/{key}/comments | Reply on the ticket as a public comment. On SUP2 tickets it also takes attachments. |
Ticket events (webhooks)#
Instead of polling for changes, register an HTTPS endpoint and we will POST an event to it whenever one of your tickets moves. The API stays available for everything else — opening tickets, posting follow-ups, and reconciling when you want to be sure — so the webhook is the fast path, not the only one.
Webhook reference: every field, header and retry rule
Set it up#
- Register the URL yourself with
POST /api/partners/webhooksand your API key (example below). You can have up to 10 — one per environment or per consuming system — and every event goes to every enabled one.GET /api/partners/webhookslists them andDELETE /api/partners/webhooks/{id}removes one. If you prefer, your Pluggy contact can register it for you. If a response says"routed": false, the endpoints are saved but your account is not wired to receive ticket events yet — tell your Pluggy contact. - The response carries that endpoint's signing secret (
whsec_…), once — it cannot be read back. Store it where you keep your other credentials. To renew it, sendPATCH /api/partners/webhooks/{id}with the sameurl(or"rotateSecret": true): the previous secret stops verifying immediately. - Check your verification with
POST /api/partners/webhooks/{id}/test: we send a signedwebhook.testevent right away and answer with the HTTP code your endpoint returned. - Verify the signature on every request, then answer any
2xxas soon as you have durably accepted the event. Queue it and process it asynchronously: we wait 8 seconds for a response, and anything slower counts as a failed delivery.
curl -X POST https://docs.pluggy.ai/api/partners/webhooks \
-H "Authorization: Bearer $PLUGGY_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://hooks.example.com/pluggy/tickets", "description": "production" }'
# 201 → { "endpoint": { "id": "3f6c…", "url": "…", "enabled": true, … },
# "secret": "whsec_…" } ← shown onceEvents#
Five events, each fired from the same comparison against the ticket system that keeps our copy of your ticket correct — so an event can never claim a change that did not happen:
| Event | When it is sent |
|---|---|
ticket.status_changed | the ticket moved to a different status. previous carries what it was. |
ticket.resolved | the ticket was resolved. Sent instead of a status change, not alongside it, so one real change is one event. |
ticket.message_added | we replied on the ticket. Carries the reply text. |
ticket.priority_changed | the priority or SLA changed. The first priority we ever see on a ticket is not a change and sends nothing. |
ticket.updated | the title changed. |
Example payload:
{
"eventId": "0f2b6c4e-7a1d-4a2e-9c3f-5b8d1e0a7c44",
"sequence": 1042,
"event": "ticket.message_added",
"occurredAt": "2026-09-18T13:42:07.912Z",
"ticket": {
"key": "SUP2-13826",
"number": 418,
"title": "Transação do cartão de crédito não sendo retornada",
"state": "on_customer",
"status": "On customer",
"updatedAt": "2026-09-18T13:41:58.000Z",
"portalUrl": "https://pluggy.atlassian.net/servicedesk/customer/portal/1/SUP2-13826"
},
"message": {
"author": "Pluggy Support",
"text": "Confirmamos com a instituição: as transações entram no próximo ciclo.",
"sentAt": "2026-09-18T13:41:58.000Z"
}
}Verify the signature#
Every request carries X-Pluggy-Signature: t=<unix seconds>,v1=<hmac hex>. What is signed is the string "{t}.{raw body}" — the body exactly as it arrived on the wire, before any JSON parse, because re-serialising the parsed object changes the bytes and the signature will not match. The t is also the anti-replay token: reject anything older than five minutes.
import { createHmac, timingSafeEqual } from "node:crypto";
// `raw` is the body exactly as it arrived — parse it only after this passes.
export function verify(raw: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=", 2) as [string, string]),
);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return false; // anti-replay
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${raw}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}Delivery#
- Internal notes never leave. Our tickets carry internal notes alongside the replies you see.
ticket.message_addedonly ever carries a message that is visible to you in the portal — never an internal one. - De-duplicate on
eventId. It is stable across retries, and also sent as theX-Pluggy-Event-Idheader. The same id arriving twice means the same change, not two changes. - Ordering is not guaranteed.
sequencetells you which event was created first, so you can discard one you have already superseded — but it is not delivery order. Treat an event as a signal to read the ticket, not as the state itself. - We retry. Six attempts with backoff — 30s, 2m, 10m, 1h, then 6h — after which the delivery is marked failed and we can re-send it by hand with the same
eventId. Those delays are when a retry becomes due, not a promise of when it lands.
Ground rules#
- One ticket per problem, not per end user. If the same connector bug affects 500 of your users, that is one ticket with the volume described — not 500 tickets. This is the single biggest factor in how fast you get answers.
- You no longer send the institution. We read it from the itemId, together with whether the connector is Open Finance. If the connector isn't one of the options in our intake form it is filed as “Outra” with the real name in the description — nothing is lost.
- Before the ticket exists, we check the item Four things stop a ticket from being created — a deleted connection, no successful update in two weeks, only invalid-credential executions, or an Open Finance consent still waiting on the company's other admins. In all of them the fix is on the connection and support could only tell you the same. The response carries a
codeand a message written for your end user (see the error table below). - Always send externalId. Retries and double-fired automations are normal; idempotency is how they stop being duplicates.
- Quota. Each key has a monthly quota of tickets, and only a ticket we actually create spends it. Reads are free, following up on a ticket is free, a
dryRunis free, a rejected call is free, and a retry that returns the ticket you already opened is free — so keep sendingexternalId. Over the quota the API answers429withRetry-After. Back off and retry; don't hammer. - Use dryRun while integrating. Setting
"dryRun": truevalidates your payload and returns what we would file, without creating anything or touching your quota. - What doesn't belong here: ongoing incidents (watch the status page), integration questions (docs assistant), and feature requests (talk to your Pluggy contact).
Errors#
| Status | Meaning |
|---|---|
400 | Missing or invalid fields — the message names them. |
401 | Missing, unknown or revoked key. |
403 | The item does not belong to your account. |
404 | No such item, or the connection was deleted (ITEM_NOT_FOUND). Also for an item created in the last few minutes — retry shortly. |
409 | The item can't be investigated as it is: ITEM_TOO_OLD (re-sync the connection), ITEM_ONLY_HAS_INVALID_CREDENTIALS_EXECUTIONS (the end user has to reconnect) or OF_ITEM_RESOURCES_WITHOUT_MASTER_PERMISSIONS (the company's other admins still have to authorize). |
413 | An attachment is over 10 MB. |
429 | Monthly quota reached. Honour Retry-After. |
5xx | Our side. Retry with exponential backoff; the request is idempotent when you send externalId. |
