Invoices
Create, send, and manage payment invoices through the PayConnect API. Invoices generate hosted payment pages where your customers can pay securely via credit card or ACH.
Overview
The invoice workflow is straightforward:
- Create an invoice with customer details and amount
- Send the invoice via email (automatic or manual)
- Customer pays through the hosted payment page
- Receive a postback notification when payment completes
Create an Invoice
- cURL
- Node.js
- Python
curl -X POST https://dev.payconnect.us/api/invoices \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "x-correlation-id: YOUR_CORRELATION_ID" \
-H "Content-Type: application/json" \
-d '{
"customerId": "cust_abc123",
"description": "Consulting services - March 2026",
"amount": 150,
"dueDate": "2026-04-01",
"paymentMethods": ["cc", "ach"]
}'
const axios = require("axios");
const response = await axios.post(
"https://dev.payconnect.us/api/invoices",
{
customerId: "cust_abc123",
description: "Consulting services - March 2026",
amount: 150,
dueDate: "2026-04-01",
paymentMethods: ["cc", "ach"],
},
{
headers: {
"x-session-token": "YOUR_SESSION_TOKEN",
"x-correlation-id": "YOUR_CORRELATION_ID",
"Content-Type": "application/json",
},
}
);
console.log("Invoice ID:", response.data.invoiceId);
import requests
response = requests.post(
"https://dev.payconnect.us/api/invoices",
headers={
"x-session-token": "YOUR_SESSION_TOKEN",
"x-correlation-id": "YOUR_CORRELATION_ID",
"Content-Type": "application/json",
},
json={
"customerId": "cust_abc123",
"description": "Consulting services - March 2026",
"amount": 150,
"dueDate": "2026-04-01",
"paymentMethods": ["cc", "ach"],
},
)
invoice_id = response.json()["invoiceId"]
Response (HTTP 201):
{
"invoiceId": "INV-2026-0042",
"id": "3f9a1c2e-8b4d-4a6f-9e01-2c7d5b8a1f34",
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
invoiceId is the merchant-facing display number. id is the internal invoice identifier used in subsequent paths (e.g. GET /invoices/{id}, PUT /invoices/{id}, and search results).
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
customerId | string | Yes | PayConnect customer ID of the customer being billed. The customer must already exist — create one via the customers API first. |
description | string | Yes | Invoice description shown to the customer |
amount | number | Yes | Amount in dollars (e.g., 150 = $150.00). Must be greater than zero. |
dueDate | string | Yes | Due date (ISO 8601 format or timestamp) |
paymentMethods | array | No | Accepted payment rails: ["cc"], ["ach"], or ["cc", "ach"]. Omit it to inherit the customer's default (see Accepted payment rails below). Must contain at least one rail when sent — [] is rejected. |
items | array | No | Line items (see below) |
reminders | boolean | No | Enable payment reminder emails (default: true) |
sendEmail | boolean | No | Send invoice email on creation (default: true) |
invoiceId | string | No | Custom merchant-facing invoice number; must be unique per company. Auto-generated (format INV-<base36>) when omitted. |
customFields | object | No | Custom field values defined for your company |
Accepted payment rails
paymentMethods is the set of rails the payer is offered on the hosted payment
page. You can send it explicitly, or omit it and let the invoice inherit.
When you omit it, PayConnect resolves the rails server-side. First match wins:
- An explicit
paymentMethodson this request. - The billed customer's
defaultPaymentOptions, if set. - For a subscription-generated invoice only: the subscription's
paymentMethods. ["cc", "ach"]— both rails.
Resolution happens server-side for every create path, so the dashboard, this API, and subscription renewals all behave identically. Setting a customer's default therefore applies to invoices you create through the API without changing your integration.
Two consequences worth knowing:
-
The customer's setting beats the subscription's. A customer set to ACH only gets ACH-only renewal invoices even when their subscription lists both rails.
-
An update never re-resolves. Omitting
paymentMethodsonPUT /api/invoices/{invoiceId}leaves the invoice's existing rails alone, and editing a customer's default never changes invoices that already exist. A payer part-way through a hosted page does not have the options change underneath them. -
The invoice's rails are enforced at payment time, not just at display time. This holds at every point where a rail gets chosen:
-
A new card or bank account.
POST /api/invoices/hosted/intentiontakes apaymentMethodslist so the payment-capture iframe can render a single tab (and so a lone["ach"]intention omits the card surcharge). That list can only ever narrow what the invoice already accepts: a rail the invoice does not allow is dropped. Asking only for rails the invoice refuses is a400naming the rails it does accept — the intention is never rebuilt on a different rail than you asked for, because the surcharge and the rendered tab follow that rail and a swap would price the payment one way and collect it another. If you get that400, re-read the invoice: its rails can have been narrowed by an operator since your page loaded.[]is rejected (400) too — an empty list is not a narrower request, and previously it widened the intention to both rails.A rail the business has no payment product for is refused the same way, with a message naming it, rather than failing as an authorization error further down.
-
A saved payment method. Paying with an
accountVaultIdmints no intention at all, so the rail comes from the vault. If that vault's rail is one the invoice excludes, the payment is rejected with a400and nothing is charged. This applies toPUT /api/invoices/{invoiceId}/payment, toPUT /api/invoices(the hosted payment page's own pay call, which authenticates with the invoice token) and to the dashboard's take-a-payment screen. All three answer400with a message naming the rail the invoice does accept.If the saved method cannot be read at all, the payment still does not go through — the check fails closed rather than assuming the rail is allowed. Which error you get says whose problem it is: a vault id that does not exist, or belongs to another merchant, is a
400(retrying cannot change it), while a processor outage is a503you can retry. -
A payment method you supply yourself. A
fortisEvent(a ticket or tokenization you minted elsewhere — the customer add-payment-method capture session is not tied to any invoice) carries its ownpayment_method, and that is the rail the charge runs on:achwhen it says so, card otherwise, exactly as the charge itself reads it. It is checked against the invoice's rails like a saved vault, so a card ticket cannot settle an ACH-only invoice. -
A sale that has already settled. A
completedTransaction(or afortisEventwhose@actionissale) with anidand astatus_codedescribes money that has already moved — the hosted payment page sends its Elements sale this way, after the iframe has charged the card. Refusing it would not unwind the charge; it would only leave it unrecorded, and on the hosted page let the payer submit again. So a settled sale is always recorded against the invoice, and if its rail is one the invoice now excludes (the rails were narrowed after the payment page was opened, or the sale was made outside it) PayConnect raises an application alert naming the mismatch instead of returning an error. -
A wallet payment.
walletData/walletProvideris always a card, so the rail is checked before the wallet sale is submitted: on an invoice that excludes card you get the400and nothing is charged.
So an ACH-only invoice cannot be paid by card, whether the payer reaches for a new card, one already on file, a wallet, or a payment method you tokenized yourself.
One deliberate exception: a subscription renewal charge is the merchant's standing mandate on the payment method the customer attached through the subscription's own setup page, which
subscription.paymentMethodsgoverns. It is not a rail choice being made at pay time, so it is not subject to this check — the same accepted divergence described under Default Payment Options. -
PayConnect never leaves an invoice that cannot be paid online. [] is rejected on
create and on update (400), and every level of the chain above either holds at
least one rail or is treated as unset. If you currently send
paymentMethods: [], that request will start returning a 400 — what it produced
before was an invoice the hosted payment page rendered with no way to pay.
Invoices already stored with an empty paymentMethods are covered too: an empty
rail set normalizes to both rails, so those invoices become payable without you
having to find and repair them. Every place PayConnect reports an invoice's rails
answers the same way — GET /api/invoices/{invoiceId}, POST /api/invoices/search,
the hosted payment page, and the invoice.* webhook payloads — so a webhook you
reconcile against a fetch will never disagree with it. A rail set with one or two
real rails is always returned exactly as stored.
Line Items
Include itemized details with the items array:
{
"items": [
{
"itemId": "item-001",
"description": "Strategy consultation",
"quantity": 2,
"unitPrice": 50,
"totalPrice": 100
},
{
"itemId": "item-002",
"description": "Implementation support",
"quantity": 1,
"unitPrice": 50,
"totalPrice": 50
}
]
}
All monetary amounts are in dollars. For example, 50 = $50.00.
Get an Invoice
Invoice paths key on the internal id returned by create and search — not the INV-... display number, which is invoiceId and is for display only.
curl -X GET https://dev.payconnect.us/api/invoices/3f9a1c2e-8b4d-4a6f-9e01-2c7d5b8a1f34 \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "x-correlation-id: YOUR_CORRELATION_ID"
Response:
{
"invoiceId": "INV-2026-0042",
"invoiceStatus": "Pending",
"amount": 150,
"amountPaid": 0,
"description": "Consulting services - March 2026",
"invoiceDate": "2026-03-13T14:22:10.000Z",
"dueDate": "2026-04-01T12:00:00.000Z",
"customerId": "cust_abc123",
"paymentMethods": ["cc", "ach"],
"createdAt": "2026-03-13T14:22:10.000Z",
"pdfUrl": "https://...",
"customer": {
"firstName": "Jane",
"lastName": "Smith",
"email": "[email protected]"
}
}
Search Invoices
Search and filter invoices. Results are cursor-paginated in creation order, newest first (by the invoice's creation time — for a delayed-post invoice this differs from its invoice date):
curl -X POST https://dev.payconnect.us/api/invoices/search \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "x-correlation-id: YOUR_CORRELATION_ID" \
-H "Content-Type: application/json" \
-d '{
"filters": {
"invoiceStatus": { "value": "Pending" }
},
"limit": 25
}'
Filterable fields are id, invoiceId, customerId, subscriptionId, invoiceStatus, lastAttemptStatus, amount, amountPaid, invoiceDate, dueDate, createdAt, updatedAt, and customFields. Multiple filters are combined with AND. The request body is strict — an unknown or mis-cased key returns a 400. (The older page / pageSize inputs were removed; sending either returns a 400. sortBy / sortOrder are accepted only on a customer-scoped search. See Sorting a customer's invoices.)
Looking up invoices by id. filters.id with operator: "in" (an array of up to 100 ids) or a single eq id resolves in one response: every matching invoice, with nextCursor: null, however large the account. limit doesn't slice it, and sending a cursor with an id lookup returns a 400 (there is never a next page). More than 100 ids returns a 400 asking you to split them into batches. Other filters on the same request still apply to the resolved set. An id array always matches any of its ids, even if you omit operator: "in".
Looking up an invoice by number. filters.invoiceId with a complete invoice number (INV-…, or your own number) resolves by a direct read on the first page, however large the account: the matching invoice comes back in one response with nextCursor: null (an empty result with nextCursor: null means no invoice has that number, or the other filters excluded it). A partial number (contains / startsWith on a fragment) is a scan of the account's invoices newest-first, paged by cursor like any other search.
Response (HTTP 201):
{
"invoices": [
{
"id": "3f9a1c2e-8b4d-4a6f-9e01-2c7d5b8a1f34",
"invoiceId": "INV-2026-0042",
"invoiceStatus": "Pending",
"amount": 150,
"dueDate": "2026-04-01T12:00:00.000Z"
}
],
"nextCursor": null,
"hasMore": false,
"truncated": false
}
Paginating (limit / cursor)
Each request returns up to limit invoices (1–100, default 20) plus an opaque
nextCursor. Read the complete result set by following nextCursor until it
comes back null — that is the only completion signal. Omit cursor for the
first page; pass back the previous response's nextCursor to advance. A page may
contain fewer than limit items — even zero — while nextCursor is still
non-null, so never infer completion from page size; loop while hasMore is
true. Treat the cursor as opaque: pass it back verbatim, never parse or construct
it. There is no page number and no total count — a keyset cursor reads only what
it returns, which keeps it fast and complete on an account of any size, without
the scan cap a page-number search hits.
Ordering caveat — invoice pagination is not insert-stable. Invoices page
newest-first, so an invoice created while you are paging sorts before your
cursor and will not appear in any later page of that traversal. For a stable
change-feed, do not rely on paging alone: poll a closed updatedAt
interval (between with both bounds in the past) and page each poll to
nextCursor: null, then dedupe by invoiceId. (This differs from subscription
search, which pages in creation order and so is insert-stable.)
truncated no longer signals an incomplete invoice read — the invoice set is
always paged to completion by following nextCursor. On this endpoint it is
currently always false: no filter in the request contract triggers a
bounded resolution read. The field is retained for envelope consistency with
subscription search (where a customer-filtered search reads the customer
directory and can report a floor) and reserved for any future bounded lookup —
do not branch on it for completeness.
Sorting a customer's invoices (sortBy / sortOrder)
A search scoped to customers (a customerId filter with eq, or in with up to
100 ids) can be sorted server-side:
{
"filters": { "customerId": { "value": "cust_abc123", "operator": "eq" } },
"sortBy": "dueDate",
"sortOrder": "asc",
"limit": 25
}
sortByis one ofinvoiceDate,dueDate,amount,balance,invoiceStatus, orinvoiceId.balanceisamount − amountPaid: it orders likeamountDue, except for invoices with an ACH payment still in flight.sortOrderisasc(the default) ordesc. Sending it withoutsortByreturns a400.- Ties are broken by invoice id, and invoices missing the sort field come last in either direction, so the order is fully deterministic.
- Page with
nextCursorexactly as above. The cursor carries the sort, so passing it back with a differentsortByorsortOrderreturns a400. Keep the sort fixed for a whole traversal. - Sort on a value that can change mid-traversal at your own risk. Each page
re-reads the live set and resumes after the last row you received. If an
invoice's sort value changes between your requests (a payment changes its
balance, the overdue sweep changes itsinvoiceStatus), it's placed by its new value: it can show up twice or be skipped. Deduping byidremoves a repeat but cannot recover a skipped row; page quickly to shrink the window, and re-read in creation order (omitsortBy) when you need every row. - Sorting requires the customer scope.
sortByon a company-wide search (nocustomerIdfilter, or more than 100 customers) returns a400. Company-wide results always come back in creation order, newest first, because sorting them would mean reading the entire account on every page. - A sorted search reads the scoped customers' whole invoice set on every page, so a
scope holding more than 10,000 invoices (counted before your other filters, since
they don't reduce the read) returns a
400asking you to narrow the customer set, or to omitsortBy. - While sorting is rolling out, a sorted search on a large account can also return a
400saying sorting is not available for this account yet. OmitsortByand sort the results yourself; the same search withoutsortByalways works.
Filtering by updatedAt
Use updatedAt to pull only the invoices that changed since you last looked,
instead of re-reading the full list.
curl -X POST https://dev.payconnect.us/api/invoices/search \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"filters": {
"updatedAt": { "value": [1757462400000, 1757548800000], "operator": "between" }
}
}'
Timestamps compare exactly; date-only strings cover a whole day. A number
(epoch milliseconds) or a full datetime string such as 2026-09-10T14:30:00.000Z
is matched to the millisecond. A date-only string such as 2026-09-10 covers
that entire UTC day — with gte/lt anchored to the start of the day and
gt/lte to its end, so the day you name is always inside the range you asked
for. A datetime with no offset is read as UTC, so include one (Z or +02:00)
if you mean another zone. A value that cannot be parsed as a date returns a 400
rather than quietly returning everything. This is deliberately unlike createdAt,
which day-expands both ends of between.
Give between its bounds in order. [start, end] with start no later
than end; a reversed range returns a 400 rather than an empty page, so a
swapped cursor shows up as an error instead of looking like "nothing changed".
What counts as an update. Any write bumps updatedAt, including mechanical
ones — an email delivery event, the overdue sweep, a settlement postback.
Delivery is at-least-once: a retried payment postback legitimately re-bumps the
timestamp and the invoice appears again, so dedupe by id.
This is a polled read, not a change feed. Poll a closed interval —
between with both bounds in the past — rather than an open gte against "now":
an invoice modified while the response is being assembled would otherwise be
missed by the next poll. Read every match by paging nextCursor until it is
null (see Paginating (limit / cursor) above)
— that is the completion signal; a single limit-sized response is not the full
set. truncated does not cover this — it never reflects the invoice set
(today it is always false on this endpoint) — so never treat truncated: false
as "I have every invoice"; follow nextCursor to null instead.
Cursor pagination reads to completion. The older scan-bound behavior — where a large company's
updatedAtpoll came backtruncated: trueon every call, no matter how narrow the interval — no longer applies. The keyset cursor pages the full result set of any size: pagenextCursortonulland you have every matching invoice, regardless of company size.
Summarize a Customer's Invoices
POST /api/invoices/summary returns invoice counts and totals for one
customer or a small group of customers, in one request. Use it for dashboards and
badges ("4 invoices", "3 unpaid · $450 due") instead of paging a whole list just
to count it. Search responses deliberately carry no totals; this is where they
come from.
The request takes the same typed filters as Search Invoices,
and must include a customer scope: customerId with eq (one id) or in (up
to 100 ids). Any other filters narrow the summary exactly as they narrow a
search:
{
"filters": {
"customerId": { "value": "cust_abc123", "operator": "eq" },
"dueDate": { "value": "2026-12-31", "operator": "lte" }
}
}
Response (HTTP 201):
{
"byStatus": [
{ "invoiceStatus": "Pending", "count": 2, "amount": 300, "amountDue": 300 },
{ "invoiceStatus": "Paid", "count": 1, "amount": 75, "amountDue": 0 },
{ "invoiceStatus": "Voided", "count": 1, "amount": 100, "amountDue": 100 }
],
"total": { "count": 4, "amount": 475, "amountDue": 300 }
}
| Field | Description |
|---|---|
byStatus | One bucket per invoice status that has at least one matching invoice, in a fixed status order. Empty when nothing matches. |
byStatus[].count | Number of matching invoices in that status. |
byStatus[].amount | Sum of the invoices' face amount. |
byStatus[].amountDue | Sum of the invoices' amountDue, calculated exactly as each invoice's own amountDue (amount − amountPaid − any ACH payment still settling), so it matches what the invoices show. |
total.count, total.amount | Totals across every status. |
total.amountDue | Money still owed: the amountDue of the outstanding statuses only (Pending, Partial, Overdue, Failed, ACHPending). Closed invoices (Paid, Voided, Cancelled, Refunded) never count as due, even though their own buckets report their invoice values. |
Amounts are in the same units as an invoice's amount, rounded to cents.
- A request without a customer scope, or with more than 100 customers,
returns a
400. The endpoint never aggregates a whole account. - The summary reads every invoice of the customers in scope, so a scope holding
more than 10,000 invoices (counted before your other filters) returns a
400asking you to narrow the customer set. - While this feature is rolling out, a summary on a large account can return a
400saying summaries are not available for this account yet. Count by paging Search Invoices instead. - Under a sustained burst of traffic it may return
429with aRetry-Afterheader: back off for that many seconds and retry the same request.
Update an Invoice
Update the description, amount, due date, or line items on an unpaid invoice:
curl -X PUT https://dev.payconnect.us/api/invoices/3f9a1c2e-8b4d-4a6f-9e01-2c7d5b8a1f34 \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "x-correlation-id: YOUR_CORRELATION_ID" \
-H "Content-Type: application/json" \
-d '{
"description": "Consulting services - Updated",
"amount": 200,
"dueDate": "2026-04-15",
"items": [],
"paymentMethods": ["cc", "ach"]
}'
Send Invoice Email
Trigger an invoice email to one or more recipients:
curl -X POST https://dev.payconnect.us/api/invoices/3f9a1c2e-8b4d-4a6f-9e01-2c7d5b8a1f34/send-email \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "x-correlation-id: YOUR_CORRELATION_ID" \
-H "Content-Type: application/json" \
-d '{
"recipients": ["[email protected]", "[email protected]"]
}'
Response:
{
"invoiceId": "INV-2026-0042",
"recipients": ["[email protected]", "[email protected]"],
"status": "sent"
}
Invoice Statuses
| Status | Description |
|---|---|
Pending | Invoice created, awaiting payment |
Paid | Full payment received |
Partial | Partial payment received |
ACHPending | ACH payment initiated, awaiting settlement |
Overdue | Payment past due date |
Voided | Invoice voided by merchant |
Cancelled | Invoice cancelled |
Failed | Payment attempt failed |
Refunded | Payment was refunded |
Invoice Lifecycle
Created → Pending → Paid
→ Partial → Paid
→ ACHPending → Paid / Failed
→ Overdue → Paid
→ Voided
→ Cancelled
→ Failed → Pending (retry)
→ Refunded
When an invoice is created with sendEmail: true (the default), PayConnect automatically sends a payment email to the customer with a link to the hosted payment page. If reminders is enabled, follow-up reminders are sent as the due date approaches.
Quote the Surcharge for a Saved Payment Method
If your own payment screen charges a customer's saved card through PUT /api/invoices/{invoiceId}/payment, show the payer the surcharge PayConnect will actually charge before you do. The flat percentage in the company's fee settings is a ceiling, not the charge: PayConnect never surcharges a bank account, a debit or prepaid card, an American Express card, a payer or merchant in a state that bans surcharges, or a card whose type it cannot read, and some states cap the rate below the configured one.
POST /api/invoices/{invoiceId}/surcharge-quote
x-session-token: <token>
Content-Type: application/json
{
"accountVaultId": "11ef0f5a-…",
"amount": 125.00
}
{
"subtotal": 125,
"surcharge": 3.75,
"total": 128.75,
"percentage": 3,
"label": "Credit Card Processing Fee",
"paymentMethod": "cc",
"cardBrand": "visa",
"lastFour": "4242"
}
Nothing is charged or stored. The quote reads the same card details the charge reads and applies the same rules, so PUT /api/invoices/{invoiceId}/payment with the same accountVaultId and amount charges exactly total, unless the company's fee settings change in between. When no surcharge applies, surcharge is 0 and blockedReason says why: ach, debit_card, prepaid_card, amex, state_outright_ban, state_cap_at_cost_unsupported, unknown_card_type, unknown_billing_state, or company_disabled.
An amount over the remaining balance, a method on a rail the invoice does not accept, or a vault the processor does not have answers 400. A processor outage answers 503; retry it.
The saved method does not have to belong to the invoice's customer. Your company may pay any of its invoices with any of its customers' saved methods (an office paying its agents' invoices with the office's card); the quote and the charge then use that card's own type, so a debit card is not surcharged whatever the invoice's customer has on file. On the hosted payment page, where the payer is the customer, only the customer's own saved methods are accepted.
Charge what you quoted. Send the quote's surcharge back as quotedSurcharge on PUT /api/invoices/{invoiceId}/payment. The charge then goes ahead only if the surcharge PayConnect would charge is still exactly that; if it has moved (the company changed its fee settings, or the card's type became known after a unknown_card_type quote), the payment answers 400 with reasonCode: "QUOTE_CHANGED" and nothing is charged. Quote again, show the new total, and retry.
{
"accountVaultId": "11ef0f5a-…",
"savePaymentMethod": true,
"amount": 125.00,
"quotedSurcharge": 3.75
}
Quote and Charge a New Card
To charge a card the customer is entering for the first time on your own screen, don't fold a fee into a sale intention: the processor then charges that total before PayConnect sees the card, untagged as a surcharge and with none of the debit, prepaid, Amex or state rules applied. Capture the card as a ticket, quote it, show the payer the exact total, then confirm. For a saved card, see the saved-card quote above.
Capture the card with POST /api/payment/ticket/intention (the ticket only captures the card; nothing is charged), then quote it against the invoice:
POST /api/invoices/{invoiceId}/quote
x-session-token: <token>
Content-Type: application/json
{
"ticketId": "11ef0f5a-…",
"billingZip": "75201",
"amount": 125.00
}
The response is the same shape as the saved-card quote plus a quoteId and expiresAt: PayConnect checks the card, reads its type, and returns the exact surcharge and total. The quote is valid for 15 minutes. A failed card check (for example a billing ZIP mismatch) answers 400.
Once the payer has seen the total, charge it:
POST /api/invoices/{invoiceId}/quote/confirm
x-session-token: <token>
Content-Type: application/json
{
"quoteId": "7f3c…",
"savePaymentMethod": true
}
The card is charged exactly the quoted total, with the surcharge tagged as such on the processor transaction, and the response is the same as PUT /api/invoices/{invoiceId}/payment. The confirm takes no amount or fee: the quote decides both. A 400 carries a reasonCode: a number for a processor decline, "QUOTE_CHANGED" when the surcharge the rules give has moved since the quote (nothing is charged; quote again), or "OUTCOME_UNKNOWN" when the outcome did not arrive in time (it may have gone through; re-read the invoice before charging again). An expired or already-used quote answers 400, and an invoice settled since the quote answers 400 with reasonCode: "ALREADY_PROCESSING". A 503 carries a reasonCode too: "NOT_CHARGED" when a read or the publish failed before any charge was submitted (nothing was charged; confirm again, and if that answers 400 "This quote has expired" the failed publish had already settled the claimed quote and its card, so quote again), "OUTCOME_UNKNOWN" when the outcome could not be read after the charge was submitted (re-read the invoice before charging again). The card is deleted after the charge unless savePaymentMethod is true (always kept on a subscription's invoice); an unconfirmed quote expires and the captured card is deleted. A pending subscription's signup invoice cannot be charged here (see below).
For a subscription signup, quote the subscription's pending signup invoice (pendingInvoiceId from GET /api/subscriptions/v2/authenticated-details) and confirm with POST /api/subscriptions/v2/payment-method and quoteId instead: that activates the subscription and keeps the card for renewals. The invoice confirm refuses a subscription-bound session for that reason.
Hosted Payment Page
Each invoice generates a hosted payment page where customers pay securely. The page:
- Is PCI-compliant — card data never touches your servers
- Supports credit card and ACH payment methods (configurable per invoice)
- Displays your company branding
- Shows invoice details including line items and amount due
- Handles partial payments when applicable
See the Hosted Payment Pages guide for more details on the payment flow.
Next Steps
- Customers — manage customer records and stored payment methods
- Postbacks — receive real-time payment notifications
- API Reference — full endpoint documentation