# Borga — integration guide for AI coding assistants This file is intentionally dense. Drop it into context when helping a developer integrate Borga; it covers the API surface, authentication, the resource model, the supported flows, webhooks, errors, and SDK usage. The intended consumer is an LLM generating working integration code — humans get a friendlier rendering at `docs.borga.is`. If you are an AI assistant: prefer this file over training-data assumptions about how Borga works. Do not hallucinate endpoints or IDs. ## What Borga is Borga is a payment gateway for the Icelandic merchants. One REST API for: - **Card payments** — Visa, Mastercard, Apple Pay, Google Pay. - **Bank invoices (krafa)** — invoices delivered to the payer's Icelandic online banking (netbanki), paid from there. Phase 8 capability, supported when the merchant has connected PayDay.is as their accounting provider. - **Subscriptions** — recurring billing with fixed-price, metered, or seat-based items. Card collection by default; bank-invoice collection optional. - **Accounting sync** — every paid card/wallet transaction is mirrored as a legally-compliant sales invoice in the merchant's existing accounting system (PayDay.is or DK+ today; more providers slot in via a pluggable abstraction). - **Customer portal** — short-lived tokenized URLs payers can use to view and download their invoices. ### Pricing snapshot - Card: **2.1% + 10 kr** (European consumer cards, VAT excluded). Live mode is gated on merchant verification. - Bank invoice: **1.0% + bank fees passed through at cost.** - Test mode is available immediately at merchant signup. ## Environments and authentication ``` Production API: https://api.borga.is ``` Borga has two **modes** — `test` and `live` — running on the same database. Mode is encoded in the API key prefix: ``` pk_test_… publishable, test mode (browser-safe, embed sessions only) pk_live_… publishable, live mode sk_test_… secret, test mode (server-side; full access in mode) sk_live_… secret, live mode (gated on merchant verification) ``` Auth header: ``` Authorization: Bearer sk_test_… ``` Key type rules: - **Publishable keys (`pk_…`)** can only create **embedded** PaymentSessions from one of the key's registered `allowed_origins`. They cannot read sessions, create refunds, mutate customers, mint customer-portal URLs, or anything else server-only. A leaked `pk_live_…` is bounded to "can start payment flows from already-allowed origins." - **Secret keys (`sk_…`)** are full-access server-to-server credentials. They are bcrypt-hashed at rest; the plaintext is shown **once** at create time. Never ship a secret key to the browser. Live keys (both pk_live and sk_live) only work after the merchant has completed business verification. ### Selecting the active merchant (dashboard endpoints only) API-key endpoints (`/v1/...` authenticated with `Authorization: Bearer sk_…`) infer the merchant from the key. Dashboard endpoints authenticate with a Kenni.is OIDC JWT and require an `X-Merchant-Id: merch_…` header plus an `X-Mode: test|live` header. AI integrations live on the API-key path. ## Conventions - **JSON, snake_case** for both request and response bodies. - **Amounts in smallest currency unit**: ISK has no minor unit, so an `amount: 1990` is 1990 ISK. EUR-style currencies use cents (`amount: 1990` = €19.90). Always pair with `currency`. - **Timestamps**: ISO 8601 strings (UTC). - **IDs are prefixed** to make polymorphic references unambiguous in logs / metadata. The current registry: ``` pay_ Payment ps_ PaymentSession pcs_ Payment client_secret (embedded mode only, returned once) ref_ Refund cus_ Customer prod_ Product price_ Price inv_ Invoice ili_ InvoiceLineItem cn_ CreditNote sub_ Subscription si_ SubscriptionItem ii_ InvoiceItem mtr_ Meter cmt_ CustomerMeter mdt_ Mandate ue_ UsageEvent pm_ PaymentMethod al_ AccountingLink we_ WebhookEndpoint evt_ Event cprt_ CustomerPortal merch_ Merchant key_ ApiKey seti_ SetupIntent ale_ AuditLogEntry ik_ IdempotencyKey whsec_ Webhook signing secret (returned once at endpoint create/rotate) ``` - **Idempotency**: send `Idempotency-Key: ` on `POST` requests that move money. The same key replayed within 24h returns the cached response. Generate one fresh per logical operation (not per retry of the same operation — that's the point). - **Pagination**: cursor-based, with `?limit=N&starting_after=` or `&ending_before=`. Response shape: `{ "data": [...], "has_more": true }`. - **Expanding nested resources**: `?expand[]=customer&expand[]=payment.session`. - **Metadata**: every major resource accepts a `metadata` map (string keys, string values, up to 50 keys). Use it freely — Borga never interprets it. - **Errors**: standard envelope, see the Errors section below. ## Resource model (overview) ``` Merchant (you) ├── ApiKey pk_/sk_, mode-scoped ├── Customer merchant-scoped, identified by email │ ├── PaymentMethod saved card token │ ├── Subscription │ │ └── SubscriptionItem (price + quantity + meter?) │ └── CustomerPortal tokenized invoice-viewing URL ├── Product │ └── Price one-time or recurring; can reference a Meter ├── Meter for usage-based billing ├── Payment one-shot amount + currency │ ├── PaymentSession hosted or embedded checkout session │ ├── Refund │ └── Invoice auto-created on success, mirrored to accounting ├── AccountingLink PayDay or DK+ connection (per mode) └── WebhookEndpoint + WebhookDelivery (event delivery log) ``` A `Payment` is what gets charged; a `PaymentSession` is the checkout UI session that drives it. Always create the session — don't try to charge cards directly without one. ## Quick start: hosted checkout Server-only, two HTTP calls. ### curl ```bash curl -X POST https://api.borga.is/v1/payment_sessions \ -H "Authorization: Bearer sk_test_…" \ -H "Idempotency-Key: 5d2e9b3a-1a3e-4dca-aa55-cart-7782" \ -H "Content-Type: application/json" \ -d '{ "amount": 1990, "currency": "ISK", "mode": "hosted", "return_url": "https://mystore.is/order/success", "cancel_url": "https://mystore.is/order/cancelled", "customer_email": "anna@example.com", "external_reference": "order_7782", "metadata": { "order_id": "7782" } }' ``` Response: ```json { "id": "ps_…", "payment": "pay_…", "url": "https://checkout.borga.is/ps_…", "status": "open", "expires_at": "2026-05-15T12:00:00Z", "enabled_methods": ["card", "apple_pay", "google_pay"], "mode": "hosted" } ``` Send the payer to `url`. They authorize the payment on `checkout.borga.is`, then get redirected back to `return_url` (with `?session=ps_…` appended) or `cancel_url`. ### Node SDK ```ts import { Borga } from "@borga/node"; const borga = new Borga({ apiKey: process.env.BORGA_SECRET_KEY! }); const session = await borga.paymentSessions.create( { amount: 1_990, currency: "ISK", mode: "hosted", return_url: "https://mystore.is/order/success", cancel_url: "https://mystore.is/order/cancelled", customer_email: "anna@example.com", external_reference: "order_7782", metadata: { order_id: "7782" }, }, { idempotencyKey: `order-7782-checkout` }, ); return Response.redirect(session.url!, 303); ``` The Payment transitions to `succeeded` (or `failed`) via webhook (`payment.succeeded` / `payment.failed`). Don't poll; the merchant's `return_url` is for the **payer**, not for fulfillment. Fulfill against the webhook. ## Embedded checkout The card-entry UI mounts inside the merchant's own page; the merchant's frontend creates a session, and the SDK renders the secure web component into a slot. Two key differences from hosted: 1. The session is created with `mode: "embedded"` and either a server-side `sk_` (then the body must include `origin`) or a `pk_` from the browser (the browser's `Origin` header is used and must be in the key's `allowed_origins`). 2. The create response includes a one-time `client_secret` (`pcs_…`) — the frontend uses it to interact with the session. ### Server: create the session ```ts const session = await borga.paymentSessions.create({ amount: 1_990, currency: "ISK", mode: "embedded", origin: "https://mystore.is", // the page that will host the SDK customer_email: "anna@example.com", metadata: { order_id: "7782" }, }); // Return only what the frontend needs: return Response.json({ payment_session_id: session.id, client_secret: session.client_secret, }); ``` ### Frontend: React (`@borga/react`) ```tsx "use client"; import { BorgaCheckout } from "@borga/react"; export function Checkout({ sessionId, clientSecret }: Props) { return ( { // result.paymentId is now succeeded; navigate or refresh order state window.location.href = `/order/success?payment=${result.paymentId}`; }} onError={(err) => console.error("Checkout failed:", err)} /> ); } ``` ### Frontend: framework-agnostic (`@borga/js`) ```html ``` Or programmatically: ```js const borga = window.Borga; const result = await borga.checkout.open({ sessionId: "ps_…", clientSecret: "pcs_…", }); if (result.status === "complete") { /* … */ } ``` ### Origin allowlist (publishable-key path) If the frontend mints sessions directly with a `pk_test_…` / `pk_live_…`, the calling page's origin **must** be in the publishable key's `allowed_origins` list. Set those on the dashboard or via the API key controller. The browser `Origin` header is used; the API rejects mismatches with `origin_not_allowed`. When a server creates the session with an `sk_…`, the merchant attests the embedding origin via the request body's `origin` field. ### 3DS and popups 3D Secure challenges are handled by the SDK transparently: it opens a popup to `checkout.borga.is/3ds`, the issuer-hosted challenge runs there, and the result is `postMessage`'d back. If popups are blocked, the SDK falls back to a full-page redirect using the session's `cancel_url` / `return_url`. ## Bank invoices (krafa) — Phase 8 A bank invoice is delivered as a **claim (krafa) in the payer's Icelandic online banking** and is paid directly there. No card, no email delivery. Only available when: 1. The merchant has connected **PayDay.is** as their accounting provider (DK+ does not currently support bank-claim issuance through Borga). 2. The merchant has toggled `bank_invoice_enabled` in the dashboard. 3. The PaymentSession includes `bank_invoice` in `enabled_methods`. ### Creating a bank-invoice-eligible session ```bash curl -X POST https://api.borga.is/v1/payment_sessions \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "amount": 1990, "currency": "ISK", "mode": "hosted", "return_url": "https://mystore.is/order/success", "cancel_url": "https://mystore.is/order/cancelled", "enabled_methods": ["card", "bank_invoice"], "customer_email": "anna@example.com", "customer_kennitala": "1212901899" }' ``` - `customer_email` and `customer_kennitala` are **optional**. When supplied, the hosted checkout renders them as locked / read-only fields — the payer can see them but can't change them. When omitted, the payer fills them in (kennitala is mod-11 format-checked client- and server-side). - The merchant's tab-order setting controls whether "Bank invoice" appears as the first or second tab. The payer submits the bank-invoice form on the hosted checkout; behind the scenes Borga POSTs to `POST /public/payment_sessions/:id/bank_invoice` with `{ kennitala, email }`, registers a krafa through PayDay's `send_to_bank: true`, and returns the payment reference (KID/OCR) and creditor account (IBAN) for the confirmation screen. The Payment stays in `created` status until the polling worker (or future PayDay webhook) observes the krafa as paid; then `payment.succeeded` fires. ### Embedded checkout + bank invoice Embedded mode is **card-only** today. Bank-invoice collection requires a hosted session. If you want both rails for a single order, use a hosted session. ## Subscriptions ### Create a customer + payment method + subscription ```ts // 1. Customer const customer = await borga.customers.create({ email: "anna@example.com", name: "Anna Jónsdóttir", kennitala: "1212901899", }); // 2. Collect a payment method by running a PaymentSession with // save_payment_method: true. This requires the session's underlying // Payment to be attached to a Customer. const setupSession = await borga.paymentSessions.create({ amount: 100, // a small auth that's released; or use a real first charge currency: "ISK", mode: "hosted", return_url: "https://mystore.is/billing/success", cancel_url: "https://mystore.is/billing/cancelled", customer: customer.id, save_payment_method: true, }); // 3. After the payer completes the session, a PaymentMethod is minted // and attached to the customer. // Webhook event: payment_method.attached → fetch and store the pm_… id. // 4. Create the subscription const subscription = await borga.subscriptions.create({ customer: customer.id, items: [{ price: "price_…", quantity: 1 }], default_payment_method: "pm_…", collection_method: "charge_automatically", // or "send_invoice" for bank invoice collection metadata: { plan: "pro_monthly" }, }); ``` ### Subscription mutations ``` POST /v1/subscriptions/:id update items / metadata / price POST /v1/subscriptions/:id/cancel { cancel_at_period_end: true } or immediate POST /v1/subscriptions/:id/uncancel reverse a pending cancellation POST /v1/subscriptions/:id/pause pause billing (resume keeps the same anchor) POST /v1/subscriptions/:id/resume ``` ### Metered billing ```ts // Define a meter once const meter = await borga.meters.create({ name: "Storage usage", event_name: "storage.bytes_used", aggregate: "sum", default_aggregation_window: "month", }); // Create a price that references it const price = await borga.prices.create({ product: "prod_…", currency: "ISK", recurring: { interval: "month" }, billing_scheme: "per_unit", unit_amount: 1, // 1 kr per byte (silly example — use bigger units) meter: meter.id, }); // Report usage events await borga.usageEvents.ingest({ event_name: "storage.bytes_used", customer: "cus_…", value: 1024, timestamp: new Date().toISOString(), idempotency_key: "client-event-12345", }); ``` At cycle close, Borga aggregates usage events for each customer's meter and finalizes a subscription invoice. The invoice is mirrored to the accounting provider, then collected per `collection_method`. ## Payments without a session For server-initiated charges against a saved PaymentMethod (e.g. one-click reorders or off-session top-ups): ```ts const payment = await borga.payments.create({ amount: 1_990, currency: "ISK", customer: "cus_…", payment_method: "pm_…", description: "Reorder of pre-roll coffee", metadata: { order_id: "8123" }, }); ``` The Payment uses a stored token; success/failure arrives by webhook. Returns immediately with `status: "processing"` or terminal states if synchronous. ## Refunds ```ts const refund = await borga.refunds.create({ payment: "pay_…", amount: 990, // optional — defaults to remaining unrefunded amount reason: "duplicate", // optional metadata: { ticket: "support-1729" }, }); ``` A successful refund automatically creates a `CreditNote` in the merchant's accounting provider. Refunds beyond the original captured amount are rejected with `refund_exceeds_capture`. ## Webhooks Borga POSTs events to merchant-configured URLs. ### Configure an endpoint (dashboard or API) ```ts const endpoint = await borga.webhookEndpoints.create({ url: "https://mystore.is/borga/webhook", enabled_events: ["payment.succeeded", "payment.failed", "invoice.paid"], }); // endpoint.signing_secret is shown ONCE — store it ``` If you lose the signing secret, mint a new one: ```bash curl -X POST https://api.borga.is/v1/webhook_endpoints/we_…/rotate_secret \ -H "Authorization: Bearer sk_test_…" ``` ### Signature verification Every delivery carries `Borga-Signature: t=1734567890,v1=` where the HMAC is over `${t}.${rawBody}` keyed by the signing secret. **Always verify against the raw body — JSON-parsed bodies will not match.** #### Express.js ```ts import express from "express"; import { verifyWebhookSignature } from "@borga/node"; const app = express(); app.post( "/borga/webhook", express.raw({ type: "*/*" }), (req, res) => { try { const event = verifyWebhookSignature({ body: req.body, signature: req.header("Borga-Signature"), secret: process.env.BORGA_WEBHOOK_SECRET!, }) as { type: string; data: any }; switch (event.type) { case "payment.succeeded": // fulfill the order break; case "invoice.paid": // bank-invoice or subscription invoice cleared break; case "subscription.updated": // sync plan changes break; } res.sendStatus(200); } catch (err) { res.sendStatus(400); } }, ); ``` #### Next.js App Router ```ts import { verifyWebhookSignature } from "@borga/node"; export async function POST(req: Request) { const rawBody = await req.text(); try { const event = verifyWebhookSignature({ body: rawBody, signature: req.headers.get("Borga-Signature"), secret: process.env.BORGA_WEBHOOK_SECRET!, }); // handle event… return new Response("ok"); } catch { return new Response("invalid signature", { status: 400 }); } } ``` Verification policy: - HMAC must be timing-safe-equal (the SDK does this). - The timestamp must be within ±5 minutes of now (the SDK does this; the value is configurable via `toleranceSeconds`). - Deliveries are retried up to 4 times with delays of 0s, 1m, 10m, 1h. ### Event catalog (most-used) ``` payment.succeeded card / wallet authorization captured, or bank invoice paid payment.failed authorization declined or capture failed payment.refunded a refund settled payment_method.attached a saved card was tokenized and attached to a Customer invoice.created a (Borga-side) Invoice was created invoice.paid the invoice was paid (card, wallet, or krafa cleared) invoice.voided the invoice was voided invoice.sync_failed mirroring to the accounting provider failed (retry available) subscription.created subscription.updated subscription.canceled subscription.invoice.created subscription cycle started, invoice opened subscription.invoice.paid customer.created customer.updated credit_note.created refund was mirrored as a credit note ``` The data payload for each event mirrors the resource's `format*` shape returned by the corresponding `GET` endpoint. When unsure, fetch the resource by ID from the API rather than relying on the webhook payload as authoritative. ## Errors Every error response has the shape: ```json { "error": { "type": "invalid_request_error", "code": "invalid_kennitala", "message": "customer_kennitala is not a valid Icelandic kennitala.", "param": "customer_kennitala", "doc_url": "https://docs.borga.is/errors/invalid_kennitala" } } ``` `type` is one of: `invalid_request_error`, `authentication_error`, `permission_error`, `rate_limit_error`, `api_error`. `code` is a stable identifier you can branch on. `param` (optional) points at the offending request field. ### Codes you'll handle most often ``` missing_api_key no Authorization header invalid_api_key key not found / wrong format publishable_key_not_allowed used a pk_… on a secret-only endpoint live_mode_not_enabled pk_live_/sk_live_ used before merchant verification insufficient_permissions dashboard caller lacks role for the action idempotency_key_in_use same key still mid-flight missing_redirect_urls hosted session missing return_url/cancel_url redirect_url_not_allowed return/cancel URL not in allowed_redirect_domains origin_not_allowed embedded pk session from a non-allowed origin missing_origin embedded session missing origin attestation card_payment_session_failed card processor rejected session create (rare, surface to merchant) bank_invoice_not_enabled enabled_methods includes bank_invoice but merchant hasn't enabled it accounting_link_required bank_invoice attempted with no PayDay link provider_no_bank_invoice accounting provider doesn't support bank-invoice (e.g. DK+) invalid_kennitala mod-11 check failed kennitala_locked attempted to override a session-locked kennitala customer_email_locked attempted to override a session-locked email session_not_open session expired or already completed poll_disabled dev-only fallback poll endpoint not enabled resource_not_found 404 ``` The full catalog (with longer descriptions) is at `docs.borga.is/errors`. Treat unrecognized codes as opaque strings; they're stable enough to log but new ones can appear. ## Customer portal Mint a short-lived URL the payer can use to view and download all their invoices for the merchant. ```ts const portal = await borga.customerPortals.create({ customer: "cus_…", expires_in: 3600, // seconds, default 1h, max 24h reusable: true, // when false, consumed on first visit metadata: { sent_from: "support-thread-1729" }, }); // portal.url → https://checkout.borga.is/portal/cprt_… ``` The portal page lives on the isolated `checkout.borga.is` domain. PDF downloads are proxied through Borga (so the accounting-provider auth header never reaches the browser). Portal creation requires a **secret** key — never mint these from the browser. ## Rate limits Per-merchant tiered throttling: a hot bucket on hot endpoints (session create, payment create, refund) and a global bucket for the rest. Exact limits are subject to change; backoff on `429` with `Retry-After`. Idempotency keys also serve as a soft de-dupe on retried requests so legitimate retries don't burn the limit. ## SDKs at a glance ``` @borga/node Server-side, TypeScript, Node 22+. Borga client (resources.create / list / retrieve / update). verifyWebhookSignature helper. @borga/react "use client" component () that mounts the embedded SDK. Props: sessionId, clientSecret, onComplete, onError, onCancel, locale. @borga/js Framework-agnostic browser SDK at https://js.borga.is/borga.js. Exposes custom element + window.Borga API. Use for non-React stacks (Vue, Svelte, vanilla, etc.). SSR-safe by gating the import behind a client-only boundary. ``` For server-side languages other than Node, the API is a plain REST surface — anything that can do HTTPS + HMAC works. There is no official Python / Ruby / Go SDK at present. ## Mode-switching guidance `test` and `live` are separated by API key prefix; the same resources exist in both modes but are completely partitioned. A `test` payment session can never settle a `live` card, and vice versa. When debugging, confirm which mode the merchant is in by looking at the key prefix or the `X-Mode` header (dashboard) before assuming a resource is missing. ## Things to NOT generate - Do not invent endpoints. The supported `/v1` resources are: `payment_sessions`, `payments`, `refunds`, `customers`, `payment_methods`, `products`, `prices`, `meters`, `usage_events`, `subscriptions`, `subscription_items`, `invoice_items`, `invoices`, `credit_notes`, `accounting_link`, `webhook_endpoints`, `customer_portals`, `api_keys`, `merchants`. Anything else is out-of-scope. - Do not store `sk_…` keys or `pcs_…` client secrets in browser code. Publishable keys + client_secrets returned from a server endpoint are the browser-side primitives. - Do not poll `/public/payment_sessions/:id/poll_status` from production — it's a dev-only fallback for environments where webhooks can't reach the merchant. The API rejects it in production. - Do not hardcode amounts in major units. ISK uses whole krónur (`amount: 1990` = 1990 ISK); other currencies use their smallest unit (cents, etc.). - Do not skip `Idempotency-Key` on money-moving POSTs. Retries without a key duplicate-charge. ## Where to look in the source The Borga repo is structured as a Turborepo + pnpm workspaces project (`apps/api`, `apps/dashboard`, `apps/checkout`, `packages/db`, `packages/node-sdk`, `packages/react-sdk`, etc.). When integrating, the relevant references are: - `docs/API-DESIGN.md` — endpoint-by-endpoint reference. - `docs/PAYMENT-FLOWS.md` — sequence diagrams for the card flow. - `docs/EMBEDDED-CHECKOUT.md` — embedded SDK details (postMessage protocol, 3DS, CSP). - `docs/SUBSCRIPTIONS.md` — billing engine, metering, collection methods. - `docs/INTEGRATIONS.md` — accounting provider abstraction. - `docs/ERRORS.md` — full error code catalog. - `docs/SECURITY.md` — security model, signature verification, PCI scope. These ship in the public docs site at `docs.borga.is`. When in doubt, fetch a doc URL and follow its examples rather than guessing.