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
scheduled The customer chose to pay by bank and the debit is arranged — the money has not been collected yet
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.

scheduled is the bank rail's waiting room #

A card payment goes straight to paid — the money moves while the customer is still on the page. A bank debit cannot: it is handed to the ACH network and takes days. So a bank session sits at scheduled in between, and that status means exactly one thing — the customer has committed, and nobody has been debited yet. Do not read it as collected.

A one-time bank session becomes paid when the money is confirmed, and what counts as confirmation depends on where the carrier's money lands:

The carrier settles… paid when The event that says so
Into an Audit1 collection account (FBO) the debit is matched in that bank account payment.debit_cleared
Into its own bank (direct) the ACH processor reports it settled payment.debit_settled
Neither — no settlement rail recorded never; it stays scheduled none is sent

One family or the other, per carrier — but code for both.

Each event has a single sender today and each sender picks its own carriers, which is what makes the table above true: the bank matcher only ever sees FBO money, and the settlement sync only sends for carriers recorded as direct. The receiving contract is looser than that, so if emission is ever widened both events could arrive for one debit. Deduplicate on X-Webhook-ID and let payment.debit_cleared win — it is the bank's verdict, and it supersedes the processor's claim.

On the direct rail the money never passes through an account we can read, so the processor's report is the strongest confirmation that can exist there. Both events are documented on Payment Webhooks — subscribe rather than poll, and the settlement_source field on each payload tells you which of the two claims you are holding.

Expect roughly a week of waiting on the FBO rail: the median gap between the ACH processor reporting the debit settled and payment.debit_cleared arriving is about 7 days, measured over the 275 debits matched since the 2026-08-04 backfill that carry a processor settlement date (314 matched in all), as of 2026-08-18. The direct rail carries the processor's report itself, so it resolves sooner.

The third row is real, not hypothetical. 8 of the 69 live carriers today — 5 reporting-only, 3 with no settlement rail recorded (counted 2026-08-18) — sit on neither rail. Neither sender reaches them, so a bank session on one of them gets no debit event and never leaves scheduled. The payment may be perfectly fine; we just have nothing that can confirm it to you. Check the carrier with us before you offer the bank rail on it.

Info

A plan session stays scheduled for the life of the plan. This is deliberate, not a stuck record: a plan is not collected until its last instalment is, and paid on day one would misstate that. The down payment clearing does fire its webhook — the status just does not move. Track the instalments through the plan itself, not through this field.


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.

A session with a bank debit arranged cannot be reissued (409)"A bank debit is already arranged for this session — cancel that debit first." That covers a session sitting at scheduled, and any session still carrying a debit reference even if the link itself was cancelled since.

Cancelling a session does not call the debit back. The debit is a separate instruction already on the ACH rail, and the next batch close sends it whatever the session says. So a fresh payable link at that moment is a live link for money that is already leaving the customer's account — they pay it, and they have paid twice. It is the paid-session rule one step earlier: 409 while the money is moving, 409 once it has moved.

There is no partner-API route that cancels a session's bank debit — reach us at support@audit1.com to stop one. (A plan is different: the payer can stop the remaining instalments themselves from the payment page.)


Know when they pay #

Register a callback under Settings → Custom Checkout → Payment notifications, or via the Payment Webhooks API. Which event you get depends on how they paid:

They paid by You receive
Card payment.completed, with the amount, the employer and the policy
Bank account (ACH) payment.debit_cleared if the carrier settles into an Audit1 collection account, once the money is matched there, or payment.debit_settled if it settles into its own bank, when the ACH processor reports the debit settled. Either way it comes only once the money is confirmed — days later, not at checkout

Nothing is sent when a bank debit is arranged. There is no webhook between the customer authorising the debit and the money being confirmed, so if you need to know they committed, read the session — it sits at scheduled for that whole stretch.

payment.completed covers payments taken through Stripe checkout: the card option on this page, and — in embedded mode, where the page is yours and ours is not in the picture — a Stripe bank debit too. It never fires for this page's own Bank account (ACH) option, which runs on Audit1's ACH rails and reports through the payment.debit_* events instead. Subscribing to payment.completed alone means never hearing about a payment made there.

Both events are new (August 2026). Which rails are live for the carriers behind your policies can change — support@audit1.com will confirm before you rely on them. And an existing subscription does not pick up a new event type on its own — resend your full events list to start receiving them. Some carriers sit on neither rail and produce neither event, so their sessions never reach paid; the Payment Webhooks page has the detail, including why you should still subscribe to both names.

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 Two different causes, told apart by the error field: Forbidden — the employer or carrier is outside your key's scope. Checkout Not Enabled — Custom Checkout is off for your tenant, or still missing something it requires. The second is a settings problem, not a permissions one, and no request will succeed until it is fixed
404 The employer_id, policy_id, or carrier_id you sent does not resolve to a live record. The message names which one. This is the error a new integration hits most often — the ids are checked before anything is created, precisely so a session cannot be minted against records that do not exist
409 Cancelling or reissuing a session that is already paid, or reissuing one that already has a bank debit arranged
502 Stripe could not be reached — the session was not created
503 No Payment Page Host — you asked for mode: "link" and no verified host is serving your payment page, so there is nowhere to send the customer. We refuse rather than mint a link to an address that does not answer. mode: "embedded" is unaffected: it has no URL