Skip to content
Bookelio / Documentation
Guides by topic

Payments and invoicing

Integrate subscriptions with your application through the API

Connect to Bookelio on the server, manage subscriptions and entitlements through the API, configure proration and grant free months.

On this page

Understand the responsibilities

Your application authenticates its users and identifies their account or organisation. Bookelio stores the commercial catalogue, billing customers, subscriptions and their payment cycles. Your server queries Bookelio to authorise the features you sell. Hiding a button in the browser does not replace this server check.

The seller organisation owns one or more applications. Each application has its own features, plans and subscriptions. The module can therefore serve several applications and several sellers, each within their own scope.

The examples below are fictional. Replace the domains and identifiers with those from your installation. For the dashboard workflow, see Sell software subscriptions.

Prepare the connection and permissions

Enable the Software subscriptions module for the seller organisation. Prepare a billing customer in that organisation and a VAT policy for paid offers. Configure its payment methods and email service for payments and notifications.

Create an API key for the seller organisation with the appropriate permissions. Keep it on your application's server. It identifies the seller; it does not replace your users' authentication and can access subscriptions for several customers of the same seller.

UseKey permissions
Read applications, plans, subscriptions and authorised featuressoftwareBilling.read
Manage the catalogue, cancel subscriptions, grant offers or schedule seatssoftwareBilling.manage
Provision features and external identitiessoftwareBilling.manage and customers.create
Create a subscriptionsoftwareBilling.manage and paymentRequests.create
Update a subscription by supplying planId, including for reactivationsoftwareBilling.manage and paymentRequests.create
Apply any IMMEDIATE change, even without a supplement or for seats alonesoftwareBilling.manage and paymentRequests.create
Read or change invoice timing in the seller's settingssettings.read to read; settings.update to change

Creating or reading customers and VAT policies through their own APIs also requires permissions for those resources. The examples assume these records have already been created.

REST routes use the prefix /api/v1/admin/software-billing. The oRPC transport uses /rpc/v1 and the admin.softwareBilling.* operations. The specification available on the API instance is /spec/v1.json. In the Bookelio monorepo, the BookelioApiV1Client type describes this contract; an external application can use REST directly, without depending on an internal repository package.

Prepare HTTP calls on the server

For these examples, the variable names below are chosen by the third-party application. They do not automatically configure Bookelio itself.

SOFTWARE_BILLING_API_URL=https://api.billing.example.com
SOFTWARE_BILLING_API_KEY=REPLACE_WITH_SERVER_SIDE_KEY
SOFTWARE_BILLING_APPLICATION_ID=REPLACE_WITH_APPLICATION_ID

The following JavaScript helper is for server use only, in an environment with fetch available. It calls the REST routes with a plain JSON body. Dates received are ISO 8601 strings; the oRPC client handles Date serialisation itself.

const billingBase = new URL(
  '/api/v1/admin/software-billing/',
  process.env.SOFTWARE_BILLING_API_URL,
);

async function billingRequest(path, { method = 'GET', body } = {}) {
  const response = await fetch(new URL(path, billingBase), {
    method,
    headers: {
      'x-api-key': process.env.SOFTWARE_BILLING_API_KEY,
      'content-type': 'application/json',
    },
    body: body === undefined ? undefined : JSON.stringify(body),
    redirect: 'error',
    signal: AbortSignal.timeout(10_000),
  });
  if (!response.ok) {
    throw new Error(`Bookelio billing request failed: HTTP ${response.status}`);
  }
  return response.json();
}

Do not put the key in a URL, a browser component or a public variable. The connection URL is the API URL; payment pages are hosted by the seller's dashboard, which may use a different domain.

Create the application and plans

Catalogue configuration is an administrative operation to perform once, rather than whenever a user signs in. Store the id values returned by Bookelio. The code values are readable references and do not replace those identifiers in API calls.

To reuse a catalogue already created in the dashboard, GET /applications returns { applications: [...] } and GET /applications/{applicationId}/plans returns { plans: [...] }, under the REST prefix given above. The latter list includes inactive plans: filter on isActive before offering a plan for purchase. GET /applications/{applicationId}/subscriptions lets the seller list subscriptions, with a { subscriptions: [...] } response.

const application = await billingRequest('applications', {
  method: 'POST',
  body: {
    name: 'Example Workspace',
    code: 'example-workspace',
    features: [
      { key: 'projects', name: 'Projects' },
      { key: 'exports', name: 'Exports' },
    ],
  },
});

