Guide to Pennylane Webhooks: Features and Best Practices
Pennylane webhooks tell your integration when something changes in a Pennylane company: a customer invoice's e-invoicing status moves on, a supplier invoice arrives through e-invoicing, or a file is added to the document management system (DMS). If you build on Pennylane, the French accounting and finance platform, webhooks replace polling the API for those changes.
This guide covers how Pennylane subscriptions are created and scoped, how deliveries are signed, the events and their envelope, the status codes that switch a subscription off, and the practices that keep a Pennylane receiver reliable.
What are Pennylane webhooks?
A Pennylane webhook subscription has an HTTPS callback URL, a list of events and a signing secret. You create it through the API v2, and Pennylane then sends a JSON POST to the callback URL whenever a subscribed event happens. Pennylane flags the feature as Beta and recommends falling back on its changelog endpoints during this phase.
Every delivery carries an X-Pennylane-Signature header in the form t=<timestamp>,v1=<signature>. t is a Unix timestamp in seconds, and v1 is the hex HMAC-SHA256 of {t}.{raw request body}, keyed with the subscription's secret.
Who receives what depends on the token that created the subscription. A client-credentials OAuth token creates an app-wide subscription that delivers for every company your app can access. A company developer token creates a subscription for that one company.
Pennylane webhook features
| Feature | Details |
|---|---|
| Status | Beta |
| Configuration | API only: /api/external/v2/webhook_ (create, list, get, update, delete) |
| Scopes | companies:webhook_ to manage, plus a read scope per event, checked at delivery time |
| Scoping | App-wide (client-credentials token) or one company (developer token) |
| Signature header | X-Pennylane-Signature: t=<unix seconds>,v1=<hex> |
| Scheme | HMAC-SHA256, hex |
| Signed content | {t}.{raw body} |
| Secret | One per subscription, returned once by the create call |
| Replay window | Optional and receiver-chosen. Pennylane calls 5 minutes "a common default" |
| Response window | 2xx "within a few seconds" |
| Delivery | Asynchronous, unordered, at least once |
| Auto-disable | Immediately on 401, 403, 404 or 410, and after repeated failures |
| Source IPs | 34.242.41.54, 54.220.249.5, 54.75.175.133, 54.73.179.170, 52.17.78.48, 52.215.138.198 |
Pennylane doesn't publish its retry schedule or an exact timeout.
Common events
Pennylane documents three events, and the list is likely to grow while webhooks are in Beta.
| Event | Fires when | Required scope |
|---|---|---|
customer_ | A customer invoice's e-invoicing (PDP) status changes | customer_ or customer_invoices:all |
dms_file.created | A DMS file is created | dms_files:readonly or dms_files:all |
supplier_ | A supplier invoice is received via e-invoicing (the invoicing hub) | supplier_ or supplier_invoices:all |
The e-invoicing status can be accepted, approved, collected, in_dispute, partially_collected, refused, rejected, sent or submitted.
Every delivery uses the same envelope. Here is Pennylane's customer_ example:
{
"id": 987654,
"event": "customer_invoice.e_invoicing_status_updated",
"retry_count": 0,
"created": 1657875952,
"data": {
"context": {
"company_id": 12345,
"firm_id": 678
},
"object": {
"id": 555001,
"e_invoicing": {
"status": "accepted"
}
},
"previous_attributes": {
"e_invoicing": {
"status": "submitted"
}
}
}
}
idis the unique delivery ID. Pennylane says to deduplicate on it.eventis the event name. Route on it.retry_countis0on the first attempt and counts retries after that.createdis when the event occurred, in Unix seconds.data.contextnames the company and firm. Its envelope example uses strings such as"abc-123"and the event examples use numbers, so don't assume a type.data.objectis the event payload, usually little more than the resourceid. Fetch the invoice or file from the API for the rest.data.previous_attributesappears only on update events. On the first status transition, the previous status isnull.
Pennylane's envelope table also says the payload is "nested at data.data", but the same table and every example put it at data.object. Read data.object.
Setting up Pennylane webhooks
- Get a token with the
companies:webhook_scope. For an OAuth app, request a client-credentials token with that scope. If you getsubscriptions:all 403 Forbidden, ask Pennylane to authorise your app for it. For a single company, create a developer token under Company Settings > Connectivity > Developer tokens with the Webhook subscriptions scope and the resource scopes your events need. A developer token's scopes can't be changed after creation. - Create the subscription:
curl -X POST https://app.pennylane.com/api/external/v2/webhook_subscriptions \
-H "Authorization: Bearer $PENNYLANE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"callback_url": "https://example.com/webhooks/pennylane",
"events": ["customer_invoice.e_invoicing_status_updated", "dms_file.created"]
}'
- Store the
secretfrom the response straight away, for example asPENNYLANE_WEBHOOK_SECRET. Pennylane returns it only once and it can't be retrieved later.
The callback URL must be HTTPS. You can update a subscription's callback URL, events or enabled flag, and list or get subscriptions to see enabled, disabled_at, disabled_reason, last_failure_status and consecutive_failures.
The resource scope check happens when an event fires, not when you subscribe. If the credential lacks the scope at that moment, or a developer token is revoked or expired, Pennylane skips the delivery. Nothing tells your endpoint it missed an event.
If you receive Pennylane webhooks through Hookdeck, use the Hookdeck source URL as the callback_url and paste the subscription's secret into the Pennylane source.
Securing Pennylane webhooks
Split X-Pennylane-Signature on commas and each part on its first =. Build {t}.{raw body} with t exactly as sent, compute the hex HMAC-SHA256 with the subscription secret as the key, and compare it with v1 in constant time.
Pennylane describes a freshness check as optional replay protection, with a tolerance you choose. It is optional in the code below and off by default, and the Pennylane skill also accepts retries and replays by default, with the window behind an opt-in PENNYLANE_ setting. If you turn the window on, Pennylane's retries and any retry or replay from Hookdeck keep the original t, so late ones fail it even though their signatures are valid.
const crypto = require('crypto');
const express = require('express');
function verifyPennylaneSignature(rawBody, signatureHeader, secret, toleranceSeconds = 0) {
if (!signatureHeader || !secret) return false;
let t;
const signatures = [];
for (const part of signatureHeader.split(',')) {
const i = part.indexOf('=');
if (i <= 0) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === 't') t = value;
if (key === 'v1') signatures.push(value.toLowerCase());
}
if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false;
// Optional replay check, off by default.
if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) {
return false;
}
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'),
'utf8'
);
return signatures.some((signature) => {
const received = Buffer.from(signature, 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
app.post('/webhooks/pennylane', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.PENNYLANE_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyPennylaneSignature(req.body, req.get('x-pennylane-signature'), secret)) {
return res.sendStatus(400); // not 401: a 401 disables the subscription
}
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on event.id, route on event.event, then process asynchronously.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
import time
def verify_pennylane_signature(raw_body: bytes, signature_header, secret: str, tolerance_seconds: int = 0) -> bool:
if not signature_header or not secret:
return False
t, signatures = None, []
for part in signature_header.split(","):
key, sep, value = part.partition("=")
if not sep:
continue
key, value = key.strip(), value.strip()
if key == "t":
t = value
elif key == "v1":
signatures.append(value.lower())
if not t or not (t.isascii() and t.isdigit()) or not signatures:
return False
# Optional replay check, off by default.
if tolerance_seconds > 0 and abs(time.time() - int(t)) > tolerance_seconds:
return False
expected = hmac.new(secret.encode("utf-8"), t.encode("utf-8") + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s.encode("utf-8"), expected.encode("utf-8")) for s in signatures)
# In FastAPI: raw_body = await request.body()
# verify_pennylane_signature(raw_body, request.headers.get("x-pennylane-signature"),
# os.environ["PENNYLANE_WEBHOOK_SECRET"])
To check your verifier, use the secret pennylane-guide-test-secret, a t of 1704067200 and this exact body:
{"id":987656,"event":"dms_file.created","retry_count":0}
The header should be:
X-Pennylane-Signature: t=1704067200,v1=d038976866539a19225bb26cd7335ce634156992c52933466f1cacdfe16cafdf
If your network requires it, you can also allowlist Pennylane's six NAT gateway IPs on port 443. Pennylane describes this as an optional extra layer and recommends signature verification for authenticity.
Make Pennylane webhooks production-ready. Hookdeck Event Gateway verifies the
X-Pennylane-Signatureheader on every delivery, answers Pennylane within its short window, and queues each invoicing and document event until your service has processed it.
Pennylane webhook limitations and pain points
The wrong status code switches the subscription off
The Problem: Deliveries stop after a deploy or a configuration mistake, and they don't resume on their own.
Why It Happens: Pennylane treats 401, 403, 404 and 410 as permanent errors and disables the subscription at once, with disabled_. A verifier that returns 401 for a bad signature, an auth layer in front of your route, or a route that moved can each switch the subscription off with a single response.
Workarounds:
- Return
400for a failed signature, and5xxfor temporary problems on your side. - Poll the subscription and alert when
enabledbecomesfalse. Re-enable it with an update once the cause is fixed.
How Hookdeck Can Help: Hookdeck answers Pennylane itself and keeps each delivery, so whatever status your own service returns never reaches Pennylane, and a bad deploy can't disable the subscription.
Skipped deliveries you never see
The Problem: An event happens in Pennylane but your endpoint never receives it.
Why It Happens: Pennylane checks the resource scope when the event fires. A missing scope, a revoked developer token or an expired token without a refresh token makes Pennylane skip the delivery rather than fail it.
Workarounds:
- Give the credential every scope listed on the event's page before relying on the subscription.
- Rotate developer tokens deliberately, and reconcile with the changelog endpoints after any token change.
How Hookdeck Can Help: Hookdeck's request log shows every delivery that did arrive for each event type, so a gap in one event's traffic is easy to spot.
The secret is shown once
The Problem: You lose the secret and can't verify deliveries.
Why It Happens: The create response is the only place the secret appears. Pennylane says it "cannot be retrieved later".
Workarounds:
- Store the secret in a secrets manager in the same step that creates the subscription.
- If you lose it, create a new subscription, deploy its secret, then delete the old one. Deduplicate on
idduring the overlap.
How Hookdeck Can Help: With Hookdeck in front, the secret lives on one source rather than in every service that handles Pennylane events.
Beta, with a short and changing event list
The Problem: The webhooks don't cover everything you need, and their behaviour may change.
Why It Happens: The feature is in Beta, with three events. Parts of the docs disagree, such as the data.data note and the type of company_id.
Workarounds:
- Keep the changelog endpoints as a fallback, as Pennylane recommends, and reconcile with them periodically.
- Read
data.object, accept both string and number IDs, and log unknown event names rather than failing.
How Hookdeck Can Help: Hookdeck stores the full body of every delivery, so you can check what Pennylane actually sends against what the docs say.
Best practices
Verify against the raw body
Pennylane warns that parsing and re-serialising the JSON changes the bytes. Use express.raw({ type: '*/*' }) in Express, await request.text() in a Next.js route handler and await request.body() in FastAPI.
Deduplicate on the delivery id
Delivery is at least once. Pennylane gives the example of an endpoint that acknowledges slowly but eventually succeeds, which then receives the delivery again. Record the envelope id before processing. See our guide to webhook idempotency.
Don't rely on arrival order
Deliveries are unordered. Use the statuses in the payload, previous_attributes and created to reconcile state, and when in doubt fetch the invoice from the API.
Acknowledge within a few seconds
A slow response counts as a timeout and is retried. Verify, persist, return 200, and fetch details from the API afterwards. See why to process webhooks asynchronously.
Choose the right token type
For an integration used by many companies, a client-credentials token gives you one app-wide subscription and one secret. data.context.company_id then tells you which company each event belongs to.
Conclusion
Pennylane signs each delivery with X-Pennylane-Signature: t=...,v1=..., a hex HMAC-SHA256 of the timestamp and raw body keyed with the subscription secret. Store the secret when you create the subscription, verify against the raw bytes, deduplicate on the envelope id, and never answer with a 401, 403, 404 or 410, because any of those switches the subscription off.
Hookdeck Event Gateway sits in front of your receiver, checks every Pennylane signature, and holds each event until your service has processed it, so a mistake on your side doesn't disable the subscription.
Get started with Hookdeck for free and handle Pennylane webhooks reliably in minutes.