# Offentlig API og webhooks

Denne vejledning er til teamadministratorer og integratorer, der forbinder et eksternt system med Alunta. Den offentlige API-reference og [OpenAPI-dokumentet](https://app.alunta.com/docs/v1/openapi.yaml) er den autoritative kontrakt for felter, validering, statusser og svar. Dokumentationen her forklarer arbejdsgangen; den erstatter ikke schemaet for et konkret endpoint.

## Før du begynder

Aktivér API-modulet for det team, som integrationen skal bruge, og gå til **Indstillinger → API**. Opret et personligt API-token ved at angive et navn, og kopiér tokenet, når det vises. Tokenet er en hemmelighed. Gem det i en secret manager eller miljøvariabel, og send det kun som `Authorization: Bearer {token}`. Brug ikke tokenet i kildekode, URL'er, screenshots eller logs. Partnerintegrationer kan have en separat OAuth-godkendelse; den er ikke det samme som teamets personlige API-token.

V1-endpoints ligger under `/api/v1`. Test forbindelsen med et syntetisk eller testteam:

```http
GET /api/v1/customers
Authorization: Bearer {access-token}
Accept: application/json
```

Du kan tilbagekalde et personligt token under **Indstillinger → API**. Et tilbagekaldt token kan ikke længere bruges til API-kald; opret et nyt token og opdatér integrationens secret, hvis forbindelsen skal fortsætte.

Gem UUID'er fra svarene. UUID'en for en kunde, plan, subscription eller invoice er den stabile offentlige reference; interne database-id'er er ikke en del af kontrakten. Håndtér beløb i den enhed, som det konkrete OpenAPI-felt beskriver. I de almindelige monetære felter er `10000 DKK` 100,00 DKK, fordi beløbet er i mindste valutaenhed.

## Test før go-live

Afprøv hele kæden i en testkonto, før I går i drift. Vælg **Opret testkonto** i kontomenuen. Testkontoen får navnet "<jeres navn> (Test)" og demodata. I en testkonto kan I:

- forbinde Stripe under **Apps**. Forbindelsen oprettes altid i Stripes testtilstand, så der trækkes ingen rigtige penge. Betal med Stripes testkort, fx `4242 4242 4242 4242`.
- forbinde e-conomic, Dinero, Billy eller Uniconta, når I har bekræftet, at det er et testfirma. Testkontoens fakturaer og betalinger bogføres i testfirmaet, også de fornyelser, der allerede ligger i demodataene.
- oprette et API-token og webhooks og køre integrationen mod testkontoen på samme måde som mod en driftskonto.

Andre betalingsløsninger end Stripe kan ikke forbindes i en testkonto. En testkonto sender kundemails til ejeren i stedet for til kunderne, sender aldrig EAN-fakturaer og har `test_mode: true` i roden af sine webhooks. Integrationer, der forbindes med OAuth, kan ikke godkendes til en testkonto; brug et personligt API-token fra testkontoen. Tid kan ikke spoles frem, så en fornyelse sker først på sin rigtige dato.

Når I er klar, klikker I på mærket **Test mode** øverst i menuen og vælger **Konvertér til driftskonto**. Konverteringen sletter alle testdata (kunder, abonnementer, fakturaer, betalinger og gemte kort) og beholder planer, indstillinger, brugere, API-tokens og webhooks. Stripe i testtilstand og det testfirma, I har forbundet, bliver afbrudt, så I bagefter forbinder jeres rigtige Stripe-konto og jeres rigtige regnskab. Et testkort kan aldrig bruges i drift.

En betaling har feltet `test_mode`. Det er `true` for en betaling i udbyderens testmiljø: alle Stripe-betalinger i en testkonto, og OnPay-betalinger, som OnPay selv melder som test. Der er ikke flyttet penge, og en driftskonto bogfører ikke betalingen. Andre udbyderes betalinger har `false`, også når betalingsløsningen er sat til testtilstand. Feltet er ikke det samme som `test_mode` i roden af en webhook, som fortæller, om hele teamet er en testkonto.

## Ressourcer og rækkefølge

En typisk synkronisering opretter eller finder en kunde, læser eller opretter en plan, og opretter derefter et abonnement med de prisdata, som planens `pricing_type` kræver. På en almindelig plan hentes renewal intervals, og abonnementet bruger et gyldigt `renewal_interval_uuid`. På en tiered-plan er renewal-interval-samlingen tom; abonnementet bruger i stedet en tier-pris' `interval` og `currency` samt de dokumenterede tier-inputs. Kunder kan have en valgfri `external_customer_id`, så dit system kan bevare sin egen reference ved siden af Aluntas UUID. Planens pris, valuta, pricing type, interval og payment provider skal passe sammen.

Ved oprettelse af en kunde er `name` det eneste påkrævede felt. E-mail, telefon, adresse, postnummer, by og `country` er valgfrie. Sender du `country`, skal den være en stor ISO 3166-1 alpha-2-kode som `DK`; uden den bruger kontrakten teamets land. Et syntetisk tiered-kald kan ligne `{"plan_uuid":"00000000-0000-4000-8000-000000000002","customer_uuid":"00000000-0000-4000-8000-000000000001","interval":1,"currency":"DKK"}`. UUID'erne er fiktive og findes ikke. Brug OpenAPI til præcise feltnavne og eventuelle efterfølgende tier-endpoints; opfind ikke et renewal-interval-UUID til en tiered-plan. Et abonnement har sin egen pris, startdato, status og eventuelle planlagte ændringer.

Fakturaer, subscription transactions, payments, bank accounts og usage-events er separate ressourcer. Læs den konkrete endpointbeskrivelse, før du antager at en samling accepterer et filter eller returnerer samme felter som en anden. Følg pagination fra svaret og respekter status- og valideringsværdierne fra OpenAPI.

## Betalingskort

Kortdata kommer aldrig gennem Alunta eller jeres system. Kunden gemmer et kort i checkout (`POST /checkout-sessions`) eller administrerer sine kort i kundeportalen: tilføjer og fjerner kort og vælger det primære kort. For at tilføje et kort i portalen skal kunden have et abonnement, der ikke er opsagt, og teamet skal have en kortudbyder forbundet. Et nyt kort flytter ikke eksisterende abonnementer: de bliver opkrævet på det gamle kort, indtil kunden fjerner det. Når et kort fjernes, flyttes de abonnementer, der blev opkrævet på det, til en ikke-udløbet betalingsmetode hos samme udbyder (helst den primære); fjernelsen afvises, så længe et aktivt abonnement opkræves på kortet, og der ikke findes en sådan erstatning. Det sidste kort kan ikke fjernes, og et kort med en igangværende betaling kan først fjernes, når betalingen er gennemført. Vil I have en "Opdater betalingskort"-knap i jeres eget produkt, så lav et portallink med `POST /portal-link/{uuid}`, når kunden klikker.

Bruger teamet Stripe i sit eget produkt og indsamler betalingsoplysninger dér, kan det i stedet aflevere betalingsmetoden til Alunta med `POST /customers/{uuid}/payment-methods`. Betalingsmetoden (`pm_...`) skal være oprettet i den Stripe-konto, teamet har forbundet til Alunta, og den skal være knyttet til en Stripe-kunde (opret SetupIntent med `customer`). Kort, Link og SEPA Direct Debit understøttes; andre typer gør ikke. En SEPA-betalingsmetode skal desuden have et aktivt, genbrugeligt mandat fra en gennemført off-session SetupIntent på samme forbundne konto (ellers 422, `sepa_mandate_not_reusable`), og SEPA kan kun opkræve beløb i EUR. Hverken IBAN eller mandatoplysninger går gennem Alunta. Kaldet er idempotent pr. betalingsmetode (et gentaget kald returnerer den eksisterende betalingsmetode, opdateret med de aktuelle oplysninger fra Stripe), og med `set_as_default` (standard `true`) bliver den kundens primære betalingsmetode. Kortabonnementer uden kort bliver kun knyttet til den, når den bliver primær, eller når kunden ikke har en anden brugbar betalingsmetode hos samme udbyder. Et nyt Stripe-abonnement (med `payment_provider: stripe` eller med arvet standard) bruger kundens primære betalingsmetode, så tilføj med `set_as_default: true` (standard), før I opretter et Stripe-abonnement; en betalingsmetode, der ikke er primær, vælges ikke til nye abonnementer. Er en faktureringshandling i gang for kunden, svarer kaldet 409 (`billing_operation_in_progress`); prøv igen om lidt. Med `GET` på samme sti kan I se kundens gemte betalingsmetoder, og med `DELETE /customers/{uuid}/payment-methods/{paymentMethodUuid}` fjerner I en. Her gælder de samme regler som i portalen: det sidste kort kan ikke fjernes, et kort, som et aktivt abonnement opkræves på, kan ikke fjernes, så længe der ikke findes et andet ikke-udløbet kort hos samme betalingsudbyder (422, `payment_method_in_use`), og et kort med en igangværende betaling kan først fjernes, når betalingen er gennemført. Fjernelsen flytter de kortabonnementer, der blev opkrævet på den, til en ikke-udløbet betalingsmetode hos samme betalingsudbyder (helst den primære), og Alunta forsøger at frakoble Stripe-betalingsmetoden fra Stripe-kunden (bedste bud: sletningen i Alunta lykkes også, hvis Stripe ikke kan nås). Vil I skifte kort på eksisterende abonnementer, så tilføj det nye kort og fjern derefter det gamle.

Betalingsmetoden på et nyt abonnement vælges med `payment_provider`. Et kortabonnement kan kun oprettes via API'et, hvis kundens primære kort (fx tilføjet med `POST /customers/{uuid}/payment-methods`) er gyldigt og hører til den valgte kortudbyder, og kortudbyderen er forbundet; ellers svarer API'et med 422. Uden `payment_provider` arver abonnementet kundens primære kort, hvis det er gyldigt, og ellers bliver det et fakturaabonnement. Planens tilladte betalingsmetoder gælder i begge tilfælde.

## Flyt abonnementer fra et andet system

Alunta har ingen direkte import fra andre abonnementssystemer som Chargebee. De indbyggede importer dækker kunder fra e-conomic, Dinero, Billy og Uniconta, abonnementer fra e-conomics abonnementsmodul og abonnementer fra Stripe Billing, inklusive kundernes gemte kort. De planer, importerne opretter, og de eksisterende planer, de foreslår, bruger kontoens fornyelsestype. Et abonnement, hvis datoer fornyelsestypen ikke kan holde, fx et Stripe-abonnement der fornyes den 15. på en konto med fast fornyelse, vises som blokeret i importen med årsagen "Anden fornyelsestype" i stedet for at blive flyttet til andre datoer. Et Stripe-abonnement med en forbrugsbaseret (metered) pris flyttes slet ikke, heller ikke dets faste priser: forbruget opkræves bagud og indberettes til Stripe, så en flytning midt i perioden ville efterlade periodens forbrug uopkrævet. Det fortsætter uændret i Stripe. For at flytte det opretter teamet en forbrugsbaseret plan i Alunta, lader sit system indberette forbruget til Alunta via API'et, sætter Stripe-abonnementet til at stoppe ved periodens udløb (så Stripe opkræver den sidste periodes forbrug) og opretter abonnementerne i Alunta fra den dag, Stripe-abonnementet slutter. Alt andet flyttes via API'et: opret planer og priser, opret kunderne, og opret til sidst abonnementerne.

Startdatoen afgør, hvad der bliver faktureret. Alunta opretter alle perioder fra `start_date`, og perioder, der allerede er begyndt, bliver faktureret eller opkrævet ved de almindelige kørsler. Sender I et abonnements oprindelige startdato, bliver kunden altså faktureret igen for perioder, der allerede er betalt i det gamle system. Brug i stedet den næste fornyelsesdato fra det gamle system som `start_date`, ligesom Stripe-importen gør. Perioderne derfra følger planens fornyelsesregler: en plan med fast fornyelse følger kalenderen (og opkræver teamet en første delperiode med næste fornyelse, viser abonnementet `defers_first_partial_period`, og periodens transaktion viser `bill_from`, dagen den opkræves eller faktureres sammen med næste periode), og har teamet valgt **Forsøg at gruppere abonnementer** i fornyelsesindstillingerne, følger et nyt abonnement kundens øvrige abonnementer med en forholdsmæssig mellemperiode først. Den oprindelige startdato kommer ikke med over. Abonnementet står som `pending` indtil startdatoen.

Et abonnement oprettet via API'et får planens standardprøveperiode og introtilbud, hvis planen har dem. Send `apply_plan_trial: false` og `apply_plan_intro_offer: false` for flyttede abonnementer, så kunden ikke får prøveperioden eller introrabatten igen. Felterne accepteres på alle planer og gør ingenting, hvis planen ikke har prøveperiode eller introtilbud, så et flyttescript kan sende dem i alle kald, der ikke også sender `trial_end_date`. `apply_plan_trial` kan ikke kombineres med `trial_end_date`. `POST /customers/{uuid}/subscription-bundles` accepterer de samme to felter og anvender dem på hvert underabonnement i pakken. En plan med standardprøveperiode kan kombineres med `installment_schedule`, når abonnementet oprettes uden prøveperiode (`apply_plan_trial: false`); det afvises, så længe en prøveperiode stadig gælder.

Et kortabonnement kræver et gemt kort (se Betalingskort ovenfor). Ligger kortkundernes betalingsmetoder i teamets egen Stripe-konto, kan de flyttes ved først at tilføje betalingsmetoden med `POST /customers/{uuid}/payment-methods` og derefter oprette abonnementet med den næste fornyelsesdato som `start_date`. Skal kunden indtaste et nyt kort, eller ligger kortet i et andet system end teamets egen Stripe-konto, så flyt kunden med et tilbud (`POST /deals`) med den næste fornyelsesdato som `start_date` og eventuelt den aftalte pris som `custom_price`, og send kunden til tilbuddets `checkout_url`. Når startdatoen ligger i fremtiden, gemmes kortet uden betaling, og abonnementet bliver først opkrævet på startdatoen. Et tilbud får ikke planens introtilbud, men får planens standardprøveperiode, medmindre du sender `apply_plan_trial: false` for slet ingen prøveperiode. Skal tilbuddet have sin egen prøveperiode, så send `trial_enabled: true` med `trial_type` og `trial_value`; det kan ikke kombineres med `apply_plan_trial`, og `trial_enabled: false` alene fjerner ikke planens standardprøveperiode. Trinbaserede planer (tiered) kan også tilbydes: send `interval` og `currency` i stedet for et fornyelsesinterval. Tilbuddet prissættes ud fra det laveste tiers pris, fordi kunden lander på det tier i checkout, så `custom_price` og rabatter med fast beløb afvises med `422`, og kun procentrabatter accepteres. Et tilbuds checkout er låst til tilbuddets interval og valuta.

API'et har ingen import af historiske fakturaer og betalinger. `POST /invoices` opretter en ny faktura, der får et nummer, bliver bogført og eventuelt sendt, så brug den ikke til at genskabe gamle fakturaer. Prøv flytningen af med nogle få kunder, og kontrollér den næste opkrævning med `GET /customers/{uuid}/upcoming-invoices`, før I flytter resten. Den viser dato, linjer, moms, total og korttræk for kundens næste faktureringsdato, også når den ligger ude i fremtiden.

## Webhooks

Registrér en HTTPS-modtager og vælg kun de events, integrationen behøver. Webhooks kan blandt andet informere om `subscription.started`, `subscription.ended`, `subscription.resumed`, `subscription.payment_succeeded`, `subscription.payment_failed` og `invoice.paid`. Betalingsmetoder har `payment_method.created`, `payment_method.updated` og `payment_method.deleted`, som hver indeholder betalingsmetoden præcis som endpointet for betalingsmetoder returnerer den; der sendes ikke en event, når et kort blot udløber, så læs `is_expired` og `is_usable`. Et abonnement viser den betalingsmetode, det trækkes på, som `payment_method_uuid`, og `subscription.payment_method_changed` fortæller integrationen, når den ændres, fx når kunden udskifter kortet efter en fejlet betaling. `subscription.payment_succeeded` bekræfter den konkrete betaling, men en delbetaling eller ratebetaling kan stadig efterlade fakturaen med saldo. Eventet er en meddelelse om Aluntas registrerede tilstand; det betyder ikke, at din egen database allerede er opdateret.

Hver webhook har `team_uuid` i roden: UUID'en for den Alunta-konto, hændelsen hører til, og den samme værdi, som `GET /me` og MCP-værktøjet `get_team_context` returnerer som `team_uuid`. Brug den til at route og filtrere hændelser, også når én modtager får hændelser fra flere konti, fx en driftskonto og en testkonto; `test_mode` i roden viser, om kontoen er en testkonto. Objektet `team`, som nogle hændelser har i `data`, indeholder kun `slug`, kontoens del af dens offentlige checkout-links. Identificér kontoen ved `team_uuid`.

Kontrollér signaturen mod den rå request-krop, før JSON behandles. Deduplikér kun på `data.event_id` for de eventtyper, hvor kontrakten dokumenterer den stabile nøgle, returnér hurtigt en succesfuld status, og udfør længere arbejde bagefter. For `subscription.payment_failed` skal separate retry-forsøg bevares; eventet har ikke en stabil `event_id`, så brug `webhook_uuid` som leveringsnøgle, hvor den findes. `subscription.payment_failed` er et betalingsresultat, mens `invoice.paid` beskriver fakturaens status; ingen af dem bør alene bruges som bevis på hele abonnementets livscyklus.

## Leveringslog og replay

API'et kan vise webhook-leveringer. Brug loggen til at se status og forsøg, og replay en fejlet levering efter at have rettet modtageren. Replay sender den samme hændelse igen og reparerer ikke automatisk en fejl i dit system. Brug den stabile `data.event_id` til de eventtyper, der har den, og `webhook_uuid` som leveringsidentitet, hvor den findes; behold separate `subscription.payment_failed`-forsøg. Gem også udfaldet af behandlingen.

## Fejlfinding

- `401`: token mangler, er ugyldigt eller udløbet.
- `403`: tokenet mangler adgang, eller API-modulet er ikke tilgængeligt for teamet.
- `404`: ressourcen findes ikke i tokenets teamkontekst. En manglende post rapporteres med en engelsk besked, der navngiver ressourcen (fx `Subscription not found.` eller `Customer not found.`); styr efter statuskoden, ikke efter beskedteksten.
- `422`: requesten eller domænehandlingen er ikke gyldig; vis feltfejlene i din klient.
- `429`: respekter `Retry-After` og brug begrænsede retries. Ud over kvoterne pr. minut (120 læsninger og 600 skrivninger pr. team) behandles højst 12 samtidige kald pr. bruger og 14 på tværs af alle kald; et kald over loftet får straks `429` med `Retry-After: 1` og `code: too_many_concurrent_requests` (`scope` er `user` eller `global`), så hold kun få kald i gang ad gangen.

Gentag kun kald, der er sikre at gentage. Brug den offentlige OpenAPI-reference til præcise request- og responsefelter, og valider integrationen mod et testteam, før den rulles ud.

Hvert godkendt kald til et eksisterende endpoint under `/api/v1` får en `X-Request-Id`-header, også ved fejl. Kald, der afvises inden da (ugyldigt token `401`, en sti, der ikke findes, eller en forkert HTTP-metode), får intet ID og logges ikke. Oplys det ID, hvis du skriver til support. Et team kan under **Indstillinger → API → Log** se sine egne skrivende og fejlede API-kald de seneste 30 dage: metode, endpoint, status, token og fejlbesked. Indholdet af kaldene (request- og response-body, query string) gemmes ikke.

## Hvad jeres AI kan se om API og webhooks

Ét værktøj, `get_api_activity_overview`, dækker denne app, og kun for ejere og administratorer: andre roller kan ikke bruge det gennem AI. Det kræver intet yderligere tilvalg og dukker op, når API-appen er slået til, eller så længe der er API-kald, webhook-leveringer eller API-tokens fra før appen blev slået fra. Slås appen til, skal AI-klienten genoprette forbindelsen eller genindlæse værktøjslisten for at se det.

Det viser, om API'et er slået til, og hvor mange API-tokens der er i brug, en periodeopgørelse for op til 366 dage (som standard de seneste 30 dage) over, hvad jeres integrationer kaldte - fordelt på status, fejlprocenten for skrivende kald, de mest brugte og de fejlende endpoints (efter rutemønster, aldrig rå sti) og kald pr. tokennavn - og hvordan jeres udgående webhook-leveringer gik: leveret, forsøger stadig igen, endeligt mislykket eller undervejs, fordelt på hændelse og modtagerens vært. Leveringstallene er de samme som på kortet for webhook-sundhed på dashboardet.

Nogle ting skal holdes adskilt. API-loggen er ikke den samlede trafik: den indeholder alle skrivende kald og alle fejlede kald, men ikke vellykkede læsninger, og den gemmes i 30 dage, så en længere periode kun er delvist dækket, og svaret siger det. Den retvisende fejlprocent er den over skrivende kald. En webhook, der ikke er lykkedes, er ikke nødvendigvis tabt: den kan stadig forsøge igen. Webhook-historikken gemmes i 5 måneder.

AI ser aldrig indholdet af kald eller svar, headers, query strings, IP-adresser, fejlbeskeder, tokenværdier eller id'er, hele webhook-URL'er (kun værten) eller signeringsnøglen. Et tokens navn vises som det navn, I gav det.
