Skip to main content

Customers

Create and manage customer records, store payment methods securely, and look up customer details through the PayConnect API.

Overview​

Customers in PayConnect represent the people or businesses you bill. Each customer can have:

  • Contact information — name, email, phone, address
  • Stored payment methods — tokenized credit cards and bank accounts (account vaults)
  • Transaction history — all payments associated with the customer
  • Invoices — invoices sent to the customer
  • Subscriptions — active recurring billing

Create a Customer​

curl -X POST https://dev.payconnect.us/api/customers \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "x-correlation-id: YOUR_CORRELATION_ID" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"firstName": "Jane",
"lastName": "Smith",
"companyName": "Acme Corp",
"phoneNumber": "555-123-4567",
"address": "123 Main St",
"city": "Austin",
"state": "TX",
"zip": "78701"
}'

Request Fields​

FieldTypeRequiredDescription
emailstringYesCustomer email address
firstNamestringNoFirst name
lastNamestringNoLast name
companyNamestringNoCompany or organization name
phoneNumberstringNoPhone number
addressstringNoStreet address (line 1)
address2stringNoSecond address line (suite/unit). Informational only — shown on invoices, never forwarded to the payment provider.
citystringNoCity
statestringNoTwo-letter USPS code: a US state, DC, a territory (PR, VI, GU, AS, MP) or a military code (AA, AE, AP), e.g. TX, PR. Validated — a non-empty value that isn't a recognized code returns 400; an empty string is treated as not provided.
zipstringNoZIP/postal code
externalUserIdstringNoYour own identifier for this customer (e.g. their record ID in your CRM). Set it here to tie a PayConnect customer to your system, then look them up later via the search endpoint.
customFieldsobjectNoClient-defined custom field values keyed by field key. Validated against your company's field definitions — an unknown key returns 400.
defaultPaymentOptionsarrayNoDefault accepted payment rails for this customer: ["cc"], ["ach"], or ["cc", "ach"]. Inherited by every invoice created for them. Must contain at least one rail when sent — [] is rejected. See Default payment options.
info

Customer emails are not unique within a company. A duplicate email is accepted — there is no "email already in use" error — so two customers in the same company can share an email address. Dedupe on the returned id (or on externalUserId, which is unique per company when you set it), never on email.

Get a Customer​

curl -X GET https://dev.payconnect.us/api/customers/cust_abc123 \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "x-correlation-id: YOUR_CORRELATION_ID"

Response:

{
"id": "cust_abc123",
"email": "[email protected]",
"firstName": "Jane",
"lastName": "Smith",
"companyName": "Acme Corp",
"phoneNumber": "555-123-4567",
"address": {
"address1": "123 Main St",
"city": "Austin",
"state": "TX",
"zip": "78701"
},
"externalUserId": "crm_12345",
"contactId": "ctc_9f8e7d6c",
"createdAt": "2026-05-29T12:00:00.000Z",
"updatedAt": "2026-06-04T12:00:00.000Z"
}

defaultPaymentOptions is absent on a customer with no default — it is not returned as an empty array or null. contactId is an opaque, provider-managed handle for this customer's payment profile. Use it when saving or charging saved payment methods. Treat it as a stable token — do not parse it. It is absent until a payment profile exists for the customer (i.e. until their first saved payment method). createdAt and updatedAt are ISO-8601 UTC date-time strings (see Dates & Timestamps).

Update a Customer​

curl -X PUT https://dev.payconnect.us/api/customers/cust_abc123 \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "x-correlation-id: YOUR_CORRELATION_ID" \
-H "Content-Type: application/json" \
-d '{
"companyName": "Acme Corporation",
"phoneNumber": "555-987-6543"
}'

Updates are a partial merge — only the fields you send are touched, everything else is left unchanged.

Clearing a field with null. How null behaves depends on the field:

  • Sending null clears (removes) the value for phoneNumber, address, externalUserId, defaultPaymentMethodId, and defaultPaymentOptions.
  • Sending null is ignored (treated the same as omitting the key, so the value is left unchanged) for companyName, firstName, lastName, email, and customFields.

