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:
{
"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
- 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.
- Create a subscription:
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"
}'
- Read the subscription's secret with
GET /v1/subscriptions/{id}/secretand storedata.secretKeyserver-side, for example asPLEO_WEBHOOK_SECRET. - 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.
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:
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_, the headers webhook-id: msg_ 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 verifies the
webhook-signatureon every Pleo delivery, deduplicates oneventId, 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.createdandv1.vendor.createdby those exact names. - Route on
eventTypeand acknowledge anything unknown with a2xx.
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
2xxas 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 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.
Make handlers idempotent
Processing the same event twice should have the same result as processing it once. See our guide to 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 verifies every Pleo signature, stores each event before your integration sees it, and replays anything your service missed.
Get started with Hookdeck for free and handle Pleo webhooks reliably in minutes.