Looking for the previous documentation?Go to v1.docs.pluggy.ai

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#

FieldRequiredNotes
summaryyesShort title. Our workflow rewrites it as [YourOrg/Institution] problem (externalId), so keep it descriptive but don't rely on it.
descriptionyesWhat's wrong, since when, how many users are affected, how to reproduce. This is what the engineer reads first.
itemIdyesA 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.
productyesTransactions, Credit Card, Investments, Consent…
problemTypeyesThe closest match from the enum. Picking well is what routes the ticket correctly.
externalIdstrongly recommendedYour 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.
accountTypenoPessoa Física, Pessoa Jurídica, Corretora or Outra. Tells us which kind of account to reproduce against.
accountIdnoThe Pluggy account the problem is on, when the item has more than one. Saves us a round trip asking which.
investmentIdnoSame, for investment problems: the specific investment that is wrong or missing.
prioritynoP0 — 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.

MethodPathWhat it does
GET/api/partners/ticketsYour 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}/commentsThe ticket's public messages.
GET/api/partners/tickets/{key}/eventsThe ticket's history: status, priority and deadline changes, messages and the resolution.
POST/api/partners/tickets/{key}/commentsReply 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#

  1. Register the URL yourself with POST /api/partners/webhooks and 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/webhooks lists them and DELETE /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.
  2. 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, send PATCH /api/partners/webhooks/{id} with the same url (or "rotateSecret": true): the previous secret stops verifying immediately.
  3. Check your verification with POST /api/partners/webhooks/{id}/test: we send a signed webhook.test event right away and answer with the HTTP code your endpoint returned.
  4. Verify the signature on every request, then answer any 2xx as 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 once

Events#

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:

EventWhen it is sent
ticket.status_changedthe ticket moved to a different status. previous carries what it was.
ticket.resolvedthe ticket was resolved. Sent instead of a status change, not alongside it, so one real change is one event.
ticket.message_addedwe replied on the ticket. Carries the reply text.
ticket.priority_changedthe priority or SLA changed. The first priority we ever see on a ticket is not a change and sends nothing.
ticket.updatedthe 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_added only 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 the X-Pluggy-Event-Id header. The same id arriving twice means the same change, not two changes.
  • Ordering is not guaranteed. sequence tells 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 code and 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 dryRun is free, a rejected call is free, and a retry that returns the ticket you already opened is free — so keep sending externalId. Over the quota the API answers 429 with Retry-After. Back off and retry; don't hammer.
  • Use dryRun while integrating. Setting "dryRun": true validates 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#

StatusMeaning
400Missing or invalid fields — the message names them.
401Missing, unknown or revoked key.
403The item does not belong to your account.
404No such item, or the connection was deleted (ITEM_NOT_FOUND). Also for an item created in the last few minutes — retry shortly.
409The 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).
413An attachment is over 10 MB.
429Monthly quota reached. Honour Retry-After.
5xxOur side. Retry with exponential backoff; the request is idempotent when you send externalId.