const enterprise = await billingRequest('plans', {
  method: 'POST',
  body: {
    applicationId: application.id,
    name: 'Enterprise',
    code: 'enterprise-monthly',
    interval: 'MONTHLY',
    basePriceCents: 400000,
    seatPriceCents: 0,
    includedSeats: 0,
    trialDays: 14,
    gracePeriodDays: 7,
    vatPolicyId: 'REPLACE_WITH_SELLER_VAT_POLICY_ID',
    featureKeys: ['projects', 'exports'],
  },
});

This plan costs €4,000 excluding VAT per month, regardless of the number of seats, after a 14-day trial. It then grants seven days of grace at the start of each paid cycle to allow time for payment. seatPriceCents: 0 disables the extra charge per seat; the API does not require a pricingMode field.

Fictional modelbasePriceCentsseatPriceCentsincludedSeatsResult excluding VAT
Free000No payment
Fixed Enterprise40000000€4,000 per period, regardless of headcount
Base price and additional seats10003002€19 per period for 5 seats
All seats charged05000€25 per period for 5 seats

Prices are integers in EUR cents excluding VAT. interval is MONTHLY or YEARLY: for an annual offer, supply the price for the whole year, not a monthly price to be multiplied. trialDays ranges from 0 to 365; zero disables the trial. An entirely free plan does not create a trial leading to payment or a payment request, even if a duration has been entered.

gracePeriodDays is an integer from 0 to 365, independent of the trial. It is optional on creation: omitting it means 0 and preserves the behaviour without grace. With PATCH /plans/{planId}, omitting it keeps the current value; sending 0 disables grace for future cycles. Existing plans remain without grace until this value is changed.

The keys in featureKeys must already exist in the application. A VAT policy belonging to the seller is required whenever a fixed or per-seat price is positive. A subscription's seat count is an integer from 1 to 100,000. This technical bound does not turn a fixed price into a per-seat price and is not a commercial limit on the users included in the plan.

Identify the customer and create their subscription

customerId identifies the billing customer in the seller organisation. externalId identifies that customer's account in your application, for example a stable organisation identifier. Store this mapping on the server. Avoid a name or email address that may change.

The identity is unique for each (applicationId, externalId) pair. In the following code, accountId must come from your verified session and membership checks, rather than an arbitrary identifier sent by the browser. The plan and customer must also be selected and validated on the server.

const subscription = await billingRequest('subscriptions', {
  method: 'POST',
  body: {
    applicationId: process.env.SOFTWARE_BILLING_APPLICATION_ID,
    planId: selectedPlanId,
    customerId: billingCustomerId,
    externalId: accountId,
    seats: memberCount,
  },
});
// Store subscription.id for updates and cancellations.

A retry with the same application, external reference, customer and plan retrieves the existing subscription. It does not restart the trial, bill again or update the seat count. A different customer or plan for this reference produces a conflict. To change plans, use the update operation; if the customer differs, check your identity mapping, because this operation does not replace the billing customer. Application and plan creation are not create-or-update operations; do not replay them as you would a customer subscription registration.

Check features before an action

Use subscriptions.entitlements, rather than relying only on the stored status or plan name. This read checks the current period, its net payment and any grace period, and returns the feature keys frozen for that cycle.

async function canUseFeature(verifiedAccountId, featureKey) {
  const appId = encodeURIComponent(
    process.env.SOFTWARE_BILLING_APPLICATION_ID,
  );
  const externalId = encodeURIComponent(verifiedAccountId);
  try {
    const rights = await billingRequest(
      `applications/${appId}/entitlements/${externalId}`,
    );
    return rights.access === true
      && typeof rights.validUntil === 'string'
      && Date.parse(rights.validUntil) > Date.now()
      && Array.isArray(rights.featureKeys)
      && rights.featureKeys.includes(featureKey);
  } catch {
    // Return a temporary denial; do not grant access to paid features.
    return false;
  }
}

Fictional REST response example:

{
  "subscriptionId": "example-subscription",
  "status": "PAST_DUE",
  "access": true,
  "featureKeys": ["projects", "exports"],
  "validUntil": "2026-09-29T10:00:00.000Z",
  "seats": 5,
  "isInGracePeriod": true,
  "graceEndsAt": "2026-09-29T10:00:00.000Z"
}

This example represents the first paid cycle after a trial ended on 22 September at 10:00 UTC: payment is still awaited, but entitlements remain open until 29 September at 10:00 UTC through the seven configured days. PAST_DUE alone is therefore not enough to refuse an action; use access, the requested key and validUntil, as in the helper.

Without a subscription, the response contains subscriptionId: null, status: null, access: false, featureKeys: [], validUntil: null, seats: 0, isInGracePeriod: false and graceEndsAt: null. If you add a cache, isolate it by seller, application and external reference, keep its lifetime short and never grant access beyond validUntil. The module does not send an outgoing subscription-change webhook to the application: plan these reads at your server's authorisation checkpoints.

