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-Signature header?
  • How do I handle customer_invoice.e_invoicing_status_updated, dms_file.created, or supplier_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_subscriptions:all), 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_TIMESTAMP_TOLERANCE_SECONDS 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:

EventTriggered WhenNotes
customer_invoice.e_invoicing_status_updatedA customer invoice's e-invoicing (PDP) status changesdata.previous_attributes holds the previous status (null on the first transition)
dms_file.createdA DMS file is createdCreate event, no previous_attributes
supplier_invoice.e_invoicing_receivedA supplier invoice arrives through e-invoicingCreate 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

HeaderDescription
X-Pennylane-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
User-AgentPennylane-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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 6, 2026

View on GitHub →