Paprel Webhooks for Reliable Accounting Workflows

Inside Paprel's webhook architecture: thin events, verifiable signatures, durable retries, and practical recovery patterns for accounting integrations.

14 min read
Paprel Engineering Team
Paprel ledger outbox delivering signed webhook events to embedded accounting workflows

An invoice changes status in Paprel. What should happen next?

The product embedding Paprel may need to update its own workflow, notify an operator, refresh a dashboard, or trigger a downstream integration. Polling can eventually discover the change, but it adds delay, unnecessary requests, and more state to coordinate.

Webhooks solve the notification problem. Reliable webhooks solve the much harder production problem.

Paprel's webhook platform is built around a simple promise: when committed accounting data changes, an integration can receive a verifiable notification and process it safely—even when networks fail, workers restart, or the same event arrives more than once.

This post explains the architecture and the decisions behind it.

On this page

The Reliability Contract

Paprel webhooks use asynchronous, at-least-once delivery. That phrase carries several practical consequences:

  • an event may be delivered more than once
  • events may arrive out of order
  • a slow or unavailable receiver will be retried
  • any 2xx response acknowledges a delivery
  • consumers must deduplicate events before applying business effects

We chose to state those constraints directly because distributed delivery cannot honestly promise exactly once. A request can succeed at the receiver and still time out before Paprel sees the response. Retrying is the safe choice, which means duplicates are a normal condition rather than an exceptional one.

Each event therefore has an immutable, globally unique id. Consumers store that ID alongside their own processing result. When the same event arrives again, they acknowledge it without repeating the business operation.

Design for duplicate delivery. Persist the event id before applying a business effect. A receiver that sends 2xx without durable deduplication can process the same payment, journal, or notification more than once.

Thin Events, Authoritative Resources

Paprel sends thin event notifications instead of embedding a complete invoice, journal, client, or expense snapshot in every delivery.

JSON
{
  "id": "evt_0191a2b3-c4d5-7000-8000-000000000001",
  "type": "sales.invoice.updated",
  "version": "1.0",
  "company_id": "0191a2b3-c4d5-7000-8000-000000000010",
  "actor_id": "0191a2b3-c4d5-7000-8000-000000000030",
  "occurred_at": "2026-08-18T08:15:00.123Z",
  "resource": {
    "id": "0191a2b3-c4d5-7000-8000-000000000020",
    "type": "invoice"
  }
}

The envelope answers the questions that should never change: what happened, when it committed, which company owns it, who initiated it when known, and which public resource it concerns.

If a consumer needs the current invoice or journal, it retrieves that resource through the company-scoped API using its own credentials. The API remains the authoritative view of current state, while the webhook is the notification that tells the consumer when to look.

This separation gives us a few useful properties:

  • webhook schemas can remain stable while resource representations evolve
  • events do not expose fields a receiver did not request or need
  • secrets and authentication-adjacent data stay out of payloads and delivery logs
  • an integration that receives several quick updates can hydrate the latest state once

The receiver must tolerate a resource changing again before it hydrates it. That is intentional: a webhook records an immutable change fact, while the resource API describes the latest state.

Events Are Created With the Business Transaction

A notification is only useful if it describes something that actually committed.

The owning Paprel domain writes its webhook outbox event inside the same database transaction as the business change. If the transaction rolls back, no event exists. Delivery cannot begin until the commit succeeds.

Text
API mutation
    │
    ├── validate the business operation
    ├── write the accounting change
    ├── write the immutable webhook event
    └── commit
          │
          └── delivery worker fans out to subscribed endpoints

The webhook subsystem receives a normalized resource reference and immutable change facts from the domain that owns the resource. It does not reach across domains to reconstruct the event with database joins.

We also serialize and persist the complete event envelope once. Initial delivery, automatic retries, delivery history, and manual resend all use those same stored bytes. A later code change cannot silently alter the payload of an event that already happened.

A Catalog That Speaks Accounting

The v1 contract defines 62 event types across accounting, contacts, sales, purchases, expenses, items, company administration, and settings.

Event names follow a stable taxonomy:

Text
<domain>.<resource>[.<subresource>].<action>

Representative events show how that taxonomy maps to accounting workflows:

WorkflowRepresentative events
General ledgeraccounting.account.updated
accounting.journal.posted
accounting.journal.voided
Accounts receivablecontacts.client.updated
sales.invoice.status.changed
sales.invoice.payment.recorded
Accounts payablecontacts.vendor.updated
purchases.bill.status.changed
purchases.bill.payment.reversed
Credit notessales.credit_note.applied
sales.credit_note.application_reversed
Expensesexpenses.expense.created
expenses.expense.status.changed
Itemsitems.item.created
items.item.updated
Administrationcompany.user.role.changed
settings.payment_term.updated