The entitlements and getByExternalId reads, along with syncSeats, retain their key, permission and seller checks, but remain accessible without checking the seller's access to the module interface. This allows existing subscriptions to be checked and brought up to date; other operations remain protected by module activation.

Apply payment grace

The cycle stores a snapshot of gracePeriodDays when it is created; graceEndsAt is calculated from that value and the period's immutable dates. For a paid cycle, grace runs from periodStart until the earlier of periodStart + gracePeriodDays × 24 hours and periodEnd. The end instant is excluded: at that time, a cycle that remains unpaid no longer grants access. Grace also applies to the first paid cycle and the cycle after the trial, without changing billing dates or creating another trial.

During grace, the calculated status remains PAST_DUE, access is true, isInGracePeriod is true and validUntil equals graceEndsAt. Verified full payment moves the entitlements to a paid period: isInGracePeriod: false and validUntil: periodEnd. Expired grace with insufficient payment gives access: false, featureKeys: [] and validUntil: null, without waiting for the worker.

graceEndsAt is metadata, not proof of access: the date remains visible after payment or expiry. It is null for a cycle with no grace, a free cycle or a trial. Only isInGracePeriod indicates that current access uses this allowance. Do not add a local grace period when the API fails, or extend a cache until that date without checking access and validUntil.

These fields are added in compatible revision V1-r28. Responses from an older V1 instance may omit gracePeriodDays, graceEndsAt and isInGracePeriod: do not infer grace from their absence, and keep using access and validUntil to decide. The seller instance must be updated to configure and apply this feature.

The duration and date are frozen for each cycle: a plan change, worker retry or late payment does not move them. The worker must first create the new cycle; before that run, an expired old cycle does not grant grace in advance. A processing delay can therefore cause a brief interruption, but cannot postpone the start of grace until the worker runs.

Partial payment does not extend grace. A completed or pending refund prevents grace; a failed refund (FAILED) does not block it on its own. Effective cancellation refuses all access, and scheduled cancellation does not extend entitlements beyond their limit. Grace does not mark any payment as completed, does not trigger an invoice on its own and does not allow new bills to be issued while the cycle remains unpaid.

Present payment and track statuses

const appId = encodeURIComponent(process.env.SOFTWARE_BILLING_APPLICATION_ID);
const externalId = encodeURIComponent(accountId);
const { subscription } = await billingRequest(
  `applications/${appId}/subscriptions/by-external-id/${externalId}`,
);
const currentCycle = subscription?.cycles.find(
  (cycle) => cycle.periodStart === subscription.currentPeriodStart,
);
const paymentUrl = currentCycle?.paymentUrl ?? null;

This read returns at most the 20 most recent cycles. Display the paymentUrl for the cycle awaiting payment, exactly as returned by the seller. Do not rebuild this link using your application's domain. If it is null for a paid cycle, check the seller's BOOKELIO_WEB_URL configuration. A free plan or trial has no payment request.

StatusConsequence for the integration
TRIALINGTrial in progress; check access, the keys and validUntil
ACTIVEFree or paid period; continue checking the calculated entitlements
PAST_DUEPayment awaited; temporary access may be available during grace, checked through access, isInGracePeriod and validUntil
CANCELEDSubscription cancelled; no features granted by this subscription

The worker processes renewals every minute. At the end of a trial or paid period, it creates the next payment due. An unpaid cycle blocks the creation of further bills. Expired entitlements may be refused before the worker next runs: do not grant extra time based on an old status.

The customer pays using the seller's payment methods, or the seller records an external payment. Verified confirmations reconcile the payment; full payment triggers the invoice by default, or reuses the invoice already issued when the request was created, according to the seller's setting. A successful browser redirect does not prove payment: read the entitlements again on the server. Renewals use payment requests, rather than automatic recurring debits. Partial payments and refunds may remove the entitlement associated with a fully paid cycle.

Configure invoice timing

This setting belongs to the seller organisation, not to the plan or consuming application. It is available under Settings → Invoicing → Software subscription invoices and through admin.invoicing.settings.get / .update. The REST routes are GET and PUT /api/v1/admin/invoicing/settings/automation: they do not use the software-billing prefix of the earlier helper.

softwareSubscriptionInvoiceTimingBehaviour
PAYMENT_COMPLETEDDefault: generation after verified full payment
PAYMENT_REQUEST_CREATEDGeneration when the payment request is created, even while unpaid

