Guide to Invoice Ninja Webhooks: Features and Best Practices
Invoice Ninja webhooks tell your other systems when billing data changes: a client is added, an invoice is sent or goes late, a quote is approved, a payment comes in, an expense is recorded. If you run invoicing on Invoice Ninja, the open-source invoicing platform, either hosted or self-hosted, webhooks let you sync that activity into your accounting, CRM or fulfilment systems without polling the API.
This guide covers how Invoice Ninja webhook subscriptions work, how to authenticate them with a secret header, the event keys and the bare-entity payload, the delivery rules that can delete your subscription, and the best practices for production.
What are Invoice Ninja webhooks?
An Invoice Ninja webhook subscription has one event, one target URL, an HTTP method and an optional set of headers. When the event happens, Invoice Ninja sends the affected entity, such as the invoice or payment, to the URL as JSON.
Invoice Ninja's public API reference documents the subscription fields but not how deliveries behave. Invoice Ninja is open source, so the delivery details in this guide come from its version 5 server code. That code can change between releases without a docs update, so check them against your own version if you self-host.
Webhooks aren't signed. The base request carries only Content-Length, Accept: application/json and a User-Agent of InvoiceNinja/<version> (+https://invoiceninja.com), and Invoice Ninja merges the subscription's own headers over them. A secret header you add to the subscription is how your receiver authenticates a request.
Invoice Ninja webhook features
| Feature | Details |
|---|---|
| Configuration | The webhook settings in the Invoice Ninja app, or /api/v1/webhooks |
| Who can create them | Admin users on an account with the API feature |
| Subscription model | One event per subscription |
| Methods | POST (default) or PUT |
| Body | The entity as JSON, with no wrapper and no event field |
| Authentication | Custom headers on the subscription. No signature |
| Timeouts | 10 seconds to connect, 30 seconds in total |
| Retries | Connection errors and 429 only, up to 5 tries |
| Not retried | Other 4xx responses and every 5xx |
| Subscription deleted | On a 404, 405 or 410 response |
| Source IPs | None published. Self-hosted installs send from their own host |
Common events
Invoice Ninja has 65 event keys, each of the form action_entity:
| Entity | Event keys |
|---|---|
| Client | create_client, update_client, archive_client, restore_client, delete_client |
| Invoice | create_invoice, sent_invoice, update_invoice, late_invoice, remind_invoice, archive_invoice, restore_invoice, delete_invoice |
| Quote | create_quote, sent_quote, update_quote, approve_quote, expired_quote, remind_quote, archive_quote, restore_quote, delete_quote |
| Credit | create_credit, sent_credit, update_credit, archive_credit, restore_credit, delete_credit |
| Payment | create_payment, update_payment, archive_payment, restore_payment, delete_payment |
| Purchase order | create_purchase_order, sent_purchase_order, update_purchase_order, accept_purchase_order, archive_purchase_order, restore_purchase_order, delete_purchase_order |
| Vendor, expense, task, project, product | create_, update_, archive_, restore_ and delete_ for each |
The body is the entity itself, as Invoice Ninja's API would return it, with no envelope. A create_invoice delivery is an invoice object, a create_payment delivery is a payment object, and nothing in the request says which event fired. Because each subscription has exactly one event, the reliable way to route is by URL: give each subscription its own path or query string, such as https://example.com/webhooks/invoice-ninja?event=create_.
Invoice Ninja doesn't publish example deliveries, so capture one for each event you handle before you write a parser for it.
Setting up Invoice Ninja webhooks
- Generate a long random value, for example with
openssl rand -hex 32, and choose a header name such asX-Invoice-Ninja-Secret. - In Invoice Ninja, open the webhook settings and add a webhook. Enter the target URL, choose the event, choose the method, and add your header name and value under the webhook's headers.
- Repeat for each event, with a different URL or query string each time.
In Hookdeck. Create a source with the Invoice Ninja type, enter your header name in API Key Header and the exact value in API Key, and use the source URL, plus your ?event= value, as the target URL. Hookdeck matches the header name case-insensitively and the value exactly. The source type pre-selects POST. If a subscription uses PUT, allow PUT on the source too.
Securing Invoice Ninja webhooks
Compare the header with your stored value in constant time. Answer with care: some status codes delete your subscription.
const crypto = require('crypto');
const express = require('express');
function safeEqual(received, expected) {
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on buffers of different lengths
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// Accept both methods Invoice Ninja can use. Never let this route answer 404, 405 or 410.
app.all('/webhooks/invoice-ninja', express.raw({ type: '*/*' }), (req, res) => {
if (!['POST', 'PUT'].includes(req.method)) return res.sendStatus(400);
const expected = process.env.INVOICE_NINJA_WEBHOOK_SECRET;
if (!expected) return res.sendStatus(503); // fail closed; a 5xx is not retried either
const received = req.get(process.env.INVOICE_NINJA_WEBHOOK_HEADER || 'X-Invoice-Ninja-Secret');
if (typeof received !== 'string' || !safeEqual(received, expected)) {
return res.sendStatus(401);
}
let entity;
try {
entity = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
const event = req.query.event; // set per subscription, e.g. "create_invoice"
// Deduplicate on event + entity.id + entity.updated_at, then process async.
res.sendStatus(200);
});
The same check in Python:
import hmac
import os
def verify_invoice_ninja_request(headers) -> bool:
expected = os.environ.get("INVOICE_NINJA_WEBHOOK_SECRET")
if not expected:
return False # fail closed
header_name = os.environ.get("INVOICE_NINJA_WEBHOOK_HEADER", "X-Invoice-Ninja-Secret")
received = headers.get(header_name) # Starlette and Flask headers are case-insensitive
if not received:
return False
return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))
A matching header shows the sender knows the value. It doesn't show that the body is unchanged, and a captured request can be replayed later and still pass. Use an HTTPS target URL, keep the value long and random, and update it in each subscription and in your configuration if it may have leaked.
Make Invoice Ninja webhooks production-ready. Hookdeck Event Gateway checks the secret header on every request, answers Invoice Ninja as soon as each one arrives, and queues each event durably with retries and replay.
Invoice Ninja webhook limitations and pain points
A 404, 405 or 410 deletes the subscription
The Problem: A subscription disappears, and events stop without any error on your side.
Why It Happens: Invoice Ninja treats 404, 405 and 410 as "this URL is gone", logs the failure and deletes the subscription. A route that only accepts POST while the subscription uses PUT, a deploy that briefly removes the route, or a proxy that answers 404 for an unknown path is enough.
Workarounds:
- Make sure your route accepts the subscription's method and never returns these codes. Use
401for bad credentials and400for bad bodies. - Check Invoice Ninja's system log, and the list of webhooks, after deploys and incidents.
How Hookdeck Can Help: With a Hookdeck source as the target URL, Invoice Ninja talks to Hookdeck, which answers each request as it arrives, provided the source allows the subscription's method. Your own routing mistakes then show up as failed deliveries in Hookdeck that you can replay, instead of a deleted subscription.
Most failures are never retried
The Problem: A 500 from your service, or a 401 caused by a rotated secret, loses the event for good.
Why It Happens: In the v5 code, only connection errors and 429 responses are retried, up to 5 tries in all, with the retries roughly 10 to 15 seconds, 30 to 40 seconds, 1 minute and 3 minutes apart. Every 5xx and every other 4xx is logged and dropped.
Workarounds:
- Keep your endpoint simple and highly available, and acknowledge before doing any work that might fail.
- If you need Invoice Ninja to retry, the only response that triggers it is
429. - Reconcile against the Invoice Ninja API after incidents.
How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives and stores it, then delivers it to your service with retries on a schedule you control. A 500 from your service becomes a retry, not a lost event.
No event name in the request
The Problem: A single receiver can't tell a create_invoice from an update_invoice from the body alone.
Why It Happens: The event key belongs to the subscription, and the body is just the entity. Invoice Ninja adds no event header or field.
Workarounds:
- Give every subscription its own path or
?event=value. - If you can't, infer what you can from the entity, such as
created_atagainstupdated_at, and treat it as a hint.
How Hookdeck Can Help: Hookdeck filters can route on the request path or query string, so one source can fan subscriptions out to different destinations by event.
A static header, not a signature
The Problem: Anyone who learns the header value can send convincing invoices or payments to your endpoint.
Why It Happens: Invoice Ninja doesn't sign webhooks. The custom header is the same on every request.
Workarounds:
- Use a long random value over HTTPS.
- For payments and anything else that moves money, re-read the entity from the Invoice Ninja API by
idbefore acting.
How Hookdeck Can Help: Hookdeck's Invoice Ninja source type compares the header you name with the exact value you enter. Hookdeck answers Invoice Ninja with a 200 either way, then records a request without the right header as failed verification, so it never reaches your destination.
Best practices
One subscription, one URL
Route by path or query string, because the body doesn't carry the event. Keep a table of which subscription points where.
Deduplicate on the entity
There's no delivery ID. Use the event, the entity id and updated_at as your idempotency key. See our guide to webhook idempotency.
Choose your status codes deliberately
200 for success, 401 for bad credentials, 400 for a bad body, and 429 only when you want a retry. Never 404, 405 or 410.
Acknowledge fast and process asynchronously
Invoice Ninja waits up to 30 seconds, but anything that fails after you've started work is lost. Verify, store, respond, then process. See why to process webhooks asynchronously.
Pin your expectations to your version
If you self-host, the delivery behaviour is whatever your installed version's code does. Re-check it when you upgrade.
Conclusion
Invoice Ninja sends the bare entity for each subscribed event, using POST or PUT, with a secret header you add as the only credential. Compare that header in constant time over HTTPS, give each subscription its own URL, deduplicate on the entity, and never answer 404, 405 or 410, because Invoice Ninja deletes the subscription, and it doesn't retry most other failures.
Hookdeck Event Gateway sits in front of your receiver, checks the secret header, answers Invoice Ninja straight away, and keeps every event until your service has processed it.
Get started with Hookdeck for free and handle Invoice Ninja webhooks reliably in minutes.