Skip to content

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 https address 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 2xx within 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-Id is the same on every retry; keep the ids you have handled and skip duplicates. Nonox-Event-Type names 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

EventMeaningStatus
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-Signature string header required

    `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the subscription secret>`. Compare in constant time.

    Example: t=1772444713,v1=5f8e2a1c9b7d3e6f0a4c8b2d1e9f7a3c5b0d8e6f2a4c1b9d7e3f5a0c8b6d2e4f

  • Nonox-Event-Id string (uuid) header required

    Unique per event; the same on every retry.

  • Nonox-Event-Type string header required

    The event type, as `type` in the body.

    Example: voucher.posted

  • User-Agent string header required

    Example: NONOX-Webhooks/1.0 (+https://github.com/STAFE-GROUP-AB/nonox)

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.

  • id string (uuid) required

    Same as the Nonox-Event-Id header; use it to ignore duplicates.

  • type string required

    Open enum: new event types may be added within v1.

    Values: voucher.posted, invoice.sent, invoice.paid, supplier_invoice.approved

  • created_at string (date-time) required
  • company_id integer required
  • data object required

Responses

2XX Received. Anything else, or no answer within 10 seconds (5 to connect), is retried.

Body NONOX sends

HTTP
POST /your/webhook HTTP/1.1
Content-Type: application/json
Nonox-Signature: t=1772444713,v1=5f8e2a1c…
Nonox-Event-Id: c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f
JSON
{
  "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-Signature string header required

    `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the subscription secret>`. Compare in constant time.

    Example: t=1772444713,v1=5f8e2a1c9b7d3e6f0a4c8b2d1e9f7a3c5b0d8e6f2a4c1b9d7e3f5a0c8b6d2e4f

  • Nonox-Event-Id string (uuid) header required

    Unique per event; the same on every retry.

  • Nonox-Event-Type string header required

    The event type, as `type` in the body.

    Example: invoice.sent

  • User-Agent string header required

    Example: NONOX-Webhooks/1.0 (+https://github.com/STAFE-GROUP-AB/nonox)

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.

  • id string (uuid) required

    Same as the Nonox-Event-Id header; use it to ignore duplicates.

  • type string required

    Open enum: new event types may be added within v1.

    Values: voucher.posted, invoice.sent, invoice.paid, supplier_invoice.approved

  • created_at string (date-time) required
  • company_id integer required
  • data object required

Responses

2XX Received. Anything else, or no answer within 10 seconds (5 to connect), is retried.

Body NONOX sends

HTTP
POST /your/webhook HTTP/1.1
Content-Type: application/json
Nonox-Signature: t=1772444713,v1=5f8e2a1c…
Nonox-Event-Id: c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f
JSON
{
  "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-Signature string header required

    `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the subscription secret>`. Compare in constant time.

    Example: t=1772444713,v1=5f8e2a1c9b7d3e6f0a4c8b2d1e9f7a3c5b0d8e6f2a4c1b9d7e3f5a0c8b6d2e4f

  • Nonox-Event-Id string (uuid) header required

    Unique per event; the same on every retry.

  • Nonox-Event-Type string header required

    The event type, as `type` in the body.

    Example: invoice.paid

  • User-Agent string header required

    Example: NONOX-Webhooks/1.0 (+https://github.com/STAFE-GROUP-AB/nonox)

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.

  • id string (uuid) required

    Same as the Nonox-Event-Id header; use it to ignore duplicates.

  • type string required

    Open enum: new event types may be added within v1.

    Values: voucher.posted, invoice.sent, invoice.paid, supplier_invoice.approved

  • created_at string (date-time) required
  • company_id integer required
  • data object required

Responses

2XX Received. Anything else, or no answer within 10 seconds (5 to connect), is retried.

Body NONOX sends

HTTP
POST /your/webhook HTTP/1.1
Content-Type: application/json
Nonox-Signature: t=1772444713,v1=5f8e2a1c…
Nonox-Event-Id: c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f
JSON
{
  "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-Signature string header required

    `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the subscription secret>`. Compare in constant time.

    Example: t=1772444713,v1=5f8e2a1c9b7d3e6f0a4c8b2d1e9f7a3c5b0d8e6f2a4c1b9d7e3f5a0c8b6d2e4f

  • Nonox-Event-Id string (uuid) header required

    Unique per event; the same on every retry.

  • Nonox-Event-Type string header required

    The event type, as `type` in the body.

    Example: supplier_invoice.approved

  • User-Agent string header required

    Example: NONOX-Webhooks/1.0 (+https://github.com/STAFE-GROUP-AB/nonox)

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.

  • id string (uuid) required

    Same as the Nonox-Event-Id header; use it to ignore duplicates.

  • type string required

    Open enum: new event types may be added within v1.

    Values: voucher.posted, invoice.sent, invoice.paid, supplier_invoice.approved

  • created_at string (date-time) required
  • company_id integer required
  • data object required

Responses

2XX Received. Anything else, or no answer within 10 seconds (5 to connect), is retried.

Body NONOX sends

HTTP
POST /your/webhook HTTP/1.1
Content-Type: application/json
Nonox-Signature: t=1772444713,v1=5f8e2a1c…
Nonox-Event-Id: c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f
JSON
{
  "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:

HTTP
Nonox-Signature: t=1772444713,v1=5f8e2a1c9b7d3e6f0a4c8b2d1e9f7a3c5b0d8e6f2a4c1b9d7e3f5a0c8b6d2e4f

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

  1. Read the raw body, byte for byte, before anything parses the JSON.
  2. Split the header on , and =. There may be several v1 while a secret is being rotated; accept when any of them matches.
  3. Reject when t is more than five minutes from your clock (replay protection).
  4. Compute HMAC-SHA256 of t + "." + body and compare in constant time.
PHP
/**
 * 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);
JavaScript
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.
});
Python
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:

Shell
printf '%s' '1772444713.{"id":"c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f","type":"voucher.posted"}' \
  | openssl dgst -sha256 -hmac 'test_secret' -hex

Remember that your check rejects a time that old; test with the tolerance switched off or with a current time.