The field is optional in V1-r28. Omitting it from an update preserves the existing choice; no seller configuration means PAYMENT_COMPLETED. The PUT operation also requires the bookings, trainings and trainingPaymentReminderInvoices booleans: read the settings with GET and return their current values alongside the new softwareSubscriptionInvoiceTiming, so these independent automations are not changed. Enabling generation on creation checks the seller's email and storage services; a missing prerequisite prevents saving.

Bookelio freezes the choice in each new paid cycle. It therefore applies to new subscriptions, trial endings, renewals and resumptions that create a period, but not to existing requests. A free or trial cycle without a payment request creates no invoice. Generation on creation runs after the subscription transaction commits; the worker retries errors, and your application should not recreate the subscription because of them.

For generation on creation, both the issue date and due date match the payment request's creation date: the invoice is due on issue, not at graceEndsAt. These dates do not move when work is retried. Creation and payment triggers reuse the same invoice: later payment does not create a duplicate or replace the existing PDF/XML. Check the linked request for the up-to-date payment state.

Issuing an invoice creates no payment and does not change access. The plan's grace period and verified net payment remain independent of invoice timing. The same rule applies when Bookelio itself is a customer of the seller instance through a URL connection.

Synchronise seats and change plans

For a third-party application, define what a seat means and calculate the quantity on your server. Bookelio does not automatically observe your application's users and does not create a login quota from this count.

await billingRequest(
  `applications/${appId}/subscriptions/by-external-id/${externalId}/seats`,
  { method: 'PUT', body: { seats: 12 } },
);

await billingRequest(`subscriptions/${encodeURIComponent(subscriptionId)}`, {
  method: 'PATCH',
  body: { planId: nextPlanId, seats: 12 },
});

Without change, these changes populate pendingSeats and pendingPlanId for the next renewal. They do not recalculate the current cycle's price or entitlements, and no proration is applied. The quantity known to Bookelio at the start of the new period is frozen. Update the seats on a fixed-price plan too if you want to track headcount: with a per-seat price of zero, its amount stays the same.

To change a plan's prices, features or grace period for its future cycles, use PATCH /plans/{planId} with the fields to update, for example {"basePriceCents": 450000, "gracePeriodDays": 7}. Existing cycles retain their terms. Setting an application or plan to isActive: false prevents new subscriptions for it; this does not cancel existing contracts.

Apply an immediate change with proration

V1-r29 adds optional change to both operations above. effectiveAt: 'NEXT_PERIOD' keeps scheduling and only accepts proration: 'NONE'. To act now, send effectiveAt: 'IMMEDIATE' with a stable idempotency key of 1–100 characters. proration defaults to NONE: explicitly select the financial treatment you want.

await billingRequest(`subscriptions/${encodeURIComponent(subscriptionId)}`, {
  method: 'PATCH',
  body: {
    planId: upgradedPlanId,
    seats: 12,
    change: {
      effectiveAt: 'IMMEDIATE',
      proration: 'CHARGE_AND_CREDIT',
      idempotencyKey: 'example-account-change-2026-09-08-001',
    },
  },
});

For seats alone, add the same change object to the { seats: 12 } body of PUT .../seats. Always derive the account, quantity and authorised plan on the server. Keep the key for retries of the same command; a new key identifies a new action. Reusing a key with different parameters is a conflict. Do not generate a new key for every network attempt.

prorationEffect on the remaining period
NONEApplies the plan/seats without a financial adjustment
CHARGE_ONLYCharges increases only; no discount for decreases
CHARGE_AND_CREDITCharges increases and carries eligible funded decreases as a commercial discount

The calculation covers the fixed amount and seats, using the exact fraction of UTC time remaining in the calendar period, rounded to cents. It does not assume 30-day months. Halfway through a period, a fictional increase from €100 to €160 excluding VAT produces €30 before VAT; seat changes do not affect a plan with seatPriceCents: 0.

The subscription must not be cancelled, its period must still be valid and all its financial obligations must be settled. An unpaid bill, even during grace, blocks the adjustment. The new billing interval must be the same; schedule monthly/yearly switches at renewal. During a trial or offer, the change preserves the free end date and generates neither an amount due nor credit. To reactivate a cancelled subscription, omit change or use NEXT_PERIOD without proration.

The server appends an immutable cycle whose previousCycleId identifies its predecessor. currentPeriodStart becomes the change date, but the original end and renewal anchor stay unchanged. Previous invoices and requests remain intact. A supplement still to be paid creates a request with invoice and grace rules frozen on this new cycle; entitlements follow settlement or that grace. Read entitlements again after the call: a successful change is not evidence of payment and does not automatically debit any card.

