Plan Billing
Three calls let an application show a merchant where they stand against this month's invoice
allowance and sell them the upgrade, without sending them to the GoRoute dashboard. A fourth,
GET /api/v1/billing/summary, answers the same question for a customer who is on an agreed
package rather than a plan.
These calls are on both the production and the test service.
They reached production in the release of 8 September 2026, and they were confirmed on production
on 10 September 2026 by calling each address with no credential: all four answered 401 asking
for an API key, while an address that does not exist on the same host answered 404. Nothing
invents an authentication challenge, so the routes are there.
An earlier version of this page, written on 7 September 2026, said these calls were on the test service only and would fail on production. That was true when it was written and is not true now. If you postponed the work on the strength of it, you can build against production today.
All paths below are relative to the production base URL https://app.goroute.ai/peppol-api. The
test service serves the same paths under its own base URL โ see
Environments โ and nothing else differs between the two.
The calls on this pageโ
| Method and path | What it does |
|---|---|
GET /api/v1/billing/usage | Where the organisation stands against this month's invoice allowance, whether it buys on a price list or on agreed terms, the state of its subscription, and whether checkout and the billing portal are open to it |
POST /api/v1/billing/checkout-session | Starts a hosted Stripe checkout for a plan โ or changes the plan in place when a subscription already exists |
POST /api/v1/billing/portal-session | Opens Stripe's customer portal, where a merchant changes the card, downloads receipts and cancels |
GET /api/v1/billing/summary | One organisation's billing position for the current period, whether it is on a white-label package or on a GoRoute plan. Needs the billing:manage permission โ see its own section |
Every one of them is answered for the organisation the credential belongs to. A request with
no credential is refused with 401 and MISSING_API_KEY.
The first three carry no separate billing permission: any API key or signed-in session of the
organisation may call them, because the numbers they return are the organisation's own counters.
GET /api/v1/billing/summary is the exception and says so in its own section.
Plans managed by a store appโ
A merchant who installed the GoRoute app from the Shopify App Store or the Wix App Market
pays through that store, not Stripe. The app records the outcome with PUT /api/v1/billing/plan
(API key with the integrate scope; body plan, source, external_ref, trial_ends_at,
current_period_end, quota_override) and hands the plan back on uninstall with
DELETE /api/v1/billing/plan.
source accepts shopify or wix. Anything else is refused.
Both calls answer with the shape of GET /api/v1/billing/usage, which carries billing_source
(stripe, shopify or wix), billing_external_ref, trial_ends_at, current_period_end and
quota_override; allowance is always the effective monthly quota. Write your client to accept
all three values โ an organisation whose plan came from the Wix app returns wix, and code that
only knows stripe and shopify will reject a live answer.
While the source is shopify or wix, the checkout and portal calls answer 409 with error
plan_managed_externally, and Stripe webhooks leave the organisation's plan alone. The refusal
names where the merchant changes the plan instead: the GoRoute app in the Shopify admin for
shopify, and the GoRoute app in your Wix dashboard for wix.
The monthly invoice allowanceโ
Every plan includes a number of outbound invoices a month. Free is the only plan that stops at it. On every paid plan the allowance is a number on the bill: sending carries on past it and the overage is billed. A white-label client on an agreed package is never stopped either โ the package is the contract.
Usage is counted as outbound documents in the calendar month, whatever became of them afterwards. A document that failed to deliver still counts.
Where the stop is appliedโ
Five calls refuse to issue a document once a Free organisation has used its allowance for the month:
| Method and path |
|---|
POST /api/v1/invoices |
POST /api/v1/invoices/import/csv |
POST /api/v1/pos/fuel-invoice |
POST /api/v1/pos/exchange-invoice |
POST /api/v1/pos/sale |
The refusal is HTTP 402 with error_code PLAN_LIMIT_REACHED:
{
"error_code": "PLAN_LIMIT_REACHED",
"message": "The Free plan includes 50 invoices a month and 50 have been issued in 2026-09. Upgrade to keep issuing, or the allowance renews on 2026-10-01.",
"plan": "free",
"allowance": 50,
"used": 50,
"period": "2026-09",
"resets_on": "2026-10-01",
"request_id": "3f6c1b9a-2f4e-4b8d-9c31-7a2d5e8f0b14"
}
| Field | Meaning |
|---|---|
plan | The plan key the organisation is on |
allowance | Invoices the plan includes in a month |
used | Invoices already issued in this period |
period | The calendar month being counted, as YYYY-MM |
resets_on | The date the allowance renews, as YYYY-MM-DD โ the first of the following month |
Those five fields are there so an application can tell a merchant exactly what happened and when
it clears, rather than showing them a bare error. Read error_code, not the message text.
GET /api/v1/billing/usageโ
curl -X GET https://app.goroute.ai/peppol-api/api/v1/billing/usage \
-H "X-API-Key: pk_live_your_key_here"
Response โ an organisation on the Free plan, 23 invoices into the month:
{
"plan": "free",
"plan_name": "Free",
"period": "2026-09",
"resets_on": "2026-10-01",
"allowance": 50,
"used": 23,
"remaining": 27,
"hard_limit": true,
"exhausted": false,
"self_serve": true,
"account_manager": null,
"brand_name": null,
"subscription": {
"status": "none",
"current_period_end": null,
"cancel_at_period_end": false
},
"checkout_available": true,
"portal_available": false,
"plans": [ /* one entry per plan on sale โ see "The plan catalogue" below */ ]
}
The /* โฆ */ line is an abbreviation for this page. The API returns a normal JSON array there.
Fieldsโ
| Field | Type | Meaning |
|---|---|---|
plan | string | The organisation's plan key |
plan_name | string | How that plan is presented. It is not always a tidied-up version of the key โ the professional key is presented as Growth โ so show plan_name and match on plan |
period | string | The calendar month being counted, YYYY-MM |
resets_on | string | Date the allowance renews, YYYY-MM-DD |
allowance | integer | Invoices the plan includes in a month |
used | integer | Invoices issued so far in period |
remaining | integer or null | allowance minus used, never below zero. null when the plan carries no monthly allowance figure at all |
hard_limit | boolean | true only when this organisation will actually be stopped at the allowance โ the Free plan, and not on a white-label package |
exhausted | boolean | true when hard_limit is set and the allowance is used up. This is the flag that predicts a 402 |
self_serve | boolean | true when the organisation buys from the price list. false for enterprise, sandbox, demo and white-label package customers, who are on agreed terms |
account_manager | string or null | Where to write to change agreed terms. Always null when self_serve is true. It can also be null when the party those terms are with keeps no address on record |
brand_name | string or null | Whose terms they are. Also null when self_serve is true |
subscription | object | The last thing the payment provider told us โ see below |
checkout_available | boolean | true when a plan can actually be bought on this environment right now. It is false when the environment has no payment configuration, and false for anyone not on the price list |
portal_available | boolean | true when this organisation has a customer record with the payment provider, so the portal can be opened |
plans | array | The plan catalogue โ empty for an organisation on agreed terms |
The subscription objectโ
| Field | Type | Meaning |
|---|---|---|
status | string | none when there has never been a subscription, otherwise the provider's own status |
current_period_end | string or null | When the paid period runs out, as an ISO 8601 timestamp |
cancel_at_period_end | boolean | true when the merchant has cancelled and is running out the period they paid for |
Three statuses keep the organisation on its paid plan: active, trialing and past_due.
past_due is deliberate โ the provider is still chasing the payment, and the plan stays until it
gives up. When the status becomes canceled, unpaid or incomplete_expired the organisation
goes back to the Free plan, with the Free allowance and the Free hard stop.
The plan catalogueโ
Each entry of plans has these keys:
| Field | Type | Meaning |
|---|---|---|
plan | string | The plan key, which is what you send to POST /api/v1/billing/checkout-session |
name | string | The plan's display name |
usd_month | integer | The list price, in US dollars a month |
blurb | string | One line describing what the plan includes |
monthly_invoices | integer | The plan's monthly invoice allowance |
purchasable | boolean | true when this plan can be bought on the environment you are calling |
This page does not restate the prices. They are in usd_month, they are on goroute.ai, and a
copy of them here would go stale without anyone noticing. Read the catalogue and render it; do not
hard-code it.
POST /api/v1/billing/checkout-sessionโ
curl -X POST https://app.goroute.ai/peppol-api/api/v1/billing/checkout-session \
-H "X-API-Key: pk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"plan": "starter", "return_url": "https://your-store.example/wp-admin/admin.php?page=goroute"}'
Request bodyโ
| Field | Required | Type | Meaning |
|---|---|---|---|
plan | yes | string | The plan key to buy |
return_url | yes | string | A page on your own site where the merchant lands afterwards |
Which plan keys are accepted is decided by the environment, not by this page. Send a key that
appears in the plans array from GET /api/v1/billing/usage with purchasable set to true.
The Free plan cannot be bought. Anything else is refused with 400 and UNKNOWN_PLAN, and the
message lists the keys that environment will accept.
return_url must be an https address on a host you control, with no #fragment, and no longer
than 1,500 characters. Plain http is accepted only for localhost and 127.0.0.1, so that a
developer can work locally.
Two possible answersโ
A merchant with no subscription gets a hosted checkout page to be sent to:
{
"url": "https://checkout.stripe.com/c/pay/..."
}
Send them there. When they come back, your return_url will carry two extra query parameters:
goroute_plan, with the plan key, and session_id, with the provider's checkout session
identifier. If they abandon the checkout they come back to your return_url with neither.
A merchant who already has a live subscription does not get a checkout page at all. The existing subscription is switched to the new plan, the payment provider works out the proration, and the answer says so:
{
"changed": true,
"plan": "professional",
"url": "https://your-store.example/wp-admin/admin.php?page=goroute&goroute_plan=professional"
}
There is nothing to pay at that moment. Treat changed as the signal to show a confirmation
rather than to redirect. The new plan takes effect immediately, so the allowance a merchant sees
changes as soon as they are back.
Refusalsโ
| Status | error_code | When |
|---|---|---|
400 | UNKNOWN_PLAN | The plan key is not on sale on this environment, or is the Free plan |
400 | BAD_RETURN_URL | return_url is not an https address on a real host, carries a fragment, or is too long |
409 | ALREADY_ON_PLAN | The organisation's live subscription is already on the plan you asked for |
503 | BILLING_NOT_CONFIGURED | Self-serve upgrades are not open on this environment. checkout_available on the usage call tells you this in advance |
502 | STRIPE_ERROR | The payment provider could not be reached, or refused. Safe to retry |
POST /api/v1/billing/portal-sessionโ
curl -X POST https://app.goroute.ai/peppol-api/api/v1/billing/portal-session \
-H "X-API-Key: pk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"return_url": "https://your-store.example/wp-admin/admin.php?page=goroute"}'
return_url is optional here and follows the same rules as on checkout. Leave it out and the
merchant is returned to their GoRoute company settings page instead of to your own.
Response:
{
"url": "https://billing.stripe.com/p/session/..."
}
Send the merchant to that address. Everything after that โ card details, receipts, cancelling โ happens on the payment provider's pages, not on ours.
Refusalsโ
| Status | error_code | When |
|---|---|---|
404 | NO_SUBSCRIPTION | This organisation has never paid for a plan, so there is no customer record to open a portal on. Check portal_available first |
400 | BAD_RETURN_URL | The return_url you supplied breaks one of the rules above |
503 | BILLING_NOT_CONFIGURED | Billing is not configured on this environment |
502 | STRIPE_ERROR | The payment provider could not be reached, or refused. Safe to retry |
GET /api/v1/billing/summaryโ
The three calls above answer "how much of my plan have I used, and what can I buy". This one answers a different question: what is this organisation's billing position for the current period, whether it buys a GoRoute plan or sits under a white-label provider on an agreed package. It is the call to build a billing overview screen on.
curl -X GET https://app.goroute.ai/peppol-api/api/v1/billing/summary \
-H "X-API-Key: pk_live_your_key_here"
Who may call itโ
Unlike the three calls above, this one requires the billing:manage permission.
| Credential | Allowed |
|---|---|
Signed-in user with the org_owner or finance role | Yes |
Signed-in user with org_admin, operations, developer or viewer | No โ org_admin is deliberately excluded from billing |
API key with the admin (or legacy all) scope | Yes |
API key with the read, write, send or integrate scope | No |
Anyone else is refused with 403 and error_code INSUFFICIENT_PERMISSION. A request with no
credential is refused with 401 and MISSING_API_KEY.
This permission check is always enforced. Some permission requirements elsewhere on the platform are still being phased in, so a call can succeed today that will be refused later; this one is not among them. Build against the table above and it will not change under you.
Three shapes, chosen by what the organisation isโ
Every answer carries a mode field. Read mode first and branch on it โ package and plan
are never both filled in, and the one that does not apply is null.
mode | The organisation is | What it gets |
|---|---|---|
package | A white-label client with a package assigned by its provider | The package terms, and this billing cycle's usage and bill |
unassigned | A white-label client with no package assigned yet | Usage only. Deliberately no plan table โ GoRoute's tiers and quotas are not this customer's commercial terms, and are not ours to show to somebody else's customer |
plan | A direct GoRoute customer | A curated set of the plan's limits, and usage against the included allowance |
Fields present in every mode:
| Field | Type | Meaning |
|---|---|---|
mode | string | package, unassigned or plan, as above |
period | string | The calendar month being counted, YYYY-MM |
usage | object | What was issued and what is billable. Its contents differ by mode โ see below |
package | object or null | Filled in only when mode is package |
plan | object or null | Filled in only when mode is plan |
billing_contact | object | Where this organisation's own invoices are sent: billing_email and contact_name |
billed_by | object or null | Who issues the invoice: name and support_email of the white-label provider. null for a direct GoRoute customer, who is billed by GoRoute |
billed_by never carries the provider's own accounts-payable address. It is the provider's
customer-facing support contact, so "your provider issues the final invoice" is something a
customer can act on.
Usage counts outbound documents that were submitted, whatever became of them afterwards โ the same rule as the allowance further up this page.
mode: "package"โ
{
"mode": "package",
"period": "2026-09",
"package": {
"package_id": "pkg_starter_om",
"name": "Starter (Oman)",
"currency": "OMR",
"monthly_fee": 25.0,
"included_invoices": 200,
"overage_per_invoice": 0.05,
"setup_fee": 100.0,
"billing_frequency": "quarterly",
"assigned_at": "2026-07-01T00:00:00Z"
},
"usage": {
"period": "2026-09",
"billing_frequency": "quarterly",
"cycle_label": "Q3 2026",
"cycle_months": 3,
"monthly_fee": 25.0,
"cycle_fee": 75.0,
"included_invoices": 600,
"invoice_count": 742,
"billable_invoices": 142,
"overage_amount": 7.1,
"currency": "OMR",
"total": 82.1
},
"plan": null,
"billing_contact": { "billing_email": "accounts@client.example", "contact_name": "A. Al Balushi" },
"billed_by": { "name": "Muscat Digital", "support_email": "support@muscatdigital.example" }
}
The trap in this shape: package.included_invoices is the allowance per month, while
usage.included_invoices is the allowance over the whole billing cycle. On a quarterly
package the second is three times the first, and the same goes for package.monthly_fee against
usage.cycle_fee. Bill and compare against the usage figures; show the package figures as the
terms. Reading the monthly number as the cycle allowance is how you under-count an overage.
The package object repeats the terms as they were snapshotted when the package was assigned:
| Field | Type | Meaning |
|---|---|---|
package_id | string | The provider's package identifier |
name | string | The package's name as the provider set it |
currency | string | The currency of every money figure in this answer. Defaults to OMR when the package does not name one โ do not assume euros |
monthly_fee | number | The monthly rate |
included_invoices | integer | Invoices included per month |
overage_per_invoice | number | Charged per invoice past the cycle allowance |
setup_fee | number | A one-off, shown for reference; it is not part of usage.total |
billing_frequency | string | monthly, quarterly or yearly |
assigned_at | string or null | When the provider assigned the package |
And the usage object for this mode:
| Field | Type | Meaning |
|---|---|---|
period | string | The calendar month, YYYY-MM โ the same machine key used everywhere else on this page |
billing_frequency | string | The cycle this bill covers |
cycle_label | string | The cycle for a human to read: Sep 2026 monthly, Q3 2026 quarterly, 2026 yearly. Show this, match on period |
cycle_months | integer | Months in the cycle: 1, 3 or 12 |
monthly_fee | number | The monthly rate, repeated |
cycle_fee | number | monthly_fee multiplied by cycle_months |
included_invoices | integer | The allowance over the whole cycle |
invoice_count | integer | Invoices submitted over the whole cycle |
billable_invoices | integer | invoice_count minus included_invoices, never below zero |
overage_amount | number | billable_invoices at overage_per_invoice |
currency | string | As above |
total | number | cycle_fee plus overage_amount. The setup fee is not in it |
mode: "unassigned"โ
A white-label client whose provider has not yet assigned a package. There is nothing to bill
against, so the three usage figures other than the count are null rather than zero โ zero would
read as "an allowance of none".
{
"mode": "unassigned",
"period": "2026-09",
"package": null,
"usage": { "invoice_count": 34, "included_invoices": null, "billable_invoices": null },
"plan": null,
"billing_contact": { "billing_email": "accounts@client.example", "contact_name": "A. Al Balushi" },
"billed_by": { "name": "Muscat Digital", "support_email": "support@muscatdigital.example" }
}
Show the count and point the customer at billed_by. Do not fill the gap with GoRoute plan
prices: this customer has no GoRoute plan and never sees one.
mode: "plan"โ
A direct GoRoute customer. billed_by is always null here โ reaching this shape at all means no
white-label provider was found above the organisation.
{
"mode": "plan",
"period": "2026-09",
"package": null,
"usage": { "invoice_count": 412, "included_invoices": 1000, "billable_invoices": 0 },
"plan": {
"billing_plan": "professional",
"billing_source": "stripe",
"billing_external_ref": null,
"limits": {
"monthly_invoices": 1000,
"monthly_transactions": 1000,
"max_participants": 25,
"webhook_enabled": true,
"scheduled_sending": true,
"bulk_operations": true,
"approval_workflow": false,
"support_level": "email",
"sla_hours": 24
}
},
"billing_contact": { "billing_email": "accounts@seller.example", "contact_name": "J. Peeters" },
"billed_by": null
}
| Field | Type | Meaning |
|---|---|---|
plan.billing_plan | string | The plan key. It is the key, not the display name โ professional is presented as Growth |
plan.billing_source | string | stripe, shopify when the plan is managed by the Shopify app, or wix when it is managed by the Wix app. Defaults to stripe |
plan.billing_external_ref | string or null | The subscription reference at that source, when the plan is managed externally |
plan.limits | object | Nine fields, listed below |
usage.invoice_count | integer | Invoices submitted in period |
usage.included_invoices | integer | The effective monthly allowance โ a per-organisation quota override replaces the plan's own figure, so this can differ from the plan tier's published number |
usage.billable_invoices | integer | The count past the allowance, never below zero, and 0 when there is no allowance figure at all |
plan.limits carries these nine fields and no others. It is a deliberately curated subset: rate
limits and internal batch settings are not exposed here.
| Field | Type | Meaning |
|---|---|---|
monthly_invoices | integer | The effective monthly invoice allowance, the same figure as usage.included_invoices |
monthly_transactions | integer | Transactions included a month |
max_participants | integer | Peppol participants the organisation may register |
webhook_enabled | boolean | Whether webhooks are included |
scheduled_sending | boolean | Whether sending can be scheduled |
bulk_operations | boolean | Whether bulk operations are included |
approval_workflow | boolean | true on the Business, Agency and Enterprise plans, false on the others. It does not switch anything on. No part of the platform reads this flag, and no invoice is ever held for approval on any plan โ see Approval Events. |
support_level | string | community, email, priority or dedicated |
sla_hours | integer or null | Response-time commitment in hours. null when the plan carries none |
Choosing between this call and GET /api/v1/billing/usageโ
They are not interchangeable.
GET /api/v1/billing/usageis for a plan strip: what is left this month, and the upgrade. Any credential of the organisation may read it, so a store plugin can show it.GET /api/v1/billing/summaryis for a billing overview: the terms, the cycle and the bill, including for customers who have no GoRoute plan at all. It needsbilling:manage, so do not put it behind a screen every member of staff can open.
Building a plan stripโ
The three calls are meant to be used together, and this is the order that works:
- Call
GET /api/v1/billing/usagewhen your settings page loads. Showusedofallowance, and the date inresets_on. - If
hard_limitistrue, warn asusedapproachesallowanceโ this merchant will be stopped, not billed for the overage. - Show an upgrade button only when
checkout_availableistrue, and build its choices from theplansarray rather than from a list you maintain. - Show a "manage billing" link only when
portal_availableistrue. - When
self_serveisfalse, show no plan cards at all. Showbrand_nameandaccount_managerinstead, so the merchant writes to the right people about terms that are already agreed. - Handle
402withPLAN_LIMIT_REACHEDwherever you issue a document. It carries everything you need to explain the stop.
Relatedโ
- Error Codes โ the platform's error code reference
- Authentication โ API keys, permissions and rate limits
- Environments โ telling the test and production services apart