Base URL and authentication
All requests go to https://api.getnotaly.com over HTTPS with JSON bodies. Authenticate with your API key in either header:
Authorization: Bearer $NOTALY_API_KEY
# or
X-API-Key: $NOTALY_API_KEY
You get a key when you sign up; create, rotate and revoke keys in the dashboard. The full key is shown once, and only its hash is stored. Requests are limited to 60 per minute per key; above that the API answers 429 with Retry-After: 60.
Create an invoice
POST/invoices
Validates the payload, generates FA(3) XML and submits it to KSeF. Returns 201 with the invoice object. Send an Idempotency-Key header to make retries safe: the same key returns the original invoice instead of creating a new one.
| Field | Type | Notes |
|---|---|---|
number | string | Your invoice number, e.g. FV/2026/10/001 |
issueDate | string | YYYY-MM-DD |
currency | string | ISO 4217, e.g. PLN |
seller, buyer | object | nip (10 digits, valid checksum), name, address.line1, optional address.line2, address.country (ISO 3166 alpha-2) |
items[] | array | 1–1000 lines: name, optional unit, quantity, unitNetPrice, vatRate (23, 8, 5 or 0) |
{
"id": "inv_8f2c41d0a9b34e7c9d1e2f3a4b5c6d7e",
"status": "submitted",
"number": "FV/2026/10/001",
"ksefReferenceNumber": "20261012-EE-2A41B3C7D9-1F",
"ksefNumber": null,
"upo": null,
"pdfUrl": "/invoices/inv_8f2c…/pdf",
"error": null,
"history": [ { "status": "draft", "at": "…", "source": "api" },
{ "status": "submitted", "at": "…", "source": "ksef" } ],
"createdAt": "2026-10-12T07:23:07.000Z",
"updatedAt": "2026-10-12T07:23:08.000Z"
}
If KSeF is unavailable, the invoice is stored as draft with "error": "ksef_unavailable", and no data is lost.
Retrieve an invoice
GET/invoices/{id}
Returns the same invoice object. While the invoice is submitted, this call also checks KSeF and records the decision, so polling it is a valid alternative to webhooks.
| Status | Meaning |
|---|---|
| draft | XML generated, not sent to KSeF yet |
| submitted | Sent to KSeF; stays here while KSeF processes it |
| accepted | Accepted: ksefNumber and upo are set |
| rejected | Rejected: error holds a stable code such as ksef_rejected:<code> |
Download XML and PDF
GET/invoices/{id}/xml
The exact FA(3) XML sent to KSeF (application/xml). See the FA(3) XML guide for its structure.
GET/invoices/{id}/pdf
A PDF visualisation (application/pdf) with the KSeF verification QR code (KOD I). The legal invoice is the XML; the PDF is for people.
Webhooks
Add an HTTPS endpoint in the dashboard under Webhooks. Its signing secret is shown once. Notaly sends a POST when an invoice changes state:
| Event | When |
|---|---|
invoice.accepted | KSeF accepted the invoice; includes ksefNumber, upo and an absolute pdfUrl |
invoice.rejected | KSeF rejected the invoice; error holds the code |
invoice.error | The invoice could not be processed |
{
"id": "evt_inv_8f2c…_accepted",
"type": "invoice.accepted",
"createdAt": "2026-10-12T07:24:02.000Z",
"data": { "invoiceId": "inv_8f2c…", "status": "accepted",
"ksefReferenceNumber": "20261012-EE-…", "ksefNumber": "2745013933-20261012-…",
"upo": "…", "error": null, "pdfUrl": "https://api.getnotaly.com/invoices/inv_8f2c…/pdf" }
}
Verify the signature
Each delivery carries X-Webhook-Id, X-Webhook-Timestamp and X-Webhook-Signature: t=<unix>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" keyed with your endpoint secret.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, rawBody: string, header: string): boolean {
const p = Object.fromEntries(header.split(",").map((x) => x.split("=", 2)));
const t = Number(p.t);
if (!p.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false; // 5-minute window
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const got = Buffer.from(p.v1, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
}
Retries and delivery
- Answer with any
2xxto acknowledge. Any other status, a redirect or a timeout is retried. - Up to 8 attempts with exponential backoff (30 s, 60 s, 2 min… capped at 1 hour, with jitter). The body and event id stay identical across retries.
- Delivery is at-least-once: deduplicate on
X-Webhook-Id. - Endpoints must be public HTTPS URLs; private and internal addresses are refused.
Errors
Errors are JSON with a stable error code. Raw KSeF messages are never returned.
| HTTP | error | What to do |
|---|---|---|
| 401 | unauthorized | Missing or invalid API key |
| 402 | quota_exceeded | Sandbox used its 3 test invoices; upgrade to Starter or Scale |
| 404 | not_found | Unknown id, or the invoice was created with a different API key |
| 422 | validation_failed | Fix the fields listed in details |
| 422 | seller_nip_not_authorized | In the sandbox, seller.nip must be 2745013933 |
| 429 | rate_limited | Wait for Retry-After seconds |
Invoice-level codes in the error field: ksef_unavailable (KSeF did not answer; the invoice stays draft), ksef_rejected:<code> (rejected by KSeF) and ksef_error.
Sandbox and plans
| Plan | Price | KSeF environment | Invoices |
|---|---|---|---|
| Sandbox | Free | TEST, seller NIP 2745013933 | 3 in total |
| Starter | €49/month | Your KSeF connection | Up to 200 per month |
| Scale | €199/month | Your KSeF connection | Up to 1,000 per month |