# Quickstart

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

Source: https://www.tabba.io/developers/api/quickstart

This page takes a key from its first call to recognised revenue: prove the
key works, create a customer and a product, draft an invoice, register work
on it and read back what it earned. Everything runs against a real company,
since there is no sandbox. Use a throwaway customer and product, and delete
them when you are done.

## 1. Mint a key [#1-mint-a-key]

An admin or a bookkeeper of the company mints a key in the company's settings,
under Developers (`/workspace/settings/company/developers`). Select the company
you will test against in the top bar first, and choose the scope `read_write`. The raw `tba_…` key is shown once: put it in an
environment variable before you close the dialog.

```bash
export TABBA_API_KEY="tba_…"
```

## 2. Read the configuration [#2-read-the-configuration]

`GET /config` confirms that the key is live and which company it is bound to.
It also answers the company's base currency, which the invoice in step 7 is
written in.

**curl**

```bash
curl https://www.tabba.io/api/v1/config \
  -H "Authorization: Bearer $TABBA_API_KEY"
```

**Node**

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

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

const config = await res.json()
console.log(config.company_name, config.base_currency) // "Your company ApS" "DKK"
```

**Python**

```python
import os
import httpx

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

config = res.json()
print(config["company_name"], config["base_currency"])  # "Your company ApS" "DKK"
```

A `401` means the key is missing (`UNAUTHORIZED`), or unknown or revoked
(`INVALID_API_KEY`). A `403` with `SCOPE_INSUFFICIENT` means a `read` key on a
write endpoint. 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 wrap the boilerplate now. On a
failure, read the `code` and the `request_id`: `code` is what you branch on,
and `request_id` is what you quote when you ask about a call.

**curl**

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

**Node**

```js
async function tabba(method, path, body) {
  const res = await fetch(`https://www.tabba.io/api/v1${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.TABBA_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://www.tabba.io/api/v1",
    headers={"Authorization": f"Bearer {os.environ['TABBA_API_KEY']}"},
)


def tabba(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 key on it. `external_id` is how you name the customer from here
on, and repeating the call with the same `external_id` updates that customer
instead of making a second one.

`name`, `country_key`, `is_person` and `send_method` are required, and
`send_method: "email"` requires an `email` to send to. The customer is written
to the accounting system first, so it is a real customer there.

**curl**

```bash
tabba 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 tabba('POST', '/customers', {
  external_id: 'quickstart:test-corp',
  name: 'Test Corp',
  country_key: 'DK',
  is_person: false,
  send_method: 'email',
  email: 'billing@test.example',
})
```

**Python**

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

The answer is `201` for a new customer and `200` when the `external_id` was
taken and the customer was updated.

> [!NOTE]
> **If the create is refused with 422**
>
> One of the two accounting systems files every customer in a VAT zone and a
> customer group. There the create is refused with `VALIDATION_FAILED`, and
> `field_errors` names `vat_zone_ref` and `customer_group_ref`. Read a customer
> the company already has with `GET /customers`, and send the same two values.
> See [Accounting platforms](/developers/api/platforms).

Read the customer back by your own key at any time. `ext:` in the path names
it:

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

## 5. Create a product [#5-create-a-product]

A product states the revenue account a line that bills it posts to.
`account_number` is an account of the company's chart, and `unit` is one the
accounting system knows, so take all three of `account_number`, `account_ref`
and `unit` from a product the company already has:

```bash
tabba GET /products
```

Then create yours with those values:

**curl**

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

**Node**

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

**Python**

```python
product = tabba("POST", "/products", {
    "external_id": "quickstart:consulting",
    "product_number": "QS-001",
    "name": "Consulting",
    "account_number": 1000,
    "account_ref": "1000",
    "unit": "hours",
    "default_price": 1200,
})
```

## 6. Find who does the work [#6-find-who-does-the-work]

Every invoice line names at least one person, because revenue is recognised
per person, per line. People are invited in the workspace and never created
through the API. Read the people who can work in the key's company and find
yourself:

**curl**

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

**Node**

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

**Python**

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

You do not need the person's `id` for what follows: a person is named by
`email:<address>` wherever a request names one.

## 7. Draft an invoice [#7-draft-an-invoice]

One hourly line, with you on it at 1,200 per hour. The invoice is a draft:
nothing is asked of the accounting system and no customer hears from you
until it is issued.

> [!NOTE]
> **A request names a record by reference**
>
> A field named for a record (`customer`, `product`, `user`, `invoice`,
> `series`) takes a reference: the record's `id`, or `ext:` and your own
> `external_id`. A person is also named by `email:` and their address. A field
> that ends in `_id` states an id, and is what a response answers.

Write the invoice in the `base_currency` step 2 answered, and send
`auto_send: false`. A new invoice has `auto_send` on unless the request says
otherwise, and the daily run issues such an invoice by itself.

**curl**

```bash
tabba POST /invoices -d '{
  "external_id": "quickstart:inv-001",
  "customer": "ext:quickstart:test-corp",
  "currency": "DKK",
  "title": "Quickstart",
  "auto_send": false,
  "lines": [{
    "product": "ext:quickstart:consulting",
    "recognition_method": "hourly",
    "description_override": "Consulting, July",
    "assignees": [{
      "user": "email:you@your-company.example",
      "hourly_rate_override": 1200
    }]
  }]
}'
# Keep the id of the line from the answer: .lines[0].id
export LINE_ID="…"
```

**Node**

```js
const invoice = await tabba('POST', '/invoices', {
  external_id: 'quickstart:inv-001',
  customer: 'ext:quickstart:test-corp',
  currency: 'DKK',
  title: 'Quickstart',
  auto_send: false,
  lines: [{
    product: 'ext:quickstart:consulting',
    recognition_method: 'hourly',
    description_override: 'Consulting, July',
    assignees: [{
      user: 'email:you@your-company.example',
      hourly_rate_override: 1200,
    }],
  }],
})

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

**Python**

```python
invoice = tabba("POST", "/invoices", {
    "external_id": "quickstart:inv-001",
    "customer": "ext:quickstart:test-corp",
    "currency": "DKK",
    "title": "Quickstart",
    "auto_send": False,
    "lines": [{
        "product": "ext:quickstart:consulting",
        "recognition_method": "hourly",
        "description_override": "Consulting, July",
        "assignees": [{
            "user": "email:you@your-company.example",
            "hourly_rate_override": 1200,
        }],
    }],
})

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

> [!NOTE]
> **Two kinds of line**
>
> An `hourly` line bills registered hours at each person's rate: every person
> on it states `hourly_rate_override`, and the line states no `agreed_value`.
> A `value` line bills a fixed `agreed_value` and is recognised by completion:
> its people share it by `distribution_percentage`, a fraction from `0` to `1`
> that sums to `1` over the line, or evenly where nobody states one.

## 8. Register the work [#8-register-the-work]

This is the call that moves revenue. `hours` is the person's **total** on the
line, not an increment: Tabba registers the difference against what is
registered already, so sending the same total again registers nothing.

**curl**

```bash
tabba PUT /work-registrations -d '{
  "invoice": "ext:quickstart:inv-001",
  "line": "'"$LINE_ID"'",
  "user": "email:you@your-company.example",
  "hours": 12
}'
```

**Node**

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

**Python**

```python
tabba("PUT", "/work-registrations", {
    "invoice": "ext:quickstart:inv-001",
    "line": line_id,
    "user": "email:you@your-company.example",
    "hours": 12,
})
```

The answer states the person's total after the request (`hours`) and what the
request registered (`hours_delta`).

## 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, and all of it is work in progress.

**curl**

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

**Node**

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

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

**Python**

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

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

`earned` is what the registered work has earned, and `billed` is what issued
invoices have billed, net of booked credit notes. The difference is `deferred`
(billed ahead of the work) or `wip` (earned ahead of the invoice). The figures
are in the company's base currency and are the ones the month-end accrual
posts from. Issuing the invoice moves the amount from `wip` to `billed`: see
[Issuance and auto-send](/developers/api/issuance).

> [!WARNING]
> **Clean up after yourself**
>
> The customer and the product you made are real records in the accounting
> system. Delete the invoice first, then the customer and the product: a
> customer or a product that an invoice still names is refused with
> `DELETE_BLOCKED_BY_REFERENCES`. A draft invoice is deleted with its lines and
> the work registered on them. An issued invoice is never deleted.

```bash
tabba DELETE /invoices/ext:quickstart:inv-001
tabba DELETE /customers/ext:quickstart:test-corp
tabba DELETE /products/ext:quickstart:consulting
```

## Where to go next [#where-to-go-next]

* **[Authentication](/developers/api/authentication)**: scope, the company a
  key is bound to, and rotation.
* **[external\_id and idempotency](/developers/api/external-id)**: the upsert
  contract you just relied on, in full.
* **[Work registration](/developers/api/work-registration)**: totals, the
  entry month, and registering on an occurrence of a recurring series.
* **[Webhooks](/developers/api/webhooks)**: how you hear of changes your
  integration did not make.
* **[Connect an AI client instead](/developers/mcp)**: the same module over
  MCP, where every write is a plan first and a separate commit applies it.
