Guides
Webhooks
With webhooks your program does not have to ask for changes: NONOX sends a POST to your server when something happens. They are delivered since version 1.1.0 of the API.
How they work
- An owner or administrator adds the webhook under Anslutningar in NONOX and confirms with a passkey: an
httpsaddress and the events it should receive. The owner gets an e-mail about every new webhook. - The address is checked when it is saved and before every attempt: private networks, loopback, link-local and other internal addresses are refused, the connection is pinned to the checked address and redirects are never followed.
- Every webhook has a secret of its own (
nnxwh_…), shown once. It signs every delivery and can be rotated. - The event is sent only after the bookkeeping is saved. Answer with
2xxwithin 10 seconds (5 to connect). Anything else is retried after 1, 5 and 15 minutes and 1, 3, 6 and 13 hours. If the eighth attempt fails too, the webhook is disabled and the owner gets an e-mail. - The same event may arrive more than once.
Nonox-Event-Idis the same on every retry; keep the ids you have handled and skip duplicates.Nonox-Event-Typenames the event. - The body is short: ids, numbers, dates and amounts, never personal data. Fetch the full object from the API if you need more.
- Want to try without real bookkeeping? Create a test company under Anslutningar.
The events
| Event | Meaning | Status |
|---|---|---|
voucher.posted |
A voucher was posted | – |
invoice.sent |
A customer invoice was sent | – |
invoice.paid |
A customer invoice was paid in full | – |
supplier_invoice.approved |
A supplier invoice was approved | – |
A voucher was posted
POST
voucher.posted
voucherPosted
Sent after a voucher is posted (manually, through the API, or by issuing an invoice, registering a payment and so on), once the transaction has committed, as an HTTPS POST to every active webhook of the company that subscribes to `voucher.posted`. Verify `Nonox-Signature` over the raw body before trusting it, reject a timestamp more than five minutes from your clock, and store `Nonox-Event-Id` to ignore duplicates (a delivery whose answer was lost is sent again). Answer 2xx within 10 seconds. Anything else is retried after 1, 5 and 15 minutes and 1, 3, 6 and 13 hours (eight attempts over about 23 hours); then the webhook is turned off and the company's owner is e-mailed. Redirects are never followed.
Parameters
-
Nonox-Signaturestring header required`t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the subscription secret>`. Compare in constant time.
-
Nonox-Event-Idstring (uuid) header requiredUnique per event; the same on every retry.
-
Nonox-Event-Typestring header requiredThe event type, as `type` in the body.
-
User-Agentstring header required
Request body
application/jsonWebhookEvent required
A webhook delivery body. Events are thin: ids, references, dates and amounts, never personal data; fetch the record through the API for more.
-
idstring (uuid) requiredSame as the Nonox-Event-Id header; use it to ignore duplicates.
-
typestring requiredOpen enum: new event types may be added within v1.
-
created_atstring (date-time) required -
company_idinteger required -
dataobject required
Responses
2XX Received. Anything else, or no answer within 10 seconds (5 to connect), is retried.
Body NONOX sends
POST /your/webhook HTTP/1.1
Content-Type: application/json
Nonox-Signature: t=1772444713,v1=5f8e2a1c…
Nonox-Event-Id: c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f{
"id": "c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f",
"type": "voucher.posted",
"created_at": "2026-03-02T10:45:13+01:00",
"company_id": 42,
"data": {
"voucher_id": "0b9f3c1e-6a2d-4f7b-9c8e-1d2a3b4c5d6e",
"series": "A",
"number": 57,
"reference": "A57",
"voucher_date": "2026-03-02",
"fiscal_year_id": 7,
"source": "MANUAL",
"amount_ore": 125000,
"corrects_voucher_id": null
}
}A customer invoice was sent
POST
invoice.sent
invoiceSent
Sent after an issued customer invoice is e-mailed to the customer, as an HTTPS POST to every active webhook of the company that subscribes to `invoice.sent`. Verify `Nonox-Signature` over the raw body before trusting it, reject a timestamp more than five minutes from your clock, and store `Nonox-Event-Id` to ignore duplicates (a delivery whose answer was lost is sent again). Answer 2xx within 10 seconds. Anything else is retried after 1, 5 and 15 minutes and 1, 3, 6 and 13 hours (eight attempts over about 23 hours); then the webhook is turned off and the company's owner is e-mailed. Redirects are never followed.
Parameters
-
Nonox-Signaturestring header required`t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the subscription secret>`. Compare in constant time.
-
Nonox-Event-Idstring (uuid) header requiredUnique per event; the same on every retry.
-
Nonox-Event-Typestring header requiredThe event type, as `type` in the body.
-
User-Agentstring header required
Request body
application/jsonWebhookEvent required
A webhook delivery body. Events are thin: ids, references, dates and amounts, never personal data; fetch the record through the API for more.
-
idstring (uuid) requiredSame as the Nonox-Event-Id header; use it to ignore duplicates.
-
typestring requiredOpen enum: new event types may be added within v1.
-
created_atstring (date-time) required -
company_idinteger required -
dataobject required
Responses
2XX Received. Anything else, or no answer within 10 seconds (5 to connect), is retried.
Body NONOX sends
POST /your/webhook HTTP/1.1
Content-Type: application/json
Nonox-Signature: t=1772444713,v1=5f8e2a1c…
Nonox-Event-Id: c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f{
"id": "c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f",
"type": "invoice.sent",
"created_at": "2026-03-02T10:45:13+01:00",
"company_id": 42,
"data": {
"invoice_id": "5c2e8a41-7b3d-4f19-a6c2-0e9d8b7a6f54",
"invoice_number": 1042,
"kind": "INVOICE",
"customer_id": 18,
"total_ore": 237500,
"currency": "SEK",
"invoice_date": "2026-03-02",
"due_date": "2026-04-01",
"ocr": "104257"
}
}A customer invoice was paid in full
POST
invoice.paid
invoicePaid
Sent after a customer invoice becomes paid in full (a registered payment or a matched bank file), as an HTTPS POST to every active webhook of the company that subscribes to `invoice.paid`. Verify `Nonox-Signature` over the raw body before trusting it, reject a timestamp more than five minutes from your clock, and store `Nonox-Event-Id` to ignore duplicates (a delivery whose answer was lost is sent again). Answer 2xx within 10 seconds. Anything else is retried after 1, 5 and 15 minutes and 1, 3, 6 and 13 hours (eight attempts over about 23 hours); then the webhook is turned off and the company's owner is e-mailed. Redirects are never followed.
Parameters
-
Nonox-Signaturestring header required`t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the subscription secret>`. Compare in constant time.
-
Nonox-Event-Idstring (uuid) header requiredUnique per event; the same on every retry.
-
Nonox-Event-Typestring header requiredThe event type, as `type` in the body.
-
User-Agentstring header required
Request body
application/jsonWebhookEvent required
A webhook delivery body. Events are thin: ids, references, dates and amounts, never personal data; fetch the record through the API for more.
-
idstring (uuid) requiredSame as the Nonox-Event-Id header; use it to ignore duplicates.
-
typestring requiredOpen enum: new event types may be added within v1.
-
created_atstring (date-time) required -
company_idinteger required -
dataobject required
Responses
2XX Received. Anything else, or no answer within 10 seconds (5 to connect), is retried.
Body NONOX sends
POST /your/webhook HTTP/1.1
Content-Type: application/json
Nonox-Signature: t=1772444713,v1=5f8e2a1c…
Nonox-Event-Id: c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f{
"id": "c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f",
"type": "invoice.paid",
"created_at": "2026-03-02T10:45:13+01:00",
"company_id": 42,
"data": {
"invoice_id": "5c2e8a41-7b3d-4f19-a6c2-0e9d8b7a6f54",
"invoice_number": 1042,
"customer_id": 18,
"total_ore": 237500,
"paid_ore": 237500,
"currency": "SEK",
"payment_id": 88,
"paid_on": "2026-03-28"
}
}A supplier invoice was approved
POST
supplier_invoice.approved
supplierInvoiceApproved
Sent after a supplier invoice is approved (attesterad), as an HTTPS POST to every active webhook of the company that subscribes to `supplier_invoice.approved`. Verify `Nonox-Signature` over the raw body before trusting it, reject a timestamp more than five minutes from your clock, and store `Nonox-Event-Id` to ignore duplicates (a delivery whose answer was lost is sent again). Answer 2xx within 10 seconds. Anything else is retried after 1, 5 and 15 minutes and 1, 3, 6 and 13 hours (eight attempts over about 23 hours); then the webhook is turned off and the company's owner is e-mailed. Redirects are never followed.
Parameters
-
Nonox-Signaturestring header required`t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the subscription secret>`. Compare in constant time.
-
Nonox-Event-Idstring (uuid) header requiredUnique per event; the same on every retry.
-
Nonox-Event-Typestring header requiredThe event type, as `type` in the body.
-
User-Agentstring header required
Request body
application/jsonWebhookEvent required
A webhook delivery body. Events are thin: ids, references, dates and amounts, never personal data; fetch the record through the API for more.
-
idstring (uuid) requiredSame as the Nonox-Event-Id header; use it to ignore duplicates.
-
typestring requiredOpen enum: new event types may be added within v1.
-
created_atstring (date-time) required -
company_idinteger required -
dataobject required
Responses
2XX Received. Anything else, or no answer within 10 seconds (5 to connect), is retried.
Body NONOX sends
POST /your/webhook HTTP/1.1
Content-Type: application/json
Nonox-Signature: t=1772444713,v1=5f8e2a1c…
Nonox-Event-Id: c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f{
"id": "c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f",
"type": "supplier_invoice.approved",
"created_at": "2026-03-02T10:45:13+01:00",
"company_id": 42,
"data": {
"supplier_invoice_id": "8d1f4b2a-3c5e-4a7b-9d0e-6f1a2b3c4d5e",
"number": 77,
"supplier_id": 9,
"kind": "INVOICE",
"total_ore": 125000,
"currency": "SEK",
"invoice_currency": "SEK",
"due_date": "2026-04-02"
}
}Verify the signature
Every delivery carries the header:
Nonox-Signature: t=1772444713,v1=5f8e2a1c9b7d3e6f0a4c8b2d1e9f7a3c5b0d8e6f2a4c1b9d7e3f5a0c8b6d2e4ft is the time in Unix seconds and v1 is the HMAC-SHA256, hex, of "<t>.<raw body>" with the subscription's secret. To verify it:
- Read the raw body, byte for byte, before anything parses the JSON.
- Split the header on
,and=. There may be severalv1while a secret is being rotated; accept when any of them matches. - Reject when
tis more than five minutes from your clock (replay protection). - Compute HMAC-SHA256 of
t + "." + bodyand compare in constant time.
/**
* Verifies Nonox-Signature for a webhook. $payload is the raw body
* ($request->getContent() in Laravel), never parsed and re-encoded JSON.
*/
function verifyNonoxSignature(string $payload, string $header, string $secret, int $tolerance = 300): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) {
$timestamp = (int) $value;
}
if ($key === 'v1') {
$signatures[] = $value;
}
}
if ($timestamp === null || abs(time() - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
// In a Laravel controller:
abort_unless(verifyNonoxSignature($request->getContent(), (string) $request->header('Nonox-Signature'), config('services.nonox.webhook_secret')), 400);import crypto from 'node:crypto';
import express from 'express';
export function verifyNonoxSignature(rawBody, header, secret, toleranceSeconds = 300) {
let timestamp = null;
const signatures = [];
for (const part of String(header).split(',')) {
const [key, value = ''] = part.trim().split('=', 2);
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
if (key === 'v1') signatures.push(value);
}
if (timestamp === null || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
return false;
}
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
return signatures.some((signature) => {
const given = Buffer.from(signature, 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
// Express: read the body raw, not as JSON.
const app = express();
app.post('/nonox/webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyNonoxSignature(req.body, req.get('Nonox-Signature'), process.env.NONOX_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(204); // Answer fast, do the work in a queue.
});import hashlib
import hmac
import time
def verify_nonox_signature(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
timestamp, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
if key == "v1":
signatures.append(value)
if timestamp is None or abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + payload, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
# Flask: request.get_data() is the raw body.
# if not verify_nonox_signature(request.get_data(), request.headers.get("Nonox-Signature", ""), os.environ["NONOX_WEBHOOK_SECRET"]):
# abort(400)Test your check
With the secret test_secret, the time 1772444713 and the body {"id":"c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f","type":"voucher.posted"}, the signature is the HMAC-SHA256 of 1772444713.{"id":"c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f","type":"voucher.posted"}. Compute it yourself and compare:
printf '%s' '1772444713.{"id":"c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f","type":"voucher.posted"}' \
| openssl dgst -sha256 -hmac 'test_secret' -hexRemember that your check rejects a time that old; test with the tolerance switched off or with a current time.