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-corp5. 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 /productsThen 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 0–1.
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/recognitionThose 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.