Agent skill

Zuora Webhooks Skill

Receive and authenticate Zuora callout notifications (webhooks from Events and Notifications). Use when setting up a Zuora callout endpoint, verifying the HTTP Basic Authorization header, enabling Preemptive Authentication, debugging 401s, or handling callouts like a payment processed or invoice posted notification.

Install this skill

npx skills add hookdeck/webhook-skills --skill zuora-webhooks


Zuora calls them callouts: event-based HTTP requests that Zuora sends to a URL you choose when something happens in your tenant, such as a payment processed or an invoice posted. You build them as callout templates in Extension Studio > Events & Notifications, and an active notification definition has to select the template before anything is sent. This skill covers callout notifications, not Workflow callouts.

Callouts are not signed. The sender is authenticated with HTTP Basic auth (Authorization: Basic base64(username:password)) set on the template.

You must tick "Enable Preemptive Authentication" on the callout template. By default Zuora sends the first request without an Authorization header and only sends credentials after your server answers 401 with a WWW-Authenticate challenge. That challenge flow does not work through Hookdeck, and it makes every verification check in this skill fail on the first request. With Preemptive Authentication on, Zuora puts the credentials in the first request, and the handler below can check them directly.

A static shared credential authenticates the sender, not the body, and gives no replay protection (there is no signature and no timestamp). Use HTTPS only, treat the credential as a secret, and rotate it on the template (and in your env vars) if it leaks.

When to Use This Skill

  • How do I receive Zuora callout notifications (webhooks)?
  • How do I authenticate Zuora callouts with Basic auth?
  • Why does my Zuora callout endpoint return 401 or never receive credentials?
  • What does "Enable Preemptive Authentication" do?
  • Does Zuora sign webhook payloads? (No: Basic auth, with an optional user-defined HMAC)
  • How do I handle PaymentProcessed or InvoicePosted style callouts?

Verification (core)

const crypto = require('crypto');

// Hash both sides: equal-length buffers, so timingSafeEqual never throws.
const safeEqual = (a, b) => crypto.timingSafeEqual(
  crypto.createHash('sha256').update(a).digest(),
  crypto.createHash('sha256').update(b).digest());

function verifyZuoraRequest(headers, user = process.env.ZUORA_WEBHOOK_USERNAME,
                            pass = process.env.ZUORA_WEBHOOK_PASSWORD) {
  if (!user || !pass) return false;                      // never accept when unconfigured
  const [scheme, encoded, ...rest] = (headers.authorization || '').trim().split(/\s+/);
  if (scheme?.toLowerCase() !== 'basic' || !encoded || rest.length) return false;
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return false;  // reject non-base64
  const decoded = Buffer.from(encoded, 'base64');
  const colon = decoded.indexOf(0x3a);                   // split on the FIRST colon
  if (colon === -1) return false;
  const userOk = safeEqual(decoded.subarray(0, colon), Buffer.from(user));
  const passOk = safeEqual(decoded.subarray(colon + 1), Buffer.from(pass));
  return userOk && passOk;                               // evaluate both, no short-circuit
}
  • Header: Authorization: Basic base64(username:password); the scheme is case-insensitive.
  • Compare the username and the password separately in constant time. In Python use hmac.compare_digest on bytes (encode both sides; str raises on non-ASCII).
  • Return 401 for a missing, malformed or mismatched header, 400 for a body that isn't JSON, and 200 once handled. Zuora treats anything other than 200 as a failure.
  • Read the body raw (express.raw({ type: '*/*' })) and authenticate before parsing it.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

HTTP Methods and Body

The callout method is chosen on the template: POST (the default), GET, PUT, PATCH or DELETE. The examples accept all five on /webhooks/zuora and handle an empty body (for example GET, where merge fields arrive in the URL query instead).

Zuora adds no event field. The body is the merge fields you put in the template. The examples assume an illustrative JSON body you write yourself:

{"event": "PaymentProcessed", "accountId": "{{DataSource.Account.Id}}"}
Illustrative eventMeaning
PaymentProcessedTemplate on a payment-processed event
InvoicePostedTemplate on an invoice-posted event

These names are conventions of this skill, not Zuora-defined strings. Pick whatever your templates send and update the dispatch. Zuora recommends putting little data in a callout (usually an ID) and fetching the details from the Zuora API.

Environment Variables

ZUORA_WEBHOOK_USERNAME=your-callout-username   # Callout template > Username
ZUORA_WEBHOOK_PASSWORD=your-callout-password   # Callout template > Password

Local Development

Zuora can't reach localhost, and callout URLs must be https://. Tunnel with the Hookdeck CLI (no account required):

npx hookdeck-cli listen 3000 zuora --path /webhooks/zuora

Use the printed URL as the callout template's Request URL.

Resources


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 5, 2026

View on GitHub →