defaultPaymentMethodId sets the customer's default saved payment method — pass a vault id that belongs to this customer to set it, or null to clear it. For example, to clear the stored phone number and address while updating the company name:

curl -X PUT https://dev.payconnect.us/api/customers/cust_abc123 \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "x-correlation-id: YOUR_CORRELATION_ID" \
-H "Content-Type: application/json" \
-d '{
"companyName": "Acme Corporation",
"phoneNumber": null,
"address": null
}'

Default Payment Options​

defaultPaymentOptions sets the payment rails that every invoice created for this customer accepts — invoices you create through this API, invoices your team creates in the dashboard, and the invoices generated by the customer's subscriptions. Use it when a customer should only ever pay one way, instead of remembering to set paymentMethods on each invoice.

curl -X PUT https://dev.payconnect.us/api/customers/cust_abc123 \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "defaultPaymentOptions": ["ach"] }'

From this point on, an invoice created for cust_abc123 without a paymentMethods field accepts ACH only, and its hosted payment page offers only the bank-transfer option. To go back to no customer default, send null:

curl -X PUT https://dev.payconnect.us/api/customers/cust_abc123 \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "defaultPaymentOptions": null }'

What it does and does not affect​

Behaviour
A new invoice with no paymentMethodsInherits the customer's rails.
A new invoice with paymentMethodsThe request wins — a per-invoice override.
A subscription-generated invoice (initial + every renewal)Inherits the customer's rails; falls back to the subscription's paymentMethods only when the customer has no default.
Invoices that already existUnchanged. The setting applies from the next invoice onward.
The customer's saved cards / bank accountsUnchanged. This field is about which rails an invoice accepts, not which payment methods are on file.
The subscription recordUnchanged. PayConnect never writes subscription.paymentMethods from a customer edit.
What the payer can actually be charged onRestricted to the invoice's rails, wherever the rail comes from: a new payment method, one already saved on the customer, a wallet, or a fortisEvent / completedTransaction you supply yourself. An ACH-only invoice cannot be paid by card even if a card intention is requested directly, a card is on file, or a card ticket is handed to the pay endpoint. Subscription renewal charges are the one exception — see Accepted payment rails.

At least one rail, always​

The field is optional, but when present it must contain at least one rail — [] returns a 400. PayConnect never puts a customer in a state where they cannot pay an invoice online. "No default" is expressed by omitting the field (or clearing it with null), never by an empty array.

A customer record written outside the API can still hold an empty array. Reads report it as stored rather than rejecting the record, and invoice creation treats it as "no default" — the same as omitting the field.

A subscription's setup page can offer more rails than its invoices accept

A subscription's own paymentMethods still drives its hosted payment-setup page, and a customer default never modifies it. So if a customer is set to ACH only while their subscription lists both rails, the setup page will show both card and bank options while the resulting invoices accept ACH only. This is expected, not a bug: the customer setting governs invoices, the subscription's own value governs its setup page.

The setup page cannot currently be narrowed to match — subscription.paymentMethods is read-only over the API. See Payment rails on subscription invoices.

Search Customers​

Find customers with typed filters. This is the same request shape used by invoice and subscription search. Results are cursor-paginated. An email filter with eq or startsWith returns customers in email order, ascending (customers with the same email are ordered stably by an internal tiebreaker); every other search returns them in a stable internal order (by customer id).

POST /api/customers/search returns 201.

curl -X POST https://dev.payconnect.us/api/customers/search \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "x-correlation-id: YOUR_CORRELATION_ID" \
-H "Content-Type: application/json" \
-d '{
"filters": {
"email": { "value": "[email protected]", "operator": "eq" }
},
"limit": 25
}'

(The older page / pageSize / sortBy / sortOrder inputs were removed; sending any of them returns a 400.)

