Developers
Webhooks
A webhook is a message InvoiceTools sends to a URL you choose the moment something happens: a client opens an invoice, an invoice is paid, a quote is accepted, a payment arrives. Point it at Zapier, Make, n8n or your own server and you can update a spreadsheet, post to chat or start a job without checking the dashboard. Webhooks are on the Business plan. For a ready-made connection, see the Zapier integration.
Set them up in Account, Connections, Webhooks: add an endpoint, pick events, copy the signing secret, and press Send test event.
How it works
- You add an HTTPS URL and choose which events it receives. We show you a signing secret once.
- When a matching event happens, we send a
POSTwith a JSON body to your URL within about a minute. - Your server checks the signature, answers with any
2xxstatus within 10 seconds, and does its work. - If it does not, we retry on the schedule below.
Events
| Event | When it is sent |
|---|---|
invoice.sent | An invoice was sent or marked as sent. |
invoice.viewed | The client opened the invoice for the first time. |
invoice.paid | The invoice is paid in full. |
invoice.partially_paid | A payment arrived and a balance remains. |
invoice.overdue | The invoice passed its due date unpaid. Once per invoice. |
quote.sent | A quote was sent. |
quote.viewed | The client opened the quote for the first time. |
quote.accepted | The client accepted the quote. |
quote.declined | The client declined the quote. |
invoice.due_in_1, invoice.due_in_3, invoice.due_in_7, invoice.due_in_14 | Once per invoice, that many days before its due date, if it is still sent and has a balance. Checked once a day at 02:45 UTC against the due date as written (no time zone). The object also carries days_before_due. |
invoice.reminder_sent | A payment reminder email for an invoice was sent. One event per reminder; the object id is the reminder and invoice_id the invoice. |
quote.expired | A sent quote that was never accepted or declined passed its valid until date. Once per quote, checked daily at 02:45 UTC. |
client.updated | A client's name, email, phone, address or notes changed. The object carries changed_fields. |
payment.received | A payment was recorded or paid online. |
payment.refunded | An online payment was refunded. |
webhook.test | Sent only when you press Send test event. Always delivered, whatever events you chose. |
The envelope
Every request body has the same shape. The id is stable across retries of the same event, so use it to ignore duplicates.
| Field | Type | Meaning |
|---|---|---|
id | string | Unique event id, starts with evt_. |
type | string | One of the event names above. |
created | string | When the event happened, ISO 8601 in UTC. |
livemode | boolean | true for real events, false for test events. |
data.object | object | The document or payment the event is about. Fields below. |
Document object
Used by every invoice.* and quote.* event.
| Field | Meaning |
|---|---|
id | Document id (UUID). |
kind | invoice or quote. |
number | The document number you see, such as INV-1042. |
status | draft, sent, paid, overdue, accepted, declined or void. |
client_name | The client's name. Contact details are never included. |
currency | Three-letter currency code. |
total | Document total, as a number. |
amount_due | Invoices only: total minus what has been paid. null for quotes. |
issue_date, due_date | YYYY-MM-DD, or null. |
share_url | The public link you already send your client, or null. |
Payment object
Used by payment.received and payment.refunded.
| Field | Meaning |
|---|---|
id | Payment id. |
document_id, document_number, document_kind | The document it belongs to. |
amount | payment.received: the amount paid. |
amount_refunded, total_refunded | payment.refunded: this refund, and all refunds on the payment so far. |
currency, method | Currency code and how it was paid (bank, card, cash, cheque, paypal, other). |
paid_on | payment.received: YYYY-MM-DD. |
We never send client email, phone or address, internal notes, or anything about other users. The only link included is the public share_url you already send your client.
Example payloads
Tap an event to see a complete request body.
invoice.sent
An invoice was sent to the client by email or marked as sent.
{
"id": "evt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"type": "invoice.sent",
"created": "2026-10-16T14:05:09Z",
"livemode": true,
"data": {
"object": {
"id": "7b1f6d52-0c1e-4a8e-9e0a-2f4f0c6d9a11",
"kind": "invoice",
"number": "INV-1042",
"status": "sent",
"client_name": "Acme Ltd",
"currency": "USD",
"total": 1250,
"amount_due": 1250,
"issue_date": "2026-10-01",
"due_date": "2026-10-31",
"share_url": "https://invoicetools.com/q/?t=Qk3x9Tn2mVh7"
}
}
}invoice.viewed
The client opened the invoice link for the first time. Later views are not sent.
{
"id": "evt_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"type": "invoice.viewed",
"created": "2026-10-16T14:05:09Z",
"livemode": true,
"data": {
"object": {
"id": "7b1f6d52-0c1e-4a8e-9e0a-2f4f0c6d9a11",
"kind": "invoice",
"number": "INV-1042",
"status": "sent",
"client_name": "Acme Ltd",
"currency": "USD",
"total": 1250,
"amount_due": 1250,
"issue_date": "2026-10-01",
"due_date": "2026-10-31",
"share_url": "https://invoicetools.com/q/?t=Qk3x9Tn2mVh7"
}
}
}invoice.paid
The invoice is paid in full.
{
"id": "evt_cccccccccccccccccccccccccccccccc",
"type": "invoice.paid",
"created": "2026-10-16T14:05:09Z",
"livemode": true,
"data": {
"object": {
"id": "7b1f6d52-0c1e-4a8e-9e0a-2f4f0c6d9a11",
"kind": "invoice",
"number": "INV-1042",
"status": "paid",
"client_name": "Acme Ltd",
"currency": "USD",
"total": 1250,
"amount_due": 0,
"issue_date": "2026-10-01",
"due_date": "2026-10-31",
"share_url": "https://invoicetools.com/q/?t=Qk3x9Tn2mVh7"
}
}
}invoice.partially_paid
A payment came in and a balance is still due. Sent once per partial payment.
{
"id": "evt_dddddddddddddddddddddddddddddddd",
"type": "invoice.partially_paid",
"created": "2026-10-16T14:05:09Z",
"livemode": true,
"data": {
"object": {
"id": "7b1f6d52-0c1e-4a8e-9e0a-2f4f0c6d9a11",
"kind": "invoice",
"number": "INV-1042",
"status": "sent",
"client_name": "Acme Ltd",
"currency": "USD",
"total": 1250,
"amount_due": 750,
"issue_date": "2026-10-01",
"due_date": "2026-10-31",
"share_url": "https://invoicetools.com/q/?t=Qk3x9Tn2mVh7"
}
}
}invoice.overdue
The invoice passed its due date unpaid. Sent once per invoice.
{
"id": "evt_eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"type": "invoice.overdue",
"created": "2026-10-16T14:05:09Z",
"livemode": true,
"data": {
"object": {
"id": "7b1f6d52-0c1e-4a8e-9e0a-2f4f0c6d9a11",
"kind": "invoice",
"number": "INV-1042",
"status": "overdue",
"client_name": "Acme Ltd",
"currency": "USD",
"total": 1250,
"amount_due": 1250,
"issue_date": "2026-10-01",
"due_date": "2026-10-31",
"share_url": "https://invoicetools.com/q/?t=Qk3x9Tn2mVh7"
}
}
}quote.sent
A quote was sent to the client.
{
"id": "evt_ffffffffffffffffffffffffffffffff",
"type": "quote.sent",
"created": "2026-10-16T14:05:09Z",
"livemode": true,
"data": {
"object": {
"id": "7b1f6d52-0c1e-4a8e-9e0a-2f4f0c6d9a11",
"kind": "quote",
"number": "Q-0318",
"status": "sent",
"client_name": "Acme Ltd",
"currency": "USD",
"total": 1250,
"amount_due": null,
"issue_date": "2026-10-01",
"due_date": null,
"share_url": "https://invoicetools.com/q/?t=Qk3x9Tn2mVh7"
}
}
}quote.viewed
The client opened the quote link for the first time.
{
"id": "evt_00000000000000000000000000000000",
"type": "quote.viewed",
"created": "2026-10-16T14:05:09Z",
"livemode": true,
"data": {
"object": {
"id": "7b1f6d52-0c1e-4a8e-9e0a-2f4f0c6d9a11",
"kind": "quote",
"number": "Q-0318",
"status": "sent",
"client_name": "Acme Ltd",
"currency": "USD",
"total": 1250,
"amount_due": null,
"issue_date": "2026-10-01",
"due_date": null,
"share_url": "https://invoicetools.com/q/?t=Qk3x9Tn2mVh7"
}
}
}quote.accepted
The client accepted the quote.
{
"id": "evt_11111111111111111111111111111111",
"type": "quote.accepted",
"created": "2026-10-16T14:05:09Z",
"livemode": true,
"data": {
"object": {
"id": "7b1f6d52-0c1e-4a8e-9e0a-2f4f0c6d9a11",
"kind": "quote",
"number": "Q-0318",
"status": "accepted",
"client_name": "Acme Ltd",
"currency": "USD",
"total": 1250,
"amount_due": null,
"issue_date": "2026-10-01",
"due_date": null,
"share_url": "https://invoicetools.com/q/?t=Qk3x9Tn2mVh7"
}
}
}quote.declined
The client declined the quote.
{
"id": "evt_22222222222222222222222222222222",
"type": "quote.declined",
"created": "2026-10-16T14:05:09Z",
"livemode": true,
"data": {
"object": {
"id": "7b1f6d52-0c1e-4a8e-9e0a-2f4f0c6d9a11",
"kind": "quote",
"number": "Q-0318",
"status": "declined",
"client_name": "Acme Ltd",
"currency": "USD",
"total": 1250,
"amount_due": null,
"issue_date": "2026-10-01",
"due_date": null,
"share_url": "https://invoicetools.com/q/?t=Qk3x9Tn2mVh7"
}
}
}payment.received
A payment was recorded against a document: by you, or by a client paying online.
{
"id": "evt_33333333333333333333333333333333",
"type": "payment.received",
"created": "2026-10-16T14:05:09Z",
"livemode": true,
"data": {
"object": {
"id": "3c9e0f6a-52de-4b21-a7f1-8a4c2d6e1b90",
"document_id": "7b1f6d52-0c1e-4a8e-9e0a-2f4f0c6d9a11",
"document_number": "INV-1042",
"document_kind": "invoice",
"amount": 500,
"currency": "USD",
"method": "bank",
"paid_on": "2026-10-16"
}
}
}payment.refunded
An online payment was refunded, fully or in part.
{
"id": "evt_44444444444444444444444444444444",
"type": "payment.refunded",
"created": "2026-10-16T14:05:09Z",
"livemode": true,
"data": {
"object": {
"id": "3c9e0f6a-52de-4b21-a7f1-8a4c2d6e1b90",
"document_id": "7b1f6d52-0c1e-4a8e-9e0a-2f4f0c6d9a11",
"document_number": "INV-1042",
"document_kind": "invoice",
"amount_refunded": 100,
"total_refunded": 100,
"currency": "USD",
"method": "card"
}
}
}webhook.test
Sent when you press Send test event.
{
"id": "evt_55555555555555555555555555555555",
"type": "webhook.test",
"created": "2026-10-16T14:05:09Z",
"livemode": false,
"data": {
"object": {
"message": "This is a test event from InvoiceTools."
}
}
}Request headers
| Header | Value |
|---|---|
Content-Type | application/json |
InvoiceTools-Signature | t=<unix seconds>,v1=<hex signature>. See below. |
InvoiceTools-Event | The event type, for example invoice.paid. |
InvoiceTools-Delivery | Id of this delivery. Same value on every retry of the same event. |
InvoiceTools-Attempt | 1 on the first try, 2 on the first retry, and so on. |
User-Agent | InvoiceTools-Webhooks/1.0 |
Verify the signature
Anyone can post to your URL, so check that the request really came from InvoiceTools. We sign every request the way Stripe does:
- Read the
InvoiceTools-Signatureheader and split it intot(a Unix timestamp in seconds) andv1(a hex string). - Build the signed string: the timestamp, a literal dot, then the raw request body exactly as received:
<t>.<raw body>. - Compute HMAC-SHA256 of that string. The key is your whole signing secret, including the
whsec_prefix, as UTF-8 text. - Compare the lowercase hex result with
v1using a constant-time comparison. - Reject the request if
tis more than 5 minutes from your clock. That stops someone replaying a captured request.
Use the raw bytes. If your framework parses the JSON first and you serialize it again, whitespace and key order can change and the signature will not match.
Node.js
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.INVOICETOOLS_WEBHOOK_SECRET; // the whsec_... value, whole string
// Use the RAW body. Parsing it first and re-serializing changes the bytes and breaks the signature.
app.post('/invoicetools', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('InvoiceTools-Signature') || '';
const parts = {};
for (const p of header.split(',')) { const i = p.indexOf('='); if (i > 0) parts[p.slice(0, i).trim()] = p.slice(i + 1).trim(); }
const t = Number(parts.t), v1 = parts.v1 || '';
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400); // older than 5 minutes: reject replays
const expected = crypto.createHmac('sha256', SECRET).update(t + '.' + req.body.toString('utf8')).digest('hex');
const ok = v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
// Store event.id and skip it if you have seen it: delivery is at least once.
console.log(event.type, event.data.object.id);
res.sendStatus(200); // any 2xx within 10 seconds
});
app.listen(3000);Python
import hmac, hashlib, os, time, json
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["INVOICETOOLS_WEBHOOK_SECRET"].encode() # the whsec_... value, whole string
@app.post("/invoicetools")
def invoicetools():
raw = request.get_data() # RAW bytes, before any JSON parsing
parts = dict(p.strip().split("=", 1) for p in request.headers.get("InvoiceTools-Signature", "").split(",") if "=" in p)
try:
t = int(parts.get("t", "0"))
except ValueError:
abort(400)
if abs(time.time() - t) > 300:
abort(400) # older than 5 minutes: reject replays
expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(parts.get("v1", ""), expected):
abort(400)
event = json.loads(raw)
# Store event["id"] and skip it if you have seen it: delivery is at least once.
print(event["type"], event["data"]["object"]["id"])
return "", 200 # any 2xx within 10 secondsPHP
<?php
$secret = getenv('INVOICETOOLS_WEBHOOK_SECRET'); // the whsec_... value, whole string
$raw = file_get_contents('php://input'); // RAW body, before json_decode
$header = $_SERVER['HTTP_INVOICETOOLS_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $p) {
$kv = explode('=', trim($p), 2);
if (count($kv) === 2) { $parts[$kv[0]] = $kv[1]; }
}
$t = (int)($parts['t'] ?? 0);
if ($t === 0 || abs(time() - $t) > 300) { http_response_code(400); exit; } // reject replays
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if (!hash_equals($expected, $parts['v1'] ?? '')) { http_response_code(400); exit; }
$event = json_decode($raw, true);
// Store $event['id'] and skip it if you have seen it: delivery is at least once.
error_log($event['type'] . ' ' . $event['data']['object']['id']);
http_response_code(200); // any 2xx within 10 seconds
Check it against a known value
With secret whsec_test_secret, timestamp 1700000000 and body {"a":1} the signed string is 1700000000.{"a":1}. Your code should produce the same hex as this one-liner:
echo -n '1700000000.{"a":1}' | openssl dgst -sha256 -hmac 'whsec_test_secret'
Responses and retries
Any 2xx status within 10 seconds counts as delivered. Redirects are not followed and count as failures, as do timeouts and connection errors. A failed delivery is retried on this schedule, measured from the previous attempt:
| Retry | Wait before it |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6 | 12 hours |
| 7 | 24 hours |
| After the 8th attempt | We stop retrying that event. |
- Delivery is at least once. You can receive the same event twice. Store the event
idand skip repeats. - Events can arrive out of order, especially after a retry. Use
createdif order matters. - If an endpoint fails continuously for 3 days, we switch it off and show the reason in the dashboard. Fix the URL, then turn it back on in Edit.
- The Deliveries list in the dashboard shows the last 20 deliveries for each endpoint: event, result, time and number of tries. When a delivery fails it also shows the reason and the first part of your server's reply, so you can see what went wrong. We keep delivery history for 30 days.
- An endpoint that was switched off shows a Turn back on button. Use Send test event afterward to confirm it works.
Rules for your URL
- HTTPS only, on the standard port 443. Plain
http://, other ports and IP addresses are refused. - The host must resolve to a public address. Names that point to private, loopback, link-local or cloud metadata ranges (such as 10.x.x.x, 192.168.x.x or 169.254.169.254) are never contacted.
- Up to 10 endpoints per account. Each can subscribe to all events or a chosen few.
Secrets
The signing secret is shown once, when you create the endpoint. We cannot show it again. If you lose it or it leaks, choose Rotate secret on the endpoint: the old secret stops working immediately and the new one is shown once. Keep it in an environment variable or secrets manager, never in a browser or a public repository.
Use it with Zapier, Make and n8n
Zapier
- In Zapier choose Create Zap.
- Trigger: Webhooks by Zapier, event Catch Hook.
- Copy the custom webhook URL Zapier gives you.
- In InvoiceTools open Account, Connections, Webhooks, choose Add endpoint, paste the URL and pick your events.
- Press Send test event, then Test trigger in Zapier. The fields from
data.objectappear for you to use in later steps.
On a Business plan you can use the InvoiceTools app in Zapier instead. Connect it with an API key. It has triggers for the events above plus New Invoice, New Quote and New Client; actions to create an invoice, create a quote, add a client and record a payment; and searches to find a client, invoice or quote.
Zapier does not check signatures, which is fine for a private catch hook URL. Treat that URL like a password.
Make
- Add a Webhooks, Custom webhook module and create a webhook. Copy its address.
- Add it as an endpoint in InvoiceTools and send a test event.
- Make learns the data structure from the test event.
n8n
- Add a Webhook node, method POST, and copy its production URL.
- Add that URL as an endpoint in InvoiceTools, then send a test event.
- To verify signatures, enable Raw Body on the node and compute the HMAC in a Code node with the formula above.
API keys and the public API
Business accounts can create API keys in Account, Apps and API. The same plan gives you a REST API to list and create invoices and quotes, record payments and manage clients. Webhooks tell you when something changes, and the API lets you act on it.
curl https://invoicetools.com/api/v1/me \
-H "Authorization: Bearer itk_live_your_key"
Authentication, paging, errors, the rate limit of 120 requests a minute and every endpoint with examples are in the full API reference.
Troubleshooting
- The test event never arrives. Deliveries are sent about once a minute. Open Deliveries on the endpoint to see the status code or error.
- Signature mismatch. Almost always a parsed and re-serialized body. Verify against the raw bytes, and use the full
whsec_secret. - Not allowed URL. Use https, port 443 and a normal public host name.
- Questions. Email contact@invoicetools.com.