Gareth Wilson Gareth Wilson

Guide to Pleo Webhooks: Features and Best Practices

Published


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

FeatureDetails
AccessDeveloper Partners, with the customer's JWT token or API key
ConfigurationSubscriptions API: POST https://external.pleo.io/v1/subscriptions. No dashboard
ScopeOne entity per token or API key, so one subscription per entity
EndpointPublic HTTPS URL that accepts a JSON POST
Signature headerswebhook-id, webhook-timestamp, webhook-signature
SecretOne per subscription, whsec_ prefixed, from GET /v1/subscriptions/{id}/secret
AlternativesBasic auth (endpointAuth) or custom headers (customHeaders)
Event nameeventType in the body
Deduplication keyeventId in the body, or the webhook-id header, which stays the same when a message is resent
SuccessA 2xx. Any other code, including 3xx, is a failure
RetriesImmediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours
Source IPsSvix's published source IP list

Pleo does not state a response timeout.

Common events

Pleo documents two events:

EventSent when
v1.export-job.createdA user clicks Export in Pleo and an export job starts in pending status
v1.vendor.createdA 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

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

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


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.