← penduluminvoices.com

Outbound webhooks

Pendulum POSTs signed JSON events to your endpoint as they happen. Use it to feed your CRM, internal dashboards, or any automation tool that speaks HTTP.

Overview

Outbound webhooks are available on the Pro plan. Configure endpoints in your Pendulum settings under Webhooks. Each endpoint gets a unique signing secret shown once at creation time.

Pendulum signs every request with HMAC-SHA256 over a timestamp and the raw body. Verify the signature before trusting the payload.

Events

  • invoice.created: A new invoice was created from a Shopify order or draft order.
  • invoice.paid: An invoice was marked paid, either by Shopify orders/paid webhook or manually in the dashboard.
  • invoice.overdue: An invoice crossed its due date for the first time. Fires once per invoice.
  • reminder.sent: A reminder email was sent (manually or by the scheduled cadence).

Delivery semantics

  • At-least-once. Deduplicate using the top-level id field (also sent as X-Pendulum-Delivery).
  • Pendulum expects a 2xx response within 10 seconds. Anything else counts as a failure.
  • Failed deliveries retry on a 1 minute, 5 minute, 30 minute, 2 hour, 6 hour, 24 hour backoff. After the final retry the delivery is dropped.
  • An endpoint that fails 10 deliveries in a row is automatically deactivated. Re-enable it from your settings.

HTTP request

Every delivery is a POST with a JSON body and the following headers.

HeaderDescription
X-Pendulum-EventThe event name, e.g. invoice.created.
X-Pendulum-DeliveryUnique delivery id. Same value appears as `id` in the body. Use for deduplication.
X-Pendulum-SignatureHMAC signature in the form t=<unix_seconds>,v1=<hex>.
User-AgentPendulum-Webhooks/1.0
Content-Typeapplication/json

Verifying the signature

Recompute HMAC-SHA256 over ${timestamp}.${rawBody} using your endpoint's signing secret as the key, then constant-time compare to the v1 value from the signature header. Reject requests older than 5 minutes to prevent replay.

Node.js

// Express + Node 18+
import express from 'express'
import crypto from 'crypto'

const app = express()
const SECRET = process.env.PENDULUM_WEBHOOK_SECRET // whsec_...

// Capture the raw body so the HMAC matches exactly.
app.post(
  '/webhooks/pendulum',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.header('X-Pendulum-Signature') || ''
    const rawBody = req.body.toString('utf8')

    // Header format: t=<unix_seconds>,v1=<hex_hmac_sha256>
    const parts = Object.fromEntries(
      signature.split(',').map((p) => p.split('=', 2))
    )
    const timestamp = parts.t
    const provided = parts.v1
    if (!timestamp || !provided) return res.sendStatus(400)

    // Reject requests older than 5 minutes to prevent replay.
    const age = Math.abs(Date.now() / 1000 - Number(timestamp))
    if (age > 300) return res.sendStatus(400)

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

    const ok =
      provided.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected))

    if (!ok) return res.sendStatus(401)

    const event = JSON.parse(rawBody)
    // event.id  -> delivery id (use to dedupe; deliveries are at-least-once)
    // event.event -> 'invoice.created' | 'invoice.paid' | 'invoice.overdue' | 'reminder.sent'
    // event.data -> payload (shape per event below)

    res.sendStatus(200)
  }
)

Python

# Flask + Python 3.10+
import hmac
import hashlib
import json
import os
import time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['PENDULUM_WEBHOOK_SECRET']  # whsec_...

