# Guide to Pleo Webhooks: Features and Best Practices

Pleo webhooks tell a partner integration when something happens in a customer's Pleo account that the integration needs to act on: a user starts an accounting export, or a new vendor is created that doesn't exist in the ERP yet. If you build an accounting or ERP integration on Pleo, webhooks let you react to these events as they happen instead of polling Pleo's API.

This guide covers how Pleo subscriptions are created, how deliveries are signed, the two documented events, Pleo's retry schedule, and the best practices for running a Pleo receiver in production.

## What are Pleo webhooks?

Pleo webhooks are for Developer Partners, the third-party applications integrated with Pleo. You subscribe to events per customer with the Subscriptions API, and Pleo sends each matching event as a JSON `POST` to your endpoint.

Delivery runs on Svix. Each request carries three headers: `webhook-id`, `webhook-timestamp` and `webhook-signature`. The body has three fields: `data`, `eventId` and `eventType`, where `data` is "an exact representation of the resource" when the event was generated.

## Pleo webhook features

| Feature | Details |
| --- | --- |
| Access | Developer Partners, with the customer's JWT token or API key |
| Configuration | Subscriptions API: `POST https://external.pleo.io/v1/subscriptions`. No dashboard |
| Scope | One entity per token or API key, so one subscription per entity |
| Endpoint | Public HTTPS URL that accepts a JSON `POST` |
| Signature headers | `webhook-id`, `webhook-timestamp`, `webhook-signature` |
| Secret | One per subscription, `whsec_` prefixed, from `GET /v1/subscriptions/{id}/secret` |
| Alternatives | Basic auth (`endpointAuth`) or custom headers (`customHeaders`) |
| Event name | `eventType` in the body |
| Deduplication key | `eventId` in the body, or the `webhook-id` header, which stays the same when a message is resent |
| Success | A `2xx`. Any other code, including `3xx`, is a failure |
| Retries | Immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours |
| Source IPs | Svix's published source IP list |

Pleo does not state a response timeout.

## Common events

Pleo documents two events:

| Event | Sent when |
| --- | --- |
| `v1.export-job.created` | A user clicks **Export** in Pleo and an export job starts in `pending` status |
| `v1.vendor.created` | A vendor is created in Pleo that doesn't yet exist in the ERP or accounting system |

Pleo's overview page spells the export event `v1.export.job-created` in one place, but the payload examples and the events page both use `v1.export-job.created`. Subscribe and route on `v1.export-job.created`.

The subscription schema refers to an `EventType` enum that Pleo doesn't publish, so there may be more events than these two.

Here is the body of a `v1.export-job.created` event:

```json
{
  "data": {
    "companyId": "b5fdaec1-1b43-4b62-996a-b0471c3bb336",
    "createdAt": "2025-05-23T15:21:32Z",
    "createdBy": "e3118b03-691c-4bd4-944e-1a9c1f1031b7",
    "expiresIn": 3600,
    "id": "b4c4d068-ebbe-45b7-b562-2f16f0deb8b9",
    "numberOfItems": 1,
    "status": "pending"
  },
  "eventId": "a257a31e-e8a6-4c36-a98d-451250e5b5c4",
  "eventType": "v1.export-job.created"
}

```

A `v1.vendor.created` event carries the vendor in `data`, with fields such as `name`, `country`, `taxRegistrationNumber`, `state` (for example `DRAFT`) and `defaultCurrency`.

Pleo's docs print the headers and body together in one JSON object, with the body under `payload`. On the wire, the three `webhook-*` values are HTTP headers and the request body is the object above.

## Setting up Pleo webhooks

1. Make sure the customer's token or API key has read access to the API behind the event. For export events, that means the export jobs scopes. Scopes are set during the integration set-up and can't be changed when you create a subscription.
2. Create a subscription:

```bash
curl -X POST "https://external.pleo.io/v1/subscriptions" \
  -H "Authorization: Bearer $PLEO_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "ACTIVE",
    "eventTypes": ["v1.export-job.created", "v1.vendor.created"],
    "endpointUrl": "https://example.com/webhooks/pleo"
  }'

```

1. Read the subscription's secret with `GET /v1/subscriptions/{id}/secret` and store `data.secretKey` server-side, for example as `PLEO_WEBHOOK_SECRET`.
2. If your endpoint is behind a firewall, allow Svix's source IP addresses.

Each token or API key is issued for one entity. For a customer with several subsidiaries, create a subscription with each entity's own token or key. Pleo recommends creating subscriptions as part of the integration set-up.

## Securing Pleo webhooks

