Billing
What an organization is paying for, and the hosted Stripe pages where they change it. Syllevo does not take card details and does not render a payment form. Every one of these routes that changes money answers with a URL to send a browser to.
Payment state matters to an integrator for one reason: it decides whether other routes answer 402.
See Conventions.
Routes
| Method | Path | Needs |
|---|---|---|
| GET | /v1/billing |
Admin |
| POST | /v1/billing/sync |
Owner |
| POST | /v1/billing/checkout |
Owner |
| POST | /v1/billing/change-plan |
Owner |
| POST | /v1/billing/portal |
Owner |
| POST | /v1/billing/refund-request |
Owner |
| GET | /v1/billing/closure |
Admin |
| POST | /v1/billing/closure |
Owner |
Reading is Admin. Everything that touches the subscription is Owner, and there is exactly one
owner per organization. See Team, roles, and invitations.
These routes are in the paywall allowlist, so they keep answering when the rest of the API is
returning 402. An organization that cannot pay must still be able to reach the page where it pays.
GET /v1/billing
The whole picture in one call.
| Field | Type | Means |
|---|---|---|
package |
object | What they are on, or null when they are on nothing |
subscription |
object | The Stripe subscription as Syllevo mirrors it, or null |
usage |
object | This period's usage |
entitlements |
object | What is switched on right now |
package
| Field | Type | Means |
|---|---|---|
priceLookupKey |
string | The key you pass to checkout or change-plan |
nameKey |
string | Translation key for the package name |
audience |
string | Who the package is for |
interval |
string | Monthly or annual |
includedProfiles |
integer | How many client companies the package covers |
includedAllowances |
object | efacturaSubmissions and foreignExtractions, either null for unmetered |
nameKey is a key rather than a name, for the same reason error codes are. The wording lives in the
translation files and is rendered in the reader's language.
subscription
| Field | Type | Means |
|---|---|---|
status |
string | Stripe's own subscription status |
currentPeriodEnd |
date-time, null | When the current period ends |
cancelAtPeriodEnd |
boolean | Whether it stops at that date |
inGracePeriod |
boolean | Whether a failed payment is being retried |
gracePeriodEndsAt |
date-time, null | When grace runs out |
status is Stripe's string, passed through. Do not branch on it. Branch on entitlements.state,
which is Syllevo's own answer and the thing the API actually enforces.
usage
| Field | Type | Means |
|---|---|---|
activeProfiles |
integer | Client companies in use |
documentsThisPeriod |
integer | Documents collected this period |
periodStart |
date-time | The period being counted |
periodEnd |
date-time | Its end |
entitlements
| Field | Type | Means |
|---|---|---|
state |
EntitlementState |
0 unsubscribed, 1 active, 2 grace, 3 lapsed |
productAccess |
boolean | Whether ordinary routes work |
collectionActive |
boolean | Whether Syllevo is still collecting and chasing |
accountingExports |
boolean | Whether invoices-saga.csv is in exports |
| Value | Name | What happens |
|---|---|---|
0 |
Unsubscribed | No subscription. Full function for the first 24 hours, then stopped |
1 |
Active | Paid and current |
2 |
Grace | Payment failed and Stripe is retrying. Works exactly like active |
3 |
Lapsed | Grace ran out or it was canceled. Collection and chasing pause |
Grace is not degraded service. A card that expired on the 3rd is not a reason for a firm to miss a month-end, so everything keeps working while Stripe retries, for 14 days.
Lapsed pauses collection and chasing. Nothing is hidden, and exporting stays open, for 90 days after the subscription ended. Then the organization and its data are deleted, after two emails to every Owner. See the closure routes for the dates, and Exports and handover packs.
State 0 behaving as full function for the first day is deliberate: in the first hours after a
checkout, a missing subscription is almost always a webhook that has not landed yet rather than
somebody who never paid.
POST /v1/billing/checkout
Starts a hosted Stripe Checkout. Owner.
| Field | Type | Required | Means |
|---|---|---|---|
priceLookupKey |
string | yes | Which package. Lowercase letters, digits, and underscores |
successUrl |
string | yes | Where Stripe sends the browser after payment |
cancelUrl |
string | yes | Where Stripe sends it if they back out |
{ "url": "https://checkout.stripe.com/c/pay/cs_test_example" }
Send the browser there. Nothing has been bought when this returns; the purchase lands later, by webhook.
Both URLs must be https and their origin must be on this deployment's allowlist. A URL that is
not answers 400 validation.returnUrlNotAllowed, which is an open-redirect guard on a page that
takes card details, not a formality. Maximum 512 characters each.
There is no refusal for "you already have a subscription". An upgrade goes through checkout as well as a first purchase, and upgrading always beats overflowing.
POST /v1/billing/change-plan
Moves an existing subscription onto a different package. Owner.
| Field | Type | Required | Means |
|---|---|---|---|
priceLookupKey |
string | yes | The package to move to |
returnUrl |
string | yes | Where Stripe sends the browser afterwards |
Answers 200 with the Stripe URL where the customer confirms the change. Same URL rules as
checkout.
Refusals, all 409:
| Code | Means |
|---|---|
billing_no_subscription |
There is nothing to change. Send them to checkout instead |
billing_unknown_price |
That key names no package that is on sale |
billing_same_plan |
They are already on it |
billing_annual_term_running |
An annual subscriber asking to go monthly mid-term |
billing_gateway_unavailable |
Stripe could not be reached. Try again in a minute |
billing_annual_term_running is the only refusal in this area that carries data. Its details
object holds currentPeriodEnd, the date the paid year ends, so the screen can print it without a
second request that might disagree. An annual term runs to its end and there is no pro-rata refund
for leaving it early.
billing_unknown_price also covers a retired package. An old price stays alive for whoever is
already on it, which is why the billing screen can still show a retired package, but nobody new may
buy one.
POST /v1/billing/portal
A Stripe Customer Portal URL: payment methods, invoices, plan changes, and cancellation. Owner.
| Field | Type | Required | Means |
|---|---|---|---|
returnUrl |
string | yes | Where Stripe sends the browser afterwards |
Refused with 409 billing_no_customer when Stripe has never seen this organization, which means
no checkout has ever been started. Send them to checkout instead, because it is the only thing that
would work.
Note the difference from billing_no_subscription. A customer exists from the moment a checkout
starts, so an organization that abandoned a payment page has a customer and no subscription. The
portal opens for them and change-plan does not.
Everything that happens on the other side of that link is invisible to Syllevo until a webhook
arrives. If somebody cancels there, expect the change to reach GET /v1/billing shortly afterward,
not instantly. A cancellation there ends the same way closing does: when the subscription ends, the
90-day export window starts.
POST /v1/billing/refund-request
Asks for the 40-day money-back guarantee. Owner.
| Field | Type | Required | Means |
|---|---|---|---|
note |
string or null | no | Anything they want to say. Up to 2000 characters |
Answers 202:
{
"requestedAt": "2026-09-11T10:00:00Z",
"messageKey": "web.billing.refundRequested"
}
202 is accurate. Nothing is refunded by this call. A person issues the refund in Stripe, and the
request itself is the audit entry.
This route has no refusals. There is no state it declines from, no retention question, and nothing between the button and the confirmation. If you are building a billing screen, do not add a confirmation step that asks why.
POST /v1/billing/sync
Re-reads the organization's subscriptions from Stripe, mirrors what they say, and answers with the
same body as GET /v1/billing. Owner.
Rate limited to 20 requests a minute under the billing-sync policy, separately from the global
limiter. See Conventions.
You should not need this. Stripe webhooks keep the mirror current on their own. Use it when somebody just changed something in the portal and does not want to wait, or when the mirror and Stripe visibly disagree.
Refused with 409 billing_gateway_unavailable when Stripe cannot be reached.
GET /v1/billing/closure
Whether the organization is scheduled for deletion, and when. Admin. Never 404.
| Field | Type | Means |
|---|---|---|
state |
string | none, pending, scheduled, or deleting |
reason |
string, null | subscription_ended, closed_by_owner, or never_subscribed |
accessEndsAt |
date-time, null | pending only: when the paid period ends and the export window starts |
accessEndedAt |
date-time, null | When access ended; the export window counts from here |
deleteAfter |
date-time, null | The earliest the organization is deleted. Set when the first email goes out |
noticeSentAt |
date-time, null | When the Owners were first emailed |
reminderSentAt |
date-time, null | When the reminder went out |
| State | Means |
|---|---|
none |
Nothing scheduled |
pending |
The Owner closed the account and a paid period is still running |
scheduled |
Access has ended. The organization can export until deleteAfter |
deleting |
Deletion has started. It cannot be undone |
deleteAfter is the later of 90 days after accessEndedAt and 30 days after the first email, and
the organization is never deleted before both emails went out. A reminder that goes out late pushes
it to 7 days after the reminder. Subscribing again, or taking the cancellation back in the portal,
takes a pending or scheduled organization back to none within the hour.
never_subscribed is an organization that was created 12 months ago and never had a subscription or
a Stripe customer. Its export window starts 12 months after it was created, with the same emails and
the same dates. Subscribing takes it back to none the same way.
state and reason are string enums in the OpenAPI document, so a client generated
from it sees exactly these values. A value added later arrives as a changelog entry first.
POST /v1/billing/closure
Closes the account. Owner. No body. Answers 200 with the same body as GET /v1/billing/closure.
With a paid period running, the subscription is canceled at the end of the period at Stripe, and
the answer is pending with accessEndsAt. Nothing is refunded and nothing stops early. Without a
paid period, the export window starts at once and the answer is scheduled.
There is no "delete now". Closing starts the same 90 days a lapse does.
Idempotent: closing twice answers with the same schedule. Refused with 409
billing_gateway_unavailable when Stripe could not be reached to cancel; nothing was changed.