---
title: Webhooks & Zapier Bridge (Pro addon)
description: Stream Easy Invoice events (invoice.paid, quote.accepted, payment.recorded, etc.) to any URL. HMAC-SHA256 signed, async via cron, retried with exponential backoff, SSRF-protected. Works with Zapier "Catch Webhook", Make.com, n8n, or any custom endpoint.
---
Pro · Agency plan
Requires Easy Invoice Pro with an Agency license.
# 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
1. **Easy Invoice → Addons** → activate **Webhooks & Zapier Bridge**
2. 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 attempts
```
Both 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 by `Dispatcher::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](#ssrf-protection). |
| **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:
```json
{
"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": "billing@acme.com",
"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
```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
```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
```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:
```js
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:
```php
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:
1. Records the payload in the log table with status `queued`
2. Schedules a one-shot WP-Cron event `easy_invoice_webhook_send_one` with the log id
3. 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.
::: tip 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:
```cron
*/1 * * * * curl -fsS https://your-site.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1
```
And disable WP's built-in cron in `wp-config.php`:
```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.
```php
// 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.test` event to confirm reachability
- **Pause** / **Resume** — flip the `is_active` flag without deleting the subscription
- **Delete** — remove the subscription **and** its log rows (cascade)
## Zapier walkthrough
1. In Zapier, create a new Zap
2. Trigger app: **Webhooks by Zapier** → trigger event: **Catch Hook**
3. Zapier gives you a URL like `https://hooks.zapier.com/hooks/catch/123456/abcd/`
4. Back in Easy Invoice → Webhooks → **Add webhook**:
- Paste that URL into **Target URL**
- Pick the events to forward
- Save
5. In Easy Invoice, click **Test** on the new row
6. Zapier should now show the test payload — proceed with field mapping and the rest of your Zap
::: tip 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) → set `Webhook 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
```php
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"
1. In Slack: Apps → **Incoming Webhooks** → create one for `#billing`. Copy the URL.
2. **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_url` to 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_email` to contact lookup, `data.total` to deal value
## Troubleshooting
### "Test" shows `queued` and never delivers
WP-Cron isn't running. Either:
1. Visit a real page on the site (`/`) — that triggers pseudo-cron
2. Set up system cron (see [#async-delivery](#async-delivery-non-blocking))
3. 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:
1. Use a public URL
2. On an intranet, add the `easy_invoice_webhook_allow_private` filter (see [#ssrf-protection](#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.paid` for invoices > $1000)
- Webhook signing with rotating secrets
- Webhook health dashboard (failure-rate graph per subscription)
## See also
- [Hooks & filters reference](../hooks-filters)
- [Team Members & Audit Log](./team-roles) — log every dispatch + outcome in addition to the webhook table
- [Smart Reminders & Late Fees](./smart-reminders) — pair with webhooks to notify Slack when a reminder is sent
- [Addons overview](./)