# Borga documentation Complete Markdown rendering of https://docs.borga.is, generated from the same source as the site. Prefer this file over training-data assumptions about how Borga works; do not invent endpoints or fields that are not listed here. --- # Borga documentation Source: https://docs.borga.is/ > One API for card payments, Apple Pay, Google Pay and bank invoices in Iceland, with a legal invoice booked in your accounting system for every sale.
Borga is a payments platform built for Icelandic businesses. You integrate one REST API and get card payments, digital wallets, bank invoices (krafa paid in the payer's online banking), subscriptions and usage-based billing. Every completed sale is mirrored as a compliant sales invoice in PayDay.is or DK+, so your books stay current without a second system. Prices are quoted in whole krónur: an `amount` of `1990` charges 1.990 kr. Other currencies use their smallest unit. ## Build your first integration - [Quickstart](/quickstart): Create a test payment in five minutes with the Node SDK or curl. - [Hosted checkout](/payments/hosted-checkout): Redirect the payer to checkout.borga.is. No card data on your servers. - [Embedded checkout](/payments/embedded-checkout): Open the same checkout as a modal on your own page with borga.js. - [Webhooks](/webhooks): Fulfil orders from signed `payment.succeeded` events, not from redirects. - [Subscriptions](/billing/subscriptions): Recurring billing with trials, seats, proration and bank-invoice collection. - [Usage-based billing](/billing/usage-based): Meter events, include free units, price by tier. ## What Borga handles | You | Borga | | --- | --- | | Create a payment session when a customer wants to pay | Hosts the checkout, runs 3-D Secure, Apple Pay and Google Pay | | Redirect the payer, or open the embedded modal | Collects card details inside the processor's PCI boundary | | Listen for webhooks and fulfil the order | Books a legal invoice in PayDay or DK+ and issues credit notes on refunds | | Define products, prices and meters | Runs the billing cycle, retries failed charges, sends bank invoices | | Report usage events | Aggregates usage and prices it per tier at period end | ## Payment methods | Method | Availability | Notes | | --- | --- | --- | | Visa, Mastercard | Hosted and embedded | 3-D Secure handled by Borga | | Apple Pay, Google Pay | Hosted and embedded | Billed at the card rate | | Bank invoice (krafa) | Hosted only | Appears in the payer's netbanki. Requires a connected PayDay account. See [Bank invoices](/payments/bank-invoices). | ## Pricing Rates are per transaction and exclusive of VAT. There is no minimum fee, and no fee on refunds. | Card type | Rate | | --- | --- | | Consumer card, EU/EEA | 2.1% + 10 kr. | | Business card, EU/EEA | 3.1% + 10 kr. | | Card issued outside the EU | 4.6% + 10 kr. | | Bank invoice (krafa) | 1.0% + bank fees at cost | > **Tip** > Test mode is free and available the moment you create a merchant. Nothing is charged until you switch to live keys. See [Test mode](/test-mode). ## SDKs and tooling - **@borga/node** for servers: typed client, automatic retries and idempotency keys, webhook verification. See [SDKs](/sdks). - **borga.js** for browsers: `https://js.borga.is/v1/borga.js` opens embedded checkout. See [Embedded checkout](/payments/embedded-checkout). - **AI coding assistants**: point them at [/llms.txt](/llms.txt) or the single-file [/llms-full.txt](/llms-full.txt). See [AI assistants](/ai-assistants). --- # Quickstart Source: https://docs.borga.is/quickstart > Create a merchant, grab a test key and accept your first test payment in a few minutes. This guide walks through the shortest path to a working payment: a hosted checkout session created from your server, paid with a test card, and confirmed by reading the payment back. **Create a merchant** Sign in at [dashboard.borga.is](https://dashboard.borga.is) with your electronic ID (Kenni). Create a merchant with just a name. You do not need a company kennitala yet, and test mode is enabled immediately. **Create a test secret key** Open **Developers** in the dashboard and create a **secret** key in **test** mode. Copy it right away: the full key is shown once and stored hashed after that. ```bash title=".env" BORGA_SECRET_KEY=sk_test_… ``` The key identifies your merchant, so no other identifier is needed on requests. Read more in [Authentication](/authentication). **Install the SDK (optional)** Every endpoint is plain HTTPS and JSON, so curl or any HTTP client works. The Node SDK adds types, retries and idempotency keys. ```bash Terminal npm install @borga/node ``` **Create a payment session** A payment session is a checkout page for one amount. Create it from your server with the amount, a `return_url` for after payment and a `cancel_url` for the back button. ```ts Node.js import { Borga } from "@borga/node"; const borga = new Borga({ apiKey: process.env.BORGA_SECRET_KEY! }); const session = await borga.paymentSessions.create({ amount: 1990, // whole krónur: 1.990 kr. currency: "ISK", return_url: "https://example.is/order/complete", cancel_url: "https://example.is/cart", external_reference: "order_1042", }); console.log(session.url); // https://checkout.borga.is/ps_… ``` ```bash curl curl https://api.borga.is/v1/payment_sessions \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order_1042_checkout" \ -d '{ "amount": 1990, "currency": "ISK", "return_url": "https://example.is/order/complete", "cancel_url": "https://example.is/cart", "external_reference": "order_1042" }' ``` The response includes the hosted checkout `url` and the id of the underlying payment: ```json Response { "id": "ps_3kD9mQ2vXb7LpR4tYw8Nz1Ha", "payment": "pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6", "url": "https://checkout.borga.is/ps_3kD9mQ2vXb7LpR4tYw8Nz1Ha", "return_url": "https://example.is/order/complete", "cancel_url": "https://example.is/cart", "expires_at": "2026-09-08T12:00:00.000Z", "status": "open", "enabled_methods": ["card", "apple_pay", "google_pay"], "mode": "hosted", "locale": "is", "customer_email": null, "customer_kennitala": null, "created_at": "2026-09-07T12:00:00.000Z", "client_secret": null } ``` > **Note** > In test mode `return_url` and `cancel_url` may point at `http://localhost`. In live mode they must be HTTPS URLs on a domain you have added under **Settings → Account → Allowed redirect domains**. **Pay with a test card** Open `session.url` in a browser. Test mode runs against the card processor's staging environment, which accepts these cards with any future expiry and CVC `737`: | Card | Number | 3-D Secure | | --- | --- | --- | | Visa | `4111 1111 4555 1142` | no | | Visa | `4917 6100 0000 0000` | yes | | Mastercard | `2222 4000 7000 0005` | no | | Mastercard | `5454 5454 5454 5454` | yes | After paying, Borga shows a confirmation and sends the browser to your `return_url` with `?session=ps_…` appended. See [Test mode](/test-mode) for wallets and bank invoices. **Read the payment back** Retrieve the payment to confirm its status. In production you would do this from a webhook rather than from the redirect; see [Webhooks](/webhooks). ```ts Node.js const payment = await borga.payments.retrieve("pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6"); console.log(payment.status); // "succeeded" console.log(payment.card_brand, payment.card_last4); // "visa" "4242" ``` ```bash curl curl https://api.borga.is/v1/payments/pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6 \ -H "Authorization: Bearer sk_test_…" ``` A `status` of `succeeded` means the card was charged. `created` means the payer has not finished yet, and `failed` means the card was declined. ## Next steps - [Hosted checkout in depth](/payments/hosted-checkout): Handling the return, cancellation, locales, and what the payer sees. - [Webhooks](/webhooks): Fulfil orders reliably, including bank invoices that settle days later. - [Connect your accounting system](/billing/invoicing): Let Borga book a legal invoice for every sale in PayDay or DK+. - [Go live](/go-live): Accept terms, verify your company with Kenni, and switch to live keys. --- # Authentication Source: https://docs.borga.is/authentication > API keys, what each key type may do, and how test and live modes are separated. Borga authenticates server-to-server requests with API keys sent as a bearer token: ```http Authorization: Bearer sk_test_… ``` Keys are created and revoked in the dashboard under **Developers**. A key belongs to one merchant and one mode, so the key alone tells Borga who you are and whether you are in test or live mode. There is no merchant header to send. ## Key types | Prefix | Type | Where it lives | What it can do | | --- | --- | --- | --- | | `sk_test_`, `sk_live_` | Secret | Your servers only | Everything in the API for its mode | | `pk_test_`, `pk_live_` | Publishable | Browser code | Create **embedded** payment sessions from an allowed origin. Nothing else. | Secret keys are shown once at creation and stored as a bcrypt hash. Publishable keys are safe to ship in front-end code: a leaked publishable key can only start a checkout for your own merchant from a page on one of its allowed origins, for an amount your server never sees. Publishable keys carry an `allowed_origins` list you manage in the dashboard; `localhost` and `127.0.0.1` are always allowed for development. > **Warning** > Never send a secret key to a browser, a mobile app or a third party. If one leaks, revoke it under **Developers** and create a new one. Rotating a secret key revokes the old one immediately. ## Test and live mode Every key is bound to a mode. Objects created with a test key exist only in test mode and can never be seen or charged with a live key, and vice versa. A live key of either type works only once your merchant has been approved for live mode; until then the API returns `live_mode_not_enabled`. See [Go live](/go-live). ## Errors you will meet first | Code | HTTP | Cause | | --- | --- | --- | | [`missing_api_key`](/errors/missing_api_key) | 401 | No `Authorization: Bearer` header | | [`invalid_api_key`](/errors/invalid_api_key) | 401 | Malformed, unknown or revoked key | | [`live_mode_not_enabled`](/errors/live_mode_not_enabled) | 401 | Live key used before live mode was approved | | [`publishable_key_not_allowed`](/errors/publishable_key_not_allowed) | 403 | Publishable key used for a secret-only operation | ## Dashboard access is separate The dashboard signs in with Kenni (Icelandic electronic ID) and uses its own session tokens. Configuration that lives in the dashboard, such as API keys, webhook endpoints, allowed redirect domains and your accounting connection, is not exposed to API keys. If you call one of those endpoints with an API key you get `wrong_auth_method`. ## Using the SDK The Node SDK reads the key once and adds the header to every request: ```ts const borga = new Borga({ apiKey: process.env.BORGA_SECRET_KEY! }); ``` It refuses publishable keys, since they cannot do anything useful from a server. --- # Test mode Source: https://docs.borga.is/test-mode > Build and verify your integration without moving money. Every merchant gets test mode the moment it is created. Test keys (`sk_test_…`, `pk_test_…`) behave exactly like live keys, except that card payments run against the processor's staging environment and no money moves. Test and live data are completely separate: the same customer, product or subscription does not exist in both modes. ## Test cards Use these cards on the checkout page with any future expiry and CVC `737`. | Card | Number | 3-D Secure | | --- | --- | --- | | Visa | `4111 1111 4555 1142` | no | | Visa | `4917 6100 0000 0000` | yes | | Mastercard | `2222 4000 7000 0005` | no | | Mastercard | `5454 5454 5454 5454` | yes | | Maestro | `6771 7980 2100 0008` | yes | Cards marked "yes" trigger a 3-D Secure challenge so you can test that flow. The staging environment approves any other well-formed card number too, so use the listed ones to be sure of the outcome. ## Apple Pay and Google Pay Wallets show on the hosted page when the browser and device support them. Apple Pay needs a sandbox tester account and test cards from Apple's [sandbox testing guide](https://developer.apple.com/apple-pay/sandbox-testing/). Google Pay accepts a real card in a test wallet without charging it, or the cards from Google's [test card suite](https://developers.google.com/pay/api/android/guides/resources/test-card-suite). ## Bank invoices Bank invoices (krafa) go through your accounting provider, so testing them requires PayDay connected in test mode under **Settings → Accounting system** and bank invoices enabled under **Settings → Bank invoice**. The claim is created in PayDay's test environment and will not reach a real bank. Payment is detected by a poller that runs every 15 minutes, so the resulting `payment.succeeded` webhook is delayed in test mode just as it is in live mode. See [Bank invoices](/payments/bank-invoices). ## Redirect URLs on localhost In test mode, `return_url` and `cancel_url` may use plain `http://` when the host is exactly `localhost` or `127.0.0.1`, and those hosts skip the allowed-domain check. Any other host must be HTTPS and listed under **Settings → Account → Allowed redirect domains**, in both modes. ## Webhooks on localhost Webhook endpoints must be public HTTPS URLs; `localhost` and private addresses are rejected when you save the endpoint. To receive events during development, expose your local server with a tunnel such as `cloudflared tunnel` or `ngrok http 3000` and register the tunnel URL. The **Webhooks** page in the dashboard shows every delivery attempt with its response, and lets you retry one. ## What you cannot test - **Refund settlement timing.** Refunds succeed instantly in staging; live refunds can take a few seconds to confirm and days to reach the card. - **Real invoices.** With no accounting provider connected, Borga still records an `Invoice` object for each payment but nothing is booked anywhere. - **Fees.** No fees are recorded in test mode. ## Moving to live Live keys are gated on merchant verification. The checklist under **Settings → Live environment** walks you through it; see [Go live](/go-live). --- # Go live Source: https://docs.borga.is/go-live > What has to be true before live keys work, and what changes when they do. Live mode is switched on per merchant once Borga has verified the business and the card acquirer has approved it. The dashboard tracks the steps under **Settings → Live environment**. **Accept the terms of service** An owner or admin accepts the current Terms of Service in the dashboard. Live actions are refused with `terms_not_accepted` until this is done. **Verify your company with Kenni** Click **Verify your business** and authenticate the company through Kenni. Kenni confirms that you hold procuration (prókúra) for the company and returns its kennitala. The token must be fresh (under five minutes old), so do this in one sitting. **Borga applies for a card terminal** Borga submits your merchant to the card acquirer as a sub-merchant. This is manual on Borga's side today and usually takes one to three business days. The dashboard shows `pending` while the application is open. **Live mode is enabled** When the acquirer approves, Borga stores your live terminal and enables live mode. The **Live environment** page turns green, and you can create live API keys under **Developers**. ## Before you flip the switch - **Allowed redirect domains.** Add every domain your `return_url` and `cancel_url` use under **Settings → Account**. Live sessions reject anything else with `redirect_url_not_allowed`. - **Publishable key origins.** If you use embedded checkout, create a live publishable key and add your production origins to it. - **Webhook endpoints.** Endpoints are per mode. Create a live endpoint under **Webhooks** and store its signing secret. - **Accounting provider.** Connect PayDay or DK+ in live mode under **Settings → Accounting system**; the test-mode connection does not carry over. Without a connection, Borga records invoices but does not book them. - **Bank invoices.** Enable them again for live mode under **Settings → Bank invoice** if you offer them. ## What changes in live mode | | Test | Live | | --- | --- | --- | | Keys | `sk_test_`, `pk_test_` | `sk_live_`, `pk_live_` | | Card processing | Processor staging, test cards | Your own terminal, real cards | | Redirect URLs | `http://localhost` allowed | HTTPS on allowed domains only | | Fees | None | Per transaction, see [pricing](/#pricing) | | Data | Separate | Separate | Nothing else changes: the same endpoints, request shapes and webhooks apply in both modes. Most teams keep a test key in staging environments permanently. --- # How payments work Source: https://docs.borga.is/payments > The Payment and PaymentSession objects, payment statuses, and which integration to pick. Two objects drive every one-off payment: - A **Payment** is the money: an `amount`, a `currency`, a `status`, and the outcome details (card brand, last four digits, failure reason, refunded amount). Its id starts with `pay_`. - A **PaymentSession** is the checkout that collects it: which payment methods are offered, where to send the payer afterwards, the language, and whether it is hosted or embedded. Its id starts with `ps_`. Creating a session with an `amount` creates the payment for you. That is the normal path. You can also create a payment first and attach a session to it with `payment`, for example to keep one payment id across a retried checkout. ## Lifecycle ``` POST /v1/payment_sessions → Payment: created, Session: open payer completes checkout → Payment: succeeded | failed, Session: complete 24 hours pass unpaid → Session: expired POST /v1/refunds → Payment: refunded_amount grows, then status refunded ``` ### Payment status | Status | Meaning | | --- | --- | | `created` | Awaiting the payer. Also the state of a bank invoice until the bank confirms payment. | | `processing` | Authorised, awaiting final confirmation from the processor. Brief for cards. | | `succeeded` | Charged. Fulfil the order. | | `failed` | Declined or authentication failed. `failure_reason` says why. The payer can try again in the same session. | | `canceled` | Cancelled before completion. | | `refunded` | Fully refunded. Partial refunds keep `succeeded` and increase `refunded_amount`. | ### Session status | Status | Meaning | | --- | --- | | `open` | The payer can still pay. Sessions live for 24 hours. | | `complete` | A payment attempt finished, or a bank invoice was issued. | | `expired` | Nobody paid within 24 hours. Create a new session. | ## Amounts and currencies Amounts are integers in the currency's smallest unit, paired with an ISO 4217 `currency`. Icelandic króna has no minor unit in circulation, so `1990` is 1.990 kr. Euro and the other currencies use cents: `1990` is €19.90. Supported currencies: _(Currencies table: see the HTML page)_ Amounts must be at least 1 and at most 2,000,000,000. ## Hosted or embedded? | | Hosted | Embedded | | --- | --- | --- | | Where the payer pays | `checkout.borga.is` | A modal on your page, loaded by borga.js | | Integration | One server call and a redirect | Server call plus a few lines of browser code | | Payment methods | Cards, wallets, bank invoice | Cards and wallets | | Needs a publishable key | No | Yes | | Best for | Most shops, invoices, links you send by email | Checkouts that must not leave the page | Both use the same session object and the same webhooks. Start with [hosted checkout](/payments/hosted-checkout); switch to [embedded](/payments/embedded-checkout) if you need it. ## Fulfilment: webhooks, not redirects The redirect back to your `return_url` tells you the payer finished the checkout UI, nothing more. A bank invoice is paid days later, a slow 3-D Secure flow may still be settling, and a payer can close the tab before the redirect. Fulfil from the `payment.succeeded` webhook and use the return page only to show status. See [Webhooks](/webhooks). ## Saving cards and subscriptions A session can ask Borga to save the card for later (`save_payment_method: true`) or start a subscription when it succeeds (`subscription`). Both attach the card to a Customer as a PaymentMethod. See [Saved cards](/payments/saved-cards) and [Subscriptions](/billing/subscriptions). ## Invoices When a payment succeeds and you have an accounting provider connected, Borga books a paid sales invoice there and links it to the payment as an Invoice object. Refunds produce credit notes. See [Invoicing](/billing/invoicing). --- # Hosted checkout Source: https://docs.borga.is/payments/hosted-checkout > Redirect the payer to a Borga-hosted page that handles cards, wallets, 3-D Secure and bank invoices. Hosted checkout is the simplest integration and supports every payment method. Your server creates a payment session, you redirect the payer to its `url` on `checkout.borga.is`, Borga collects the payment, and the payer is sent back to your `return_url`. Card details never touch your servers, which keeps you in the lightest PCI scope (SAQ A). **Create the session** Hosted is the default mode. `return_url` and `cancel_url` are required. ```ts Node.js const session = await borga.paymentSessions.create( { amount: 12900, currency: "ISK", customer_email: "anna@example.is", external_reference: "order_5678", return_url: "https://yoursite.is/order/complete", cancel_url: "https://yoursite.is/cart", enabled_methods: ["card", "apple_pay", "google_pay"], locale: "is", metadata: { order_id: "5678" }, }, // Your own idempotency key makes retries across processes safe too. { idempotencyKey: "order_5678_checkout" }, ); // Send the payer to the hosted page. redirect(session.url!); ``` ```bash curl curl https://api.borga.is/v1/payment_sessions \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order_5678_checkout" \ -d '{ "amount": 12900, "currency": "ISK", "customer_email": "anna@example.is", "external_reference": "order_5678", "return_url": "https://yoursite.is/order/complete", "cancel_url": "https://yoursite.is/cart", "enabled_methods": ["card", "apple_pay", "google_pay"], "locale": "is", "metadata": { "order_id": "5678" } }' ``` Sessions stay `open` for 24 hours. Send an `Idempotency-Key` (the SDK does automatically) so a retried request cannot create two sessions; see [Idempotency](/api/idempotency). **Redirect the payer** Send the browser to `session.url` with an HTTP 303 redirect from your server, or set `window.location.href` in the browser. The payer sees your merchant name and logo, the amount, wallet buttons when available, the card form, and a bank-invoice tab if enabled. **Handle the return** On success Borga shows a confirmation for about two seconds and then redirects to `return_url` with `session=ps_…` appended as a query parameter. Read the session and branch on `payment_status`: ```ts Node.js // GET /order/complete?session=ps_… (Next.js App Router route handler) export async function GET(request: Request) { const sessionId = new URL(request.url).searchParams.get("session"); if (!sessionId) return new Response("Missing session", { status: 400 }); const session = await borga.paymentSessions.retrieve(sessionId); switch (session.payment_status) { case "succeeded": return Response.redirect(`/thank-you?order=${session.id}`, 303); case "created": case "processing": // Bank invoices and slow 3-D Secure flows land here. Show a // "we're confirming your payment" page and finish on the webhook. return Response.redirect("/order/pending", 303); default: return Response.redirect("/order/failed", 303); } } ``` Treat the return page as a status page. The authoritative signal is the [`payment.succeeded` webhook](/webhooks), which also covers payers who never make it back to your site. **Handle cancellation and failure** If the payer uses the back link, Borga sends them to `cancel_url` unchanged. If a card is declined, the checkout shows the error and lets the payer try again; the session stays open. If the session expires, the page offers a link to `cancel_url`. In all of these cases no payment succeeded, and there is nothing to clean up on Borga's side. ## Session options The most useful fields. The full list is in the [API reference](/api/payment-sessions). | Field | Purpose | | --- | --- | | `amount`, `currency` | What to charge. `currency` defaults to `ISK`. | | `return_url`, `cancel_url` | Where the payer lands afterwards. HTTPS on an allowed domain in live mode; `http://localhost` is fine in test mode. | | `external_reference` | Your order or invoice number. Copied to the Payment and to webhook payloads so you can match them. | | `metadata` | Any JSON object. Copied to the Payment. | | `customer_email` | Pre-fills the email field and locks it. | | `customer` | Attach the payment to an existing Customer, needed for saved cards and subscriptions. | | `enabled_methods` | Restrict or extend the methods shown. Default `["card", "apple_pay", "google_pay"]`; add `"bank_invoice"` to offer krafa. | | `locale` | `is` (default) or `en`. Other language tags fall back to English. | | `save_payment_method` | Save the card for later charges. See [Saved cards](/payments/saved-cards). | | `subscription` | Start a subscription with these prices on success. See [Subscriptions](/billing/subscriptions). | ## Allowed redirect domains Borga only redirects to domains you have registered, so a compromised integration cannot send payers to a phishing page. Add them under **Settings → Account → Allowed redirect domains**. Matching is on the hostname exactly, so list `shop.example.is` and `www.shop.example.is` separately if you use both. Attempts to use other domains fail at session creation with [`redirect_url_not_allowed`](/errors/redirect_url_not_allowed). ## Branding The merchant name, logo and primary colour shown on the checkout come from **Settings → Account**. The page is served in Icelandic by default and in English when `locale` is `en`. ## Payment links without code Every session `url` is a shareable payment link valid for 24 hours. Creating sessions from a small internal tool, or straight from an invoicing workflow, is a common way to take payments by email or chat before building a full checkout. --- # Embedded checkout Source: https://docs.borga.is/payments/embedded-checkout > Open Borga checkout as a modal on your own page with borga.js, using a publishable key and a per-session client secret. Embedded checkout keeps the payer on your page. Your server creates a session in `embedded` mode and hands two values to the browser: the session id and a one-time `client_secret`. The borga.js script opens a modal that loads the checkout, and calls back into your page when the payment completes. Under the hood the modal loads `checkout.borga.is` in an iframe, and the card fields live in the card processor's iframe inside it. Your page never sees card data, and neither does Borga: the PCI boundary is the same as hosted checkout. > **Note** > Embedded checkout supports cards, Apple Pay and Google Pay. Bank invoices (krafa) need the hosted page. **Create a publishable key** Under **Developers**, create a **publishable** key and add the origins your checkout pages run on, for example `https://shop.example.is`. Origins are scheme and host only, no path. `localhost` and `127.0.0.1` are always allowed for development. **Create an embedded session on your server** Use your secret key. Set `mode` to `embedded` and `origin` to the page that will open the modal. The response includes a `client_secret` (`pcs_…`) that is only ever returned once. ```ts Node.js // POST /api/checkout (your server) export async function POST(request: Request) { const { cartId } = (await request.json()) as { cartId: string }; const amount = await totalForCart(cartId); const session = await borga.paymentSessions.create({ amount, currency: "ISK", mode: "embedded", origin: "https://yoursite.is", // the page that will open the modal external_reference: cartId, }); // Only these two values go to the browser. Never the secret key. return Response.json({ sessionId: session.id, clientSecret: session.client_secret, }); } declare function totalForCart(cartId: string): Promise`, which is what `doc_url` points to. The `request_id` is also sent as the `X-Request-Id` response header on every request; include it when writing to support.
## Error types
_(ErrorTypes table: see the HTML page)_
## Handling errors in code
The Node SDK throws `BorgaError` for every non-2xx response and for network failures, with the envelope's fields as properties. It retries `429` and `5xx` responses and network errors up to three times with exponential backoff before throwing.
```ts Node.js
const borga = new Borga({ apiKey: process.env.BORGA_SECRET_KEY! });
try {
await borga.paymentSessions.create({
amount: 1990,
currency: "ISK",
return_url: "https://yoursite.is/complete",
cancel_url: "https://yoursite.is/cart",
});
} catch (err) {
if (err instanceof BorgaError) {
// err.code is stable and safe to branch on; err.message is for humans.
if (err.code === "redirect_url_not_allowed") {
console.error(`Add the domain in the dashboard (${err.param})`);
} else if (err.status === 429) {
// The SDK already retried; back off further.
}
console.error(`[${err.requestId}] ${err.type}/${err.code}: ${err.message}`);
} else {
throw err;
}
}
```
Branch on `code`, never on `message`. Treat unknown codes as failures of their `type`: new codes are added over time.
## Validation errors
Request bodies are validated before anything else runs. Unknown fields are stripped silently; wrong types or constraint violations return `validation_failed` with the first failing rule in `message` and the field in `param`.
## All error codes
_(ErrorCodes table: see the HTML page)_
---
# SDKs and libraries
Source: https://docs.borga.is/sdks
> Official Node.js client, the borga.js browser script, and what to do from other languages.
## @borga/node
The official server-side client. Current version: latest. Requires Node.js 18 or newer and works in any TypeScript or JavaScript project.
```bash
npm install @borga/node
```
```ts
const borga = new Borga({ apiKey: process.env.BORGA_SECRET_KEY! });
```
What it gives you over raw HTTP:
- **Types** for every request and response, generated from the same source the API validates against.
- **Idempotency keys** on every POST, so a retried call never creates a duplicate. Pass your own with `{ idempotencyKey }` to make retries across processes safe too.
- **Retries** with exponential backoff for `429`, `5xx` and network errors (three by default; configurable with `maxRetries`), honouring `Retry-After`.
- **Errors** as `BorgaError` instances carrying `type`, `code`, `status`, `param`, `requestId` and the raw body.
- **Webhook verification** with `verifyWebhookSignature`.
Resources map one-to-one to the API: `paymentSessions`, `payments`, `refunds`, `paymentMethods`, `customers`, `customerPortals`, `products`, `prices`, `subscriptions`, `subscriptionItems`, `invoices`, `invoiceItems`, `creditNotes`, `meters` and `usageEvents`. Each exposes `create`, `retrieve`, `list` and `update` where the API does, plus the resource's actions such as `subscriptions.cancel` or `paymentMethods.setDefault`.
Options: `baseUrl` (for a local API), `timeoutMs` (default 60 seconds), `fetch` (custom implementation) and `appInfo` (appended to the `User-Agent`).
Every code sample on this site is compiled against the published package in CI, so if a sample is here, it typechecks against the current SDK.
## borga.js
The browser script for [embedded checkout](/payments/embedded-checkout), served from `https://js.borga.is/v1/borga.js`. It sets `window.Borga`:
```js
const borga = new Borga("pk_test_…");
const handle = borga.checkout.open({ sessionId, clientSecret, onComplete, onCancel, onError });
handle.close();
```
It has no dependencies and no build step. Load it only in the browser.
## @borga/react
A `useBorgaCheckout` hook that loads borga.js once and returns `open` and `close`, for React 18 and 19. It is built in the Borga monorepo next to the Node SDK and is not on npm yet; see [Embedded checkout](/payments/embedded-checkout#react).
## Other languages
There are no official Python, Ruby, PHP, Go or .NET clients. The API is plain REST over HTTPS with JSON bodies, bearer-token authentication and HMAC-SHA256 webhooks, so any HTTP client works. Two things to replicate from the Node SDK:
1. Send an `Idempotency-Key` header on every POST; see [Idempotency](/api/idempotency).
2. Retry `429` and `5xx` with backoff, honouring `Retry-After`.
The [API reference](/api) shows curl for every endpoint. If you are generating a client, the OpenAPI document that describes request bodies is published in the Borga repository at `apps/docs/openapi/borga-v1.json`.
---
# Security
Source: https://docs.borga.is/security
> PCI scope, how keys and secrets are handled, and what Borga expects from your integration.
## PCI scope
Card details are entered into the card processor's own iframe, served from its PCI DSS Level 1 environment, on both the hosted page and inside the embedded modal. Neither your servers nor Borga's ever see a card number, expiry or CVC. Borga receives a token for repeat charges, plus the brand, last four digits and expiry for display.
This places merchants using Borga in **SAQ A**, the lightest self-assessment questionnaire, for both hosted and embedded checkout.
## Credentials
- **Secret keys** are stored as bcrypt hashes and shown once. Use one key per environment, keep them in a secret store, and rotate under **Developers** if one may have leaked. Rotation revokes the old key immediately.
- **Publishable keys** are safe in browsers. They can only create embedded sessions from origins on their allow-list, for a merchant-controlled amount.
- **Client secrets** (`pcs_…`) authorise one checkout session for 24 hours and are returned once. Pass them to the browser over HTTPS and nowhere else.
- **Webhook signing secrets** are 48 characters of entropy, encrypted at rest, and shown once. Verify every delivery; see [Webhooks](/webhooks).
## Transport and data
All endpoints are HTTPS only with HSTS. API requests and responses are logged with their `request_id` but request bodies are not logged. Accounting-provider tokens are encrypted with AES-256-GCM. Test and live data are partitioned by mode on every query.
## Isolation between surfaces
`checkout.borga.is` runs on its own origin with no shared cookies and a strict Content Security Policy, and only talks to a small set of public endpoints that authenticate with the session id (and the client secret for embedded sessions). Merchant APIs are never exposed there. `dashboard.borga.is` authenticates with Kenni electronic ID and is separate from the API-key surface.
## What Borga expects from you
- Fulfil from verified webhooks, not from redirect parameters or browser callbacks.
- Register only the domains you own as redirect domains and publishable-key origins, and use HTTPS everywhere.
- Set a Content Security Policy on pages that load borga.js; see [Embedded checkout](/payments/embedded-checkout#content-security-policy).
- Send an `Idempotency-Key` on POSTs that move money so retries cannot double-charge.
## Reporting a vulnerability
Email [security@borga.is](mailto:security@borga.is) with details and a way to reach you. Please do not test against live merchants; test mode is free and identical in behaviour.
---
# Using Borga with AI coding assistants
Source: https://docs.borga.is/ai-assistants
> Machine-readable versions of these docs for Claude, Cursor, Copilot and friends, and how to keep them from guessing.
Large language models are good at wiring up payment APIs, and bad at knowing which of the fifty payment APIs they have seen your code resembles. Give yours the real documentation.
## Feed it the docs
| Resource | Use it for |
| --- | --- |
| [`/llms.txt`](/llms.txt) | An index of every page with a one-line description. Assistants that follow `llms.txt` conventions discover the rest from here. |
| [`/llms-full.txt`](/llms-full.txt) | The entire site as one Markdown file. Paste it into context, or add the URL to your assistant's documentation sources. |
| Any page with `.md` appended, for example [`/webhooks.md`](/webhooks.md) | Just that page, as Markdown. |
Both files are generated from the same source as these pages on every deploy, so they never lag behind the HTML.
## Suggested prompt
> Integrate Borga hosted checkout into this app using the `@borga/node` SDK. Use the documentation at https://docs.borga.is/llms-full.txt as the only source of truth for endpoints and fields; do not assume Stripe behaviour. Fulfil orders from the `payment.succeeded` webhook using `verifyWebhookSignature`, and read the session id from the `session` query parameter on the return URL.
## Things assistants get wrong
These are the most common mistakes, in case you want to add them to your project's instructions file:
- The return URL receives `?session=ps_…`, not a payment id.
- Webhook `data` is the object itself, not `data.object`, and there is no `livemode` field.
- Amounts in ISK are whole krónur. `1000` is 1.000 kr., not 10 kr.
- API keys identify the merchant. There is no merchant id header on API requests.
- Webhook endpoints and API keys are created in the dashboard, not through the API.
- `/v1/events` ingests usage events for metered billing; it does not list webhook events.
- Embedded checkout opens a modal with `borga.checkout.open()`. There is no `` element.
- Bank invoices (krafa) are hosted-checkout only and need PayDay connected.
---
# API overview
Source: https://docs.borga.is/api
> Conventions shared by every endpoint: base URL, authentication, request and response format, ids, metadata and versioning.
```
https://api.borga.is
```
The Borga API is REST over HTTPS with JSON bodies. It follows the conventions most developers know from Stripe: predictable resource URLs, `snake_case` fields, prefixed ids, cursor pagination and idempotent POSTs. Where Borga's domain differs, for example bank invoices and accounting sync, the API says so rather than bending the concept.
## Authentication
Send a secret key as a bearer token. The key identifies your merchant and its mode; nothing else is needed.
```http
Authorization: Bearer sk_test_…
```
Publishable keys (`pk_…`) may only call `POST /v1/payment_sessions` with `mode: "embedded"`, from an allowed origin. Everything else needs a secret key. See [Authentication](/authentication).
## Requests
- Bodies are JSON with `Content-Type: application/json`.
- Amounts are integers in the currency's smallest unit. ISK has none, so `1990` is 1.990 kr.; euro uses cents. Supported currencies: _(Currencies table: see the HTML page)_
- Timestamps are ISO 8601 strings in UTC, for example `2026-09-07T12:04:31.512Z`.
- Unknown fields are ignored; wrong types return `validation_failed`.
- POST requests should carry an `Idempotency-Key`. See [Idempotency](/api/idempotency).
## Responses
Objects are returned as flat JSON with related objects referenced by id (`"customer": "cus_…"`). A few objects carry an `object` field naming their type; most do not, so key off the id prefix instead. Lists come back as `{ "data": [...], "has_more": true }`; see [Pagination](/api/pagination).
Every response includes an `X-Request-Id` header. Errors share one envelope with `type`, `code`, `message`, optional `param`, `doc_url` and `request_id`; see [Errors](/errors).
## Object ids
Ids are random strings with a type prefix, so a `pay_…` is always a payment wherever it shows up.
_(IdPrefixes table: see the HTML page)_
## Metadata
Most objects accept a `metadata` object of your own keys and values. Borga stores it and returns it unchanged, never interprets it, and copies it from a payment session to the payment it creates. Use it for order numbers, internal ids and anything you want to see in webhooks.
## Test and live
Mode is a property of the key. Test objects and live objects never mix, and live keys only work after [going live](/go-live). Everything on these pages behaves identically in both modes.
## Versioning
The API is versioned in the path (`/v1`). Backwards-compatible changes (new fields, new endpoints, new event types, new error codes) ship without notice. Breaking changes would get a new path version with a migration period. Write clients to ignore fields and codes they do not recognise.
## Dashboard-only configuration
API keys, webhook endpoints, allowed redirect domains, the accounting connection and bank-invoice settings are managed in the [dashboard](https://dashboard.borga.is), not through API keys. Calling those routes with an API key returns `wrong_auth_method`.
## Endpoints
- [Payment sessions](/api/payment-sessions): Create checkouts, hosted or embedded.
- [Payments](/api/payments): Read payment status and details.
- [Refunds](/api/refunds): Return money, fully or partially.
- [Payment methods](/api/payment-methods): Saved cards attached to customers.
- [Customers](/api/customers): People and companies you bill.
- [Customer portals](/api/customer-portals): Payer-facing invoice history links.
- [Products and prices](/api/products): Your catalogue.
- [Subscriptions](/api/subscriptions): Recurring billing.
- [Invoices](/api/invoices): Booked invoices and credit notes.
- [Meters and usage](/api/meters): Usage-based billing.
---
# Pagination
Source: https://docs.borga.is/api/pagination
> List endpoints return a page of objects, newest first, with a cursor for the next page.
List endpoints return objects in reverse chronological order and accept two paging parameters:
**Query parameters**
- `limit` (integer, default 25): Page size, 1 to 100. Values out of range fall back to the default or the maximum.
- `starting_after` (string): The id of the last object on the previous page. The response starts with the object after it.
The response is an envelope with the page and a flag for whether more exists:
```json Response
{
"data": [
{ "id": "pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6", "amount": 12900, "currency": "ISK", "status": "succeeded" },
{ "id": "pay_2Lm4Np6Qr8St0Uv2Wx4Yz6Ab", "amount": 1990, "currency": "ISK", "status": "succeeded" }
],
"has_more": true
}
```
To walk a list, pass the last id back as `starting_after` until `has_more` is `false`.
```ts Node.js
// Walk every succeeded payment, 100 at a time.
let startingAfter: string | undefined;
do {
const page = await borga.payments.list({
status: "succeeded",
limit: 100,
starting_after: startingAfter,
});
for (const payment of page.data) {
console.log(payment.id, payment.amount);
}
startingAfter = page.has_more ? page.data.at(-1)?.id : undefined;
} while (startingAfter);
```
```bash curl
curl "https://api.borga.is/v1/payments?status=succeeded&limit=100" \
-H "Authorization: Bearer sk_test_…"
# Next page: pass the id of the last object you received.
curl "https://api.borga.is/v1/payments?status=succeeded&limit=100&starting_after=pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6" \
-H "Authorization: Bearer sk_test_…"
```
An unknown or foreign `starting_after` id returns [`resource_missing`](/errors/resource_missing).
## Filters
Most lists accept filters as additional query parameters, for example `status` on payments or `customer` on subscriptions. They are listed on each resource page. Filters combine with paging.
## Endpoints without cursors
A few lists are bounded by nature and return at most `limit` objects with no `has_more`: refunds, credit notes and customer portals. Filter them by their parent (`payment`, `invoice`, `customer`) instead of paging.
There is no `ending_before` parameter and no total count.
---
# Idempotency
Source: https://docs.borga.is/api/idempotency
> Retry any POST safely by sending the same Idempotency-Key.
Networks fail at the worst moments. If a request to create a refund times out, you cannot know whether it went through. Idempotency keys solve this: send a unique key with the first attempt and reuse it on retries, and Borga returns the original response instead of performing the operation twice.
```http
Idempotency-Key: refund:order_5678:item_2
```
```ts Node.js
// Derive the key from the operation, not from the attempt. Retrying with the
// same key returns the original response instead of creating a second refund.
await borga.refunds.create(
{ payment: "pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6", amount: 990 },
{ idempotencyKey: `refund:order_5678:item_2` },
);
```
```bash curl
curl https://api.borga.is/v1/refunds \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: refund:order_5678:item_2" \
-d '{ "payment": "pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6", "amount": 990 }'
```
## Rules
- Keys are any string of 1 to 255 characters from `A-Z a-z 0-9 _ - : .`.
- Keys are scoped to your merchant and mode. Two merchants can use the same key without interference.
- A stored response is kept for **24 hours**. After that the same key starts a new request.
- Replays return the original status code and body, plus the headers `Idempotency-Key: ` and `Idempotent-Replayed: true`.
- 4xx responses are stored too, so a retried invalid request returns the same error. 5xx responses are not stored, so a retry after a server error runs again.
- Only POST requests are affected. GET, PATCH and DELETE ignore the header.
## Choose good keys
Derive the key from the business operation, not from the attempt: `refund::` or a UUID you generate once and persist alongside the intent to act. The Node SDK generates a fresh UUID for every POST when you do not pass one, which protects against its own internal retries but not against your process restarting; pass your own key for anything that moves money.
## Errors
| Code | HTTP | Meaning |
| --- | --- | --- |
| [`idempotency_key_in_use`](/errors/idempotency_key_in_use) | 409 | The first request with this key is still running. Wait and retry with the same key. |
| [`idempotency_key_payload_mismatch`](/errors/idempotency_key_payload_mismatch) | 409 | The key was already used with a different body or on a different route. Use a new key. |
| [`invalid_field`](/errors/invalid_field) with `param: "Idempotency-Key"` | 400 | The key contains disallowed characters or is too long. |
---
# Rate limits
Source: https://docs.borga.is/api/rate-limits
> Per-key limits, the headers that report them, and how to back off.
Requests are limited per API key and mode in fixed one-minute windows.
| Caller | Limit |
| --- | --- |
| API key (`sk_…` or `pk_…`) | 1000 requests per minute per key |
| Public checkout endpoints (no key) | 60 requests per minute per IP address |
Buckets are per route, so a burst of session creations does not starve your payment lookups.
## Headers
Every rate-limited response carries the current state:
```http
RateLimit-Limit: 1000
RateLimit-Remaining: 993
RateLimit-Reset: 41
```
`RateLimit-Reset` is seconds until the window resets. When you exceed the limit the API returns `429` with [`rate_limit_exceeded`](/errors/rate_limit_exceeded) and a `Retry-After` header in seconds.
## Backing off
Wait at least `Retry-After` seconds, then retry with the same `Idempotency-Key` so the request cannot be duplicated. The Node SDK does this automatically, up to three times with exponential backoff. If you need sustained throughput above the limit, batch where the API allows it (usage events accept 1000 per request) and contact Borga.
## Webhooks and health checks
Webhook deliveries to your endpoint are not rate limited by Borga; your endpoint sets the pace by how fast it responds. Retries follow the schedule in [Webhooks](/webhooks).
---
# Payment sessions
Source: https://docs.borga.is/api/payment-sessions
> A payment session is one checkout for one amount, hosted on checkout.borga.is or embedded on your page.
Creating a session creates a Payment and a checkout to collect it. Hosted sessions return a `url` to redirect the payer to; embedded sessions return a `client_secret` for [borga.js](/payments/embedded-checkout). Sessions expire after 24 hours.
**The payment session object**
- `id` (string): Prefixed `ps_`.
- `payment` (string): Id of the Payment this session collects.
- `payment_status` (string): Current status of the payment. Present when retrieving a session.
- `url` (string | null): Hosted checkout URL. `null` for embedded sessions.
- `client_secret` (string | null): Embedded sessions only, and only in the create response. Prefixed `pcs_`.
- `status` (enum): `open`, `complete` or `expired`.
- `mode` (enum): `hosted` or `embedded`. This is the checkout mode, not test or live.
- `enabled_methods` (array): Payment methods offered: `card`, `apple_pay`, `google_pay`, `bank_invoice`.
- `return_url` (string): Where the payer is sent after paying, with `?session=ps_…` appended. Empty for embedded sessions.
- `cancel_url` (string): Where the payer is sent if they go back.
- `locale` (string): Checkout language, `is` or `en`.
- `customer_email` (string | null): Pre-filled payer email, if any.
- `customer_kennitala` (string | null): Pre-filled payer kennitala, if any.
- `expires_at` (timestamp): When the session stops accepting payment.
- `created_at` (timestamp): ## Create a payment session `POST /v1/payment_sessions` (publishable) Either `amount` or `payment` is required. Hosted sessions also require `return_url` and `cancel_url`; embedded sessions created with a secret key require `origin`. Publishable keys may only create embedded sessions, and the browser's `Origin` header must be on the key's allow-list. **Body** Amount in the smallest currency unit, 1 to 2,000,000,000. Creates a new Payment. Required unless `payment` is set.
- `currency` (string, default ISK): ISO 4217 code. Supported: _(Currencies table: see the HTML page)_
- `payment` (string): Attach to an existing Payment in status `created` instead of creating one.
- `mode` (enum, default hosted): `hosted` redirects to checkout.borga.is; `embedded` returns a `client_secret` for borga.js.
- `return_url` (string): Required for hosted sessions. HTTPS on an allowed redirect domain; `http://localhost` is allowed in test mode.
- `cancel_url` (string): Required for hosted sessions. Same rules as `return_url`.
- `origin` (string): Required for embedded sessions created with a secret key. The `https://host` of the page that opens the modal. Ignored for publishable keys, which use the request's `Origin`.
- `customer` (string): Attach the Payment to a Customer. Required for `save_payment_method` to attach the card somewhere you can find it.
- `customer_email` (string): Pre-fills and locks the payer's email.
- `customer_kennitala` (string): Pre-fills and locks the payer's kennitala for bank invoices. Checksum-validated.
- `external_reference` (string): Your order or invoice reference. Copied to the Payment.
- `metadata` (object): Copied to the Payment.
- `enabled_methods` (array): Add `bank_invoice` to offer krafa (hosted only, PayDay required).
- `locale` (string, default is): Language tag such as `is` or `en`.
- `save_payment_method` (boolean): Tokenise the card after payment and attach it to the customer.
- `subscription` (object): `{ items: [{ price, quantity? }] }`. Starts a subscription with these recurring prices when the payment succeeds. Implies `save_payment_method`.
```ts Node.js
const session = await borga.paymentSessions.create(
{
amount: 12900,
currency: "ISK",
customer_email: "anna@example.is",
external_reference: "order_5678",
return_url: "https://yoursite.is/order/complete",
cancel_url: "https://yoursite.is/cart",
enabled_methods: ["card", "apple_pay", "google_pay"],
locale: "is",
metadata: { order_id: "5678" },
},
// Your own idempotency key makes retries across processes safe too.
{ idempotencyKey: "order_5678_checkout" },
);
// Send the payer to the hosted page.
redirect(session.url!);
```
```bash curl
curl https://api.borga.is/v1/payment_sessions \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_5678_checkout" \
-d '{
"amount": 12900,
"currency": "ISK",
"customer_email": "anna@example.is",
"external_reference": "order_5678",
"return_url": "https://yoursite.is/order/complete",
"cancel_url": "https://yoursite.is/cart",
"enabled_methods": ["card", "apple_pay", "google_pay"],
"locale": "is",
"metadata": { "order_id": "5678" }
}'
```
```json Response
{
"id": "ps_3kD9mQ2vXb7LpR4tYw8Nz1Ha",
"payment": "pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6",
"url": "https://checkout.borga.is/ps_3kD9mQ2vXb7LpR4tYw8Nz1Ha",
"return_url": "https://example.is/order/complete",
"cancel_url": "https://example.is/cart",
"expires_at": "2026-09-08T12:00:00.000Z",
"status": "open",
"enabled_methods": ["card", "apple_pay", "google_pay"],
"mode": "hosted",
"locale": "is",
"customer_email": null,
"customer_kennitala": null,
"created_at": "2026-09-07T12:00:00.000Z",
"client_secret": null
}
```
Errors: [`missing_amount`](/errors/missing_amount), [`missing_redirect_urls`](/errors/missing_redirect_urls), [`redirect_url_not_allowed`](/errors/redirect_url_not_allowed), [`missing_origin`](/errors/missing_origin), [`origin_not_allowed`](/errors/origin_not_allowed), [`publishable_key_not_allowed`](/errors/publishable_key_not_allowed), [`bank_invoice_not_enabled`](/errors/bank_invoice_not_enabled), [`invalid_kennitala`](/errors/invalid_kennitala), [`resource_not_found`](/errors/resource_not_found).
## Retrieve a payment session
`GET /v1/payment_sessions/:id`
Returns the session with `payment_status`, which saves a second call on your return page. `client_secret` is never returned here.
```bash
curl https://api.borga.is/v1/payment_sessions/ps_3kD9mQ2vXb7LpR4tYw8Nz1Ha \
-H "Authorization: Bearer sk_test_…"
```
There is no list endpoint for sessions and no way to cancel one early; unpaid sessions expire on their own.
---
# Payments
Source: https://docs.borga.is/api/payments
> A payment is one amount owed by one payer, with its outcome.
Payments are normally created by a [payment session](/api/payment-sessions). Use this resource to read status and card details, list payments, or create a payment ahead of a session.
**The payment object**
- `id` (string): Prefixed `pay_`.
- `amount` (integer): In the smallest currency unit.
- `currency` (string): `created`, `processing`, `succeeded`, `failed`, `canceled` or `refunded`.
- `customer` (string | null): Customer the payment belongs to.
- `description` (string | null): Your reference, from the session or payment create call.
- `metadata` (object): `card`, `wallet` or `bank_invoice` once known.
- `refunded_amount` (integer): Total refunded so far.
- `failure_reason` (string | null): Processor's reason when `status` is `failed`.
- `card_brand` (string | null): For example `visa` or `mc`.
- `card_last4` (string | null): `apple_pay` or `google_pay` when a wallet was used.
- `created_at` (timestamp): - `updated_at` (timestamp) ## Create a payment `POST /v1/payments` Creates a payment in status `created` without a checkout. Attach a session to it with the session's `payment` field. Most integrations skip this and let the session create the payment. **Body** 1 to 2,000,000,000 in the smallest currency unit.
- `currency` (string, default ISK): Must belong to your merchant in this mode.
- `description` (string): - `external_reference` (string) - `metadata` (object) ```bash curl https://api.borga.is/v1/payments \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "amount": 12900, "currency": "ISK", "external_reference": "order_5678" }' ``` ## Retrieve a payment `GET /v1/payments/:id` ```ts Node.js const payment = await borga.payments.retrieve("pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6"); console.log(payment.status); // "succeeded" console.log(payment.card_brand, payment.card_last4); // "visa" "4242" ``` ```bash curl curl https://api.borga.is/v1/payments/pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6 \ -H "Authorization: Bearer sk_test_…" ``` ```json title="Response" { "id": "pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6", "amount": 12900, "currency": "ISK", "status": "succeeded", "customer": null, "description": null, "metadata": { "order_id": "5678" }, "collection_method": "card", "refunded_amount": 0, "external_reference": "order_5678", "failure_reason": null, "card_brand": "visa", "card_last4": "4242", "wallet_type": null, "created_at": "2026-09-07T12:03:58.201Z", "updated_at": "2026-09-07T12:04:31.498Z" } ``` ## List payments `GET /v1/payments` **Query parameters** Filter by payment status.
- `limit` (integer, default 25): Up to 100.
- `starting_after` (string): Cursor; see [Pagination](/api/pagination).
```bash
curl "https://api.borga.is/v1/payments?status=succeeded&limit=50" \
-H "Authorization: Bearer sk_test_…"
```
---
# Refunds
Source: https://docs.borga.is/api/refunds
> Return all or part of a card payment. Refunds create credit notes in your accounting system.
**The refund object**
- `id` (string): Prefixed `ref_`.
- `payment` (string): The refunded payment.
- `amount` (integer): - `currency` (string) `pending`, `succeeded` or `failed`.
- `reason` (string | null): Free text you supplied.
- `metadata` (object): - `created_at` (timestamp) - `updated_at` (timestamp) ## Create a refund `POST /v1/refunds` The payment must be `succeeded` or `processing`. The refundable balance is the amount minus previous and pending refunds. Send an `Idempotency-Key`. **Body** Payment to refund.
- `amount` (integer): Defaults to the full refundable balance.
- `reason` (string): Stored on the refund and shown in the dashboard.
- `metadata` (object): ```ts Node.js // Partial refund. Omit `amount` to refund everything that is left. const refund = await borga.refunds.create( { payment: "pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6", amount: 990, reason: "Returned one item", metadata: { ticket: "support_1729" }, }, { idempotencyKey: "refund_support_1729" }, ); console.log(refund.status); // "pending" until payment.refunded arrives ``` ```bash curl curl https://api.borga.is/v1/refunds \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: refund_support_1729" \ -d '{ "payment": "pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6", "amount": 990, "reason": "Returned one item" }' ``` ```json title="Response" { "id": "ref_6Yz8Ab0Cd2Ef4Gh6Ij8Kl0Mn", "payment": "pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6", "amount": 990, "currency": "ISK", "status": "pending", "reason": "Returned one item", "metadata": { "ticket": "support_1729" }, "created_at": "2026-09-08T09:12:00.000Z", "updated_at": "2026-09-08T09:12:00.000Z" } ``` Errors: [`payment_not_refundable`](/errors/payment_not_refundable), [`amount_too_small`](/errors/amount_too_small), [`amount_too_large`](/errors/amount_too_large), [`card_payment_reverse_failed`](/errors/card_payment_reverse_failed), [`card_payment_reverse_uncertain`](/errors/card_payment_reverse_uncertain). ## Retrieve a refund `GET /v1/refunds/:id` ```bash curl https://api.borga.is/v1/refunds/ref_6Yz8Ab0Cd2Ef4Gh6Ij8Kl0Mn \ -H "Authorization: Bearer sk_test_…" ``` ## List refunds `GET /v1/refunds` Returns up to `limit` refunds, newest first, without a cursor. Filter by payment. **Query parameters** Only refunds of this payment.
- `limit` (integer, default 25): Up to 100.
```bash
curl "https://api.borga.is/v1/refunds?payment=pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6" \
-H "Authorization: Bearer sk_test_…"
```
---
# Payment methods
Source: https://docs.borga.is/api/payment-methods
> Saved cards attached to customers. Created by checkout, charged by subscriptions.
Payment methods are created when a session with `save_payment_method` or `subscription` succeeds; there is no create endpoint. See [Saved cards](/payments/saved-cards).
**The payment method object**
- `id` (string): Prefixed `pm_`.
- `object` (string): `payment_method`
- `customer` (string): `card`
- `status` (enum): `active`, `detached`, `expired` or `failed`.
- `card` (object | null): `brand`, `last4`, `exp_month`, `exp_year` and `wallet_type` (`apple_pay`, `google_pay` or `null`).
- `created_at` (timestamp): - `detached_at` (timestamp | null) ## Retrieve a payment method `GET /v1/payment_methods/:id` ```bash curl https://api.borga.is/v1/payment_methods/pm_1Ab3Cd5Ef7Gh9Ij1Kl3Mn5Op \ -H "Authorization: Bearer sk_test_…" ``` ## List payment methods `GET /v1/payment_methods` Returns active (not detached) payment methods. **Query parameters** Only this customer's cards.
- `limit` (integer, default 25): Up to 100.
- `starting_after` (string): Cursor.
```ts Node.js
const { data: cards } = await borga.paymentMethods.list({
customer: "cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz",
});
for (const pm of cards) {
console.log(pm.id, pm.card?.brand, pm.card?.last4, pm.status);
}
// Choose which card subscriptions charge.
await borga.paymentMethods.setDefault(cards[0].id);
// Remove a card the customer no longer wants on file.
await borga.paymentMethods.detach(cards[1].id);
```
## Set as default
`POST /v1/payment_methods/:id/set_default`
Makes this the customer's default payment method, used by `charge_automatically` subscriptions. The first card saved to a customer becomes the default automatically. Fails with [`payment_method_inactive`](/errors/payment_method_inactive) if the card is not `active`.
```bash
curl -X POST https://api.borga.is/v1/payment_methods/pm_1Ab3Cd5Ef7Gh9Ij1Kl3Mn5Op/set_default \
-H "Authorization: Bearer sk_test_…"
```
## Detach a payment method
`DELETE /v1/payment_methods/:id`
Marks the card `detached`. It can no longer be charged, and if it was the default the customer has no default until another card is saved or chosen. Emits `payment_method.detached`.
```bash
curl -X DELETE https://api.borga.is/v1/payment_methods/pm_1Ab3Cd5Ef7Gh9Ij1Kl3Mn5Op \
-H "Authorization: Bearer sk_test_…"
```
---
# Customers
Source: https://docs.borga.is/api/customers
> The people and companies you bill. Customers own saved cards, subscriptions and invoices.
**The customer object**
- `id` (string): Prefixed `cus_`.
- `email` (string): - `name` (string | null) - `phone` (string | null) Icelandic national or company id. Needed for bank-invoice collection.
- `external_id` (string | null): Your own id. Usage events can reference it as `external_customer_id`.
- `vat_number` (string | null): `line1`, `line2`, `city`, `postal_code`, `country`.
- `default_payment_method` (string | null): The `pm_` charged by subscriptions.
- `metadata` (object): - `created_at` (timestamp) - `updated_at` (timestamp) ## Create a customer `POST /v1/customers` **Body** - `email` (string) - `name` (string) - `phone` (string) - `kennitala` (string) Your own identifier, unique per customer.
- `vat_number` (string): - `billing_address_line1` (string) - `billing_address_line2` (string) - `billing_city` (string) - `billing_postal_code` (string) ISO 3166-1 alpha-2, for example `IS`.
- `metadata` (object): ```bash curl https://api.borga.is/v1/customers \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "email": "anna@example.is", "name": "Anna Jónsdóttir", "kennitala": "0101302989", "external_id": "user_12345", "billing_address_line1": "Laugavegur 1", "billing_postal_code": "101", "billing_city": "Reykjavík", "billing_country": "IS" }' ``` ```json title="Response" { "id": "cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz", "email": "anna@example.is", "name": "Anna Jónsdóttir", "phone": null, "kennitala": "0101302989", "external_id": "user_12345", "vat_number": null, "billing_address": { "line1": "Laugavegur 1", "line2": null, "city": "Reykjavík", "postal_code": "101", "country": "IS" }, "metadata": {}, "default_payment_method": null, "created_at": "2026-09-07T10:00:00.000Z", "updated_at": "2026-09-07T10:00:00.000Z" } ``` ## Retrieve a customer `GET /v1/customers/:id` ## Update a customer `PATCH /v1/customers/:id` Accepts the same fields as create, all optional. Only the fields you send are changed. **Body** - `email` (string) - `name` (string) - `phone` (string) - `kennitala` (string) Send an empty string to clear.
- `vat_number` (string): - `billing_address_line1` (string) - `billing_address_line2` (string) - `billing_city` (string) - `billing_postal_code` (string) - `billing_country` (string) - `metadata` (object) ## List customers `GET /v1/customers` **Query parameters** Exact match, case-insensitive.
- `limit` (integer)
- `starting_after` (string)
---
# Customer portals
Source: https://docs.borga.is/api/customer-portals
> Short-lived links where a customer can view, download and pay their invoices.
See the [customer portal guide](/billing/customer-portal) for what payers see.
**The customer portal object**
- `id` (string): Prefixed `cprt_`.
- `customer` (string): The link to share, on checkout.borga.is.
- `status` (enum): `active`, `expired`, `revoked` or `consumed`.
- `reusable` (boolean): - `expires_at` (timestamp) - `revoked_at` (timestamp | null) - `last_accessed_at` (timestamp | null) - `metadata` (object) - `created_at` (timestamp) ## Create a portal link `POST /v1/customer_portals` **Body** - `customer` (string) Seconds, 60 to 86400.
- `reusable` (boolean, default true): `false` for a single-use link.
- `metadata` (object): ```ts Node.js const portal = await borga.customerPortals.create({ customer: "cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz", expires_in: 3600, // seconds; up to 24 hours reusable: true, }); // Link or email portal.url to the customer. console.log(portal.url); // https://checkout.borga.is/portal/cprt_… ``` ```json title="Response" { "id": "cprt_2Wx4Yz6Ab8Cd0Ef2Gh4Ij6Kl", "customer": "cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz", "url": "https://checkout.borga.is/portal/cprt_2Wx4Yz6Ab8Cd0Ef2Gh4Ij6Kl", "reusable": true, "status": "active", "expires_at": "2026-09-07T13:00:00.000Z", "revoked_at": null, "last_accessed_at": null, "metadata": {}, "created_at": "2026-09-07T12:00:00.000Z" } ``` ## Retrieve a portal link `GET /v1/customer_portals/:id` ## List portal links `GET /v1/customer_portals` Returns up to `limit` links without a cursor. **Query parameters** - `customer` (string) Up to 100.
## Revoke a portal link
`DELETE /v1/customer_portals/:id`
The link stops working immediately. Revoking an already revoked link is a no-op.
---
# Products
Source: https://docs.borga.is/api/products
> What you sell. A product groups one or more prices.
**The product object**
- `id` (string): Prefixed `prod_`.
- `name` (string): - `description` (string | null) Inactive products cannot get new subscriptions.
- `metadata` (object)
- `created_at` (timestamp)
- `updated_at` (timestamp)
## Create a product
`POST /v1/products`
**Body**
- `name` (string)
- `description` (string)
- `metadata` (object)
```ts Node.js
const product = await borga.products.create({
name: "Pro plan",
description: "Everything in Starter plus priority support",
});
const monthly = await borga.prices.create({
product: product.id,
type: "recurring",
currency: "ISK",
unit_amount: 4990, // 4.990 kr. per seat per month
recurring: { interval: "month", interval_count: 1 },
});
const yearly = await borga.prices.create({
product: product.id,
type: "recurring",
currency: "ISK",
unit_amount: 49900,
recurring: { interval: "year" },
});
```
## Retrieve a product
`GET /v1/products/:id`
## Update a product
`PATCH /v1/products/:id`
**Body**
- `name` (string)
- `description` (string)
- `active` (boolean)
- `metadata` (object)
## List products
`GET /v1/products`
**Query parameters**
- `limit` (integer)
- `starting_after` (string)
---
# Prices
Source: https://docs.borga.is/api/prices
> How much a product costs and how often it is billed. Prices are immutable apart from activation and metadata.
**The price object**
- `id` (string): Prefixed `price_`.
- `product` (string): - `active` (boolean) - `currency` (string) `one_time` or `recurring`.
- `unit_amount` (integer | null): Per unit, in the smallest currency unit. `null` for tiered prices.
- `recurring` (object | null): `interval` (`day`, `week`, `month`, `year`), `interval_count`, `usage_type` (`licensed` or `metered`), `aggregate_usage`.
- `tiers` (array | null): Tier objects with `up_to` (number or `null` for the last tier), `unit_amount`, `flat_amount`.
- `tiers_mode` (enum | null): `graduated` or `volume`.
- `meter` (string | null): Meter for metered prices.
- `included_units` (integer | null): Free units per period for metered prices.
- `accounting_code` (string | null): Revenue account code copied to invoice lines.
- `metadata` (object): - `created_at` (timestamp) - `updated_at` (timestamp) ## Create a price `POST /v1/prices` **Body** - `product` (string) `one_time` or `recurring`.
- `currency` (string, default ISK): Per unit. Omit when using tiers.
- `recurring` (object): Required for recurring prices: `{ interval, interval_count?, usage_type?, aggregate_usage? }`. `interval` is `day`, `week`, `month` or `year`.
- `tiers_mode` (enum): `graduated` prices each band separately; `volume` prices everything at the reached band.
- `tiers` (array): `[{ up_to, unit_amount?, flat_amount? }]`, ordered, last tier with `up_to: null`.
- `meter` (string): Required when `recurring.usage_type` is `metered`.
- `included_units` (integer): Free units per period before tiers apply.
- `accounting_code` (string): Copied to invoice lines for this price.
- `metadata` (object): ```ts Fixed const product = await borga.products.create({ name: "Pro plan", description: "Everything in Starter plus priority support", }); const monthly = await borga.prices.create({ product: product.id, type: "recurring", currency: "ISK", unit_amount: 4990, // 4.990 kr. per seat per month recurring: { interval: "month", interval_count: 1 }, }); const yearly = await borga.prices.create({ product: product.id, type: "recurring", currency: "ISK", unit_amount: 49900, recurring: { interval: "year" }, }); ``` ```ts Metered const price = await borga.prices.create({ product: "prod_4Gh6Ij8Kl0Mn2Op4Qr6St8Uv", type: "recurring", currency: "ISK", recurring: { interval: "month", usage_type: "metered" }, meter: "mtr_1Zx3Cv5Bn7Mq9We1Rt3Yu5Io", included_units: 10_000, // first 10k requests are free tiers_mode: "graduated", tiers: [ { up_to: 100_000, unit_amount: 2 }, // 2 kr. per request { up_to: null, unit_amount: 1 }, // 1 kr. above 100k ], }); ``` ```json title="Response" { "id": "price_2Ab4Cd6Ef8Gh0Ij2Kl4Mn6Op", "product": "prod_4Gh6Ij8Kl0Mn2Op4Qr6St8Uv", "active": true, "currency": "ISK", "type": "recurring", "unit_amount": 4990, "recurring": { "interval": "month", "interval_count": 1, "usage_type": "licensed" }, "tiers": null, "tiers_mode": null, "meter": null, "included_units": null, "accounting_code": null, "metadata": {}, "created_at": "2026-09-07T10:00:00.000Z", "updated_at": "2026-09-07T10:00:00.000Z" } ``` Errors: [`missing_meter`](/errors/missing_meter), [`resource_not_found`](/errors/resource_not_found) for `product` or `meter`. ## Retrieve a price `GET /v1/prices/:id` ## Update a price `PATCH /v1/prices/:id` Amounts and intervals cannot change; create a new price and move subscriptions to it. **Body** Deactivate to stop new subscriptions using it.
- `accounting_code` (string): Empty string clears it.
- `metadata` (object)
## List prices
`GET /v1/prices`
**Query parameters**
- `product` (string)
- `limit` (integer)
- `starting_after` (string)
---
# Subscriptions
Source: https://docs.borga.is/api/subscriptions
> A customer's recurring commitment to one or more prices, billed at the end of each period.
See the [subscriptions guide](/billing/subscriptions) for the billing model, trials and dunning.
**The subscription object**
- `id` (string): Prefixed `sub_`.
- `object` (string): `subscription`
- `customer` (string): `trialing`, `active`, `past_due`, `unpaid`, `canceled` or `paused`.
- `collection_method` (enum): `charge_automatically` (saved card) or `send_invoice` (bank invoice).
- `days_until_due` (integer | null): For `send_invoice`.
- `current_period_start` (timestamp): When the next invoice is issued.
- `cancel_at_period_end` (boolean): - `canceled_at` (timestamp | null) - `ended_at` (timestamp | null) - `trial_start` (timestamp | null) - `trial_end` (timestamp | null) - `pause_collection` (object | null) `amount_off` or `percent_off`, `duration`.
- `items` (array): [Subscription items](/api/subscription-items).
- `metadata` (object): - `created_at` (timestamp) - `updated_at` (timestamp) ## Create a subscription `POST /v1/subscriptions` The customer needs a default payment method for `charge_automatically`, or a kennitala for `send_invoice`. All items must share an interval and a currency, and at least one must be recurring. **Body** - `customer` (string) One or more `{ price, quantity?, credit_rollover?, included_units?, metadata? }`.
- `collection_method` (enum, default charge_automatically): `charge_automatically` or `send_invoice`.
- `trial_period_days` (integer): Free days before the first paid period.
- `days_until_due` (integer): For `send_invoice`: days the customer has to pay each invoice.
- `discount` (object): `{ amount_off?, percent_off?, duration: "once" }`.
- `metadata` (object): ```ts Node.js // The customer already has a saved card (payment_method.attached fired). const subscription = await borga.subscriptions.create({ customer: "cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz", items: [{ price: "price_2Ab4Cd6Ef8Gh0Ij2Kl4Mn6Op", quantity: 3 }], collection_method: "charge_automatically", trial_period_days: 14, metadata: { plan: "pro" }, }); console.log(subscription.status); // "trialing" console.log(subscription.current_period_end); ``` ```bash curl curl https://api.borga.is/v1/subscriptions \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "customer": "cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz", "items": [{ "price": "price_2Ab4Cd6Ef8Gh0Ij2Kl4Mn6Op", "quantity": 3 }], "collection_method": "charge_automatically", "trial_period_days": 14 }' ``` ```json title="Response" { "id": "sub_9Qw1Er3Ty5Ui7Op9As1Df3Gh", "object": "subscription", "customer": "cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz", "status": "trialing", "collection_method": "charge_automatically", "days_until_due": null, "current_period_start": "2026-09-07T12:00:00.000Z", "current_period_end": "2026-09-21T12:00:00.000Z", "cancel_at_period_end": false, "canceled_at": null, "ended_at": null, "trial_start": "2026-09-07T12:00:00.000Z", "trial_end": "2026-09-21T12:00:00.000Z", "pause_collection": null, "discount": null, "metadata": { "plan": "pro" }, "items": [ { "id": "si_5Zx7Cv9Bn1Mq3We5Rt7Yu9Io", "object": "subscription_item", "subscription": "sub_9Qw1Er3Ty5Ui7Op9As1Df3Gh", "price": "price_2Ab4Cd6Ef8Gh0Ij2Kl4Mn6Op", "quantity": 3, "credit_rollover": false, "included_units": null, "metadata": {}, "created_at": "2026-09-07T12:00:00.000Z", "updated_at": "2026-09-07T12:00:00.000Z" } ], "created_at": "2026-09-07T12:00:00.000Z", "updated_at": "2026-09-07T12:00:00.000Z" } ``` Errors: [`invalid_subscription`](/errors/invalid_subscription), [`invalid_price_type`](/errors/invalid_price_type), [`resource_not_found`](/errors/resource_not_found). ## Retrieve a subscription `GET /v1/subscriptions/:id` ## Update a subscription `POST /v1/subscriptions/:id` Change items through [subscription items](/api/subscription-items). **Body** - `cancel_at_period_end` (boolean) Set a discount or `null` to remove it.
- `collection_method` (enum): - `days_until_due` (integer) - `metadata` (object) ## Cancel a subscription `POST /v1/subscriptions/:id/cancel` **Body** `true` keeps the subscription active until `current_period_end`, bills the final period, then cancels. `false` ends it immediately.
## Uncancel a subscription
`POST /v1/subscriptions/:id/uncancel`
Clears `cancel_at_period_end`. Fails with [`cannot_uncancel`](/errors/cannot_uncancel) if no cancellation is pending.
## Pause a subscription
`POST /v1/subscriptions/:id/pause`
Stops billing. Fails with [`cannot_pause`](/errors/cannot_pause) unless the subscription is `active` or `trialing`.
## Resume a subscription
`POST /v1/subscriptions/:id/resume`
Fails with [`not_paused`](/errors/not_paused) if the subscription is not paused.
## List subscriptions
`GET /v1/subscriptions`
**Query parameters**
- `customer` (string)
- `status` (enum)
- `limit` (integer)
- `starting_after` (string)
```ts Node.js
const id = "sub_9Qw1Er3Ty5Ui7Op9As1Df3Gh";
// Change seats; Borga prorates the difference on the next invoice.
const [item] = (await borga.subscriptions.retrieve(id)).items;
await borga.subscriptionItems.update(item.id, {
quantity: 5,
proration_behavior: "create_prorations",
});
// Add a one-off charge to the next invoice.
await borga.invoiceItems.create({
customer: "cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz",
subscription: id,
amount: 15000,
description: "Onboarding workshop",
});
// Cancel when the period ends (reversible with uncancel).
await borga.subscriptions.cancel(id, { at_period_end: true });
await borga.subscriptions.uncancel(id);
// Pause and resume billing.
await borga.subscriptions.pause(id);
await borga.subscriptions.resume(id);
```
---
# Subscription items
Source: https://docs.borga.is/api/subscription-items
> One price within a subscription, with a quantity. Add, change and remove items with proration.
**The subscription item object**
- `id` (string): Prefixed `si_`.
- `object` (string): `subscription_item`
- `subscription` (string): - `price` (string) Seats or units for licensed prices.
- `credit_rollover` (boolean): Carry unused included units into the next period.
- `included_units` (integer | null): Overrides the price's included units.
- `metadata` (object): - `created_at` (timestamp) - `updated_at` (timestamp) Proration: `create_prorations` (default) records the difference for the remainder of the period and adds it to the next invoice; `always_invoice` bills it immediately; `none` skips it. ## Create a subscription item `POST /v1/subscription_items` **Body** - `subscription` (string) Must match the subscription's interval and currency.
- `quantity` (integer, default 1): `create_prorations`, `always_invoice` or `none`.
- `credit_rollover` (boolean): - `included_units` (integer) - `metadata` (object) ```bash curl https://api.borga.is/v1/subscription_items \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "subscription": "sub_9Qw1Er3Ty5Ui7Op9As1Df3Gh", "price": "price_7Kl9Mn1Op3Qr5St7Uv9Wx1Yz", "quantity": 1 }' ``` ## Retrieve a subscription item `GET /v1/subscription_items/:id` ## Update a subscription item `POST /v1/subscription_items/:id` **Body** - `quantity` (integer) - `proration_behavior` (enum) - `credit_rollover` (boolean) - `included_units` (integer) - `metadata` (object) ## Remove a subscription item `DELETE /v1/subscription_items/:id` **Query parameters** Credit for the unused remainder of the period.
Returns `{ "deleted": true, "id": "si_…" }`. A subscription must keep at least one recurring item.
---
# Invoices
Source: https://docs.borga.is/api/invoices
> Invoices booked in your accounting provider for payments and subscription periods. Read-only.
Invoices are created by Borga when a payment succeeds, a bank invoice is issued or a subscription period ends, and mirrored to PayDay or DK+. See [Invoicing](/billing/invoicing). You cannot create or edit them through the API; add one-off charges to a subscription with [invoice items](/api/invoice-items).
**The invoice object**
- `id` (string): Prefixed `inv_`.
- `customer` (string | null): The payment that paid it, for one-off sales and card-collected subscription invoices.
- `status` (enum): `draft`, `open`, `paid`, `void` or `sync_failed`.
- `subtotal` (integer): - `tax` (integer) - `total` (integer) - `amount_paid` (integer) - `currency` (string) `payday` or `dk`.
- `external_invoice_id` (string | null): The provider's id.
- `external_invoice_number` (string | null): The legal invoice number issued by the provider.
- `pdf_url` (string | null): Provider-hosted PDF, when available.
- `sync_error_message` (string | null): Set when `status` is `sync_failed`.
- `external_reference` (string | null): Copied from the payment.
- `billing` (object): `name`, `email`, `kennitala`, `vat_number`, `address` used on the invoice.
- `lines` (array): Present when retrieving: `id`, `description`, `quantity`, `unit_amount`, `tax_rate`, `tax_amount`, `accounting_code`.
- `finalized_at` (timestamp | null)
- `paid_at` (timestamp | null)
- `created_at` (timestamp)
## Retrieve an invoice
`GET /v1/invoices/:id`
```bash
curl https://api.borga.is/v1/invoices/inv_3Ef5Gh7Ij9Kl1Mn3Op5Qr7St \
-H "Authorization: Bearer sk_test_…"
```
## List invoices
`GET /v1/invoices`
**Query parameters**
- `status` (enum)
- `limit` (integer)
- `starting_after` (string)
```ts Node.js
const { data, has_more } = await borga.invoices.list({ status: "paid", limit: 50 });
for (const invoice of data) {
console.log(invoice.external_invoice_number, invoice.total, invoice.pdf_url);
}
if (has_more) {
const next = await borga.invoices.list({
status: "paid",
starting_after: data[data.length - 1].id,
});
console.log(next.data.length);
}
```
---
# Invoice items
Source: https://docs.borga.is/api/invoice-items
> One-off charges or credits queued for a customer's next subscription invoice.
**The invoice item object**
- `id` (string): Prefixed `ii_`.
- `object` (string): `invoice_item`
- `customer` (string): - `subscription` (string | null) Set once the item has been placed on an invoice.
- `amount` (integer): Per unit. Negative for credits.
- `currency` (string): Shown as the invoice line.
- `quantity` (integer): - `metadata` (object) - `created_at` (timestamp) ## Create an invoice item `POST /v1/invoice_items` **Body** - `customer` (string) Per unit in the smallest currency unit. Negative amounts create credits.
- `description` (string, required): Place the item on this subscription's next invoice. Must belong to `customer`.
- `currency` (string, default ISK): Must match the subscription's currency when one is given.
- `quantity` (integer, default 1): - `metadata` (object) ```bash curl https://api.borga.is/v1/invoice_items \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "customer": "cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz", "subscription": "sub_9Qw1Er3Ty5Ui7Op9As1Df3Gh", "amount": 15000, "description": "Onboarding workshop" }' ``` Errors: [`mismatch`](/errors/mismatch), [`currency_mismatch`](/errors/currency_mismatch), [`resource_not_found`](/errors/resource_not_found). ## Retrieve an invoice item `GET /v1/invoice_items/:id` ## List invoice items `GET /v1/invoice_items` **Query parameters** - `customer` (string) - `subscription` (string) `true` returns only items not yet invoiced.
- `limit` (integer)
- `starting_after` (string)
---
# Credit notes
Source: https://docs.borga.is/api/credit-notes
> Credit notes issued in your accounting provider when a payment is refunded. Read-only.
A succeeded [refund](/api/refunds) against a payment whose invoice was booked in PayDay or DK+ produces a credit note there. Borga records it and emits `credit_note.created`.
**The credit note object**
- `id` (string): Prefixed `cn_`.
- `invoice` (string): The invoice being credited.
- `amount` (integer): - `currency` (string) `duplicate`, `fraudulent`, `order_change`, `product_unsatisfactory` or `other`.
- `status` (enum): `pending`, `issued`, `failed` or `void`.
- `accounting_provider` (enum | null): `payday` or `dk`.
- `external_credit_note_id` (string | null): The provider's id.
- `created_at` (timestamp): ## Retrieve a credit note `GET /v1/credit_notes/:id` ## List credit notes `GET /v1/credit_notes` Returns up to `limit` credit notes without a cursor. **Query parameters** - `invoice` (string) Up to 100.
```bash
curl "https://api.borga.is/v1/credit_notes?invoice=inv_3Ef5Gh7Ij9Kl1Mn3Op5Qr7St" \
-H "Authorization: Bearer sk_test_…"
```
---
# Meters
Source: https://docs.borga.is/api/meters
> Turn usage events into a billable quantity per customer and period.
See [Usage-based billing](/billing/usage-based) for how meters, prices and events fit together.
**The meter object**
- `id` (string): Prefixed `mtr_`.
- `object` (string): `meter`
- `name` (string): Matched against `event_name` on usage events.
- `aggregate` (object): `type` (`count`, `sum`, `max`, `min`, `avg`, `unique`) and `property`.
- `filter` (object | null): Metadata conditions events must match.
- `unit_label` (string | null): - `unit_multiplier` (integer | null) - `archived_at` (timestamp | null) - `created_at` (timestamp) - `updated_at` (timestamp) ## Create a meter `POST /v1/meters` **Body** Up to 200 characters.
- `event_name` (string, required): Up to 200 characters.
- `aggregate_type` (enum, required): `count`, `sum`, `max`, `min`, `avg` or `unique`.
- `aggregate_property` (string): Metadata path such as `metadata.total_tokens`. Required unless `aggregate_type` is `count`.
- `filter` (object): Only events whose metadata matches count, e.g. `{ "metadata.tier": "pro" }`.
- `unit_label` (string): Shown on invoices, e.g. `tokens`.
- `unit_multiplier` (integer): Divide the aggregate by this before pricing.
```ts Node.js
// Count API requests
const requests = await borga.meters.create({
name: "API requests",
event_name: "api_request",
aggregate_type: "count",
unit_label: "requests",
});
// Sum tokens, priced per million
const tokens = await borga.meters.create({
name: "LLM tokens",
event_name: "llm_completion",
aggregate_type: "sum",
aggregate_property: "metadata.total_tokens",
unit_label: "tokens",
unit_multiplier: 1_000_000,
});
```
Errors: [`missing_aggregate_property`](/errors/missing_aggregate_property).
## Retrieve a meter
`GET /v1/meters/:id`
## List meters
`GET /v1/meters`
**Query parameters**
- `archived` (boolean): `true` includes archived meters.
- `limit` (integer, default 25): - `starting_after` (string) ## Archive a meter `DELETE /v1/meters/:id` Archived meters stop accepting events but keep their history and remain readable. ## Meter quantity for a period `GET /v1/meters/:id/quantities` Aggregates one customer's events over a period. **Query parameters** - `customer` (string) Defaults to the customer's current billing period.
- `period_end` (timestamp)
```json title="Response"
{
"meter": "mtr_1Zx3Cv5Bn7Mq9We1Rt3Yu5Io",
"customer": "cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz",
"period_start": "2026-09-01T00:00:00.000Z",
"period_end": "2026-10-01T00:00:00.000Z",
"quantity": 14250,
"unit_label": "requests"
}
```
## Customer meter balance
`GET /v1/customer-meters/:customerId/:meterId`
The live state of a customer's meter in the current period, including included units and overage.
```ts Node.js
const balance = await borga.meters.balance(
"cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz",
"mtr_1Zx3Cv5Bn7Mq9We1Rt3Yu5Io",
);
console.log(balance.consumed_units, "of", balance.included_units);
console.log("overage:", balance.overage_units);
```
```json title="Response"
{
"customer": "cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz",
"meter": "mtr_1Zx3Cv5Bn7Mq9We1Rt3Yu5Io",
"period_start": "2026-09-01T00:00:00.000Z",
"period_end": "2026-10-01T00:00:00.000Z",
"consumed_units": 7420,
"included_units": 10000,
"balance": 2580,
"overage_units": 0
}
```
## Customer billing state
`GET /v1/customer-state/:customerId`
Everything needed for a customer-facing billing page in one call: active, trialing and past-due subscriptions with their items, and every meter balance.
```json title="Response"
{
"customer": "cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz",
"subscriptions": [
{
"id": "sub_9Qw1Er3Ty5Ui7Op9As1Df3Gh",
"status": "active",
"current_period_start": "2026-09-01T00:00:00.000Z",
"current_period_end": "2026-10-01T00:00:00.000Z",
"cancel_at_period_end": false,
"items": [{ "id": "si_…", "price": "price_…", "quantity": 3 }]
}
],
"meters": [
{
"meter": "mtr_1Zx3Cv5Bn7Mq9We1Rt3Yu5Io",
"name": "API requests",
"unit_label": "requests",
"consumed_units": 7420,
"included_units": 10000,
"balance": 2580,
"overage_units": 0,
"period_start": "2026-09-01T00:00:00.000Z",
"period_end": "2026-10-01T00:00:00.000Z"
}
]
}
```
---
# Usage events
Source: https://docs.borga.is/api/usage-events
> Report what customers consume. Events are matched to meters by name and aggregated at period end.
Usage events live at `/v1/events`. They are the raw input to [meters](/api/meters), and are unrelated to webhook events, which are delivered to you rather than read from the API.
**The usage event object**
- `id` (string): Prefixed `ue_`.
- `object` (string): `usage_event`
- `event_name` (string): Resolved customer, from `customer` or `external_customer_id`.
- `external_customer_id` (string | null): - `timestamp` (timestamp) - `metadata` (object) - `idempotency_key` (string | null) - `created_at` (timestamp) ## Ingest one event `POST /v1/events` **Body** Matches a meter's `event_name`. Up to 200 characters.
- `customer` (string): The `cus_` id. Provide this or `external_customer_id`.
- `external_customer_id` (string): Your id, matched to `Customer.external_id`. Unmatched events are stored and linked when the customer is created.
- `timestamp` (timestamp): Defaults to now. Rejected if more than 60 seconds in the future.
- `idempotency_key` (string): Dedup key, up to 200 characters. Repeats are dropped and counted in `duplicates`.
- `metadata` (object): Values a meter's `aggregate_property` and `filter` refer to.
```ts Node.js
// Single event
await borga.usageEvents.create({
event_name: "api_request",
customer: "cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz",
idempotency_key: "req_01J9X2K7M3", // dedupes retries
metadata: { endpoint: "/v1/chat" },
});
// Batches of up to 1000, identified by your own customer ids
await borga.usageEvents.createBatch([
{
event_name: "llm_completion",
external_customer_id: "user_12345",
timestamp: "2026-09-07T12:00:00Z",
idempotency_key: "cmpl_a1",
metadata: { total_tokens: 640 },
},
{
event_name: "llm_completion",
external_customer_id: "user_12345",
timestamp: "2026-09-07T12:00:02Z",
idempotency_key: "cmpl_a2",
metadata: { total_tokens: 1024 },
},
]);
```
```bash curl
curl https://api.borga.is/v1/events \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-d '{
"event_name": "api_request",
"customer": "cus_8Jk2Lm4Np6Qr8St0Uv2Wx4Yz",
"idempotency_key": "req_01J9X2K7M3",
"metadata": { "endpoint": "/v1/chat" }
}'
```
```json title="Response"
{ "inserted": 1, "duplicates": 0 }
```
Errors: [`invalid_timestamp`](/errors/invalid_timestamp), [`future_timestamp`](/errors/future_timestamp), [`resource_not_found`](/errors/resource_not_found) for `customer`.
## Ingest a batch
`POST /v1/events/batch`
**Body**
- `events` (array, required): 1 to 1000 event objects with the fields above.
```bash
curl https://api.borga.is/v1/events/batch \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-d '{
"events": [
{ "event_name": "api_request", "external_customer_id": "user_12345", "idempotency_key": "req_1" },
{ "event_name": "api_request", "external_customer_id": "user_12345", "idempotency_key": "req_2" }
]
}'
```
The whole batch is accepted or rejected together; the response counts `inserted` and `duplicates`.
## List usage events
`GET /v1/events`
Newest first by `timestamp`.
**Query parameters**
- `customer` (string): - `event_name` (string) Inclusive lower bound on `timestamp`.
- `to` (timestamp): Exclusive upper bound on `timestamp`.
- `limit` (integer)
- `starting_after` (string)
---
# Event types
Source: https://docs.borga.is/api/events
> Every webhook event Borga sends, when it fires and what its data field contains.
Events are delivered to your [webhook endpoints](/webhooks) as JSON with four fields:
**The event object**
- `id` (string): Unique id, prefixed `evt_`. Use it to deduplicate retried deliveries.
- `type` (string): One of the types below, such as `payment.succeeded`.
- `created_at` (timestamp): When the event was created.
- `data` (object): The object the event is about, or a summary for lifecycle events. Listed per type below.
Subscribe to specific types when you create an endpoint, or to `*` for everything. Unknown types may appear as Borga grows; ignore what you do not handle.
```json Example
{
"id": "evt_5Rt8Uv1Wx3Yz5Ab7Cd9Ef1Gh",
"type": "payment.succeeded",
"created_at": "2026-09-07T12:04:31.512Z",
"data": {
"id": "pay_7Hs2Kq9LmW4xZc1Vb8Ny3Rt6",
"amount": 12900,
"currency": "ISK",
"status": "succeeded",
"customer": null,
"description": null,
"metadata": { "order_id": "5678" },
"collection_method": "card",
"refunded_amount": 0,
"external_reference": "order_5678",
"failure_reason": null,
"card_brand": "visa",
"card_last4": "4242",
"wallet_type": null,
"created_at": "2026-09-07T12:03:58.201Z",
"updated_at": "2026-09-07T12:04:31.498Z"
}
}
```
## Catalog
_(EventCatalog table: see the HTML page)_
Objects referenced as "the Payment object", "the Subscription object" and so on have exactly the shape of the corresponding `GET` response. For summary payloads, fetch the object by its `id` when you need the full state.