Guide to Zuora Webhooks: Features and Best Practices
Zuora webhooks, which Zuora calls callouts, notify your systems when something happens in your Zuora tenant: an invoice is posted, a payment is processed, a subscription changes. They're how billing events reach your provisioning service, CRM, data warehouse or finance tooling without polling the Zuora API.
This guide covers how callout templates work, what a callout request contains, how to authenticate it with Basic credentials (and why preemptive authentication matters), Zuora's retry and timeout rules, and the best practices for running callouts in production. It's about callout notifications from Events and Notifications, not the callout tasks in Zuora Workflow.
What are Zuora webhooks?
A callout is an HTTP request that Zuora sends to a URL you choose when an event fires. You define it as a callout template in Extension Studio > Events & Notifications. Each template is tied to one Related Event, either a standard event from the Billing, Finance, Payments or System categories, or a custom event you've defined.
Zuora doesn't send a fixed payload. The template decides the method, the URL, the headers and the body, built from merge fields such as {{DataSource.Account.Id}}. Zuora's own guidance is to keep the data small, typically an ID, and have the receiver call the Zuora API for the rest. That's also its security model: the callout carries little of value, and the details are behind an authenticated API call.
Callouts aren't signed by default. A template can authenticate with a username and password (Basic or NTLM) or OAuth 2.0, add your own static headers, and optionally add an HMAC header whose algorithm, signed data and header you define yourself.
Zuora webhook features
| Feature | Details |
|---|---|
| Configuration | Callout templates in Extension Studio > Events & Notifications |
| Trigger | One standard or custom Related Event per template |
| URL | HTTPS only, port 443 by default, merge fields allowed, up to 1,000 characters |
| Method | POST by default, or GET, PUT, PATCH or DELETE |
| Body | Merge fields or a custom body you write. No Zuora-defined event field |
| Default headers | Zuora-Request-Id, Zuora-Notification-Id, X-Amzn-Trace-Id |
| Authentication | None, Username/Password (Basic or NTLM), or OAuth 2.0 |
| Optional extras | Custom headers, a pinned SSL certificate, a user-defined HMAC header |
| Success | HTTP 200 only |
| Retried responses | 1xx, 403, 408, 429, 5xx and negative codes |
| Retry schedule | Three retries at 30-minute intervals by default, configurable |
| Timeout | 25 seconds per attempt: 10 to connect, 15 for data transfer |
| Redirects | 3xx responses aren't followed |
What a callout contains
Because you build the request, there's no standard event list to route on. Decide on a convention when you create your templates. The simplest is one path per template, for example /webhooks/zuora/payment-processed, or a literal event name in the body:
{
"event": "PaymentProcessed",
"accountId": "{{DataSource.Account.Id}}"
}
event here is a value you type into the template, not something Zuora adds. The merge fields available depend on the Related Event, and the template editor generates the tags for you.
Zuora adds three headers to every callout:
| Header | Purpose |
|---|---|
Zuora-Request-Id | Identifies this request. A new value is used on every retry |
Zuora-Notification-Id | Identifies the notification. The same value is used on every retry |
X-Amzn-Trace-Id | A trace ID derived from Zuora-Request-Id |
Zuora-Notification-Id is the one to deduplicate on, because it stays the same when Zuora retries.
Inspect Zuora webhooks as they arrive. Point Zuora at a Hookdeck Inspect URL to inspect and replay real deliveries, with no account or setup required. It's the quickest way to check what your merge fields render to.
Setting up Zuora webhooks
In Zuora, go to Extension Studio > Events & Notifications, open the Callout Templates tab and click + Add New Callout Template. Then fill in:
- Basic information: a unique Name, the Related Event, and the Active toggle switched on. Zuora doesn't send callouts for inactive templates.
- Request information: the Http Method and the Request URL, which must start with
https://. - Request body: the merge fields to include, or your own body built from merge field tags.
- Request Authentication: set Authentication Type to Username/Password, enter a Username and a long random Password, and tick Enable Preemptive Authentication.
- Additional settings: tick Retriable so failed callouts are retried.
Ticking Enable Preemptive Authentication is the step that's easy to miss. By default, Zuora's Basic authentication follows the challenge flow from RFC 7617: the first request goes out with no Authorization header, the receiver answers 401 with a WWW-Authenticate header, and only then does Zuora resend with the credentials. With the setting ticked, the credentials are in the first request.
Leave Confirm success by parsing response content unticked unless your receiver returns JSON. When it's ticked, Zuora counts a 200 as successful only if the response has a Content-Type of application/json and a body with success set to true.
Use Preview in the template to check the rendered request before saving. An invalid custom request body gives a -2000 response code, and Zuora neither sends nor retries that callout.
For local development, use the Hookdeck CLI: hookdeck listen 3000 zuora --path /webhooks/zuora gives you a public HTTPS URL that forwards to your local server, plus a web UI for inspecting and replaying deliveries, with no account required. Use the generated URL as the Request URL.
With Hookdeck. Create a Zuora source in Hookdeck and enter the template's Username and Password. Make sure Enable Preemptive Authentication is ticked on the template. Hookdeck doesn't issue a 401 challenge: a request without credentials gets a 200 response, and Hookdeck marks it as failed verification and creates no event. Zuora sees the 200 and has no reason to send the credentials, so without preemptive authentication every callout is lost. The source pre-selects POST as the allowed method. If your template uses GET, PUT, PATCH or DELETE, allow that method on the source as well. If you tick Confirm success by parsing response content, set a custom response on the source that returns {"success": true} as JSON, because Hookdeck's default response doesn't include that field.
The Zuora source checks Basic credentials only. If you'd rather verify Zuora's optional HMAC header, use a generic Webhook source with HMAC verification instead. Set the template's HMAC Data to {{Request.Body}} alone and the Header Value to {{Request.HMAC.Signed}} alone, because the Webhook source signs the raw body and expects the header to contain just the signature. Then pick an algorithm and output format that the Webhook source also offers, such as SHA-256 with BASE64.
Securing Zuora webhooks
With preemptive Basic authentication, every callout carries Authorization: Basic <base64 of username:password>. Decode it, split on the first colon, and compare both parts against your stored values in constant time. Check lengths before calling crypto.timingSafeEqual, which throws on buffers of different lengths, and evaluate both comparisons so the timing doesn't reveal which one failed. Sending a WWW-Authenticate header with the 401 also lets a direct receiver handle Zuora's default challenge flow.
const crypto = require('crypto');
const express = require('express');
const app = express();
function safeEqual(a, b) {
const x = Buffer.from(String(a), 'utf8');
const y = Buffer.from(String(b), 'utf8');
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
function verifyZuoraRequest(authorization) {
const user = process.env.ZUORA_WEBHOOK_USERNAME;
const pass = process.env.ZUORA_WEBHOOK_PASSWORD;
if (!user || !pass) return false; // fail closed when unconfigured
const match = /^basic\s+([A-Za-z0-9+/]+={0,2})$/i.exec((authorization || '').trim());
if (!match) return false;
const decoded = Buffer.from(match[1], 'base64').toString('utf8');
const i = decoded.indexOf(':');
if (i < 0) return false;
const userOk = safeEqual(decoded.slice(0, i), user);
const passOk = safeEqual(decoded.slice(i + 1), pass);
return userOk && passOk;
}
app.all('/webhooks/zuora', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyZuoraRequest(req.headers.authorization)) {
res.set('WWW-Authenticate', 'Basic realm="zuora-callouts"');
return res.sendStatus(401);
}
let body = null;
if (req.body && req.body.length > 0) {
try {
body = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
}
res.sendStatus(200); // Zuora counts only a 200 as success
// Queue the work, keyed on req.headers['zuora-notification-id'].
// Merge fields sent by GET arrive in req.query instead of the body.
});
The same check in Python:
import base64
import binascii
import hmac
import os
def verify_zuora_request(authorization) -> bool:
user = os.environ.get("ZUORA_WEBHOOK_USERNAME")
password = os.environ.get("ZUORA_WEBHOOK_PASSWORD")
if not user or not password or not authorization:
return False
scheme, _, encoded = authorization.strip().partition(" ")
if scheme.lower() != "basic":
return False
try:
decoded = base64.b64decode(encoded.strip(), validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError):
return False
got_user, sep, got_pass = decoded.partition(":")
if not sep:
return False
user_ok = hmac.compare_digest(got_user.encode(), user.encode())
pass_ok = hmac.compare_digest(got_pass.encode(), password.encode())
return user_ok and pass_ok
Basic credentials prove the sender knows the password. They don't protect the body, and nothing stops a captured request from being replayed, because there's no signature or timestamp. Base64 is an encoding, not encryption, so Zuora's HTTPS requirement matters here. Keep the body to IDs, fetch the details from the Zuora API, and change the password on the template and in your environment together if it might have leaked. If you need body integrity, add the HMAC option with {{Request.Body}} in the signed data.
Make Zuora webhooks production-ready. Hookdeck Event Gateway checks the callout's Basic credentials, answers Zuora with a
200as soon as the callout is stored, and retries delivery to your service on a schedule you control.
Zuora webhook limitations and pain points
Challenge-based Basic auth by default
The Problem: Callouts never authenticate, even though the username and password are right.
Why It Happens: Zuora's default is to send the first request without credentials and wait for a 401 challenge. A receiver that doesn't send WWW-Authenticate, or a gateway that doesn't challenge at all, never gets the credentials.
Workarounds:
- Tick Enable Preemptive Authentication on every callout template.
- If you receive callouts directly, return
WWW-Authenticate: Basicwith your401.
How Hookdeck Can Help: Hookdeck's Zuora source expects preemptive credentials. A request without valid ones still gets a 200 response, but Hookdeck marks it as failed verification and creates no event, so it never reaches your destination. Those requests stay visible in Hookdeck, which makes a template with the setting unticked easy to find.
Only some failures are retried
The Problem: A callout fails once and is never sent again.
Why It Happens: Zuora counts only a 200 as success, but it only retries 1xx, 403, 408, 429, 5xx and negative codes. A 400, 401, 404 or 201 is a failure that isn't retried. Retries also depend on Retriable being ticked, and the default is three attempts 30 minutes apart.
Workarounds:
- Return exactly
200once you've accepted a callout, and use503for temporary problems you want retried. - Raise the retry count and interval in Zuora's callout settings if three attempts over 90 minutes isn't enough.
How Hookdeck Can Help: Hookdeck answers with a 200 as soon as the callout is stored, then delivers it with automatic retries on your own schedule. Issues alert you when deliveries fail, and stored callouts can be replayed after a fix.
A 25-second timeout
The Problem: Slow handlers cause failed and retried callouts.
Why It Happens: Each attempt has 10 seconds to connect and 15 seconds to transfer data, and the limit can't be changed. Zuora recommends doing the work in background jobs that don't depend on how long it keeps the connection open.
Workarounds:
- Acknowledge first and process in the background.
- Keep the callout body small so the transfer is quick.
How Hookdeck Can Help: Hookdeck replies to Zuora immediately and uses its own delivery timeout towards your service, so slow processing doesn't count against Zuora's 25 seconds.
No standard payload or event field
The Problem: Every template sends something different, and nothing in the request names the event unless you put it there.
Why It Happens: The template author chooses the body and URL from merge fields. Zuora adds only request and notification IDs.
Workarounds:
- Use a separate path per template, or a literal event name in each body.
- Document your templates alongside the handler code.
How Hookdeck Can Help: Hookdeck filters can route on the path, a header or a body field, so one source can feed different services per template, and transformations can normalise bodies into a common shape.
Best practices
Send IDs, fetch details
Put object IDs in the callout and call the Zuora API for the rest. That keeps the callout fast, keeps sensitive data out of it, and means your handler always works with current data.
Make handlers idempotent
Retries resend the same notification with a new Zuora-Request-Id but the same Zuora-Notification-Id. Record that ID and skip repeats. Hookdeck's deduplication can drop repeats based on the zuora-notification-id header before they reach you. See our guide to webhook idempotency.
Don't assume order
Zuora processes events in the order they're triggered, but retries, concurrency and network latency can change the order in which callouts arrive. Check the object's current state through the API before acting.
Keep templates consistent
Use the same authentication settings, path convention and body shape across templates, and check each one with the template preview before you activate it.
Acknowledge fast
Return 200 before calling the Zuora API or downstream systems, and do that work in the background. See why to process webhooks asynchronously.
Conclusion
Zuora callouts are requests you design: a template per event, with the method, URL, headers and body built from merge fields. Authenticate them with Basic credentials and tick Enable Preemptive Authentication so the credentials arrive on the first request, deduplicate on Zuora-Notification-Id, return exactly 200 within 25 seconds, and remember that only some failures are retried.
Hookdeck Event Gateway checks the credentials, stores every callout, and retries delivery to your service, so a billing event isn't lost because of a slow or failing handler.
Get started with Hookdeck for free and handle Zuora webhooks reliably in minutes.