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
idfield (also sent asX-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.
| Header | Description |
|---|---|
| X-Pendulum-Event | The event name, e.g. invoice.created. |
| X-Pendulum-Delivery | Unique delivery id. Same value appears as `id` in the body. Use for deduplication. |
| X-Pendulum-Signature | HMAC signature in the form t=<unix_seconds>,v1=<hex>. |
| User-Agent | Pendulum-Webhooks/1.0 |
| Content-Type | application/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 '', 200Event 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.