creditGrantedCents records the discount granted, creditAppliedCents the discount consumed and creditBalanceCents the remaining balance, in pre-tax cents for this subscription alone. The balance reduces future charges before VAT: it is not an accounting credit note on an earlier invoice, a bank refund or a payment. No negative request or fabricated payment is created. The discount is capped at funded value; a trial, offer or gifted increase cannot create creditable value. Generic refunds for a subscription with adjustments are refused until a dedicated reconciliation workflow is available.

Grant free months through the API

At creation, add, for example, offer: { label: 'Welcome offer', freeMonths: 1 } to the POST /subscriptions body. For an existing subscription, use subscriptions.grantOffer:

await billingRequest(
  `subscriptions/${encodeURIComponent(subscriptionId)}/offers`,
  {
    method: 'POST',
    body: {
      label: 'One free month — fictional example',
      freeMonths: 1,
      idempotencyKey: 'example-commercial-offer-001',
    },
  },
);

The label is 1–200 characters, freeMonths is an integer from 1 to 36 and the key is 1–100 characters. Only one offer may be pending. Replaying the same key and data does not add months, even after application; changing the data under that key produces a conflict. Replaying creation of an existing subscription does not add an offer: use the dedicated operation. A cancelled subscription must be reactivated before the grant.

An initial offer supplied on a creation retry must match the one already recorded; an absent or different offer on the existing subscription produces a conflict. Responses expose at most the 20 most recent offers, like the 20 recent cycles: this is not a complete history export.

The offer waits for the next normally paid cycle, after the trial or already issued period; without a trial, it can start at creation. Unpaid bills remain blocking, and entirely free plans leave it pending. No existing document or amount due is changed. One month means one UTC calendar month even on an annual plan, with month-end clamping; paid billing resumes at the offer end with that new anchor.

The offered cycle exposes isComplimentary: true and offerId, remains ACTIVE and creates neither a payment request nor an automatic invoice. It is not a new trial; always check access and validUntil. offers exposes the label, duration, appliedAt and period dates once consumed. New proration and offer output fields are optional to accept older sellers: their absence proves neither a discount nor an offer. The seller must be on V1-r29 to apply these options; check its revision before offering these actions.

If the worker is delayed, an offer never starts before its grant date. If its entire free window would already have expired before processing, it starts when the worker recovers so it is not consumed entirely in the past. The dates returned by Bookelio are authoritative.

Cancel and reactivate

await billingRequest(
  `subscriptions/${encodeURIComponent(subscriptionId)}/cancel`,
  { method: 'POST', body: { cancelAtPeriodEnd: true } },
);

true schedules cancellation at the end of the period: entitlements that are already valid remain usable within their current limit, without extending grace that expires before that date. false requests immediate cancellation and also ends any current grace; it does not mean “undo a scheduled cancellation”. Issued payment requests and financial history are retained.

Once the status is CANCELED, send a PATCH /subscriptions/{subscriptionId} with an explicit planId to reactivate. Synchronising seats alone is not sufficient. An already paid period that is still valid resumes without being billed again; after expiry, a new cycle starts. Resuming does not grant another trial and is refused when a previous amount remains unpaid. Repeating POST /subscriptions retrieves the existing subscription and does not reactivate it.

Changing the plan through subscriptions.update before a scheduled cancellation takes effect does not clear cancelAtPeriodEnd. Since V1-r33, explicit activation through subscriptions.activate, described below, applies the plan now and removes that schedule after checking outstanding amounts.

Connect Bookelio to a billing instance by URL

This configuration applies to Bookelio itself. On the seller instance, create a Bookelio application and prepare your plans. At startup, Bookelio adds missing keys from its catalogue, for example invoicing, trainings or softwareBilling, without overwriting existing definitions. Then explicitly select the features included in each plan: adding a key does not automatically make it available in offers. The subscription sales module can therefore itself be included in an offer.

On the client instance's API and workers, configure the three variables recognised by Bookelio:

BOOKELIO_BILLING_URL=https://api.billing.example.com
BOOKELIO_BILLING_API_KEY=REPLACE_WITH_SELLER_ORGANIZATION_KEY
BOOKELIO_BILLING_APPLICATION_ID=REPLACE_WITH_BOOKELIO_APPLICATION_ID

The connection always uses HTTP /rpc/v1, even when the URL points to the same instance. The address must be reachable from the API and workers. Use an API URL without embedded credentials, query parameters or a fragment. On the seller instance's API and workers, BOOKELIO_WEB_URL specifies its public dashboard origin for payment links. The connection key needs softwareBilling.read, softwareBilling.manage and customers.create for consultation, provisioning and synchronisation; add paymentRequests.create to enrol organisations in plans.