Pleo recommends verifying the signature and the timestamp, but doesn't describe the computation. Its headers, `v1,` base64 signature list and `whsec_` secret match Svix's documented Standard Webhooks scheme, which is what this code implements: build `{webhook-id}.{webhook-timestamp}.{raw body}`, compute an HMAC-SHA256 with the base64-decoded part of the secret after `whsec_`, base64-encode it, and compare it in constant time with each `v1,` entry in `webhook-signature`.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyPleoSignature(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatureHeader = headers['webhook-signature'];
  if (!id || !timestamp || !signatureHeader) return false;
  if (typeof secret !== 'string' || !secret.startsWith('whsec_')) return false;

  // The key is the base64-decoded part of the secret after "whsec_".
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  // The header can hold several space-separated "v1,<base64>" entries.
  return signatureHeader.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/pleo', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.PLEO_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyPleoSignature(req.body, req.headers, secret)) {
    return res.sendStatus(401);
  }

  // Optional: if your own policy needs it, reject webhook-timestamp values that are too old.
  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.eventId, route on event.eventType, then process async.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_pleo_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature_header = headers.get("webhook-signature")
    if not (msg_id and timestamp and signature_header):
        return False
    if not isinstance(secret, str) or not secret.startswith("whsec_"):
        return False

    key = base64.b64decode(secret[len("whsec_"):])
    signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode(), expected):
            return True
    return False

```

Pleo gives no timestamp tolerance. If you add a check on `webhook-timestamp`, choose a window that suits your own policy.

To check your verifier, use the secret `whsec_aG9va2RlY2stZ3VpZGUtdGVzdC1rZXktMzJieXRlcyE=`, the headers `webhook-id: msg_2kPleoTest0001` and `webhook-timestamp: 1761040800`, and this exact body:

```
{"eventId":"a257a31e-e8a6-4c36-a98d-451250e5b5c4","eventType":"v1.export-job.created","data":{}}

```

The `webhook-signature` header should be:

```
v1,ywdVAV5zilUmn7RfaoHsgikkZRbRJDqi/dq1fFGOFn4=

```

If you can't verify signatures, Pleo supports two alternatives. With `endpointAuth` of type `BASIC`, Pleo sends a username and password on each notification. With `customHeaders`, it sends headers you choose, such as an API key or bearer token.

> **Make Pleo webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `webhook-signature` on every Pleo delivery, deduplicates on `eventId`, and queues export and vendor events durably before your integration sees them.

## Pleo webhook limitations and pain points

### The signing scheme is implied, not stated

**The Problem:** You have to assume how the signature is computed.

**Why It Happens:** Pleo names the headers and gives a `whsec_` secret, but never states the hash or the signed content. Everything it does document matches Svix's Standard Webhooks scheme.

**Workarounds:**

* Verify with a Standard Webhooks or Svix library, or the code above.
* Check your verifier against a real delivery before you go live.

**How Hookdeck Can Help:** Hookdeck's Pleo source verifies `webhook-signature` with the Standard Webhooks scheme. Enter the subscription's `whsec_` secret and Hookdeck rejects requests that don't match.

### One subscription per entity

**The Problem:** A multi-entity customer's events only arrive for one subsidiary.

**Why It Happens:** Each JWT token or API key is issued for one entity, and a subscription only delivers events for the entity that created it.

**Workarounds:**

* Create a subscription with each entity's token or API key.
* Store each subscription's ID and secret against the entity, because each has its own secret.

**How Hookdeck Can Help:** Hookdeck sources each hold one secret, so a source per subscription verifies each entity's events before routing them to the same service.

### The event list may be incomplete

**The Problem:** You can't plan for events you don't know about.

**Why It Happens:** Pleo documents two events and refers to an `EventType` enum it doesn't publish. The overview prose also misspells one of the two.

**Workarounds:**

* Subscribe to `v1.export-job.created` and `v1.vendor.created` by those exact names.
* Route on `eventType` and acknowledge anything unknown with a `2xx`.

**How Hookdeck Can Help:** Hookdeck stores every event with its full body, so you can see exactly which `eventType` values Pleo sends.

### Retries end after about 27 hours

**The Problem:** An endpoint that's down for more than a day misses events for good.

**Why It Happens:** Pleo follows Svix's schedule of eight attempts, the last one roughly 27 and a half hours after the first. Pleo documents no replay for you to call afterwards.

**Workarounds:**

* Return a `2xx` as soon as the signature checks out and process the event afterwards.
* After an outage, reconcile through Pleo's APIs, such as `GET https://external.pleo.io/v2/export-jobs`.

**How Hookdeck Can Help:** Hookdeck accepts each event as soon as it arrives and stores it, so Pleo's first attempt succeeds even while your service is down. You then [retry](/docs/retries) and replay events on your own schedule.

## Best practices

### Deduplicate on `eventId`

Svix delivery means an event can arrive more than once. Store each `eventId` you process and skip repeats.

### Treat `data` as a snapshot

`data` shows the resource as it was when the event was generated. An export job starts in `pending`, so fetch the job from the API before you act on its final state.

### Return `2xx` only on success

Pleo treats any non-`2xx` response, including redirects, as a failure. Don't redirect the webhook URL.

### Acknowledge fast and process asynchronously

Return `200` after verification and queue the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make handlers idempotent

Processing the same event twice should have the same result as processing it once. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Pleo sends export job and vendor events to Developer Partners through Svix, with `webhook-id`, `webhook-timestamp` and `webhook-signature` headers and a `whsec_` secret per subscription. Verify with the Standard Webhooks scheme, create a subscription per entity, deduplicate on `eventId`, and fetch the resource before acting on a snapshot.

[Hookdeck Event Gateway](https://hookdeck.com) verifies every Pleo signature, stores each event before your integration sees it, and replays anything your service missed.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Pleo webhooks reliably in minutes.