# Pleo Webhooks

[Pleo](https://www.pleo.io) is a spend management platform. Its webhooks notify accounting and ERP integrations when, for example, a user exports expenses or creates a vendor. You subscribe through the **Subscriptions API** (`POST /v1/subscriptions`), and Pleo delivers each event through **Svix** with Standard Webhooks headers.

## When to Use This Skill

* How do I receive Pleo webhooks?
* How do I verify a Pleo webhook signature?
* Where do I get my Pleo webhook secret?
* How do I handle `v1.export-job.created` or `v1.vendor.created`?
* Why is my Pleo `webhook-signature` verification failing?
* How do I create a Pleo webhook subscription?

## Signature at a Glance

| Property | Value |
| --- | --- |
| Headers | `webhook-id`, `webhook-timestamp`, `webhook-signature` |
| Signature header format | Space-delimited list of `v1,<base64>` |
| Algorithm | HMAC-SHA256, base64 (inferred from Svix delivery; Pleo's docs don't state it) |
| Signed content | `{webhook-id}.{webhook-timestamp}.{raw body}` (Svix scheme) |
| Secret | Per subscription, `whsec_...`, from `GET /v1/subscriptions/{id}/secret` → `data.secretKey` |
| Key | Base64-decode the part after `whsec_` |
| Timestamp | Reject if more than ~5 minutes off |
| Alternatives | Basic auth or custom headers, set on the subscription (fallbacks only) |

## Verification (core)

Pleo has no webhook SDK. Use the Standard Webhooks library (`standardwebhooks` on npm and PyPI), which checks the timestamp, recomputes the HMAC and compares in constant time against every `v1,` entry. **Pass the raw body**, not re-serialised JSON.

```javascript
const { Webhook } = require('standardwebhooks');

// rawBody: express.raw() buffer as string, or await request.text() in Next.js
function verifyPleoWebhook(rawBody, headers) {
  const wh = new Webhook(process.env.PLEO_WEBHOOK_SECRET); // full "whsec_..." value
  return wh.verify(rawBody, {
    'webhook-id': headers['webhook-id'],
    'webhook-timestamp': headers['webhook-timestamp'],
    'webhook-signature': headers['webhook-signature'],
  }); // returns { data, eventId, eventType }; throws -> respond 400
}

```

Python: `from standardwebhooks.webhooks import Webhook`, then `Webhook(secret).verify(await request.body(), headers)`. For a dependency-free version, see [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/pleo-webhooks/references/verification.md).

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/pleo-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/pleo-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/pleo-webhooks/examples/fastapi/).

## Event Payload

```json
{
  "data": { "id": "b4c4d068-...", "companyId": "b5fdaec1-...", "numberOfItems": 1, "status": "pending", "...": "..." },
  "eventId": "a257a31e-e8a6-4c36-a98d-451250e5b5c4",
  "eventType": "v1.export-job.created"
}

```

`data` is the resource as it was when the event fired. Switch on `eventType`.

## Common Event Types

| Event | Triggered When |
| --- | --- |
| `v1.export-job.created` | A user clicks **Export** in Pleo; the export job starts in `pending` |
| `v1.vendor.created` | A vendor is added in Pleo that isn't in the accounting system yet (`state: "DRAFT"`) |

Pleo's events page prose spells the first one `v1.export.job-created`, but payloads and the Subscriptions API use `v1.export-job.created`. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/pleo-webhooks/references/overview.md) for payload fields.

## Delivery Semantics

* Return a `2xx` to acknowledge. Anything else, including `3xx`, is a failure.
* Retries back off over about 27.5 hours (immediately, 5s, 5m, 30m, 2h, 5h, 10h, 10h), then the message is dropped. Pleo suggests a backstop poll.
* Retries keep the same `webhook-id`. Dedupe on it and return `200` for duplicates.
* Firewalled endpoints should allowlist the Svix source IPs.

## Environment Variables

```bash
PLEO_WEBHOOK_SECRET=whsec_xxxxx   # from GET /v1/subscriptions/{id}/secret; handlers fail closed without it

```

## Local Development

Receive webhooks locally with the Hookdeck CLI. No install or account required:

```bash
npx hookdeck-cli listen 3000 pleo --path /webhooks/pleo

```

Use the printed URL as `endpointUrl` when creating the subscription, then trigger an export or create a vendor in Pleo. FastAPI examples use port `8000`.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/pleo-webhooks/references/overview.md) - Event types, payload fields, delivery and retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/pleo-webhooks/references/setup.md) - Create a subscription, fetch the secret, Basic auth and custom header fallbacks, IPs
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/pleo-webhooks/references/verification.md) - Library and manual verification, gotchas, debugging