After opening its HTTP server, the client API provisions the missing catalogue and accounts corresponding to non-legacy local organisations. It uses the organisation ID as externalId, its name and its member count, including the owner, with a minimum of one seat. A remote link is unique to the application and this identifier. Retries reuse the link; a non-overlapping scan retries failures and discovers new organisations every minute. Legacy organisations are excluded from accounts and seat synchronisation, but not from provisioning the shared catalogue. Startup does not wait for a response from its own URL before it starts listening.

If a remote subscription already exists for this identity, its customer is reused. Otherwise, local business details create a billing customer only when the required profile is complete and valid. No country or tax identifier is invented. An incomplete profile leaves a linked account without a customer; details of an already linked customer are not overwritten. The applications.provision operation itself creates no plan, subscription, payment request, invoice or settlement.

After this operation, if a subscription already exists, the scan calls subscriptions.syncSeats without an immediate option: seats are scheduled at renewal, without proration or a new payment obligation. A cancelled subscription is not reactivated. This call preserves existing automation: it may retry an invoice already due for an existing payment request in PAYMENT_REQUEST_CREATED mode, without creating a new request.

The seller must run V1-r30 and have the additive schema deployed before enabling this client startup process. An older instance can still answer existing reads but does not offer the new provisioning operation. The key must have both provisioning permissions, even when a batch ultimately needs no new customer.

The operation is also available to another application: applications.provision, or POST /applications/{applicationId}/provision under the module's REST prefix. Fictional example without a billing profile, therefore without customer creation:

const provisioned = await billingRequest(
  `applications/${encodeURIComponent(applicationId)}/provision`,
  {
    method: 'POST',
    body: {
      features: [{ key: 'reporting', name: 'Reports' }],
      accounts: [{ externalId: 'example-organisation-001', name: 'Example organisation', seats: 5 }],
    },
  },
);

Batches accept at most 200 features and 100 accounts. The response identifies the application, keys and, for each identity, accountId, customerId and subscriptionId; the last two may be null. A provisioned identity is therefore not evidence of a subscription or access. Always use entitlements to authorise features. An identical call duplicates neither keys nor accounts and does not replace existing feature metadata.

Assign plans and apply remote entitlements

In the superadmin, the Billing page checks the connection. An organisation's subscription tab can associate a plan and a customer from the seller instance. The owner can also select a plan under Settings → My Bookelio subscription, after confirmation. Bookelio uses the client organisation's ID as externalId and synchronises the member count, including the owner, with a minimum of one seat.

The connection uses the same calculated entitlements, including grace configured on the seller's plan. No additional grace setting is needed on the client instance, even when it uses its own URL for billing.

Offers and adjustments made by the seller also use this connection. Automatic Bookelio member synchronisation sends no immediate option: seat changes remain scheduled at renewal. Your own subscription page shows offers and the discount balance read-only; commercial changes are made on the seller side.

For non-legacy organisations, the Bookelio provider decides all features, including those previously enabled by default or without pricing. A key absent from its entitlements is disabled. Since V1-r32, Bookelio plans use only an external provider: old local plans and subscriptions, default values and individual overrides no longer grant any access. The local catalogue retains definitions, key mappings and global activation, not commercial entitlements. Historical tables are not deleted; the seller module continues to store its own plans, read through HTTP even on the same instance.

The BOOKELIO_FEATURE_PROVIDER_* generic provider is not queried alongside Bookelio. A partial Bookelio configuration or outage triggers no fallback. If all three BOOKELIO_BILLING_* variables are absent, the generic provider alone may supply entitlements; without any provider, a non-legacy organisation has no features. That generic provider exposes a read-only contract: Bookelio does not invent a write API to create keys or organisations there. The provisioning described here applies exclusively to a Bookelio instance configured through BOOKELIO_BILLING_*.

Explicit exemption for legacy organisations

V1-r32 adds Organization.bookelioLegacyAccess, defaulting to false. Only a superadministrator can mark an organisation legacy, for example for Bookelio itself or a custom-development contract. The audited superadmin.organizations.setLegacyAccess operation accepts strictly { organizationId, enabled } and returns { id, bookelioLegacyAccess }. Its REST route is PUT /api/v1/superadmin/organizations/{organizationId}/legacy-access. An owner or organisation key cannot call it; no generic variable or per-feature override grants this exemption.

Legacy grants access to all globally active features without querying the provider, even if absent, misconfigured or unavailable. It bypasses neither authentication, organisation isolation, business permissions for members and keys, nor a global feature deactivation. Diagnostics expose the optional providers.legacy: { active: true } object and retain the V1 manual source. Old override routes remain available for compatibility, but their data no longer grants entitlements.

