# Quickstart

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

Source: https://invoia.io/developers/api/quickstart

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 [#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.

```bash
export INVOIA_API_KEY="ivk_…"
```

## 2. Read your configuration [#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**

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

**Node**

```js
const res = await fetch('https://invoia.io/api/v1/config', {
  headers: { Authorization: `Bearer ${process.env.INVOIA_API_KEY}` },
})

if (!res.ok) throw new Error(`${res.status} ${await res.text()}`)

const config = await res.json()
console.log(config.base_currency) // "DKK"
```

**Python**

```python
import os
import httpx

res = httpx.get(
    "https://invoia.io/api/v1/config",
    headers={"Authorization": f"Bearer {os.environ['INVOIA_API_KEY']}"},
)
res.raise_for_status()

print(res.json()["base_currency"])  # "DKK"
```

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](/developers/api/errors).

## 3. Write the client once [#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.

**curl**

```bash
# 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" "$@"
}
```

**Node**

```js
async function invoia(method, path, body) {
  const res = await fetch(`https://invoia.io/api/v1${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.INVOIA_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  })

  const json = await res.json()
  if (!res.ok) {
    const { code, message, request_id: requestId } = json.error
    throw new Error(`${code}: ${message} (request_id=${requestId})`)
  }
  return json
}
```

**Python**

```python
import os
import httpx

client = httpx.Client(
    base_url="https://invoia.io/api/v1",
    headers={"Authorization": f"Bearer {os.environ['INVOIA_API_KEY']}"},
)


def invoia(method: str, path: str, body: dict | None = None) -> dict:
    res = client.request(method, path, json=body)
    payload = res.json()
    if res.is_error:
        err = payload["error"]
        raise RuntimeError(
            f"{err['code']}: {err['message']} (request_id={err['request_id']})"
        )
    return payload
```

## 4. Create a customer [#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.

**curl**

```bash
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"
}'
```

**Node**

```js
const customer = await invoia('POST', '/customers', {
  external_id: 'quickstart:test-corp',
  name: 'Test Corp',
  country_key: 'DK',
  is_person: false,
  send_method: 'email',
  email: 'billing@test.example',
})

console.log(customer.id) // the UUID — you need it in step 7
```

**Python**

```python
customer = invoia("POST", "/customers", {
    "external_id": "quickstart:test-corp",
    "name": "Test Corp",
    "country_key": "DK",
    "is_person": False,
    "send_method": "email",
    "email": "billing@test.example",
})

print(customer["id"])  # the UUID — you need it in step 7
```

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

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

## 5. Create a product [#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:

```bash
invoia GET /products
```

Then create yours with the same account number:

**curl**

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

**Node**

```js
const product = await invoia('POST', '/products', {
  external_id: 'quickstart:consulting',
  product_number: 'QS-001',
  name: 'Consulting',
  account_number: 1000,
  unit: 'hours',
  default_price: 1200,
})
```

**Python**

```python
product = invoia("POST", "/products", {
    "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 [#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:

**curl**

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

**Node**

```js
const { data: users } = await invoia('GET', '/users')
const me = users.find((u) => u.email === 'you@your-agency.example')
```

**Python**

```python
users = invoia("GET", "/users")["data"]
me = next(u for u in users if u["email"] == "you@your-agency.example")
```

## 7. Draft an invoice [#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.

> [!NOTE]
> **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.

**curl**

```bash
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
    }]
  }]
}'
```

**Node**

```js
const invoice = await invoia('POST', '/invoices', {
  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: me.id,
      distribution_percentage: 100,
      hourly_rate_override: null,
    }],
  }],
})

const lineId = invoice.lines[0].id
```

**Python**

```python
invoice = invoia("POST", "/invoices", {
    "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": None,
        "discount": None,
        "description_override": "Consulting — July",
        "position": 0,
        "assignees": [{
            "user_id": me["id"],
            "distribution_percentage": 100,
            "hourly_rate_override": None,
        }],
    }],
})

line_id = invoice["lines"][0]["id"]
```

> [!NOTE]
> **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 [#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.

**curl**

```bash
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
}'
```

**Node**

```js
await invoia('PUT', '/work-registrations', {
  invoice: 'ext:quickstart:inv-001',
  line: lineId,
  user: 'email:you@your-agency.example',
  hours: 12,
  completion_rate: 0.6,
})
```

**Python**

```python
invoia("PUT", "/work-registrations", {
    "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 [#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.

**curl**

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

**Node**

```js
const rec = await invoia('GET', '/invoices/ext:quickstart:inv-001/recognition')

console.log(rec.earned, rec.billed, rec.deferred, rec.wip)
// 14400 0 0 14400
```

**Python**

```python
rec = invoia("GET", "/invoices/ext:quickstart:inv-001/recognition")

print(rec["earned"], rec["billed"], rec["deferred"], rec["wip"])
# 14400 0 0 14400
```

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](/developers/api/issuance).

> [!WARNING]
> **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 [#where-to-go-next]

* **[Authentication](/developers/api/authentication)** — scope,
  integration binding, and zero-downtime rotation.
* **[external\_id and idempotency](/developers/api/external-id)** — the
  upsert contract you just relied on, in full.
* **[Work registration](/developers/api/work-registration)** — absolute
  semantics, entry-month bucketing, and registering against a recurring
  series instead of a one-off invoice.
* **[Webhooks](/developers/api/webhooks)** — how you hear about changes
  your integration did not make.
* **[Connect an AI agent instead](/developers/mcp)** — the same product
  over MCP, where the agent proposes and you approve.
