Webhooks & Zapier Bridge
Subscribe any HTTPS URL to Easy Invoice events. The addon dispatches non-blocking HMAC-signed POSTs to your target, retries on failure, and logs every attempt. Designed to plug straight into Zapier's "Catch Webhook" trigger — no Zapier app submission required — but works with anything that accepts JSON.
When to use it
- You want invoice / quote / payment events to flow into Zapier, Make.com, n8n, or your CRM
- You want to send a Slack message when an invoice is paid
- You want to push billing data into HubSpot, Pipedrive, Mailchimp, Asana, Google Sheets — anywhere webhook-able
Enabling
- Easy Invoice → Addons → activate Webhooks & Zapier Bridge
- Settings open at Easy Invoice → (sidebar) → Webhooks (slug:
easy-invoice-addon-webhooks)
On first activation the addon creates two custom tables:
{prefix}_easy_invoice_webhooks ← subscriptions
{prefix}_easy_invoice_webhook_log ← delivery attemptsBoth are kept on deactivation (re-enabling restores all subscriptions).
The addon schedules two WP-Cron events on bootstrap:
easy_invoice_webhooks_retry_tick— hourly. Picks up failed deliveries and retries them.easy_invoice_webhook_send_one— one-shot per delivery. Scheduled byDispatcher::dispatch()with the log id as arg.
Catalog of events
15 events out of the box. The list is filterable via easy_invoice_webhook_events for custom events.
Invoice events
| Event | Fires when |
|---|---|
invoice.created | save_post_easy_invoice (with $update=false) — new invoice published |
invoice.updated | save_post_easy_invoice (with $update=true) — existing invoice saved |
invoice.sent | Email sent to client (currently fires alongside invoice.updated when status flips to available) |
invoice.paid | easy_invoice_payment_completed AND _easy_invoice_status === 'paid' |
invoice.overdue | Status flips to overdue (cron-driven) |
invoice.deleted | before_delete_post for post_type easy_invoice |
Quote events
| Event | Fires when |
|---|---|
quote.created | save_post_easy_invoice_quote (new) |
quote.accepted | easy_invoice_service_quote_accepted |
quote.declined | easy_invoice_service_quote_declined |
quote.expired | easy_invoice_quote_expired (daily cron) |
Payment events
| Event | Fires when |
|---|---|
payment.recorded | easy_invoice_payment_completed |
payment.refunded | (planned — core hook in progress) |
payment.failed | (planned — core hook in progress) |
Other events
| Event | Fires when |
|---|---|
client.created | User with role customer is registered |
recurring.charged | easy_invoice_pro_recurring_invoice_created — recurring template generated a child invoice |
Subscribing a URL
In the Subscriptions tab → Add webhook:
| Field | Required | What it does |
|---|---|---|
| Label | optional | Friendly name for the row; defaults to the URL hostname |
| Target URL | required | Where to POST. Must pass the SSRF gate. |
| Secret | optional | Used to sign each request (HMAC-SHA256). Auto-generated 32-char password if blank. |
| Events | required | Tick checkboxes grouped by domain (invoice / quote / payment / client / recurring) |
Save. The webhook is Active immediately. Click Test on the row to fire a synthetic webhook.test event — check the Delivery Log tab to see the result.
Payload format
Every delivery is a POST with a JSON body shaped like:
{
"event": "invoice.paid",
"site": "https://example.com/",
"sent_at": "2026-05-16T10:00:00+05:45",
"data": {
"invoice_id": 123,
"invoice_number": "INV-000123",
"status": "paid",
"total": 1250.00,
"subtotal": 1250.00,
"currency": "USD",
"client_id": 42,
"customer_name": "Acme Corp",
"customer_email": "[email protected]",
"issue_date": "2026-05-01",
"due_date": "2026-05-15",
"public_url": "https://example.com/invoice/inv-000123/"
}
}Quote payloads have the same shape with quote_id, quote_number, quote_status etc. Payment events extend the invoice payload with amount, payment_method, gateway_name, transaction_id.
Headers we send
| Header | Example | Use |
|---|---|---|
Content-Type | application/json | Always |
User-Agent | EasyInvoice-Webhook/1.0 | For request logging on your side |
X-EI-Event | invoice.paid | The event name — handy when one endpoint subscribes to many events |
X-EI-Timestamp | 1715847600 | Unix timestamp at signing time (used inside the HMAC payload) |
X-EI-Signature | sha256=abc1234… | HMAC-SHA256 of timestamp + "." + body keyed with your secret |
X-EI-Webhook-Id | 7 | Internal id of the subscription that fired |
Verifying signatures
Always verify on your receiver — it's the only way to prove the request came from your Easy Invoice install (and not a random internet bot finding your webhook URL).
Node.js
const crypto = require('crypto');
function verify(req, secret) {
const sig = (req.header('X-EI-Signature') || '').replace('sha256=', '');
const ts = req.header('X-EI-Timestamp');
const body = req.rawBody.toString('utf8');
const expected = crypto.createHmac('sha256', secret)
.update(ts + '.' + body)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}PHP
function verify_ei_webhook($body, $headers, $secret) {
$sig = preg_replace('/^sha256=/', '', $headers['X-EI-Signature'] ?? '');
$ts = $headers['X-EI-Timestamp'] ?? '';
$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
return hash_equals($expected, $sig);
}Python
import hmac, hashlib
def verify(body: bytes, headers: dict, secret: str) -> bool:
sig = headers.get('X-EI-Signature', '').replace('sha256=', '')
ts = headers.get('X-EI-Timestamp', '')
expected = hmac.new(secret.encode(), (ts + '.' + body.decode()).encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)Replay-window check (recommended)
The X-EI-Timestamp is included in the HMAC payload so an attacker can't replay an intercepted request with a different body. To also reject stale requests (i.e., re-played later), check the timestamp:
const now = Math.floor(Date.now() / 1000);
const ts = parseInt(req.header('X-EI-Timestamp'), 10);
if (Math.abs(now - ts) > 300) { // 5-minute window
return res.status(401).send('stale request');
}Retry policy
A non-2xx response (or a connection error) marks the delivery as retry and schedules a re-attempt with exponential backoff:
| Attempt | Delay (after the previous attempt) |
|---|---|
| 2 | ~2 minutes |
| 3 | ~8 minutes |
| 4 | ~32 minutes |
| 5 | ~2 hours |
| 6+ | (none — marked failed) |
Max 5 total attempts. After that the row's status is failed and the easy_invoice_webhook_failed action fires (so you can hook it for alerting). The hourly retry cron easy_invoice_webhooks_retry_tick picks up due retries.
SSRF protection
The Dispatcher refuses to send to any URL whose hostname resolves to:
- Loopback:
127.0.0.1,::1,localhost,0.0.0.0 - Cloud metadata:
169.254.169.254(AWS/Azure/OpenStack),metadata.google.internal(GCP) - Any private RFC1918 range (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) - Any link-local range (
169.254.0.0/16) - Non-
http(s)schemes (file://,gopher://, etc.)
The gate runs at write time (the Subscriptions form refuses to save a blocked URL) AND at dispatch time (in case a URL was edited later). Blocked deliveries are logged with status failed and reason Blocked: target URL not allowed.
To allow private URLs on an intranet install:
add_filter('easy_invoice_webhook_allow_private', '__return_true');You own that risk — only enable on installs where webhooking an internal hostname is intentional and your network is trusted.
Async delivery (non-blocking)
Dispatcher::dispatch() does not POST inline. It:
- Records the payload in the log table with status
queued - Schedules a one-shot WP-Cron event
easy_invoice_webhook_send_onewith the log id - Returns immediately
The user's request that triggered the event (e.g. saving an invoice) is not blocked by the webhook POST. The cron worker fires the POST, updates the log row, and (if needed) schedules a retry.
Cron health
WP-Cron is pseudo-cron — it runs on real page loads. On a quiet site a delivery may be delayed by minutes. For real-time delivery, configure system cron:
*/1 * * * * curl -fsS https://your-site.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1And disable WP's built-in cron in wp-config.php:
define('DISABLE_WP_CRON', true);Delivery log
The Delivery Log tab shows the most recent 150 attempts across all webhooks. Each row:
- When — wall clock
- Webhook — label or
#id - Event — e.g.
invoice.paid - Status —
queued/success/retry/failed/cancelled - HTTP — response code (200, 404, 500, …)
- Attempts — how many tries so far
Status colour-coding:
- 🟢
success— green - 🔵
queued/pending— blue - 🟡
retry— amber - 🔴
failed— red - ⚪
cancelled— grey (webhook was deleted / paused mid-retry)
Log size cap
To prevent a flapping webhook from filling the database, the log is pruned automatically to a maximum of 5,000 rows (configurable). Pruning runs at most once every 6 hours via a transient flag.
// Override default 5000-row cap
add_filter('easy_invoice_webhook_log_max', fn() => 20000);Clearing the log
The Clear log button truncates the log table. Subscriptions are untouched.
Pause / Resume / Delete
Each row in the subscriptions list has three actions:
- Test — fire a synthetic
webhook.testevent to confirm reachability - Pause / Resume — flip the
is_activeflag without deleting the subscription - Delete — remove the subscription and its log rows (cascade)
Zapier walkthrough
- In Zapier, create a new Zap
- Trigger app: Webhooks by Zapier → trigger event: Catch Hook
- Zapier gives you a URL like
https://hooks.zapier.com/hooks/catch/123456/abcd/ - Back in Easy Invoice → Webhooks → Add webhook:
- Paste that URL into Target URL
- Pick the events to forward
- Save
- In Easy Invoice, click Test on the new row
- Zapier should now show the test payload — proceed with field mapping and the rest of your Zap
Why not a published Zapier app?
A custom Zapier app requires a developer account and Zapier's review process. Catch Hook works exactly the same for the trigger side, with zero approval overhead. (Custom "Actions" in Zapier — e.g. "Create invoice from new HubSpot Deal" — would need a Zapier app and aren't in the addon's scope. Use Make.com / n8n / a small PHP script for that.)
Make.com / n8n
Same flow as Zapier:
- Make.com — module
Webhooks → Custom webhook→ copy the URL → paste into Easy Invoice - n8n — node
Webhook(trigger) → setWebhook URL→ paste into Easy Invoice
Hooks for developers
| Hook | Type | When |
|---|---|---|
easy_invoice_webhook_events | filter | Add custom event names to the catalog |
easy_invoice_webhook_allow_private | filter | Allow private/loopback URLs (intranet) |
easy_invoice_webhook_log_max | filter | Cap the delivery log size |
easy_invoice_webhook_delivered (hook, event, http_code) | action | A delivery just succeeded |
easy_invoice_webhook_failed (hook, event, http_code, body) | action | A delivery exhausted retries |
easy_invoice_webhook_blocked (hook, event, reason) | action | A delivery was blocked by SSRF gate |
easy_invoice_mc_rates_refreshed (base, rates) | action | (Multi-Currency, unrelated) |
Emit a custom event from your code
use EasyInvoicePro\Addons\Webhooks\EventBridge;
EventBridge::emit('my_plugin.something_happened', [
'something' => 'value',
'count' => 42,
]);The event will be dispatched to every subscription that includes my_plugin.something_happened in its events list (add it to the catalog via the easy_invoice_webhook_events filter so users can tick it in the UI).
Common scenarios
"Post to Slack when an invoice is paid"
- In Slack: Apps → Incoming Webhooks → create one for
#billing. Copy the URL. - You'll need a transformer — Slack expects
{"text":"…"}, not Easy Invoice's payload shape. Use Zapier or a tiny serverless function as the intermediary.
In Zapier:
- Trigger: Catch Hook (Easy Invoice → Webhooks → subscribe to
invoice.paid→ paste the Zapier URL) - Action: Slack → Send Channel Message → format the text with Zapier's "Formatter" using the invoice payload fields
"Sync paid invoices to Google Sheets"
- Zapier → trigger: Catch Hook (subscribe to
invoice.paid) - Zapier → action: Google Sheets → Create Spreadsheet Row
- Map
data.invoice_number,data.total,data.customer_name,data.public_urlto columns
"Create a HubSpot deal when a quote is accepted"
- Zapier → trigger: Catch Hook (subscribe to
quote.accepted) - Zapier → action: HubSpot → Create Deal
- Map
data.customer_emailto contact lookup,data.totalto deal value
Troubleshooting
"Test" shows queued and never delivers
WP-Cron isn't running. Either:
- Visit a real page on the site (
/) — that triggers pseudo-cron - Set up system cron (see #async-delivery)
- Manually run
wp cron event run easy_invoice_webhook_send_one(WP-CLI)
Delivery shows failed with Blocked: target URL not allowed
Your target URL resolves to a private/loopback/cloud-metadata range. Either:
- Use a public URL
- On an intranet, add the
easy_invoice_webhook_allow_privatefilter (see #ssrf-protection)
Delivery shows failed with HTTP code 200
The dispatcher only treats 2xx as success. If your receiver returns 200 but the body indicates failure, that's still a success here. Configure your receiver to return 4xx/5xx for failures so retries kick in.
Signature verification fails
- Confirm you're using the raw body (not parsed JSON) in the HMAC
- Confirm you're using the timestamp from the header, not your own clock
- Confirm the secret matches exactly (Easy Invoice stores it in plaintext so you can verify it — see the Subscriptions tab settings)
Duplicate deliveries on the same event
The addon dedupes save events within a single request, but the same event firing in two separate requests will produce two webhook deliveries. If your receiver is non-idempotent, use the data.invoice_id + event combination as an idempotency key on your side.
Roadmap
- REST API for reading invoices / quotes (currently webhooks-out only)
- Per-event filter on the subscription (e.g. only fire
invoice.paidfor invoices > $1000) - Webhook signing with rotating secrets
- Webhook health dashboard (failure-rate graph per subscription)
See also
- Hooks & filters reference
- Team Members & Audit Log — log every dispatch + outcome in addition to the webhook table
- Smart Reminders & Late Fees — pair with webhooks to notify Slack when a reminder is sent
- Addons overview