Skip to content
Pro · Agency planRequires 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

EventFires when
invoice.createdsave_post_easy_invoice (with $update=false) — new invoice published
invoice.updatedsave_post_easy_invoice (with $update=true) — existing invoice saved
invoice.sentEmail sent to client (currently fires alongside invoice.updated when status flips to available)
invoice.paideasy_invoice_payment_completed AND _easy_invoice_status === 'paid'
invoice.overdueStatus flips to overdue (cron-driven)
invoice.deletedbefore_delete_post for post_type easy_invoice

Quote events

EventFires when
quote.createdsave_post_easy_invoice_quote (new)
quote.acceptedeasy_invoice_service_quote_accepted
quote.declinedeasy_invoice_service_quote_declined
quote.expiredeasy_invoice_quote_expired (daily cron)

Payment events

EventFires when
payment.recordedeasy_invoice_payment_completed
payment.refunded(planned — core hook in progress)
payment.failed(planned — core hook in progress)

Other events

EventFires when
client.createdUser with role customer is registered
recurring.chargedeasy_invoice_pro_recurring_invoice_created — recurring template generated a child invoice

Subscribing a URL

In the Subscriptions tab → Add webhook:

FieldRequiredWhat it does
LabeloptionalFriendly name for the row; defaults to the URL hostname
Target URLrequiredWhere to POST. Must pass the SSRF gate.
SecretoptionalUsed to sign each request (HMAC-SHA256). Auto-generated 32-char password if blank.
EventsrequiredTick 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":  "[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

HeaderExampleUse
Content-Typeapplication/jsonAlways
User-AgentEasyInvoice-Webhook/1.0For request logging on your side
X-EI-Eventinvoice.paidThe event name — handy when one endpoint subscribes to many events
X-EI-Timestamp1715847600Unix timestamp at signing time (used inside the HMAC payload)
X-EI-Signaturesha256=abc1234…HMAC-SHA256 of timestamp + "." + body keyed with your secret
X-EI-Webhook-Id7Internal 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)

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:

AttemptDelay (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.

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
  • Statusqueued / 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

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

HookTypeWhen
easy_invoice_webhook_eventsfilterAdd custom event names to the catalog
easy_invoice_webhook_allow_privatefilterAllow private/loopback URLs (intranet)
easy_invoice_webhook_log_maxfilterCap the delivery log size
easy_invoice_webhook_delivered (hook, event, http_code)actionA delivery just succeeded
easy_invoice_webhook_failed (hook, event, http_code, body)actionA delivery exhausted retries
easy_invoice_webhook_blocked (hook, event, reason)actionA 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)
  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)

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