Payment Sessions
A payment session is an invitation to pay. You create one, get a URL on your own web address, and send it by email or text. Your customer opens it, sees your brand — not ours — and pays by bank account or card.
This is different from Payment Links, which hands back a Stripe-hosted page. A payment session is served on your domain, with your logo, colour and sender identity, all configured once in the provider portal under Settings → Custom Checkout.
Before you start #
Everything about how the page looks and where it lives is configuration, not code. Set it once in the portal:
| Setting | What it controls |
|---|---|
| Display name, logo, accent colour | What the customer sees on the page and in the email |
| Support email | Where a confused customer writes |
| Your web address | e.g. pay.yourcompany.com — links are served here once it verifies |
| Link format | e.g. /pay/{token} — must contain {token} |
| Sender identity | The name and address the invitation appears to come from |
| Link lifetime | How long a link stays valid, and whether it can be paid twice |
Until your own address verifies, links are issued on the Audit1 address and keep working. This is deliberate. A link is never issued on a name nobody has proven they own.
Create a session #
POST /api/v1/payment-sessions
| Field | Type | Required | Description |
|---|---|---|---|
employer_id |
string | Yes | Employer ObjectId |
policy_id |
string | Yes | Policy ObjectId |
carrier_id |
string | Conditional | Required unless your API key is scoped to exactly one carrier |
amount_cents |
integer | Yes* | The whole total, in cents |
premium_cents |
integer | Yes* | The premium alone — use instead of amount_cents |
fee_cents |
integer | No | Fees on top of the premium |
fee_breakdown |
array | No | [{ label, amount_cents }] — itemised on the page |
description |
string | No | What the payment is for, shown to the customer |
customer.name |
string | Yes | Stripe needs it to open a checkout |
customer.email |
string | Yes | Stripe needs it to open a checkout |
customer.phone |
string | No | US +1XXXXXXXXXX; used if you send by text |
mode |
string | No | link (default) or embedded |
ttl_minutes |
integer | No | 15 to 10080. Defaults to your configured lifetime |
single_use |
boolean | No | Defaults to your configured setting |
deliver.email |
boolean | No | Send the invitation by email now |
deliver.sms |
boolean | No | Send it by text now |
deliver.email_to |
string | No | Override the recipient; defaults to customer.email |
deliver.sms_to |
string | No | Override the number; defaults to customer.phone |
created_by |
string | No | Your own attribution, e.g. "agency_42" |
total_installments |
integer | No | Sells a payment plan instead of a one-time payment — see Instalment plans |
down_payment_percent |
number | No | 0–100. Only with total_installments |
down_payment_cents |
integer | No | Flat down payment; wins over the percentage. Only with total_installments |
* Send either amount_cents or premium_cents + fee_cents. If you send both and they disagree, the request is rejected — see Amounts.
Example #
POST /api/v1/payment-sessions
{
"employer_id": "68a1f4c2e9b3d70012ab34cd",
"policy_id": "68a1f4c2e9b3d70012ab34ce",
"premium_cents": 1153800,
"fee_cents": 20000,
"fee_breakdown": [{ "label": "Audit fee", "amount_cents": 20000 }],
"description": "Workers' Comp premium — August 2026",
"customer": {
"name": "Sample Insured LLC",
"email": "ap@sampleinsured.com",
"phone": "+15551234567"
},
"deliver": { "email": true }
}
{
"ok": true,
"data": {
"id": "68b7c1d4e9b3d70012ab9911",
"url": "https://pay.yourcompany.com/pay/9xK2f...",
"token_prefix": "9xK2fQ7a",
"mode": "link",
"status": "pending",
"amount_cents": 1173800,
"line_items": [
{ "label": "Premium", "amount_cents": 1153800 },
{ "label": "Audit fee", "amount_cents": 20000 }
],
"expires_at": "2026-08-12T22:48:00.000Z",
"single_use": true,
"host_is_custom": true,
"config_version": 7,
"delivery": [
{ "channel": "email", "to": "ap@sampleinsured.com", "status": "sent", "at": "..." }
],
"delivery_ok": true
}
}
Keep the URL #
url is returned once and cannot be recovered. The token in it is a bearer credential — anyone holding it can open a page naming your insured and an amount — so only a hash of it is stored. If you lose it, use reissue.
Amounts #
The rules refuse rather than guess. Every one of these is a 400:
| Situation | Why it is refused |
|---|---|
amount_cents disagrees with premium_cents + fee_cents |
Choosing either would charge an amount you did not intend, and you could not see which was chosen. The error names both figures. |
fee_breakdown does not add up to fee_cents |
An itemisation that does not reconcile is worse than none — it looks authoritative. |
| Total below 50 cents | Below Stripe's floor; the charge could never succeed. |
| Total above $1,000,000 | That is a typo, not a premium. |
| Fractional cents |
Fees are always shown as their own line on the page. They are never folded into the premium.
Instalment plans #
Send total_installments together with a down payment and the session sells a plan instead of a one-time payment.
| Field | Type | Required | Description |
|---|---|---|---|
total_installments |
integer | Yes | How many instalments after the down payment. 1–60 |
down_payment_percent |
number | Conditional | 0–100, applied to the total including fees |
down_payment_cents |
integer | Conditional | A flat down payment. Wins over the percentage when both are sent |
A down payment field without total_installments is a 400: a down payment on its own is just a partial payment, and the rest would never be collected.
The reverse is allowed and is worth knowing: total_installments with neither down payment field means a down payment of 0, so the session's amount_cents is 0 and nothing is collected on the day the payer signs. Send down_payment_percent: 0 deliberately if that is what you mean.
The arithmetic #
The rules below are exact. They match the billing rail an agent already builds plans on, so the amounts collected are the amounts the customer was shown.
- The base includes the fees. The percentage is applied to
premium_cents + fee_cents, not to the premium alone. - A flat
down_payment_centsoverrides the percentage entirely. - The down payment is floored, never rounded. Rounding up collects more today than the percentage stated.
- Each instalment is floored:
per_installment = floor(remaining / total_installments). - The last instalment is derived by subtraction, never computed:
last = remaining - per_installment * (total_installments - 1). That is what makes the schedule sum exactly for any N and any percentage.
So total_installments: 9 means 8 instalments at the flat amount plus one final instalment — not 9 flat plus a tenth.
Worked example
A $1,361.00 total, 25% down, 9 instalments:
| Step | Calculation | Result |
|---|---|---|
| Total | premium_cents + fee_cents |
136100 ($1,361.00) |
| Down payment | floor(136100 × 25 / 100) |
34025 ($340.25) |
| Remaining | 136100 - 34025 |
102075 ($1,020.75) |
| Per instalment | floor(102075 / 9) |
11341 ($113.41) |
| Last instalment | 102075 - 11341 × 8 |
11347 ($113.47) |
The schedule is 8 × $113.41 + 1 × $113.47, and $340.25 + $1,020.75 = $1,361.00 exactly.
What gets charged today #
Warning
amount_cents on the session is the DOWN PAYMENT for a plan, not the total. The payment page and the ACH rail both charge amount_cents, so that is what the payer is debited today. The total lives on the plan, where nothing charges it by accident. The plan schedule is visible on the payer-facing page and via GET /public/payment-sessions/{token}/plan; the tenant-facing session response echoes only the down payment in amount_cents and does not return a plan object at all.
A plan is bank-rail only #
Only the bank rail can run a schedule — each instalment is minted as a leg by the ACH gateway's daily cron. On a card, the payer would be charged the down payment and the other N would never be collected.
So the card mount on the payment page refuses a plan outright. POST /public/payment-sessions/{token}/checkout answers 409:
{
"ok": false,
"error": "Unavailable",
"message": "This is a payment plan, which can only be set up from a bank account. Please use the bank option on this page."
}
Warning
Create plans as link sessions. Never as embedded. An embedded mount is your own app with Stripe inside it — it has none of our bank rail, and its Stripe session is built from the premium and fees rather than from amount_cents. An embedded plan session therefore puts the whole balance on a card today instead of the down payment. POST /api/v1/payment-sessions refuses the combination with a 400 naming the alternative; treat that refusal as the guard rail, not as the reason — the shape does not work regardless of what any version of the API does with it.
The instalment dates are monthly, starting one month after creation, anchored on the UTC day of the month the plan was created, at 17:00 UTC (noon Eastern in winter — the hour is fixed in UTC, not shifted for daylight saving). Month-end is clamped, never rolled — a plan created on 31 January takes its first instalment on 28 February, because rolling into March would put two instalments in one month.
down_payment_months is refused #
{
"ok": false,
"error": "Bad Request",
"message": "down_payment_months has no unambiguous meaning here — there is no honest conversion from a count of months to an amount. Send down_payment_percent (0-100), or down_payment_cents for a flat amount (which wins over the percentage)."
}
Info
This is deliberate, not an oversight. down_payment_months is accepted by POST /api/v1/payment-links, where the billing sheet that gives it meaning is present. Here it would have to be guessed at, so it is a loud 400 instead.
Example: a plan #
curl -X POST /api/v1/payment-sessions \
-H "X-Client-ID: $CLIENT_ID" \
-H "X-Client-Secret: $CLIENT_SECRET" \
-d '{
"employer_id": "681xyz789abc123456789012",
"carrier_id": "680abc456def789012345678",
"policy_id": "682def789ghi012345678901",
"premium_cents": 136100,
"total_installments": 9,
"down_payment_percent": 25,
"customer": {
"name": "Smith Industries",
"email": "ap@smithindustries.com"
},
"deliver": { "email": true }
}'
The response's amount_cents is 34025 — the down payment.
Delivery #
Set deliver.email or deliver.sms and we send the invitation immediately, from your configured sender identity.
A failed send is reported, never swallowed. The response carries delivery (one entry per channel, with the reason on failure) and delivery_ok. Check it — if it is false the session still exists and the url is still valid, so you can send it another way. Treating a failed send as success means waiting on a payment nobody was ever asked to make.
Text messages currently send from the Audit1 number. The
sms_fromsetting is stored but not yet applied — the underlying service sends from one fleet number and has no per-message override.
Embedded mode #
mode: "embedded" skips the link entirely and returns a client_secret for mounting Stripe's Embedded Checkout in your own application.
{
"ok": true,
"data": {
"id": "...",
"client_secret": "cs_live_...",
"publishable_key": "pk_live_...",
"status": "pending"
}
}
Delivery fields are rejected in this mode — there is no link to send.
A payment plan cannot be sold in embedded mode.
Embedded checkout is
Stripe's card form, and instalments are collected from a bank account on the
hosted page. Sending plan fields with mode: "embedded" is a 400. See
Instalment plans.
There is no hosted mode. Stripe's own hosted page carries branding set at the account level — the same look for every provider on the platform. Since the point of this feature is that the page is yours, we serve the page and mount Stripe's Embedded Checkout inside it.
Track a session #
GET /api/v1/payment-sessions List — filter by employer_id, policy_id, carrier_id, status
GET /api/v1/payment-sessions/:id One session
| Status | Meaning |
|---|---|
pending |
Created, never opened |
opened |
The customer has looked at the page |
paid |
Paid. A single-use link stops being payable at this moment |
expired |
Past its lifetime |
cancelled |
You cancelled it |
opened vs pending distinguishes "never saw it" from "saw it and did not pay" — different conversations.
Cancel a link #
DELETE /api/v1/payment-sessions/:id
A paid session cannot be cancelled (409). The money already moved; changing the record would misstate what happened. Refund the payment instead.
Reissue a link #
POST /api/v1/payment-sessions/:id/reissue
The original token was never stored, so the same link cannot be re-sent. This mints a fresh one with the same details — and cancels the old one in the same call, before the new one exists.
That ordering is deliberate: two live links for one debt is how one payment becomes two, and nobody asking for a resend is asking for that. If the mint fails, nothing is payable — the safe direction.
Accepts ttl_minutes and the same deliver block. The response carries replaces with the cancelled session's id.
Know when they pay #
Register a callback under Settings → Custom Checkout → Payment notifications, or via the Payment Webhooks API. You will receive payment.completed with the amount, the employer and the policy.
Every request is signed. The signing secret is shown once, when you register the callback or rotate it — copy it then.
Sandbox #
Payment sessions have no sandbox mode and will return a 400 on a sandbox-scoped key. The upstream Stripe service ignores the environment flag, so a sandbox key would create a real session against live credentials. Use POST /api/v1/payment-links for sandbox testing.
Errors #
| Status | Meaning |
|---|---|
400 |
Amounts disagree, a required field is missing, a text number is not +1XXXXXXXXXX, or the key is sandbox-scoped |
401 |
Missing or invalid API credentials |
403 |
The employer or carrier is outside your key's scope |
409 |
Cancelling or reissuing a session that is already paid |
502 |
Stripe could not be reached — the session was not created |