Filterable Fields​

Each filter is an object with a value and an operator.

FieldOperatorsNotes
ideq, ineq with a single id; in with an array of up to 100 ids for a bulk lookup.
externalUserIdeqExact match on your own identifier (case-sensitive).
emaileq, contains, startsWithCompared case-insensitively on the normalized (trim + lowercase) form. eq and startsWith are indexed lookups; contains walks the account and filters.
firstNameeq, containsCase-insensitive; defaults to contains.
lastNameeq, containsCase-insensitive; defaults to contains.
companyNameeq, containsCase-insensitive; defaults to contains.
createdAteq, gt, gte, lt, lte, betweenValue is a Unix timestamp (ms) or ISO 8601 date string. eq matches the whole calendar day; between takes a [start, end] array.
updatedAteq, gt, gte, lt, lte, betweenLast-modified date — see Filtering by updatedAt below. Note these bounds compare exactly, unlike createdAt above.
customFields—Filter by client-defined custom field values, keyed by field key. Validated against your company's definitions (an unknown key returns 400).

Bulk lookup by id. Pass an array of up to 100 ids with operator: "in" to resolve many customers in a single request:

{
"filters": {
"id": { "value": ["cust_abc123", "cust_def456"], "operator": "in" }
}
}

A bulk lookup (id or externalUserId) resolves the whole supplied list in one response — it is never paginated, limit does not slice it, and nextCursor is always null. Because there is nothing to page, sending a cursor alongside one returns a 400 rather than being silently ignored.

Filters combine with AND. Every filter you supply must match. There is no OR operator — run parallel requests and merge the results client-side to compose an OR.

The request body is strict. An unknown or mis-cased key (anywhere in the body, including inside a filter) returns a 400 naming the offending key — it is never silently ignored. For example, Email (capitalized) is rejected rather than treated as email.

Filtering by updatedAt​

Use updatedAt to pull only the customers that changed since you last looked, instead of re-reading the full list.

curl -X POST https://dev.payconnect.us/api/customers/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 not how createdAt behaves on this endpoint: createdAt expands both ends of a range to whole days, which would push an exact upper bound out to 23:59:59.999 and return customers past the interval you asked for.

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 — saving a payment method, a provider contact-id backfill. Delivery is at-least-once: a retried write legitimately re-bumps the timestamp and the customer appears again, so dedupe by id.

Customers created before this filter shipped may carry a day-granular timestamp. Historically a newly created customer was stamped at UTC midnight of its creation day rather than at the moment it was written; customers created since then carry the exact write instant. A customer created before the change and never edited since therefore reports midnight of its creation day. Existing customers are not rewritten, so widen your first backfill window accordingly.

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": a customer 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 below) — that is the completion signal; a single limit-sized response is not the full set.

Cursor pagination reads to completion. The older scan-bound behavior — where a large company's poll could come back truncated: true and drop matches — no longer applies. The keyset cursor pages the full result set of any size: page nextCursor to null and you have every matching customer, regardless of company size.

Response.

{
"customers": [
{
"id": "cust_abc123",
"name": "Acme Corp",
"email": "[email protected]",
"firstName": "Jane",
"lastName": "Smith",
"companyName": "Acme Corp",
"phoneNumber": "555-123-4567",
"externalUserId": "crm_12345",
"contactId": "ctc_9f8e7d6c",
"createdAt": "2026-05-29T12:00:00.000Z",
"updatedAt": "2026-06-04T12:00:00.000Z"
}
],
"nextCursor": null,
"hasMore": false,
"truncated": false
}
FieldTypeDescription
customersarrayOne page of matching customers — email-ascending under an email eq/startsWith filter, otherwise a stable internal order.
nextCursorstring | nullOpaque token for the next page; null when the result set is complete. Pass it back verbatim as cursor.
hasMorebooleantrue exactly when nextCursor is non-null.
truncatedbooleanAlways false on this endpoint — the cursor pages the result set to completion. Retained for envelope consistency with invoice and subscription search.

