Developers

Quickstart

From a key to recognised revenue, in curl, Node and Python.

Eight minutes, end to end: prove a key works, draft an invoice, register the work against it, and read back the revenue that moved. Everything runs against your live account — there is no sandbox — so use a throwaway customer and delete it when you are done.

1. Mint a key

Settings → Developers → API keys, as an admin. Pick read_write if you intend to follow this page to the end. The raw ivk_… secret is shown once; put it in an environment variable before you close the dialog.

export INVOIA_API_KEY="ivk_…"

2. Read your configuration

GET /config is the cheapest authenticated call in the API. It returns the account's base currency, VAT defaults and the connected accounting organisation — enough to confirm the key is live and bound to the integration you expected.

curl https://invoia.io/api/v1/config \
  -H "Authorization: Bearer $INVOIA_API_KEY"

A 401 means the key is wrong or revoked; a 403 means it is a read key on a write endpoint. Both carry a typed code — see Errors.

3. Write the client once

The rest of this page is one call per step, so it is worth wrapping the boilerplate now. Read the typed code and the request_id on failure — code is what you branch on, request_id is what you quote when something is wrong at our end.

# A shell function is enough: base URL, auth, JSON.
invoia() {
  local method="$1" path="$2"
  shift 2
  curl -sS -X "$method" "https://invoia.io/api/v1$path" \
    -H "Authorization: Bearer $INVOIA_API_KEY" \
    -H "Content-Type: application/json" "$@"
}

4. Create a customer

Stamp your own id on it. external_id is how you address the row from here on, so you never have to store an Invoia UUID — and repeating the call with the same external_id updates rather than duplicates.

name, country_key, is_person and send_method are required — send_method: "email" then requires an email to send to.

invoia POST /customers -d '{
  "external_id": "quickstart:test-corp",
  "name": "Test Corp",
  "country_key": "DK",
  "is_person": false,
  "send_method": "email",
  "email": "billing@test.example"
}'

Read it back by your own id any time — ext: in the path resolves it:

invoia GET /customers/ext:quickstart:test-corp

5. Create a product

A product carries the revenue account the line books to. account_number is a Dinero ledger account, not an Invoia id, so take a real one from a product you already have rather than inventing it:

invoia GET /products

Then create yours with the same account number:

invoia POST /products -d '{
  "external_id": "quickstart:consulting",
  "product_number": "QS-001",
  "name": "Consulting",
  "account_number": 1000,
  "unit": "hours",
  "default_price": 1200
}'

6. Find who is doing the work

Every invoice line needs at least one assignee — recognition is per person, per line. Users are invited in the app and never created over the API, so read the roster and pick yourself:

invoia GET /users
# → { "object": "list", "data": [ { "id": "…", "email": "…", … } ], … }

7. Draft an invoice

One line, billed by the hour at 1,200 per hour, all of it assigned to you. The invoice is a draft — nothing is booked to Dinero and no customer hears from you until you issue it.

Paths take ext:, bodies take UUIDs

ext: refs work wherever a resource is addressed in the URL. Inside a request body, ids are UUIDs — customer_id, product_id and user_id here come from the responses in steps 4 to 6. It is the one place you do hold Invoia ids.

invoia POST /invoices -d '{
  "external_id": "quickstart:inv-001",
  "customer_id": "'"$CUSTOMER_ID"'",
  "currency": "DKK",
  "title": "Quickstart",
  "lines": [{
    "product_id": "'"$PRODUCT_ID"'",
    "recognition_method": "hourly",
    "hourly_rate": 1200,
    "agreed_value": null,
    "discount": null,
    "description_override": "Consulting — July",
    "position": 0,
    "assignees": [{
      "user_id": "'"$USER_ID"'",
      "distribution_percentage": 100,
      "hourly_rate_override": null
    }]
  }]
}'

distribution_percentage is 0–100

It splits the line's revenue between assignees, so the percentages on a line should add up to 100. Not to be confused with completion_rate in the next step, which is 01.

8. Register the work

This is the call that moves revenue. hours is an absolute cumulative total for that person on that line — not an increment — so a retried sync is safe without an idempotency key. Name the user by email; users are invited in the app, never created over the API.

invoia PUT /work-registrations -d '{
  "invoice": "ext:quickstart:inv-001",
  "line": "'"$LINE_ID"'",
  "user": "email:you@your-agency.example",
  "hours": 12,
  "completion_rate": 0.6
}'

9. Read the revenue back

Twelve hours at 1,200 is 14,400 earned. The invoice has not been issued, so nothing is billed yet — which makes the whole 14,400 work in progress.

invoia GET /invoices/ext:quickstart:inv-001/recognition

Those four figures are the product. earned is what you have delivered, billed is what you have invoiced, and the gap falls into deferred (billed ahead of delivery) or wip (delivered ahead of billing). Issue the invoice and the same 14,400 moves from wip to billed — see Issuance and auto-send.

Clean up after yourself

The customer, product and invoice you just made are real. Delete the invoice and then the customer — DELETE /invoices/ext:quickstart:inv-001 and DELETE /customers/ext:quickstart:test-corp — or remove them from the app. A draft invoice deletes cleanly; an issued one never does.

Where to go next

  • Authentication — scope, integration binding, and zero-downtime rotation.
  • external_id and idempotency — the upsert contract you just relied on, in full.
  • Work registration — absolute semantics, entry-month bucketing, and registering against a recurring series instead of a one-off invoice.
  • Webhooks — how you hear about changes your integration did not make.
  • Connect an AI agent instead — the same product over MCP, where the agent proposes and you approve.