Agent skill
Pennylane Webhooks Skill
Receive and verify Pennylane webhooks. Use when setting up Pennylane webhook handlers, debugging X-Pennylane-Signature verification, or handling accounting events such as customer_invoice.e_invoicing_status_updated, dms_file.created, and supplier_invoice.e_invoicing_received.
Install this skill
npx skills add hookdeck/webhook-skills --skill pennylane-webhooks
Pennylane is French accounting software. Its webhooks are in Beta.
When to Use This Skill
- How do I receive Pennylane webhooks?
- How do I verify the
X-Pennylane-Signatureheader? - How do I handle
customer_,invoice.e_ invoicing_ status_ updated dms_file.created, orsupplier_?invoice.e_ invoicing_ received - Why is my Pennylane webhook subscription disabled?
- Why is my Pennylane webhook signature verification failing?
Essential Code (USE THIS)
Every Pennylane delivery carries X-Pennylane-Signature: t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 (hex) of "<t>.<raw body>", keyed with the subscription secret as-is. Pennylane returns the secret only once, in the create-subscription response (scope companies:webhook_), so save it then. Pennylane's docs show a single v1 and don't describe secret rotation; the code below accepts the request if any v1 matches (harmless either way) and rejects a header with no t or no v1.
Verification (Node.js)
const crypto = require('crypto');
// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyPennylane(rawBody, header, secret) {
if (!secret || !header) return false;
let t;
const v1 = [];
for (const part of header.split(',')) { // "t=1,v1=ab" or "t=1, v1=ab"
const i = part.indexOf('=');
if (i <= 0) continue;
const key = part.slice(0, i).trim(), value = part.slice(i + 1).trim();
if (key === 't' && t === undefined) t = value;
else if (key === 'v1') v1.push(value);
}
if (!t || v1.length === 0) return false;
// Optional replay window, off by default: Hookdeck retries and replays keep the original timestamp
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'));
// 64 hex chars first: Buffer.from(x, 'hex') silently truncates invalid hex
return v1.some((v) => /^[0-9a-f]{64}$/i.test(v) &&
crypto.timingSafeEqual(Buffer.from(v.toLowerCase()), expected));
}
Verification (Python)
import hashlib, hmac
def verify_pennylane(raw_body: bytes, header: str, secret: str) -> bool:
if not secret or not header:
return False
t, v1 = None, []
for part in header.split(","): # "t=1,v1=ab" or "t=1, v1=ab"
key, sep, value = part.partition("=")
key, value = key.strip(), value.strip()
if not sep or not key:
continue
if key == "t" and t is None:
t = value
elif key == "v1":
v1.append(value)
if not t or not v1:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest().encode()
# bytes on both sides: a non-ASCII header is a mismatch (401/400), not a TypeError (500)
return any(hmac.compare_digest(v.lower().encode(), expected) for v in v1)
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Timestamp window: off by default
Pennylane suggests (optionally) rejecting a t older than about 5 minutes. Hookdeck retries and replays resend the original timestamp, so a 5-minute window rejects late retries. The examples therefore skip the freshness check unless you set PENNYLANE_ to a positive number of seconds (default 0 = off). Deduplicate on the delivery id instead. references/verification.md shows exactly where the window can be dropped or widened.
Status codes
Return 400 for a bad signature. Pennylane disables the subscription on a permanent delivery error (HTTP 401, 403, 404 or 410), whereas other failures are retried (Pennylane doesn't document the schedule) until repeated failures over a sustained period disable it too. Return 2xx within a few seconds, then process asynchronously.
Event Types
Unlike some providers, the event name is in the body (event). The payload is under data.object:
| Event | Triggered When | Notes |
|---|---|---|
customer_ | A customer invoice's e-invoicing (PDP) status changes | data.previous_attributes holds the previous status (null on the first transition) |
dms_file.created | A DMS file is created | Create event, no previous_attributes |
supplier_ | A supplier invoice arrives through e-invoicing | Create event, no previous_attributes |
Envelope: id (delivery id, use it to deduplicate), event, retry_count, created (unix seconds), data.context (company_id, firm_id), data.object. Deliveries are at-least-once and not ordered. See references/overview.md.
Important Headers
| Header | Description |
|---|---|
X-Pennylane-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
User-Agent | Pennylane-Webhooks |
Environment Variables
PENNYLANE_WEBHOOK_SECRET=your_subscription_secret # secret returned once by the create-subscription call
PENNYLANE_TIMESTAMP_TOLERANCE_SECONDS=0 # optional; a positive value opts in to the replay window
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 pennylane --path /webhooks/pennylane
Reference Materials
- references/overview.md - Pennylane webhook concepts, events, payload, retries
- references/setup.md - Creating the subscription through the API and saving the secret
- references/verification.md - Signature details, the timestamp window, and debugging