The distinction between events matters. An invoice being created and its accounting journal being posted are separate commits and may arrive seconds apart. A system-generated journal whose first durable state is posted emits accounting.journal.posted, rather than creating artificial intermediate events just to make the catalog look uniform.

Endpoints subscribe to explicit event names. Presets such as General ledger, Accounts receivable, or Accounts payable are selection shortcuts only. Paprel stores the expanded list, so adding a new catalog event never silently expands an existing endpoint's access.

The Paprel API documentation is the source of truth for the complete event catalog and current subscription contract.

Signing and Verifying Webhook Requests

Every endpoint has its own signing secret, independent of API or App Connect credentials. For each delivery, Paprel signs the timestamp and the exact raw request body with HMAC-SHA256:

Text
signed_payload = timestamp + "." + raw_body
signature = HMAC-SHA256(endpoint_secret, signed_payload)

Webhook-Signature: t=<unix-seconds>;v1=<lowercase-hex>
Webhook-Event-Id: <event-id>
Webhook-Delivery-Attempt: <attempt-number>
User-Agent: Paprel-Webhooks/1.0

A receiver should read the body without parsing or reserializing it, reject stale timestamps, compute the HMAC over the exact bytes, compare signatures in constant time, and only then parse and process the event. We recommend a five-minute timestamp tolerance.

Verify the raw body. Parsing JSON and serializing it again can change whitespace or field ordering, producing different bytes and an invalid signature. Capture the request body exactly as received before any middleware transforms it.

Signing protects both origin and integrity. TLS protects the request in transit; the signature lets the receiver verify that the payload was produced with the endpoint's secret and was not altered on the way.

TypeScript Verification Example

This compact Node.js example verifies the timestamp and every v1 signature against the unmodified request body:

TypeScript
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verifyWebhook(
  rawBody: Buffer,
  signatureHeader: string,
  secret: string,
  toleranceSeconds = 300,
) {
  const pairs = signatureHeader.split(';').map(part => part.trim().split('=', 2))
  const timestamp = pairs.find(([key]) => key === 't')?.[1]
  const signatures = pairs.filter(([key]) => key === 'v1').map(([, value]) => value)

  if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest()

  return signatures.some((hex) => {
    if (!/^[a-f0-9]{64}$/.test(hex)) return false
    const candidate = Buffer.from(hex, 'hex')
    return candidate.length === expected.length && timingSafeEqual(candidate, expected)
  })
}

Read Webhook-Signature from the incoming request and pass the raw Buffer captured before JSON parsing. After verification succeeds, parse the event and deduplicate it using Webhook-Event-Id or the envelope id.

Secret Rotation Without Downtime

Paprel keeps the previous secret active for 24 hours through dual-signing, rather than choosing one secret or the other for each request.

When an endpoint secret is rotated, Paprel moves the encrypted current secret into the previous-secret slot and sets its expiry to 24 hours from the rotation time. The newly generated secret becomes current. Authorized operators can explicitly reveal that current secret; the previous secret is never revealable.

During the overlap, Paprel calculates two HMACs using the same timestamp and exact request body:

Text
Webhook-Signature: t=<unix-seconds>;v1=<current-signature>;v1=<previous-signature>

The receiver compares its computed HMAC with every v1 value and accepts the request if either one matches. This allows the new secret to be deployed without interrupting deliveries still verified with the previous secret.

After 24 hours, delivery stops decrypting and signing with the previous secret. Only the current secret remains valid. The expired ciphertext may remain stored, but it is ignored by the delivery path.

Rotation has a few deliberate boundaries:

  • rotating again immediately replaces the previous secret and starts a new 24-hour overlap, so at most two secrets are usable
  • secret reveal returns the current secret only
  • endpoint verification signs with the current secret only
  • if the previous secret cannot be decrypted, delivery continues with the current signature instead of failing the request
Update the receiver within the overlap window. Dual-signing prevents downtime during a planned rotation, but the previous secret stops working after 24 hours—or immediately when another rotation replaces it.

Retries That Survive Process Restarts

Retries are persisted as delivery state, not implemented as a sleep inside an application process.

A background worker claims a bounded set of due deliveries in a short database transaction. It commits that claim before making any outbound HTTP request, so it never holds a database connection or transaction open across network I/O.