Paginating (limit / cursor)​

Each request returns up to limit customers (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 — and pass it back with the same filters it was minted under: a cursor reused with the email filter added, removed, or changed returns a 400. 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.

Under a sustained burst of search traffic the endpoint may return 429 with a Retry-After header — back off for that many seconds and retry the same request (same cursor); it is a transient capacity signal, not a bad request.

Every customer is reachable. Some older customer records have no email address (for example, customers created from a payment-processor contact that had none). They are returned by every search that does not filter by email — an unfiltered list, a name filter, an updatedAt delta poll — with email omitted. They never match an email filter, because they have no email to match.

Ordering caveat — customer pagination is not insert-stable. Neither order is creation order: customers page by email (under an email filter) or by customer id (otherwise), so a customer created (or re-emailed) while you are paging that sorts before your cursor position 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 id. (This differs from subscription search, which pages in creation order and so is insert-stable.)

Payment Methods​

Payment methods are stored as account vaults — tokenized representations of credit cards and bank accounts. Card data never touches your servers.

List Payment Methods​

curl -X GET https://dev.payconnect.us/api/customers/cust_abc123/paymentmethods \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "x-correlation-id: YOUR_CORRELATION_ID"

Response:

{
"accountvaults": [
{
"id": "av_xyz789",
"paymentMethod": "cc",
"lastFour": "4242",
"firstSix": "424242",
"expDate": "1228",
"accountHolderName": "Jane Smith",
"title": "Visa ending in 4242",
"active": true,
"isDefault": true
},
{
"id": "av_abc456",
"paymentMethod": "ach",
"lastFour": "6789",
"accountHolderName": "Acme Corp",
"accountType": "checking",
"title": "Checking ending in 6789",
"active": true,
"isDefault": false
}
],
"defaultPaymentMethodId": "av_xyz789",
"page": 1,
"pageSize": 100,
"total": 2
}

Payment Method Fields​

FieldTypeDescription
idstringAccount vault ID (use this for transactions)
paymentMethodstringcc (credit card) or ach (bank account)
lastFourstringLast 4 digits of card/account number
firstSixstringFirst 6 digits (credit cards only)
expDatestringExpiration date MMYY (credit cards only)
accountHolderNamestringName on the account
accountTypestringchecking or savings (ACH only)
titlestringDisplay label
activebooleanWhether the payment method is active
isDefaultbooleanWhether this is the customer's default payment method (also surfaced as the top-level defaultPaymentMethodId)
contactIdstringOpaque, provider-managed identifier for the payment profile this saved method belongs to; used when charging the saved method. Treat as a stable token — do not parse.
binInfoobjectTranslated card-type indicator (credit/debit classification) from the BIN lookup; null for ACH or when the lookup found no indicator, absent when never populated.

Update a Payment Method​

Rename a stored payment method:

curl -X PUT https://dev.payconnect.us/api/customers/cust_abc123/paymentmethods/av_xyz789 \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "x-correlation-id: YOUR_CORRELATION_ID" \
-H "Content-Type: application/json" \
-d '{
"title": "Company Visa"
}'
info

The title field has a maximum length of 16 characters.

Delete a Payment Method​

curl -X DELETE https://dev.payconnect.us/api/customers/cust_abc123/paymentmethods/av_xyz789 \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "x-correlation-id: YOUR_CORRELATION_ID"

Response:

{
"message": "success"
}
warning

Deleting a payment method that is actively used by a subscription will cause future charges to fail. Update the subscription's payment method before deleting.

Adding Payment Methods​

New payment methods are added through hosted payment forms to maintain PCI compliance. The flow is:

  1. Create a payment intention to get a client token
  2. Render the hosted payment form using the token
  3. Customer enters card/bank details in the secure form
  4. PayConnect tokenizes the data and creates an account vault

See Hosted Payment Pages for the full integration guide.

Next Steps​