Overview#
If you open tickets through the Partner Support API, you do not have to poll GET /api/partners/tickets to learn that a case moved. Register an HTTPS endpoint and we will POST a JSON event to it whenever one of your tickets changes — a status change, a resolution, a reply from our support team, a priority change or a new title.
The API does not go away. It stays the authority: you keep using it to open tickets, to post follow-ups and to reconcile. The webhook is the fast path, not the only one.
POST https://your-endpoint.example.com/pluggy/tickets
Content-Type: application/json
X-Pluggy-Signature: t=1758202927,v1=6f9c…
X-Pluggy-Event-Id: 0f2b6c4e-7a1d-4a2e-9c3f-5b8d1e0a7c44
Only customer-visible replies are ever sent
Our support desk holds internal notes on the very same tickets. They are never emitted. ticket.message_added carries a reply written to you and nothing else — the filter is positive (a public reply from our staff), so anything new and internal is excluded by default rather than included until somebody remembers to exclude it. Your own messages are not echoed back to you either.
Getting configured#
You set up your endpoints yourself, through the Partner Support API and with the same API key you open tickets with. None of these calls count against your ticket quota.
1. Register an endpoint#
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.yourcompany.com/pluggy/tickets",
"description": "homologação",
"enabled": false
}'{
"endpoint": {
"id": "3f0b8c1e-6a2d-4c3b-9e1f-2a7d5b8c9e01",
"url": "https://hooks.yourcompany.com/pluggy/tickets",
"description": "homologação",
"enabled": false,
"secretRotatedAt": "2026-10-27T12:00:00.000Z",
"createdAt": "2026-10-27T12:00:00.000Z",
"updatedAt": "2026-10-27T12:00:00.000Z"
},
"secret": "whsec_8c1f0e5a3b9d4c7e2f6a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e",
"routed": true
}The URL must be https, without credentials or a #fragment, and on a public host name — IP addresses, localhost and internal names are refused, and the error says which rule the URL broke. enabled defaults to true; sending false lets you test the endpoint before it starts receiving ticket events.
2. Store the secret#
secret is the signing secret for this endpoint: whsec_ followed by 64 hex characters. It is in this response and nowhere else — we cannot read it back to you afterwards, and GET never returns it. Store it as a secret on your side before doing anything else.
3. Send yourself a test event#
curl -X POST https://docs.pluggy.ai/api/partners/webhooks/3f0b8c1e-6a2d-4c3b-9e1f-2a7d5b8c9e01/test \
-H "Authorization: Bearer $PLUGGY_PARTNER_KEY"{
"delivered": true,
"responseCode": 200,
"error": null,
"eventId": "5d2e9a7b-0c4f-4e1a-b3d6-8f9a0b1c2d3e",
"durationMs": 143
}We send one webhook.test event to the endpoint right away, signed exactly like a ticket event, and tell you what your endpoint answered. It works on a disabled endpoint too. delivered: false comes with the status code or the reason — a timeout, a connection error, a redirect (we do not follow them). The call itself answers 200 either way; it is the attempt that failed, not the call. Run it until your endpoint verifies the signature and answers 2xx.
4. Enable it#
curl -X PATCH https://docs.pluggy.ai/api/partners/webhooks/3f0b8c1e-6a2d-4c3b-9e1f-2a7d5b8c9e01 \
-H "Authorization: Bearer $PLUGGY_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{ "enabled": true }'From that moment, every change on a ticket that belongs to your account produces an event. That is the whole setup.
If a response carries "routed": false and a warning, your endpoints are saved but ticket events cannot reach your account yet — contact us and we will finish setting it up on our side.
Renewing a secret#
If a secret is lost or leaked, issue a new one:
curl -X PATCH https://docs.pluggy.ai/api/partners/webhooks/3f0b8c1e-6a2d-4c3b-9e1f-2a7d5b8c9e01 \
-H "Authorization: Bearer $PLUGGY_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{ "rotateSecret": true }'The new secret comes back in secret, once, like at creation. It replaces the old one immediately — every delivery from then on is signed with it — so cut over in one step. Sending url in a PATCH also issues a new secret, even when it is the same URL the endpoint already has; changing only description or enabled keeps the current one.
More than one endpoint#
You can register up to 10 endpoints — one per environment, or one per system that consumes the events. Each has its own secret, so verify every delivery with the secret of the endpoint that received it.
Every event goes to every enabled endpoint. There is no per-endpoint filter: a homologation endpoint and a production endpoint both receive all of your tickets' events. A disabled endpoint receives nothing, and events that happen while it is disabled are not kept for it — enabling it again only affects what happens next.
| Call | What it does |
|---|---|
GET /api/partners/webhooks | Your endpoints, the events they receive and whether events can reach your account. No secrets. |
POST /api/partners/webhooks | Register an endpoint. Returns its secret, once. |
GET /api/partners/webhooks/{id} | One endpoint. No secret. |
PATCH /api/partners/webhooks/{id} | Change url, description or enabled, or renew the secret with rotateSecret. |
DELETE /api/partners/webhooks/{id} | Remove it. Deliveries still waiting to be retried to it are dropped. To pause instead, PATCH it with enabled: false. |
POST /api/partners/webhooks/{id}/test | Send a signed webhook.test to it now. |
The full schemas are in the Partner Support API reference. If you would rather not do this yourself, ask us — we can also configure it for you.
Answering deliveries#
Your endpoint should answer any 2xx as soon as it has durably accepted the event — queue it and process asynchronously. We wait 8 seconds for a response; anything slower is treated as a failed delivery and retried. Redirects are not followed: a 3xx is a failed delivery, so register the final URL.
The payload#
Every ticket event has the same envelope (the webhook.test event you ask for is smaller, see below):
| Field | Meaning |
|---|---|
eventId | UUID, unique per event and stable across retries. Your idempotency key. Also sent as the X-Pluggy-Event-Id header. |
sequence | Monotonic, assigned when the event is created. Increasing sequence is creation order — it is not delivery order, see below. |
event | One of the five ticket event types below. |
occurredAt | When we observed the change. A retry does not move it. |
ticket | The ticket after the change: key, number (our desk number), externalId, openedBy, itemId, derivedFromItem, title, state, status, updatedAt, portalUrl, and priority where we know it. |
previous | Present only where there is a previous value — state/status, priority or title. |
message | Present only on ticket.message_added. |
The ticket block is deliberately small. It tells you what moved; GET /api/partners/tickets/{key} is the authority on everything else.
externalId and openedBy are your own: the id you sent when you opened the ticket and who you said opened it, handed back in exactly the shape GET /api/partners/tickets/{key} returns them. They are there so you can join the event to your own record without parsing the title. Both are always present and null when we have none — a ticket opened without them, or before we started storing them. They are also null on a ticket another organization opened and shares with yours: you still receive its events, but its externalId and openedBy are theirs.
itemId and derivedFromItem (the connector name and whether it is Open Finance) tell you which connection the ticket is about, for tickets opened through the API and in the portal. Only the organization that owns the ticket receives them: on a ticket opened in the portal and shared by several organizations they are null. They are also null for a few minutes after a portal ticket is opened, while we read its form, and on payment tickets. GET /api/partners/tickets and GET /api/partners/tickets/{key} return the same two fields plus state.
state is the coarse lifecycle bucket and is one of new, on_you, on_customer, on_hold or closed. status is the readable label that goes with it — New, On you, On customer, Aguardando Detentora, Aguardando Engenharia, Closed — and a status we have not mapped yet comes through as itself, so match on state and treat status as display text.
Every timestamp is UTC, RFC 3339, Z-suffixed. ticket.updatedAt and message.sentAt originate in the ticket system, which reports them with an offset; we normalise them before sending so one payload never carries two formats.
Verifying the signature#
X-Pluggy-Signature is formatted t=<unix seconds>,v1=<hmac hex>. The signed string is "{t}.{raw body}" — the request body exactly as it arrived on the wire, before any JSON parse. Re-serialising the parsed object changes the bytes and the signature will not match.
t is also the anti-replay token: reject a request whose t is more than five minutes old.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyPluggySignature(rawBody, header, secret) {
// header: "t=1758202927,v1=6f9c…"
const parts = new Map(
String(header ?? "")
.split(",")
.map((part) => {
const i = part.indexOf("=");
return [part.slice(0, i).trim(), part.slice(i + 1).trim()];
}),
);
const timestamp = Number(parts.get("t"));
const received = parts.get("v1");
if (!Number.isFinite(timestamp) || !received) return false;
// Anti-replay: an old signature is still a valid signature.
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(received, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}Wired into an Express app, keeping the raw body:
import express from "express";
const app = express();
app.post(
"/pluggy/tickets",
express.raw({ type: "application/json" }),
(req, res) => {
const raw = req.body.toString("utf8"); // the bytes we signed
if (!verifyPluggySignature(raw, req.get("X-Pluggy-Signature"), process.env.PLUGGY_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(raw);
enqueue(event); // de-duplicate on event.eventId, then process
res.status(200).end();
},
);A worked check you can run against a delivery you captured: createHmac("sha256", secret).update(t + "." + rawBody).digest("hex") must equal the v1 value, character for character.
Events#
ticket.status_changed#
The ticket moved in the workflow — state, the portal status, or both. previous carries what they were.
{
"eventId": "0f2b6c4e-7a1d-4a2e-9c3f-5b8d1e0a7c44",
"sequence": 1042,
"event": "ticket.status_changed",
"occurredAt": "2026-09-18T13:42:07.912Z",
"ticket": {
"key": "SUP2-1234",
"number": 418,
"externalId": "YOUR-1947",
"openedBy": { "email": "ana@yourcompany.com", "name": "Ana Souza" },
"title": "[YourOrg/Itaú] Transação não retornada (YOUR-1947)",
"state": "on_you",
"status": "On you",
"updatedAt": "2026-09-18T13:41:58Z",
"portalUrl": "https://pluggy.atlassian.net/servicedesk/customer/portal/1/SUP2-1234"
},
"previous": { "state": "new", "status": "New" }
}ticket.resolved#
The ticket closed. It is sent instead of ticket.status_changed, never in addition: one real-world change produces one event, so a consumer that acts on both does not act twice. There is no previous block.
{
"eventId": "7c1a55d2-3e64-4b0a-8f2d-9a0c6e4b21f7",
"sequence": 1043,
"event": "ticket.resolved",
"occurredAt": "2026-09-18T18:05:31.004Z",
"ticket": {
"key": "SUP2-1234",
"number": 418,
"externalId": "YOUR-1947",
"openedBy": { "email": "ana@yourcompany.com", "name": "Ana Souza" },
"title": "[YourOrg/Itaú] Transação não retornada (YOUR-1947)",
"state": "closed",
"status": "Closed",
"updatedAt": "2026-09-18T18:05:12Z",
"portalUrl": "https://pluggy.atlassian.net/servicedesk/customer/portal/1/SUP2-1234"
}
}ticket.message_added#
A reply from our support team was published on the ticket. This is the only event that carries content: message.text is the reply as written, in plain text.
ticket.updatedAt on this event is the moment the message was sent.
{
"eventId": "b83f0c19-55ad-4c6e-9f41-2d7e8ab10c53",
"sequence": 1044,
"event": "ticket.message_added",
"occurredAt": "2026-09-18T15:20:44.881Z",
"ticket": {
"key": "SUP2-1234",
"number": 418,
"externalId": "YOUR-1947",
"openedBy": { "email": "ana@yourcompany.com", "name": "Ana Souza" },
"title": "[YourOrg/Itaú] Transação não retornada (YOUR-1947)",
"state": "on_you",
"status": "On you",
"updatedAt": "2026-09-18T15:20:31Z",
"portalUrl": "https://pluggy.atlassian.net/servicedesk/customer/portal/1/SUP2-1234"
},
"message": {
"author": "Pluggy Support",
"text": "Identificamos a causa na coleta da fatura fechada e o ajuste entrou em produção hoje. Pode validar nos itens afetados e nos confirmar?",
"sentAt": "2026-09-18T15:20:31Z"
}
}ticket.priority_changed#
The ticket's priority changed. previous.priority is the value it had before.
The new value is ticket.priority, and previous.priority is what it was. Both are the ticket system's own names (Highest, High, Medium, Low), which are not the P0 — Crítico … P3 — Baixo labels the create endpoint accepts — the same concept in two vocabularies, which we would rather tell you than have you discover.
The first time we ever observe a priority on a ticket is not a change, and produces no event.
{
"eventId": "2ad9e5b7-1c08-49f3-b6a1-70c2d4e95f18",
"sequence": 1045,
"event": "ticket.priority_changed",
"occurredAt": "2026-09-18T14:02:19.377Z",
"ticket": {
"key": "SUP2-1234",
"number": 418,
"externalId": "YOUR-1947",
"openedBy": { "email": "ana@yourcompany.com", "name": "Ana Souza" },
"title": "[YourOrg/Itaú] Transação não retornada (YOUR-1947)",
"state": "on_you",
"status": "On you",
"updatedAt": "2026-09-18T14:02:05Z",
"portalUrl": "https://pluggy.atlassian.net/servicedesk/customer/portal/1/SUP2-1234"
},
"previous": { "priority": "Medium" }
}ticket.updated#
The ticket's title changed. Not sent when the ticket closed in the same pass — the resolution is the event that matters there.
{
"eventId": "e40b7f36-9c52-4a8d-83b1-6f0a2c5d7e91",
"sequence": 1046,
"event": "ticket.updated",
"occurredAt": "2026-09-18T16:11:02.560Z",
"ticket": {
"key": "SUP2-1234",
"number": 418,
"externalId": "YOUR-1947",
"openedBy": { "email": "ana@yourcompany.com", "name": "Ana Souza" },
"title": "[YourOrg/Itaú] Transações do cartão não retornadas (YOUR-1947)",
"state": "on_you",
"status": "On you",
"updatedAt": "2026-09-18T16:10:47Z",
"portalUrl": "https://pluggy.atlassian.net/servicedesk/customer/portal/1/SUP2-1234"
},
"previous": { "title": "[YourOrg/Itaú] Transação não retornada (YOUR-1947)" }
}webhook.test#
Sent only when you call POST /api/partners/webhooks/{id}/test, and only to that endpoint — never on its own, never because a ticket moved. It carries the same headers and the same signature as a ticket event, so it proves your verification end to end, but it has no ticket block: endpoint says which of your endpoints it was sent to.
Verify it, answer 2xx, and do nothing else with it. A consumer that routes on event should ignore a type it does not know rather than fail on it.
{
"eventId": "5d2e9a7b-0c4f-4e1a-b3d6-8f9a0b1c2d3e",
"event": "webhook.test",
"occurredAt": "2026-10-27T12:05:00.000Z",
"endpoint": {
"id": "3f0b8c1e-6a2d-4c3b-9e1f-2a7d5b8c9e01",
"url": "https://hooks.yourcompany.com/pluggy/tickets"
}
}A test is sent once and is not retried: if it fails, fix the endpoint and send another.
Idempotency#
De-duplicate on eventId. It is a UUID, unique per event, and it does not change when we retry — a resend of the same event carries the same id. The same id arriving twice means the same change, not two changes.
You will see repeats in normal operation. Two independent paths notice a ticket moved (a live notification and a periodic full comparison, which is what makes the mirror unmissable), and a manual re-send by our team reuses the original eventId on purpose.
Treat an event whose id you already processed as a no-op and answer 2xx — a 409 from your side looks like a failed delivery to us and gets retried.
Ordering#
Ordering is not guaranteed. A retried event arrives after events created later than it, and two changes seconds apart can land in either order. sequence tells you which event was created first, so you can discard one you have already superseded — but it is not delivery order, and you should not reconstruct state from the stream. When it matters, read the ticket.
The rule that keeps a consumer correct: read the ticket when you get an event. The event tells you that something moved; GET /api/partners/tickets/{key} tells you what is true now.
Retries#
A delivery succeeds on any 2xx. Any other status, a connection error, or no response within 8 seconds counts as a failure and is retried with backoff:
| Attempt | Sent |
|---|---|
| 1 | Immediately, when the event is created |
| 2 | ~30 seconds later |
| 3 | ~2 minutes later |
| 4 | ~10 minutes later |
| 5 | ~1 hour later |
| 6 | ~6 hours later |
Six attempts in total. After the sixth failure the delivery is marked failed and is not retried automatically — ask us and we can re-send it by hand, with the same eventId.
The delays are when a retry becomes due; the retry pass runs on a schedule of its own, so the first couple of attempts can land somewhat later than the nominal delay. Every attempt is recorded on our side — what we sent, what came back, and how many times we tried — so "did you actually send it?" has an answer that is not a guess.
Reconciling a missed delivery#
If your endpoint was down longer than the retry window, or you want to check that nothing slipped through, GET /api/partners/tickets/{key}/events returns the ticket's history — status, priority and deadline changes, our public replies and the resolution, oldest first, each with its date. It uses the same event names as the webhook (ticket.status_changed, ticket.priority_changed, ticket.message_added, ticket.resolved), so you can match what you received against it. One difference to expect: a status there is the name the support portal shows — the same one GET /api/partners/tickets/{key} returns — not the webhook's state bucket, and its time is when the change was made rather than our occurredAt. Like every read, it does not count against your ticket quota.
Checklist before going live#
- Verify the signature over the raw body, and reject on mismatch.
- Reject a
tolder than five minutes. - De-duplicate on
eventIdbefore doing any work. - Answer
2xxfast, process asynchronously. - Re-read the ticket instead of trusting event order.
- Reconcile against
GET /api/partners/tickets/{key}/eventsafter an outage on your side. - Send a
webhook.testto every endpoint and see it verified before enabling it. - Keep the secret out of source control, and renew it with
PATCH … { "rotateSecret": true }if it leaks.
