# Support Ticket Webhooks

## Overview

If you open tickets through the [Partner Support API](/partners), you do not have to poll `GET /api/partners/tickets` to learn that a case moved. Give us an HTTPS URL 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
```

<Callout variant="info" title="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.
</Callout>

## Getting configured

1. **Send us an HTTPS URL.** One per environment if you want to validate against a separate consumer first. Plain HTTP is rejected.
2. **We return a signing secret**, of the form `whsec_` followed by 64 hex characters. It is shown **once**, at generation time, and we cannot read it back to you afterwards — store it as a secret on your side. If it is lost or leaked, ask us to rotate it; the new secret replaces the old one immediately, so cut over in one step.
3. **We enable delivery.** From that moment, every change on a ticket that belongs to your account produces an event.

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.

## The payload

Every delivery has the same envelope:

| 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 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), `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.

`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.

```js
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:

```js
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.

```json
{
  "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,
    "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.

```json
{
  "eventId": "7c1a55d2-3e64-4b0a-8f2d-9a0c6e4b21f7",
  "sequence": 1043,
  "event": "ticket.resolved",
  "occurredAt": "2026-09-18T18:05:31.004Z",
  "ticket": {
    "key": "SUP2-1234",
    "number": 418,
    "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.

```json
{
  "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,
    "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.

```json
{
  "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,
    "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.

```json
{
  "eventId": "e40b7f36-9c52-4a8d-83b1-6f0a2c5d7e91",
  "sequence": 1046,
  "event": "ticket.updated",
  "occurredAt": "2026-09-18T16:11:02.560Z",
  "ticket": {
    "key": "SUP2-1234",
    "number": 418,
    "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)" }
}
```

## 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.

## Checklist before going live

- Verify the signature over the **raw body**, and reject on mismatch.
- Reject a `t` older than five minutes.
- De-duplicate on `eventId` before doing any work.
- Answer `2xx` fast, process asynchronously.
- Re-read the ticket instead of trusting event order.
- Keep the secret out of source control, and tell us if it needs rotating.