# factuurmaken API: create an invoice draft

Public REST API for Dutch invoicing. This guide and the OpenAPI specification are public;
customer data and write operations require a Bearer API key.

- [OpenAPI 3.1.1 specification](https://factuurmaken.nl/openapi.json)
- [Developer documentation](https://factuurmaken.nl/developers)
- [Create or revoke API keys](https://factuurmaken.nl/app/settings/api)
- [Plans](https://factuurmaken.nl/pricing)

## Before you start

Use an active API-enabled subscription (Pro, Business, or qualifying complimentary access).
Complete your company profile and bank account settings in the app. Create an API key with
clients:read, invoices:read, invoices:write. Add clients:write only if you need to create clients.
Creating a draft does not need invoices:send.

Store the key in your integration's secret store. The shell examples assume it is already loaded
into the FM_API_KEY environment variable. Never paste the key into a conversation, URL or source file.
This is a REST API for clients that can make authenticated HTTP requests. Importing a specification
does not automatically connect an ordinary chat session. MCP and OAuth login are not available yet.

## Administration and conventions

- Every API key acts in the account's DEFAULT administration, regardless of the selected administration in the app. There is no businessId parameter. Confirm the default administration in account settings before writing.
- Money is integer euro cents, excluding VAT for unitPriceCents. 9500 means EUR 95.00. Invoice creation currently uses EUR; there is no currency input.
- Quantities are numbers rounded to 3 decimal places. VAT rates are 21, 9, 0 percent.
- The client vatMode controls VAT treatment: standard, reverse_charge or vat_exempt. Ask about ambiguous tax treatment; do not infer it from a company name.
- The server calculates totals and snapshots sender/recipient details. Later profile edits do not rewrite existing invoices.
- issueDate and dueDate use YYYY-MM-DD. issueDate defaults to today in UTC; dueDate defaults to that date plus the account payment term. Timestamp responses are ISO 8601.
- A draft starts without an invoice number. status is a derived display value: an unpaid draft with a past due date can be overdue. Do not infer delivery from status alone.
- A successful creation is not a completeness check. For example, a reverse-charge client may still need a VAT number. Review the draft in the app before finalizing.

## 1. Find the client

Search existing clients first. Use the returned id, never a guessed ID. If several match, ask the
user which client they mean. If none match, collect the missing details and use POST /api/v1/clients
with clients:write, or let the user create the client in the app. Name/email are not unique.

```sh
curl --fail-with-body --get 'https://factuurmaken.nl/api/v1/clients' \
  --header "Authorization: Bearer $FM_API_KEY" \
  --data-urlencode 'q=Acme'
```

Response: {"clients": [...]} with id, name, email, vatMode and address details. An empty list is
not an API failure. Replace REPLACE_WITH_CLIENT_ID in the next request with the selected client's id.

## 2. Create a separate draft

This example bills 8 hours at €95.00 excluding VAT. With standard VAT treatment at 21%,
the server returns €760.00 subtotal, €159.60 VAT and €919.60 total.

```sh
curl --fail-with-body 'https://factuurmaken.nl/api/v1/invoices' \
  --header "Authorization: Bearer $FM_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
  "clientId": "REPLACE_WITH_CLIENT_ID",
  "onOpenConcept": "create",
  "lines": [
    {
      "description": "Advies",
      "quantity": 8,
      "unit": "uur",
      "unitPriceCents": 9500,
      "vatRate": 21
    }
  ]
}'
```

Illustrative HTTP 201 response (IDs, dates and payment term vary):

```json
{
  "invoice": {
    "id": "example_invoice_id",
    "number": null,
    "status": "draft",
    "invoiceType": "invoice",
    "clientId": "example_client_id",
    "recipient": {
      "name": "Acme B.V.",
      "email": "administratie@example.com"
    },
    "issueDate": "2026-09-26",
    "dueDate": "2026-10-26",
    "paidAt": null,
    "scheduledSendAt": null,
    "currency": "EUR",
    "vatMode": "standard",
    "notes": null,
    "subtotalCents": 76000,
    "vatCents": 15960,
    "totalCents": 91960,
    "createdAt": "2026-09-26T10:00:00.000Z",
    "updatedAt": "2026-09-26T10:00:00.000Z",
    "lines": [
      {
        "description": "Advies",
        "quantity": 8,
        "unit": "uur",
        "unitPriceCents": 9500,
        "vatRate": 21,
        "id": "example_line_1",
        "position": 0,
        "details": null,
        "discountPct": 0,
        "periodStart": null,
        "periodEnd": null
      }
    ]
  },
  "appended": false,
  "sendPlanned": false
}
```

onOpenConcept=create creates a new draft without scheduling delivery. Do not use append for this
workflow: append can add lines to a draft that is already scheduled to be sent, and ignores the
new request's dates and notes when appending.

## 3. Read back and review

Replace REPLACE_WITH_INVOICE_ID with invoice.id from the response.

```sh
curl --fail-with-body 'https://factuurmaken.nl/api/v1/invoices/REPLACE_WITH_INVOICE_ID' \
  --header "Authorization: Bearer $FM_API_KEY"
```

Show the server-returned totals and open https://factuurmaken.nl/app/invoices/{invoice.id} for review.
The review page requires login and the same default administration selected in the app.
The user can edit, finalize, download or send there. The public REST API currently has no standalone
finalization, editing, PDF or UBL export endpoint. Do not fabricate download or recipient URLs.

## Retries and sending

Client creation, invoice creation and line appending are NOT idempotent. Idempotency-Key is not
supported. After a timeout or uncertain write response, check existing clients/invoices before
deciding whether another write is needed; do not automatically retry. Reads can be retried.

Sending is a separate POST /api/v1/invoices/{id}/send operation with invoices:send permission.
Only call it when the user asks for delivery. An empty body schedules for tomorrow's send window;
mode=now still uses the short undo delay. The response means scheduled, not delivered.
An existing schedule is left unchanged (alreadyPlanned=true); a repeat after dispatch can return
not_a_draft. Use the app to change or cancel a schedule.

## Operations

### GET /api/v1/clients

Clients in the account default administration, newest first. Search name/email with q. No pagination. Ask the user when multiple clients match.

Required permission: clients:read.

- 200: Matching clients.
- 401: invalid_api_key: missing, malformed, unknown or revoked Bearer key.
- 403: subscription_inactive, plan_not_allowed or insufficient_scope.

### POST /api/v1/clients

Creates a saved client. Omit number to assign the next client number. Search first to avoid duplicates; name/email are not unique. Retries are not deduplicated. Individual clients discard business-only fields.

Required permission: clients:write.

- 201: Created client; null if removed before read-back.
- 400: invalid_json or validation; issues groups messages by top-level field.
- 401: invalid_api_key: missing, malformed, unknown or revoked Bearer key.
- 403: subscription_inactive, plan_not_allowed or insufficient_scope.
- 409: duplicate_number: client number already exists or automatic numbering could not find a free number.

### GET /api/v1/clients/{id}

Reads a client in the account default administration.

Required permission: clients:read.

- 200: Client.
- 401: invalid_api_key: missing, malformed, unknown or revoked Bearer key.
- 403: subscription_inactive, plan_not_allowed or insufficient_scope.
- 404: not_found: unknown client or outside this administration.

### GET /api/v1/invoices

Invoices in the account default administration, newest first. Includes drafts and existing credit invoices.

Required permission: invoices:read.

- 200: Page of invoices.
- 400: invalid_status: unsupported status filter.
- 401: invalid_api_key: missing, malformed, unknown or revoked Bearer key.
- 403: subscription_inactive, plan_not_allowed or insufficient_scope.

### POST /api/v1/invoices

Creates an unnumbered draft for a saved client. Sender, bank account and payment term come from account settings; VAT treatment comes from the client. The server calculates totals. Use onOpenConcept=create for a separate draft. append may change a draft already scheduled for delivery. Creation/appending is NOT idempotent; an Idempotency-Key header is not supported. Dates and notes are ignored on append. A successful draft can still need details before finalization; review it in the app.

Required permission: invoices:write.

- 200: Lines appended; appended=true. sendPlanned=true means these lines will join an existing scheduled send.
- 201: New draft; appended=false and sendPlanned=false. invoice can be null if removed before read-back.
- 400: invalid_json or validation; issues groups messages by top-level field.
- 401: invalid_api_key: missing, malformed, unknown or revoked Bearer key.
- 402: invoice_cap: account invoice allowance reached.
- 403: subscription_inactive, plan_not_allowed or insufficient_scope.
- 404: client_not_found: unknown client or outside this administration.
- 422: no_company_profile: complete company settings in the app.

### GET /api/v1/invoices/{id}

Returns the invoice and calculated totals. No PDF, UBL, public recipient token or download URL is returned.

Required permission: invoices:read.

- 200: Invoice with lines.
- 401: invalid_api_key: missing, malformed, unknown or revoked Bearer key.
- 403: subscription_inactive, plan_not_allowed or insufficient_scope.
- 404: not_found: unknown invoice or outside this administration.

### POST /api/v1/invoices/{id}/send

Schedules a real email to the recipient. Only call when the user requests delivery. mode=now schedules after the 20-second undo window; delivery runs asynchronously. mode=scheduled (also an empty request body) defaults to tomorrow in the account send window. date may be at most 180 days ahead. An existing schedule is preserved with alreadyPlanned=true. After dispatch a repeat may return not_a_draft. A successful response means scheduled, not delivered. The invoice is numbered during dispatch if it is not already finalized.

Required permission: invoices:send.

- 200: Delivery scheduled or existing schedule preserved.
- 400: invalid_json, validation or date_too_far.
- 401: invalid_api_key: missing, malformed, unknown or revoked Bearer key.
- 403: subscription_inactive, plan_not_allowed or insufficient_scope.
- 404: not_found.
- 409: not_a_draft: invoice has already been sent or paid.
- 422: email_required: recipient has no email address.


## Errors and permissions

Errors have {"error":"code"}; validation errors additionally have issues grouped by top-level
field, for example {"error":"validation","issues":{"lines":["invalid_vat_rate"]}}.
Fix invalid input before retrying. A 401 requires a valid key; a 403 requires an active eligible
subscription and the operation's permission. A 402 invoice_cap means the invoice allowance is reached.

Available key permissions: clients:read, clients:write, invoices:read, invoices:write, invoices:send. Existing keys can be revoked in API settings.
The OpenAPI security scheme is HTTP Bearer; x-required-scopes documents the permissions checked
for each operation. It is not an OAuth scope negotiation mechanism.
