# Public API and webhooks

This guide is for team administrators and integrators connecting an external system to Alunta. The public API reference and [OpenAPI document](https://app.alunta.com/docs/v1/openapi.yaml) are authoritative for fields, validation, statuses, and responses. This guide explains the workflow; it does not replace the schema for a particular endpoint.

## Before you begin

Enable the API module for the team the integration will use, then go to **Settings → API**. Create a personal API token by entering a name and copy it when it is shown. The token is a secret. Store it in a secret manager or environment variable and send it only as `Authorization: Bearer {token}`. Do not put it in source code, URLs, screenshots, or logs. Partner integrations may have a separate OAuth authorization flow; that is different from the team's personal API token.

V1 endpoints are under `/api/v1`. Test the connection with synthetic data or a test team:

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

You can revoke a personal token under **Settings → API**. A revoked token can no longer be used for API requests; create a new token and update the integration's secret if the connection should continue.

Store UUIDs from responses. The UUID for a customer, plan, subscription, or invoice is the stable public reference; internal database IDs are not part of the contract. Handle amounts in the unit documented by the specific OpenAPI field. For common monetary fields, `10000 DKK` means DKK 100.00 because the amount is in the smallest currency unit.

## Testing before go-live

Try the whole chain in a test account before you go live. Choose **Create test account** in the account menu. The test account is named "<your name> (Test)" and comes with demo data. In a test account you can:

- connect Stripe under **Apps**. The connection is always made in Stripe's test mode, so no real money is charged. Pay with Stripe's test cards, for example `4242 4242 4242 4242`.
- connect e-conomic, Dinero, Billy or Uniconta once you have confirmed it is a test company. The test account's invoices and payments are booked in the test company, including the renewals already in the demo data.
- create an API token and webhooks and run your integration against the test account exactly as against a production account.

Payment providers other than Stripe cannot be connected in a test account. A test account sends customer emails to the owner instead of the customers, never sends EAN invoices and has `test_mode: true` at the root of its webhooks. Integrations connected with OAuth cannot be authorised for a test account; use a personal API token from the test account instead. Time cannot be fast-forwarded, so a renewal happens on its real date.

When you are ready, click the **Test mode** badge at the top of the menu and choose **Convert to production account**. Converting deletes all test data (customers, subscriptions, invoices, payments and saved cards) and keeps plans, settings, users, API tokens and webhooks. Stripe in test mode and the test company you connected are disconnected, so afterwards you connect your real Stripe account and your real books. A test card can never be used in production.

A payment has a `test_mode` field. It is `true` for a payment in the provider's test environment: every Stripe payment in a test account, and OnPay payments that OnPay itself reports as test. No money moved, and a production account does not book the payment. Other providers' payments are `false`, even while the payment provider is set to test mode. It is not the same as `test_mode` at the root of a webhook, which says whether the whole team is a test account.

## Resources and sequence

A typical synchronization creates or finds a customer, reads or creates a plan, then creates a subscription using the price data required by the plan's `pricing_type`. On a regular plan, fetch renewal intervals and use a valid `renewal_interval_uuid`. A tiered plan has an empty renewal-interval collection; use the tier price's `interval` and `currency` plus the documented tier inputs instead. Customers may have an optional `external_customer_id`, allowing your system to keep its own reference beside Alunta's UUID. A plan's price, currency, pricing type, interval, and payment provider must fit together.

When creating a customer, `name` is the only required field. Email, phone, address, ZIP code, city, and `country` are optional. If you send `country`, use an uppercase ISO 3166-1 alpha-2 code such as `DK`; when omitted, the contract uses the team's country. A synthetic tiered request can look like `{"plan_uuid":"00000000-0000-4000-8000-000000000002","customer_uuid":"00000000-0000-4000-8000-000000000001","interval":1,"currency":"DKK"}`. These UUIDs are fictional and do not exist. Use OpenAPI for exact field names and any subsequent tier endpoints; do not invent a renewal-interval UUID for a tiered plan. A subscription has its own price, start date, status, and possible scheduled changes.

Invoices, subscription transactions, payments, bank accounts, and usage events are separate resources. Read the specific endpoint before assuming a collection accepts a filter or returns the same fields as another. Follow pagination from the response and respect the status and validation values defined by OpenAPI.

## Payment cards

Card data never passes through Alunta or your systems. The customer saves a card in checkout (`POST /checkout-sessions`) or manages their cards in the customer portal: adds and removes cards and chooses the primary card. To add a card in the portal, the customer needs a subscription that is not cancelled, and the team needs a connected card gateway. A new card does not move existing subscriptions: they are charged on the old card until the customer removes it. When a card is removed, the subscriptions charged on it move to a non-expired payment method from the same provider (preferring the primary one); the removal is refused while an active subscription is charged on the card and no such replacement exists. The last card cannot be removed, and a card with a payment in progress can only be removed after the payment completes. To offer an "Update payment card" button in your own product, mint a portal link with `POST /portal-link/{uuid}` when the customer clicks it.

If the team uses Stripe in its own product and collects payment details there, it can instead hand the payment method to Alunta with `POST /customers/{uuid}/payment-methods`. The payment method (`pm_...`) must be created in the Stripe account the team has connected to Alunta, and it must be attached to a Stripe customer (create the SetupIntent with `customer`). Card, Link and SEPA Direct Debit are supported; other types are not. A SEPA payment method also needs an active, reusable mandate from a successful off-session SetupIntent on the same connected account (otherwise 422, `sepa_mandate_not_reusable`), and SEPA can only collect EUR amounts. No IBAN or mandate data passes through Alunta. The call is idempotent per payment method (a repeat returns the existing payment method, refreshed with the current details from Stripe), and with `set_as_default` (default `true`) it becomes the customer's primary payment method. Card subscriptions without a card are linked to it only when it becomes the primary payment method or the customer has no other usable payment method from the same provider. A new Stripe subscription (with `payment_provider: stripe`, or inheriting the default) uses the customer's primary payment method, so attach with `set_as_default: true` (the default) before you create a Stripe subscription; a payment method that is not the primary one is not selected for new subscriptions. If a billing operation is running for the customer, the call returns 409 (`billing_operation_in_progress`); retry shortly. `GET` on the same path lists the customer's saved payment methods, and `DELETE /customers/{uuid}/payment-methods/{paymentMethodUuid}` removes one. The same rules as in the portal apply: the last card cannot be removed, a card that an active subscription is charged on cannot be removed while no other non-expired card from the same payment provider exists (422, `payment_method_in_use`), and a card with a payment in progress can only be removed after the payment completes. Removing moves the card subscriptions that were charged on it to a non-expired payment method from the same payment provider (preferring the primary one), and Alunta tries to detach the Stripe payment method from the Stripe customer (best effort: the deletion in Alunta succeeds even if Stripe cannot be reached). To change the card on existing subscriptions, add the new card and then remove the old one.

The payment method of a new subscription is chosen with `payment_provider`. A card subscription can only be created through the API when the customer's primary card (for example one added with `POST /customers/{uuid}/payment-methods`) is valid and belongs to the chosen card gateway, and that gateway is connected; otherwise the API responds with 422. Without `payment_provider`, the subscription inherits the customer's primary card if it is valid, and otherwise becomes an invoice subscription. The plan's allowed payment methods apply in both cases.

## Moving subscriptions from another system

Alunta has no direct import from other subscription systems such as Chargebee. The built-in imports cover customers from e-conomic, Dinero, Billy and Uniconta, subscriptions from e-conomic's subscription module, and subscriptions from Stripe Billing, including the customers' saved cards. Plans the imports create, and the existing plans they suggest, use the account's renewal type. A subscription whose dates that renewal type cannot keep, for example a Stripe subscription that renews on the 15th on an account with fixed renewal, is shown as blocked in the import with the reason "Different renewal type" instead of being moved to other dates. A Stripe subscription with a metered (usage-based) price is not moved at all, nor are its fixed prices: usage is billed in arrears and reported to Stripe, so moving it mid-period would leave that period's usage unbilled. It continues unchanged in Stripe. To move it, the team creates a usage-based plan in Alunta, has its system report usage to Alunta through the API, sets the Stripe subscription to cancel at the end of the period (so Stripe bills the last period's usage) and creates the subscriptions in Alunta from the day the Stripe subscription ends. Everything else moves through the API: create plans and prices, create the customers, and create the subscriptions last.

The start date decides what gets billed. Alunta creates every period from `start_date`, and periods that have already begun are invoiced or charged by the normal runs. If you send a subscription's original start date, the customer is billed again for periods already paid in the old system. Use the next renewal date from the old system as `start_date` instead, the same way the Stripe import works. The periods from there follow the plan's renewal rules: a plan with fixed renewal follows the calendar (and when the team bills a first partial period with the next renewal, the subscription shows `defers_first_partial_period` and that period's transaction shows `bill_from`, the day it is charged or invoiced with the next period), and when the team has chosen **Try to group subscriptions** in the renewal settings, a new subscription follows the customer's other subscriptions with a prorated catch-up period first. The original start date is not carried over. The subscription is `pending` until its start date.

A subscription created through the API gets the plan's default trial and intro offer if the plan has them. Send `apply_plan_trial: false` and `apply_plan_intro_offer: false` for migrated subscriptions, so the customer does not get the trial or intro discount again. The fields are accepted on every plan and do nothing when the plan has no trial or intro offer, so a migration script can send them on every call that does not also send `trial_end_date`. `apply_plan_trial` cannot be combined with `trial_end_date`. `POST /customers/{uuid}/subscription-bundles` accepts the same two fields and applies them to every child subscription of the bundle. A plan with a default trial can be combined with `installment_schedule` when the subscription is created without a trial (`apply_plan_trial: false`); it is rejected while a trial still applies.

A card subscription requires a saved card (see Payment cards above). When the card customers' payment methods are in the team's own Stripe account, move them by first attaching the payment method with `POST /customers/{uuid}/payment-methods` and then creating the subscription with the next renewal date as `start_date`. When the customer has to enter a new card, or the card lives in a system other than the team's own Stripe account, move them with an offer (`POST /deals`) instead, using the next renewal date as `start_date` and, if needed, the agreed price as `custom_price`, and send the customer to the offer's `checkout_url`. When the start date is in the future, the card is saved without a payment and the subscription is first charged on its start date. An offer does not get the plan's intro offer, but does get the plan's default trial unless you send `apply_plan_trial: false` for no trial at all. To give the offer its own trial instead, send `trial_enabled: true` with `trial_type` and `trial_value`; it cannot be combined with `apply_plan_trial`, and `trial_enabled: false` alone does not remove the plan's default trial. Tiered plans can be offered too: send `interval` and `currency` instead of a renewal interval. The offer is quoted from the lowest tier's price, because the customer lands on that tier at checkout, so `custom_price` and fixed-amount discounts are rejected with `422` and only percentage discounts are accepted. The checkout of an offer is locked to the offer's interval and currency.

The API has no import for historical invoices and payments. `POST /invoices` creates a new invoice that is numbered, booked and possibly sent, so do not use it to recreate old invoices. Try the move with a few customers first, and check the next charge with `GET /customers/{uuid}/upcoming-invoices` before you move the rest. It shows the date, lines, VAT, total and card charge of the customer's next billing date, also when that date is in the future.

## Webhooks

Register an HTTPS receiver and subscribe only to events the integration needs. Webhooks can include `subscription.started`, `subscription.ended`, `subscription.resumed`, `subscription.payment_succeeded`, `subscription.payment_failed`, and `invoice.paid`. Payment methods have `payment_method.created`, `payment_method.updated` and `payment_method.deleted`, each carrying the payment method exactly as the payment-methods endpoint returns it; there is no event when a card merely passes its expiry date, so read `is_expired` and `is_usable`. A subscription shows the payment method it is charged with as `payment_method_uuid`, and `subscription.payment_method_changed` tells the integration when that changes, for example when the customer replaces the card after a failed payment. `subscription.payment_succeeded` confirms the payment itself, but a partial payment or installment can leave an invoice balance. An event reports a state recorded by Alunta; it does not mean your own database has already been updated.

Every webhook has `team_uuid` at the root: the UUID of the Alunta account the event belongs to, the same value `GET /me` and the `get_team_context` MCP tool return as `team_uuid`. Use it to route and filter events, also when one receiver gets events from several accounts, such as a live account and a test account; `test_mode` at the root tells whether the account is a test account. The `team` object some events include in `data` carries only `slug`, the account's segment in its public checkout links. Identify the account by `team_uuid`.

Verify the signature against the raw request body before parsing JSON. Deduplicate on `data.event_id` only for event types where the contract documents that stable key, return a successful response quickly, and do longer work afterwards. Preserve separate retry attempts for `subscription.payment_failed`; it has no stable `event_id`, so use `webhook_uuid` as the delivery key where available. `subscription.payment_failed` is a payment outcome, while `invoice.paid` describes invoice status; neither should be used alone as proof of the entire subscription lifecycle.

## Delivery log and replay

The API can expose webhook deliveries. Use the log to inspect status and attempts, then replay a failed delivery after fixing the receiver. Replay sends the same event again and does not automatically repair an error in your system. Use stable `data.event_id` for event types that provide it and `webhook_uuid` as delivery identity where available; retain separate `subscription.payment_failed` attempts. Keep the processing outcome too.

## Troubleshooting

- `401`: the token is missing, invalid, or expired.
- `403`: the token lacks access, or the API module is unavailable to the team.
- `404`: the resource does not exist in the token's team context. A missing record is reported as `{"message": "<Resource> not found."}` (for example `Subscription not found.` or `Customer not found.`); branch on the status code, not on the message text.
- `422`: the request or domain action is invalid; show field errors in your client.
- `429`: honour `Retry-After` and use bounded retries. Beyond the per-minute quotas (120 reads and 600 writes per team), at most 12 concurrent requests per user and 14 across all callers are processed; a request over a cap gets an immediate `429` with `Retry-After: 1` and `code: too_many_concurrent_requests` (`scope` is `user` or `global`), so keep only a few requests in flight at a time.

Retry only operations that are safe to repeat. Use the public OpenAPI reference for exact request and response fields, and validate the integration against a test team before rollout.

Every authenticated call to an existing `/api/v1` endpoint returns an `X-Request-Id` header, including on failures. Requests rejected before that (an invalid token `401`, an unknown path or a wrong HTTP method) get no id and are not logged. Quote that id when contacting support. A team can also see its own writing and failed API calls for the last 30 days under **Settings → API → Log**: method, endpoint, status, token, and error message. The content of the calls (request/response body, query string) is never stored.

## What your AI can see about the API and webhooks

One tool, `get_api_activity_overview`, covers this app, and only for owners and administrators: other roles cannot use it through AI. It needs no further opt-in and appears once the API app is switched on, or while API calls, webhook deliveries or API tokens remain from before it was switched off. When the app is switched on, the AI client must reconnect or reload its tool list to see it.

It shows whether the API is on and how many API tokens are in use, a period report of up to 366 days (the last 30 days by default) of what your integrations called - by status, the failure rate of write calls, the busiest and the failing endpoints (by route pattern, never by raw path) and the calls per token name - and how your outgoing webhook deliveries went: delivered, still retrying, permanently failed or in flight, by event and by destination host. The delivery figures are the same as the webhook health card on the dashboard.

Some things must be kept apart. The API log is not total traffic: it holds every write and every failed call but not successful reads, and it is kept for 30 days, so a longer period is only partly covered and the answer says so. The honest failure rate is the one over write calls. A webhook that has not succeeded is not necessarily lost: it may still be retrying. Webhook history is kept for 5 months.

AI never sees request or response contents, headers, query strings, IP addresses, error messages, token values or ids, full webhook URLs (only the host) or the signing secret. A token's name is shown as the name you gave it.
