Receiving & verifying

Each delivery is an HTTP POST with Content-Type: application/json and User-Agent: Pennylane-Webhooks. The body has a consistent envelope:

{
  "id": 987654,
  "event": "customer_invoice.e_invoicing_status_updated",
  "retry_count": 0,
  "created": 1782864000,
  "data": {
    "context": {
      "company_id": "abc-123",
      "firm_id": "firm-456"
    },
    "object": {
      "id": 42,
      "e_invoicing": { "status": "accepted" }
    },
    "previous_attributes": {
      "e_invoicing": { "status": "submitted" }
    }
  }
}

FieldDescription
idUnique delivery ID. Use it to deduplicate — see Delivery semantics.
eventThe event name, e.g. customer_invoice.e_invoicing_status_updated.
retry_countHow many times this delivery has been retried (0 on first attempt).
createdUnix epoch timestamp (seconds) of when the event occurred.
dataThe event envelope. Note: the event-specific payload is nested at data.data.
data.contextThe public company_id and firm_id the change belongs to.
data.objectThe event-specific payload — documented on each event's reference page.
data.previous_attributesPresent only for update events; the prior values of the changed fields.

📘

Respond quickly

Your endpoint should return a 2xx status within a few seconds. Acknowledge the request first, then do heavy processing asynchronously — a slow response is treated as a timeout and the delivery will be retried.


Verify the signature

Every request carries an X-Pennylane-Signature header so you can confirm it genuinely came from Pennylane and was not tampered with:


X-Pennylane-Signature: t=1657875952,v1=5257a869e7ecebeda32aff...
  • t is a Unix timestamp in seconds.
  • v1 is an HMAC-SHA256, in hexadecimal, of the string "{t}.{raw_request_body}", keyed with your subscription secret.

To verify: take the t value and your raw request body, recompute the HMAC, and compare it to v1 using a constant-time comparison.

🚧

Use the raw request body

Compute the HMAC over the raw body bytes exactly as received. If you parse and re-serialize the JSON first, the bytes change and the signature will not match.


require 'openssl'

def valid_signature?(raw_body, header, secret)
  parts = header.split(',').to_h { |p| p.split('=', 2) }
  signed = "#{parts['t']}.#{raw_body}"
  expected = OpenSSL::HMAC.hexdigest('sha256', secret, signed)

  Rack::Utils.secure_compare(expected, parts['v1'])
end
const crypto = require('crypto');

function validSignature(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('='))
  );
  const signed = `${parts.t}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signed)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);

  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hashlib
import hmac

def valid_signature(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    signed = f"{parts['t']}.".encode() + raw_body
    expected = hmac.new(
        secret.encode(), signed, hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, parts["v1"])

Optional: replay protection

The signed t timestamp also lets you reject stale or replayed deliveries. Compare t to the current time and discard requests older than a tolerance you choose (five minutes is a common default).

Delivery semantics

Webhooks reflect changes that have already been committed in Pennylane and are observed asynchronously. Design your receiver around these guarantees:

  • Asynchronous. A webhook may arrive shortly after the change, not synchronously with the API call that caused it.
  • Unordered. Deliveries are not guaranteed to arrive in the order the changes happened. Don't rely on arrival order; use the data in the payload (statuses, timestamps) to reconcile state.
  • At-least-once. A delivery may be sent more than once — for example, if your endpoint acknowledges slowly but eventually succeeds. Deduplicate using the delivery id and make your processing idempotent.
  • Respond quickly. If your endpoint doesn't return a 2xx in time, the request is treated as a failed delivery and retried.