The admin.organization.subscription.get/options reads expose the optional bookelioLegacyAccess boolean. For legacy, they return connected: false without a remote read, with subscription: null for get and no plans for options. This result means an exemption rather than a provider outage. My Bookelio subscription explains it without a selector; configure refuses a plan for a legacy organisation.

Enabling the exemption does not cancel an existing seller subscription, stop its future invoices or remove outstanding debt or documents. Manage that subscription separately and explicitly on the seller. Disabling legacy restores external checks; synchronisation may resume without automatically creating a subscription.

Before deploying V1-r32, have the additive Organization.bookelioLegacyAccess Boolean @default(false) column generated, reviewed and applied manually. Prepare external entitlements or explicitly mark only the intended legacy organisations during the switch. Do not automatically mark existing customers or delete historical tables: without a provider, a non-legacy organisation no longer receives its previous local entitlements.

Owner plan selection through the client API

V1-r31 adds two operations on the client instance's API, not the seller's API. They require a human session and an owner membership still present in the database for the active organisation. An API key, a non-owner administrator or another organisation cannot use them. The check does not depend on the sales module, so expired access can be resolved. Other authorised roles retain the historical admin.organization.subscription.get read operation.

oRPC operationREST routeEffect
admin.organization.subscription.optionsGET /api/v1/admin/organization/subscription/optionsRead only: connected, active plans, server-calculated seats and billingDetailsRequired
admin.organization.subscription.configurePUT /api/v1/admin/organization/subscription/planAfter confirmation: select with { planId, expectedOrganizationId, activation? }, returning the Bookelio subscription

expectedOrganizationId identifies the organisation for which the owner has just confirmed. It is only a precondition: if the session has switched organisations meanwhile, the API rejects with CONFLICT before any write. The target, external identity and seats remain derived from the session and database; no additional price, customer, quantity, offer or proration parameter is accepted.

Reading options creates no customer or subscription. During configuration, the server reuses the remote subscription's or account's customer. The seller's subscriptions.getByExternalId result now exposes an optional account for this link before subscription; old responses without that field remain accepted. Without a linked customer, a complete business profile is passed to existing provisioning; insufficient details require completing Settings → Business details before confirmation. No country or tax identifier is invented, and an already linked customer is not rewritten.

Since V1-r33, the dashboard sends activation: { idempotencyKey } to apply the plan immediately. The key remains the same when retrying the same confirmation. Without this optional field, older calls keep their behaviour: first creation, a new plan at renewal or protected resumption. A first selection respects the plan's free and trial rules; a later selection never starts another trial.

The server then calls the seller's admin.softwareBilling.subscriptions.activate (POST /api/v1/admin/software-billing/subscriptions/activate) with the strict input { applicationId, externalId, customerId, planId, seats, idempotencyKey }. This atomic operation requires softwareBilling.manage and paymentRequests.create and keeps a receipt for every successful activation, even if the plan is already applied. The key and fingerprint prevent duplicate charges or reapplying an older choice after a later change. The seller calculates prices and financial treatment; these are never chosen in the browser.

The policy is CHARGE_ONLY: with the same billing interval, only a positive difference is charged pro rata for the time remaining until the original end. Changing the interval starts a new period now and deducts the remaining value excluding VAT of service with verified funding; any excess is neither carried forward nor refunded. A trial or complimentary period keeps its free end, even if the interval changes. An expired, settled period restarts now without charging for past periods. Explicit activation also removes an already scheduled cancellation.

A choice matching the current snapshot, with no pending changes, returns any existing payment request without another charge. Outstanding obligations prevent a different configuration. Reapplying the current plan can refresh stale feature snapshots, for example those of an older free cycle. Past cycles, payments and invoices remain intact. Reread the subscription and entitlements: the page offers Pay now for a request due, but confirming a plan neither automatically debits a card nor proves settlement. Paid features follow verified payment or applicable grace. Offers remain seller decisions.

The server key towards the seller retains softwareBilling.read, softwareBilling.manage, customers.create and paymentRequests.create permissions. It is never sent to the browser. Calls remain HTTP /rpc/v1, even for the same instance. Have the additive BookelioSoftwareActivation table, relation and unique key generated, reviewed and applied manually before deploying seller V1-r33, then the client requesting activation. An older seller receives no deferred change as a fallback. Existing additive cycle and account schemas and the legacy column remain required; no historical receipt should be invented and no additional environment variable is needed. Without a connection or for a legacy organisation, no plan can be selected through this flow.

