# Billing

> Read your credit balance, top-up history, ledger, invoices and Steam wallet payments. Read only, by design.


Everything the **Billing** pages show, over HTTP: what your balance is, where it came from, and the invoices for it.

One scope covers the domain: `billing.read`.

## Nothing here moves money

There is no `billing.write` scope, and no endpoint on this surface accepts anything but `GET`.

You cannot start a top-up, mint a checkout session, cancel a deposit, or adjust a balance with an API key. That is not an oversight to be filled in later, it is the design: a key lives in a config file on a server, and a key that leaks should be able to tell you what you paid, never to cause a payment. Paying happens in the dashboard, where you are present to complete a hosted checkout.

Subscription plans, renewals and cancellation are not on the API at all, for the same reason.

## Balance

```endpoint
method: GET
path: /api/v1/billing/balance
description: Your current credit balance.
auth: bearer
```

```bash tab=curl
curl "https://dashboard.steamlabs.dev/api/v1/billing/balance" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "balance_cents": 12345,
    "currency": "EUR",
    "preferred_currency": "USD",
    "pending_deposits": { "count": 1, "max": 3 }
}
```

`currency` is what your balance is denominated in and what you are charged in. `preferred_currency` is only your display preference, and is reported so you can decide whether to convert; nothing here is converted for you, because an approximate figure has no business being the answer to "what is my balance".

`pending_deposits` mirrors the cap on the top-up page. At `count` equal to `max`, the dashboard refuses another top-up until one settles or is cancelled.

## Deposits

A deposit is one attempted top-up of your credit. The charge breakdown is frozen when it is created, so what you see here is exactly what the card was asked for and what the invoice says.

```endpoint
method: GET
path: /api/v1/billing/deposits
description: Your top-up history, newest first.
auth: bearer
```

```json
{
    "data": [
        {
            "id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
            "status": "complete",
            "method": "card",
            "currency": "EUR",
            "credited_cents": 5000,
            "charged_ex_vat_cents": 5000,
            "vat_cents": 1050,
            "charged_total_cents": 6050,
            "vat_rate_bps": 2100,
            "vat_treatment": "domestic",
            "gateway": "stripe",
            "gateway_reference": "cs_test_a1b2c3d4",
            "settled_amount_cents": 6050,
            "settled_currency": "EUR",
            "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_a1b2c3d4",
            "checkout_expires_at": "2026-07-30T16:02:11+00:00",
            "invoice": {
                "id": "019fb431-4d10-7a92-b0c1-8e7f2a5b9c33",
                "invoice_number": "SL-2026-000481",
                "status": "paid",
                "total_cents": 6050,
                "currency": "EUR",
                "issued_at": "2026-07-30T00:00:00+00:00",
                "pdf_url": "https://dashboard.steamlabs.dev/billing/invoices/019fb431-4d10-7a92-b0c1-8e7f2a5b9c33"
            },
            "completed_at": "2026-07-30T14:03:52+00:00",
            "expired_at": null,
            "created_at": "2026-07-30T14:02:11+00:00",
            "updated_at": "2026-07-30T14:03:52+00:00"
        }
    ],
    "meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 }
}
```

`credited_cents` is what lands on your balance; `charged_total_cents` is what you paid, VAT included. `settled_amount_cents` is what the gateway actually captured, once it has, which is what you reconcile against.

`checkout_url` is the session that was minted when the deposit was started. Reading it starts nothing, and it does expire. There is deliberately no API equivalent of the dashboard's "resume checkout" button: that one asks the gateway for a fresh session, which is exactly the kind of side effect this surface will not have.

### Filters

| Parameter | Values |
| --- | --- |
| `status` | `pending`, `complete`, `failed`, `expired`, `cancelled`, `refunded`, `charged_back`, `pending_review` |
| `method` | `card`, `crypto`, `manual` |

```endpoint
method: GET
path: /api/v1/billing/deposits/{id}
description: One deposit.
auth: bearer
```

Same shape, unwrapped. A deposit belonging to another account is a `404`, exactly like one that never existed.

## Ledger

The ledger is the source of truth. Your balance is only a cached sum of these rows, and each one records what moved, why, and what the balance was afterwards.

```endpoint
method: GET
path: /api/v1/billing/ledger
description: Every change to your balance, newest first.
auth: bearer
```