Paprel makes one immediate attempt and up to six automatic retries. Delays are measured from the preceding failed attempt:

Text
1 minute → 10 minutes → 1 hour → 8 hours → 24 hours → 48 hours

Each delay receives independent jitter to prevent large groups of deliveries from retrying at exactly the same moment. Paprel retries network failures, 408, 425, 429, and 5xx responses. A valid Retry-After response on 429 is honored within the remaining retry and retention window. Other 4xx responses are terminal because repeating the same request is unlikely to fix a receiver-side validation or authorization problem.

Every attempt has its own internal delivery ID but retains the same immutable event ID and payload. The delivery ID supports Paprel's operational history; it is not part of the public HTTP request contract. The event describes what happened in Paprel, while deliveries describe Paprel's attempts to notify one endpoint about it.

Safe Outbound Requests

A webhook platform accepts URLs and then asks its infrastructure to call them. That makes server-side request forgery protections part of the core design, not an optional hardening pass.

Paprel accepts only HTTPS endpoints on public DNS hostnames. Endpoint URLs cannot use IP literals, query strings, or fragments. Paprel rejects private and reserved network addresses, validates DNS and resolved addresses again when delivery occurs, and blocks Paprel- and partner-owned destinations. Redirects are disabled, so an approved public URL cannot redirect the worker toward a different target.

Verification is not domain ownership proof. A successful endpoint verification confirms that Paprel can reach the URL and that its handler accepts a correctly signed request. It does not prove that the configuring actor owns the hostname.

Timeouts and Redirects

RequestConnection timeoutTotal timeoutRedirects
Production event delivery5 seconds15 secondsDisabled
Endpoint verification3 seconds5 secondsDisabled

Endpoint verification follows the same signed request path as production delivery, so a receiver does not need special challenge-handling code. Its tighter timeout budget is intentional: verification is an interactive configuration check and should fail quickly instead of making an operator wait through the production delivery window.

Creating an endpoint places it in pending_verification. Authorized operators can explicitly reveal the current secret when configuring the receiver. Endpoint list and detail responses never include it automatically. A successful signed verification request activates the endpoint, while changing its URL returns it to pending verification.

Custom Delivery Headers

An endpoint can attach up to three custom headers to every verification and production delivery. This is useful when a receiver sits behind an API gateway, needs an integration-specific routing value, or expects an additional shared credential.

Custom header names must begin with X-, are case-insensitively unique, and cannot use the reserved Webhook- namespace or namespaces used for proxies, forwarding, and original-request metadata. Values are validated to reject control characters.

Header values are encrypted independently at rest. Paprel never includes them in activity metadata or delivery logs, and API responses expose header names only—not their values. Replacing a value requires supplying it again rather than reading the stored secret back.

Treat custom-header values as write-only secrets. Save the value in your secret manager when configuring the endpoint. Paprel will not return it later through the API or delivery history.

Custom headers provide an additional receiver-side control, but they are deliberately excluded from the payload signature calculation and should not replace verification of Webhook-Signature.

Endpoint Health and Operator Recovery

Delivery failures need to be visible without letting one broken integration generate traffic forever.

Paprel exposes endpoint-scoped delivery history with the exact immutable request payload and delivery diagnostics. Operators can resend a completed successful or failed delivery. A resend creates a new delivery record; it never rewrites history.

An endpoint moves into a failing state after repeated unsuccessful attempts. If three separate events exhaust their retry schedules without an intervening success, Paprel automatically pauses the endpoint. One successful event resets the consecutive-exhaustion counter.

Paused endpoints remain visible with their history. Resuming enables newly created subscribed events, but does not pretend that notifications are the source of truth or silently backfill the paused period.

For recovery, integrations reconcile against Paprel's company-scoped resource APIs and ledger exports, then resume live consumption. The accounting system of record—not a delivery log—is what makes state reconstructible.

Posted by: Paprel Engineering Team · Engineering
Posted on: (Updated: August 23, 2026)

Engineering notes from the team building Paprel's ledger core and API — ledger architecture, multi-tenancy, idempotency, and the patterns behind audit-grade accounting. Reflects how the system actually works and is updated as the product evolves.

Evaluate Paprel

Build in sandbox, launch with a production trial

Use sandbox for developer testing with no billing. When you are ready for real workflows, start production on a monthly plan with a 14-day free trial.

API-First Delivery
Audit-Ready Controls
Sandbox And Guided Rollout