Hand customers a self-service portal
Mint a session, send the link, and let customers change plan, fix a card, download invoices and cancel without you building any of it.
The billing portal is a hosted page BillKit serves under your branding. It is the fastest way to cover the account-management screens you would otherwise build twice: changing plan, replacing a dead card, downloading invoices, cancelling.
It is also what BillKit’s own customer emails link to, so the dunning notice and the “your invoice is ready” message already land somewhere useful before you write any code. What a link in an email opens is narrower than a session you mint, because an email can be forwarded: see Sessions from BillKit’s emails.
Mint a session
curl https://api.billkit.eu/v1/billing_portal/sessions \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subscription_id": "sub_Pv9Kc3nX8mQ2rTyLbd",
"return_url": "https://example.com/account"
}'The key needs the subscriptions:write scope. Redirect the customer to the url in the response,
exactly as given. The token rides in the part after #, which browsers never send to a server, so
it stays out of access logs and Referer headers; do not move it into the path or the query.
Do not assume the host. Do not rebuild that URL, and do not check it against a fixed origin. The host is not part of the contract.
There is no customer login, no password and no email round trip: the token in that URL is the credential. So mint it on demand from a page where you already know who the customer is, and never put it anywhere it could be shared or indexed.
| Scope | Exactly one subscription. A customer with two needs two links. |
| Lifetime | Two hours from minting, or 30 minutes of inactivity, whichever comes first. expires_at on the response is the two-hour limit. |
| Token | Returned once on the create call and never recoverable. Mint a new session rather than trying to read it back. |
| return_url | Where the portal’s "Back to" link, above your name in the portal header, sends them. Sessions opened from BillKit’s own emails have no return link. |
| Host | Not part of the contract. A session stays on the host it was minted for, whatever changes afterwards. |
Set deliver_email: true and BillKit also emails the link to the address on the customer record,
branded like the rest of its mail. The response still carries the url. A customer with no
address on file gets no email, and the call succeeds anyway.
POST /v1/billing_portal/sessions/{id}/revoke ends a session immediately, which is what you call
if you have reason to think a link has been shared. It cannot be undone, and revoking a session
that is already revoked returns 200 and changes nothing. Deleting or purging the customer
revokes every session they hold and the portal links in BillKit’s emails to them.
Do not put a portal URL in a shared inbox, a support ticket or a chat transcript. Anyone holding the link is that customer for the next two hours. Revoke a session rather than hoping it expires.
Sessions from BillKit’s emails
Invoice, dunning, trial-ending, upcoming-payment and card-expiry emails each carry a portal link.
Those links stay valid for 90 days, because invoice emails get filed and opened again weeks later,
and each click opens a fresh session. An email can be forwarded to an accountant, sit in a shared billing@ inbox or be quoted in
a support ticket, so a session opened from one of these links can view and pay, and nothing else:
| From an emailed link | Only from a session you mint |
|---|---|
| The subscription, invoices, credit notes, payment history and PDF downloads | Changing plan |
Replacing the payment method, which on a past_due subscription also pays the unpaid period | Cancelling, reversing a cancellation, resuming |
| Adding the first payment method to a subscription you imported | Entering a coupon code |
| Approving a billing-email change from the link BillKit sends to the current address | Editing name, billing email, business name, country or VAT number |
The portal hides the other controls on such a session and tells the customer to open billing from
their account with you. A request that tries anyway gets 403 with code: "insufficient_scope" and
reason: "portal_session_limited". To give a customer the whole portal, mint a session with
POST /v1/billing_portal/sessions from a page where you already know who they are. The links in
BillKit’s emails never need to change for that, and should not. No email offers a button for
something its session cannot do: the trial-ending and upcoming-payment emails tell the customer to
change or cancel from their account with you, and the cancellation email, which carries no portal
link, tells them to reactivate there.
Your own domain
A domain you own buys two things: mail that comes from you rather than from BillKit, and this
portal served on billing.yourdomain.com. They share one verification and turn on separately, so
you can take the mail now and the hostname later.
Start under Settings → Domain. Enter the domain, publish the TXT record the console shows, and press check. BillKit also re-checks on its own every few minutes, so a record that lands overnight is picked up without anyone watching. A domain that is never proved is given up on after 48 hours; add it again to start over.
| Record | What it is for |
|---|---|
TXT at _billkit.yourdomain.com | Proves the domain is yours. Required for both halves, and the only record either half waits on. |
CNAME at billing.yourdomain.com | Points the portal hostname at our CDN, which is what lets a certificate be issued for it. Only needed for the portal. |
The console shows the exact values, including a token minted for your domain. Do not copy them from anywhere else.
Mail from your domain
Turn on sending and your customers’ invoices, receipts, trial reminders and dunning notices go out
as billing@yourdomain.com. Pick a different mailbox if you prefer; billing is only the default.
Sending needs two more records, which the console lists once the domain is proved. SPF and DKIM
are required: they are what a receiver checks to decide the mail really is from you, and our
provider will not accept the From until both are published. DMARC is not required but you should
publish it, even at p=none, because without a policy a receiver has nothing to apply and you get
no reporting. An MX record is offered too and is genuinely optional for sending. The console grades
each one and distinguishes “published but wrong” from “not published”, which is usually enough to
tell you what to change.
Replies go to the support_email on your branding, if you have set one. Leave it unset and the mail
carries no reply address rather than pointing your customer at BillKit’s support desk.
Not every message moves. Anything addressed to your customers sends from your domain; anything addressed to you stays with us, because mail telling you a webhook is failing or that your own card was declined should not arrive forged as your own company.
| From your domain | Always from BillKit |
|---|---|
| Invoices and receipts | Console sign-in links |
| Dunning and payment-failure notices | Team invitations |
| Trial and renewal reminders | Webhook-failure alerts |
Portal links you send with deliver_email | Notices about your own BillKit bill |
Until you turn any of this on, the mail still carries your name. It goes out as “Acme via BillKit” from a BillKit address, which stays properly authenticated because alignment is a property of the address, not of the name in front of it.
The portal on your hostname
Publish the CNAME, turn the hostname on, and we obtain and renew the certificate for you. There is nothing to install and nothing to expire.
Until that record resolves, the portal keeps answering on its BillKit host. That is the safe state, not a broken one: a hostname with no certificate would give your customers a TLS warning, so we do not send anyone there until there is a certificate to send them to. The console says which of the two it is currently serving.
The links inside invoice and dunning emails start at api.billkit.eu and then land on whichever
host is live when the customer clicks. That is deliberate, and a customer will eventually ask about
it: those links live for months, a hostname can be switched off in a second, and the first hop has
to be somewhere that is always served. Only the hop is ours. Everything the customer then reads is
yours.
Turning the hostname off signs out the customers who are in the portal on it. The CDN stops serving that name straight away, so leaving their sessions alive would hand them a page that cannot load. They can open a new link. Anyone already on the BillKit host is untouched, and so is every link in an inbox.
Leave the TXT record in place. It is re-checked for as long as you use the domain, not just at setup. If it disappears, you have three days to put it back; after that the domain is demoted, mail returns to “Acme via BillKit” and the portal hostname is released. This is the one that catches people during a DNS migration.
What the customer can do there
On a session you mint, all of the following. A session opened from one of BillKit’s emails is narrower: see Sessions from BillKit’s emails.
| Area | Actions |
|---|---|
| Plan | See the proration quote, then switch. The same preview-then-commit pair as POST /v1/subscriptions/{id}/update, limited to the plans the portal lists. Not while the subscription is past_due: the unpaid period is paid first. |
| Lifecycle | Cancel at period end, reverse a pending cancellation, and resume a subscription you paused. Customers cannot pause; only your key can. |
| Payment method | Replace the payment method with a new mandate. On a past_due subscription to a licensed price that is still set to renew, the button reads Pay €X now with the amount, and paying the unpaid period is what replaces the method. Otherwise it reads Update payment method. This is the recovery path the dunning email links to. |
| Documents | Their invoices and credit notes, with PDF downloads. |
| History | The payment ledger for their subscription. |
| Details | Name, billing email, business name, billing country and VAT number. A country cannot be cleared once set, only changed. You can lock the country and the VAT number against portal edits with the capability toggles below. A new billing email is only a request until the current address approves it from a link BillKit sends there; the customer record, and customer.updated, change when it is approved. |
| Discounts | Enter a coupon code, once you turn coupons on, on a plan whose product allows promotion codes. |
The plan list holds your active, licensed prices in the subscription’s currency, one per product and billing interval. On the interval of the product’s default price, that price is offered. On the other interval, the newest active price is. So to raise a price, create the new one and make it the default; customers on the old price keep it until they switch. Archiving the default price clears the default, and the portal then offers the newest active price on that interval. Any other active price on a slot, such as a deal you sell through its own checkout link, is not offered.
The customer’s own price is always listed and marked as current, even when it is no longer the one
the product offers, and it is never a switch target. The portal refuses a switch to any price
outside the list with a 400, however the request is made. Your key is not limited this way:
POST /v1/subscriptions/{id}/update moves a customer to any active price.
Replacing a payment method on a subscription in good standing takes a verification payment. It is free when your Mollie account offers only cards, and otherwise a small amount, at most EUR 1.00, that is not refunded. The portal shows the amount before the customer starts. The payment ledger lists a verification only when it actually took money; one that was abandoned or charged nothing is left out.
On a past_due subscription to a licensed price that is still set to renew there is no separate verification. The customer pays the unpaid
renewal itself, the same amount every retry charges, on a method they choose at Mollie. When it is
paid, the period moves on, the subscription is active again, and that method is used for renewals
from then on. It is also the only way through a decline where the bank wants the customer present.
Brand it before you send anyone there
Until you set it, the portal shows your account name and nothing else, and an unbranded page asking for card details is indistinguishable from phishing. Set this up before your first live checkout, not after the first support ticket.
Branding is one setting for both modes, and live customers see it, so the call needs a live-mode
key. A test key is refused with 403 mode_mismatch. The console’s branding settings work in either
mode.
curl https://api.billkit.eu/v1/tenant/portal_branding \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"business_name": "Acme BV",
"support_email": "billing@acme.example",
"logo_url": "https://acme.example/logo.png",
"theme": {"primary": "#6d28d9", "radius": 10},
"capabilities": {"coupons": true, "invoice_pdf": true}
}'The key needs the tenant:write scope. Every field is a partial update: leave one out and it is
untouched, send null and it is cleared. Changes reach portal sessions that are already open. The
same branding drives the portal, the customer emails, the invoice PDFs and the embedded checkout
header.
logo_url must be https and at most 2048 characters; anything else is a 400. BillKit does not
fetch the URL to check it, so confirm it loads yourself.
theme takes primary, primary_600, primary_50, primary_100, on_primary, radius (a
number of pixels), font, display and palette (studio or blank). Colours are CSS values.
Capability toggles
Every flag but coupons defaults to true; send false to turn one off. coupons defaults to
false, because a code field invites guessing against every coupon you have; send true to offer
it.
| Flag | What it controls |
|---|---|
| coupons | Whether the portal accepts a coupon code. Off by default. Off, the field is hidden and the portal refuses one. On, a code still applies only on a plan whose product has allow_promotion_codes: true, and the field is shown only on such a plan, and rejected codes are limited per subscription, per customer and per account (see Discounts with coupons). |
| invoice_pdf | Whether invoice and credit-note downloads are offered. |
| cycle_enabled | Whether the customer may switch between monthly and yearly. Off, the plan list offers only prices on their current interval. |
| show_features | Whether a plan’s marketing features are listed on the switcher. |
| powered_by | The BillKit line in the footer. |
| customer_country_editable | Whether the customer may change their billing country on the details form. Off, the field is read-only and the portal refuses a change with 403 customer_field_locked. Your key and the console can still change it. |
| customer_vat_number_editable | The same lock for the VAT number. |
| checkout_header | Whether the embedded checkout element draws its header (logo, business name, product, amount). Not a portal setting, but part of the same branding. |
GET /v1/tenant/portal_branding reads it back, with anything you never set as null. Two extra
fields, effective_business_name and effective_support_email, are what the customer actually
sees once fallbacks apply: your account name for the business name, and no support address at all
when you have not set one.
Where the portal fits with your own UI
The portal is a complete surface, but nothing forces you to use all of it. Nearly every action it offers has an API route behind it, so you can build the screens that matter to your product and link out for the rest. The exception is a customer entering a coupon on an existing subscription, which only the portal does.
The one worth linking out for is the payment-method flow. Replacing a mandate means a real payment at Mollie (a verification, or the unpaid renewal on a past-due subscription) and a redirect back, and the portal already handles all of it. See Recover failed payments.
The portal’s actions are the same operations your key performs, so the events are the same too:
a customer cancelling in the portal emits subscription.updated exactly as your own call
would. Keep gating on serves_customer
and you do not need to know which surface a change came from.
BillKit