@app.post('/webhooks/pendulum')
def pendulum_webhook():
    signature = request.headers.get('X-Pendulum-Signature', '')
    raw_body = request.get_data(as_text=True)

    # Header format: t=<unix_seconds>,v1=<hex_hmac_sha256>
    parts = dict(p.split('=', 1) for p in signature.split(','))
    timestamp = parts.get('t')
    provided = parts.get('v1')
    if not timestamp or not provided:
        abort(400)

    # Reject requests older than 5 minutes.
    if abs(time.time() - int(timestamp)) > 300:
        abort(400)

    expected = hmac.new(
        SECRET.encode(),
        f'{timestamp}.{raw_body}'.encode(),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(provided, expected):
        abort(401)

    event = json.loads(raw_body)
    # event['id']  -> delivery id (use to dedupe; deliveries are at-least-once)
    # event['event']  -> 'invoice.created' | 'invoice.paid' | 'invoice.overdue' | 'reminder.sent'
    # event['data']  -> payload (shape per event below)

    return '', 200

Event payloads

Every event has the same envelope. id is the delivery id, event is the event name, and data holds the event-specific fields below.

invoice.created

{
  "id": "01J5GVZ...",
  "event": "invoice.created",
  "created_at": "2026-05-19T17:42:11.000Z",
  "data": {
    "invoice_id": "9d2f7a...",
    "shopify_order_id": 5872103424,
    "shopify_draft_order_id": null,
    "order_name": "#1042",
    "customer_name": "Jane Doe",
    "customer_email": "jane@example.com",
    "amount_due": 1499.0,
    "currency": "USD",
    "status": "unpaid",
    "due_at": "2026-06-18T17:42:11.000Z",
    "paid_at": null,
    "shopify_invoice_url": "https://example.myshopify.com/..."
  }
}

invoice.paid

{
  "id": "01J5GW1...",
  "event": "invoice.paid",
  "created_at": "2026-05-22T09:14:03.000Z",
  "data": {
    "invoice_id": "9d2f7a...",
    "shopify_order_id": 5872103424,
    "shopify_draft_order_id": null,
    "order_name": "#1042",
    "customer_name": "Jane Doe",
    "customer_email": "jane@example.com",
    "amount_due": 1499.0,
    "currency": "USD",
    "status": "paid",
    "due_at": "2026-06-18T17:42:11.000Z",
    "paid_at": "2026-05-22T09:14:02.000Z",
    "shopify_invoice_url": "https://example.myshopify.com/..."
  }
}

invoice.overdue

{
  "id": "01J5GW2...",
  "event": "invoice.overdue",
  "created_at": "2026-06-19T14:00:00.000Z",
  "data": {
    "invoice_id": "9d2f7a...",
    "shopify_order_id": 5872103424,
    "shopify_draft_order_id": null,
    "order_name": "#1042",
    "customer_name": "Jane Doe",
    "customer_email": "jane@example.com",
    "amount_due": 1499.0,
    "currency": "USD",
    "status": "unpaid",
    "due_at": "2026-06-18T17:42:11.000Z",
    "paid_at": null,
    "shopify_invoice_url": "https://example.myshopify.com/...",
    "days_overdue": 1
  }
}

reminder.sent

{
  "id": "01J5GW3...",
  "event": "reminder.sent",
  "created_at": "2026-06-22T14:00:00.000Z",
  "data": {
    "reminder_send_id": "ab12cd...",
    "step": 1,
    "recipient_email": "jane@example.com",
    "subject": "Friendly reminder: #1042 payment of $1,499.00",
    "delivery_status": "sent",
    "postmark_message_id": "9c5d...",
    "sent_at": "2026-06-22T14:00:01.000Z",
    "invoice": {
      "invoice_id": "9d2f7a...",
      "order_name": "#1042",
      "customer_name": "Jane Doe",
      "customer_email": "jane@example.com",
      "amount_due": 1499.0,
      "currency": "USD",
      "status": "unpaid",
      "due_at": "2026-06-18T17:42:11.000Z",
      "paid_at": null,
      "shopify_order_id": 5872103424,
      "shopify_draft_order_id": null,
      "shopify_invoice_url": "https://example.myshopify.com/..."
    }
  }
}

Retry behavior

If your endpoint returns a non-2xx response, fails to connect, or takes longer than 10 seconds, Pendulum will retry on the following schedule, measured from the previous attempt.

  • +1 minute
  • +5 minutes
  • +30 minutes
  • +2 hours
  • +6 hours
  • +24 hours

Total elapsed from the original event to the final drop is roughly 32 hours, enough to survive a typical overnight outage on your side. After the final retry the delivery is marked failed and dropped. If an endpoint accumulates 10 consecutive failed deliveries Pendulum disables it automatically and emails you at your brand-settings reply-to address. You will see the endpoint as Inactive in settings and can re-enable it once your service is healthy.