InvoiceTools

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

  1. You add an HTTPS URL and choose which events it receives. We show you a signing secret once.
  2. When a matching event happens, we send a POST with a JSON body to your URL within about a minute.
  3. Your server checks the signature, answers with any 2xx status within 10 seconds, and does its work.
  4. If it does not, we retry on the schedule below.

Events

EventWhen it is sent
invoice.sentAn invoice was sent or marked as sent.
invoice.viewedThe client opened the invoice for the first time.
invoice.paidThe invoice is paid in full.
invoice.partially_paidA payment arrived and a balance remains.
invoice.overdueThe invoice passed its due date unpaid. Once per invoice.
quote.sentA quote was sent.
quote.viewedThe client opened the quote for the first time.
quote.acceptedThe client accepted the quote.
quote.declinedThe client declined the quote.
invoice.due_in_1, invoice.due_in_3, invoice.due_in_7, invoice.due_in_14Once 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_sentA payment reminder email for an invoice was sent. One event per reminder; the object id is the reminder and invoice_id the invoice.
quote.expiredA sent quote that was never accepted or declined passed its valid until date. Once per quote, checked daily at 02:45 UTC.
client.updatedA client's name, email, phone, address or notes changed. The object carries changed_fields.
payment.receivedA payment was recorded or paid online.
payment.refundedAn online payment was refunded.
webhook.testSent 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.

FieldTypeMeaning
idstringUnique event id, starts with evt_.
typestringOne of the event names above.
createdstringWhen the event happened, ISO 8601 in UTC.
livemodebooleantrue for real events, false for test events.
data.objectobjectThe document or payment the event is about. Fields below.

Document object

Used by every invoice.* and quote.* event.

FieldMeaning
idDocument id (UUID).
kindinvoice or quote.
numberThe document number you see, such as INV-1042.
statusdraft, sent, paid, overdue, accepted, declined or void.
client_nameThe client's name. Contact details are never included.
currencyThree-letter currency code.
totalDocument total, as a number.
amount_dueInvoices only: total minus what has been paid. null for quotes.
issue_date, due_dateYYYY-MM-DD, or null.
share_urlThe public link you already send your client, or null.

Payment object

Used by payment.received and payment.refunded.

FieldMeaning
idPayment id.
document_id, document_number, document_kindThe document it belongs to.
amountpayment.received: the amount paid.
amount_refunded, total_refundedpayment.refunded: this refund, and all refunds on the payment so far.
currency, methodCurrency code and how it was paid (bank, card, cash, cheque, paypal, other).
paid_onpayment.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

HeaderValue
Content-Typeapplication/json
InvoiceTools-Signaturet=<unix seconds>,v1=<hex signature>. See below.
InvoiceTools-EventThe event type, for example invoice.paid.
InvoiceTools-DeliveryId of this delivery. Same value on every retry of the same event.
InvoiceTools-Attempt1 on the first try, 2 on the first retry, and so on.
User-AgentInvoiceTools-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:

  1. Read the InvoiceTools-Signature header and split it into t (a Unix timestamp in seconds) and v1 (a hex string).
  2. Build the signed string: the timestamp, a literal dot, then the raw request body exactly as received: <t>.<raw body>.
  3. Compute HMAC-SHA256 of that string. The key is your whole signing secret, including the whsec_ prefix, as UTF-8 text.
  4. Compare the lowercase hex result with v1 using a constant-time comparison.
  5. Reject the request if t is 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 seconds

PHP

<?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:

RetryWait before it
11 minute
25 minutes
330 minutes
42 hours
56 hours
612 hours
724 hours
After the 8th attemptWe stop retrying that event.
  • Delivery is at least once. You can receive the same event twice. Store the event id and skip repeats.
  • Events can arrive out of order, especially after a retry. Use created if 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

  1. In Zapier choose Create Zap.
  2. Trigger: Webhooks by Zapier, event Catch Hook.
  3. Copy the custom webhook URL Zapier gives you.
  4. In InvoiceTools open Account, Connections, Webhooks, choose Add endpoint, paste the URL and pick your events.
  5. Press Send test event, then Test trigger in Zapier. The fields from data.object appear 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

  1. Add a Webhooks, Custom webhook module and create a webhook. Copy its address.
  2. Add it as an endpoint in InvoiceTools and send a test event.
  3. Make learns the data structure from the test event.

n8n

  1. Add a Webhook node, method POST, and copy its production URL.
  2. Add that URL as an endpoint in InvoiceTools, then send a test event.
  3. 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.

Send it yourself, or send it and watch it

Everything here is free. Generators, calculators and downloads need no account. Send a document as a link instead and you see when it was opened, which line they questioned, and who signed it, so chasing becomes a decision rather than a guess.

The first four documents you send as a tracked link are free with an account. Everything else on InvoiceTools.com stays free. Generators, calculators and downloads need no account; template designs need a free one.