Skip to content
API pagesBilling

API documentation

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.