Before owner activation, the server reads the seller's public /api-versions.json registry without sending the API key. A V1 entry at revision 33 or later is required before any provisioning or activation. An older seller may still list its plans, but the owner flow refuses activation with a SERVICE_UNAVAILABLE error asking for an upgrade; this does not mean the plan or account has disappeared. If the registry is unavailable or invalid, the error explains that the version cannot be verified: restore that endpoint before retrying. Older calls without activation remain unchanged.

Diagnose and validate the integration

SymptomCheck
Key rejected or access forbiddenValid organisation key, operation permissions and module enabled for the seller
Features missing without a providerConfigure external entitlements or ask a superadministrator to check the legacy exemption; no local plan serves as a fallback
Legacy organisation without a plan selectorExpected behaviour; the exemption does not cancel any seller subscription
Bookelio plan choice refusedOwner session for the active organisation; no API key replaces this check. After switching organisations, reload before confirming
Details requested before subscribingComplete the business details or have the already linked provider customer checked; never use fabricated data
Application, plan or customer not foundIdentifiers belonging to the same seller; the plan must belong to the application
Startup link missingSeller running V1-r30, additive migration applied, reachable URL and softwareBilling.manage / customers.create permissions; wait for the next scan after correcting the issue
Linked account without a subscription or customerIncomplete billing profile or no assigned plan; complete the profile or explicitly select the customer and plan, without expecting automatic billing
Conflict during creationExternal reference already used; read the subscription, then update it rather than recreate it
No entitlements after returning from paymentFull payment actually confirmed, period still valid, feature key included
PAST_DUE while access is still openActive grace: check isInGracePeriod and validUntil, without treating that access as payment
Grace missing or already endedValue frozen in the cycle, period start, graceEndsAt, cancellation or refund; changing the plan does not change history
New plan or quantity not visibleWithout an immediate option, check pendingPlanId and pendingSeats; otherwise reread entitlements and the new request to pay
Immediate change refusedNo debt, paymentRequests.create permission and a consistent idempotency key; subscriptions.update also requires a valid current period and the same interval, while subscriptions.activate requires a migrated V1-r33 seller
Offer still pendingTrial or issued period running, free plan, blocking unpaid bill; inspect its dates in offers
No payment linkFree/trial cycle or missing seller public origin
Invoice expected on request creation but missingSeller choice frozen when the cycle was created, generation/delivery services available and worker active; no retrospective effect
No renewalActive worker, end of period reached, no blocking unpaid cycle

On a test installation prepared for this module, check a free plan, a trial, an incomplete then complete payment, a fixed-price plan with different headcounts and a per-seat offer. Test grace disabled and then enabled, its exact deadline, payment during and after it, a refund and an edit that changes only future cycles. Check both invoice triggers, no duplicate after payment and no retrospective effect on existing requests. Also check that an account can never read another account's entitlements or payment cycles by changing an identifier in the browser. Finish with a scheduled change, cancellation and resumption without another trial, followed by a simulated connection outage.

For V1-r29 options, test an immediate increase then decrease, each proration mode, added seats and retries using the same key. Check a discount consumed against a future charge and no credit after a gifted upgrade. Test one free month on an annual plan, after a trial and with an unpaid invoice, plus refusal of a second pending offer. Check the new actions with and without their financial permissions.

For V1-r30, test startup with a remote URL and then the same instance's URL, two successive scans, a feature already defined by the seller and an organisation created after startup. Check complete and incomplete profiles, reuse of an existing customer, no new subscription or financial obligation and rejection of keys lacking permission. Also check scheduled seats and normal retries of expected invoices on existing requests. Simulate an outage: no local value or override must bypass the configured provider's entitlements.

For V1-r31, verify that reading options creates no customer or subscription, that a non-owner or API key cannot configure a plan, and that switching organisations before confirmation causes a conflict. Test reuse of a linked customer, missing details, a first free/trial plan, a deferred change and resumption without another trial. The choice must not accept browser-supplied prices, customers, quantities or commercial options.

For V1-r32, verify refusal without a provider for a non-legacy organisation, even with an old plan or override. Test legacy with an absent or unavailable provider, an inactive feature and a member lacking business permission. Check that only a superadministrator changes this status, that audit is recorded and that no remote account, seat or plan-selection call is made for legacy. Removing the exemption must restore external checks without erasing debt or creating a subscription.

For V1-r33, test immediate activation of a free or paid plan, refreshing the current plan's features, the prorated supplement, interval changes and resumption. Check the payment offered after confirmation and reload, refusal of a different configuration with outstanding debt, retries without duplicates and older calls without activation still scheduling changes. An older seller must refuse activation without silently applying a change at renewal.

Payment cycles, entitlements and invoices are controlled by Bookelio. The consuming application uses the API to read and manage them; it does not directly modify cycles, payment statuses or the billing database.