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 0100. 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. 160
down_payment_percent number Conditional 0100, 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.

  1. The base includes the fees. The percentage is applied to premium_cents + fee_cents, not to the premium alone.
  2. A flat down_payment_cents overrides the percentage entirely.
  3. The down payment is floored, never rounded. Rounding up collects more today than the percentage stated.
  4. Each instalment is floored: per_installment = floor(remaining / total_installments).
  5. 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_from setting 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.


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.


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