```json
{
    "data": [
        {
            "id": "019fb44a-2c30-7bd1-9f22-51ac6e0d7712",
            "type": "spend",
            "amount_cents": -420,
            "currency": "EUR",
            "balance_after_cents": 11925,
            "reason": "Market listing fee",
            "source": null,
            "created_at": "2026-07-30T15:41:52+00:00"
        },
        {
            "id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
            "type": "deposit",
            "amount_cents": 5000,
            "currency": "EUR",
            "balance_after_cents": 12345,
            "reason": null,
            "source": { "type": "billing_deposit", "id": "019fb42e-9a61-70d2-818a-f6a56593f3a5" },
            "created_at": "2026-07-30T14:03:52+00:00"
        }
    ],
    "meta": { "page": 1, "per_page": 50, "total": 2, "last_page": 1 }
}
```

Amounts are signed: positive credits your balance, negative claws it back. They are never converted, because this is the audit trail of what actually moved.

Filter with `type`: `deposit`, `refund`, `chargeback`, `admin_credit`, `admin_debit`, `adjustment`, `spend`, `transfer_in`, `transfer_out`.

`source` names what caused the entry, as a stable short type rather than an internal class name. It is `null` for entries with no origin object.

## Invoices

```endpoint
method: GET
path: /api/v1/billing/invoices
description: Issued invoices, newest first.
auth: bearer
```

```json
{
    "data": [
        {
            "id": "019fb431-4d10-7a92-b0c1-8e7f2a5b9c33",
            "invoice_number": "SL-2026-000481",
            "series": "SL",
            "year": 2026,
            "status": "paid",
            "currency": "EUR",
            "subtotal_cents": 5000,
            "vat_cents": 1050,
            "total_cents": 6050,
            "vat_rate_bps": 2100,
            "vat_treatment": "domestic",
            "is_reverse_charge": false,
            "deposit_id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
            "issued_at": "2026-07-30T00:00:00+00:00",
            "created_at": "2026-07-30T14:03:53+00:00",
            "pdf_url": "https://dashboard.steamlabs.dev/billing/invoices/019fb431-4d10-7a92-b0c1-8e7f2a5b9c33"
        }
    ],
    "meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 }
}
```

Filter with `status` (`issued`, `paid`, `credit_note`) and `year`.

```endpoint
method: GET
path: /api/v1/billing/invoices/{id}
description: One invoice, with its line items and party snapshots.
auth: bearer
```

Reading a single invoice adds `line_items`, `seller` and `customer`: the frozen snapshots the PDF is printed from. They are snapshots on purpose, so editing your billing address never rewrites an invoice that has already gone out.

### The PDF

`pdf_url` points at the dashboard's own download route, not at this API, and it is authenticated by your **session**, not by your API key. Open it in a browser, or hand it to your user; do not send your bearer token to it.

That is deliberate. An invoice is a document a person opens, not a payload a script parses, so you get a link rather than megabytes proxied through an API call.

## Steam wallet payments

A different kind of money, worth keeping straight. Deposits credit the SteamLabs balance you spend on the platform. These are paysafecard top-ups on an individual bot account's own Steam wallet, opened by an add-funds task, so that account can buy on the Community Market.

```endpoint
method: GET
path: /api/v1/billing/wallet-payments
description: Steam wallet top-up records, newest first.
auth: bearer
```

```json
{
    "data": [
        {
            "id": "019fb45c-77a1-7c31-8db4-2f0e91a3c6d8",
            "steam_account": {
                "id": "019fb3f0-1e44-70aa-b8c2-77d6e2a11b90",
                "username": "bot_one"
            },
            "task_id": "019fb45c-1120-7f0a-9c3d-4a2b8e7f5511",
            "transid": "108423995678901234",
            "mtid": "912345678901",
            "amount": 20,
            "currency": "EUR",
            "payment_url": "https://customer.cc.at.paysafecard.com/psccustomer/GetCustomerPanelServlet?mid=1000000433&mtid=912345678901&amount=20.00&currency=EUR&language=en",
            "status": "pending",
            "opened_at": null,
            "paid_at": null,
            "created_at": "2026-07-30T15:10:00+00:00"
        }
    ],
    "meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 }
}
```

`amount` is whole major units, as Steam resolved the top-up, not cents. It is the one money field on the API without a `_cents` suffix, and that naming is the warning.

### Filters

| Parameter | Values |
| --- | --- |
| `status` | `pending`, `paid`, `discarded`, `expired` |
| `opened` | `true` or `false`, on whether the payment link has been opened |
| `account_id` | A Steam account id |

The dashboard's tabs are these two filters combined: "To pay" is `status=pending&opened=false`, "Pending transactions" is `status=pending&opened=true`. Splitting them means you can also ask for things a tab cannot, like every unopened link across all statuses.

The row actions on that page (open the link, recheck a transaction with Steam, discard, move back to pay) are not on the API. Two of them either move money or spend worker fleet capacity, and this surface stays read only.
