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
- Node.js
- Python
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"
}'
const axios = require("axios");
const response = await axios.post(
"https://dev.payconnect.us/api/customers",
{
email: "[email protected]",
firstName: "Jane",
lastName: "Smith",
companyName: "Acme Corp",
phoneNumber: "555-123-4567",
address: "123 Main St",
city: "Austin",
state: "TX",
zip: "78701",
},
{
headers: {
"x-session-token": "YOUR_SESSION_TOKEN",
"x-correlation-id": "YOUR_CORRELATION_ID",
"Content-Type": "application/json",
},
}
);
console.log("Customer ID:", response.data.id);
import requests
response = requests.post(
"https://dev.payconnect.us/api/customers",
headers={
"x-session-token": "YOUR_SESSION_TOKEN",
"x-correlation-id": "YOUR_CORRELATION_ID",
"Content-Type": "application/json",
},
json={
"email": "[email protected]",
"firstName": "Jane",
"lastName": "Smith",
"companyName": "Acme Corp",
"phoneNumber": "555-123-4567",
"address": "123 Main St",
"city": "Austin",
"state": "TX",
"zip": "78701",
},
)
customer_id = response.json()["id"]
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Customer email address |
firstName | string | No | First name |
lastName | string | No | Last name |
companyName | string | No | Company or organization name |
phoneNumber | string | No | Phone number |
address | string | No | Street address (line 1) |
address2 | string | No | Second address line (suite/unit). Informational only — shown on invoices, never forwarded to the payment provider. |
city | string | No | City |
state | string | No | Two-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. |
zip | string | No | ZIP/postal code |
externalUserId | string | No | Your 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. |
customFields | object | No | Client-defined custom field values keyed by field key. Validated against your company's field definitions — an unknown key returns 400. |
defaultPaymentOptions | array | No | Default 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. |
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
nullclears (removes) the value forphoneNumber,address,externalUserId,defaultPaymentMethodId, anddefaultPaymentOptions. - Sending
nullis ignored (treated the same as omitting the key, so the value is left unchanged) forcompanyName,firstName,lastName,email, andcustomFields.
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 paymentMethods | Inherits the customer's rails. |
A new invoice with paymentMethods | The 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 exist | Unchanged. The setting applies from the next invoice onward. |
| The customer's saved cards / bank accounts | Unchanged. This field is about which rails an invoice accepts, not which payment methods are on file. |
| The subscription record | Unchanged. PayConnect never writes subscription.paymentMethods from a customer edit. |
| What the payer can actually be charged on | Restricted 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 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.
| Field | Operators | Notes |
|---|---|---|
id | eq, in | eq with a single id; in with an array of up to 100 ids for a bulk lookup. |
externalUserId | eq | Exact match on your own identifier (case-sensitive). |
email | eq, contains, startsWith | Compared case-insensitively on the normalized (trim + lowercase) form. eq and startsWith are indexed lookups; contains walks the account and filters. |
firstName | eq, contains | Case-insensitive; defaults to contains. |
lastName | eq, contains | Case-insensitive; defaults to contains. |
companyName | eq, contains | Case-insensitive; defaults to contains. |
createdAt | eq, gt, gte, lt, lte, between | Value is a Unix timestamp (ms) or ISO 8601 date string. eq matches the whole calendar day; between takes a [start, end] array. |
updatedAt | eq, gt, gte, lt, lte, between | Last-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: trueand drop matches — no longer applies. The keyset cursor pages the full result set of any size: pagenextCursortonulland 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
}
| Field | Type | Description |
|---|---|---|
customers | array | One page of matching customers — email-ascending under an email eq/startsWith filter, otherwise a stable internal order. |
nextCursor | string | null | Opaque token for the next page; null when the result set is complete. Pass it back verbatim as cursor. |
hasMore | boolean | true exactly when nextCursor is non-null. |
truncated | boolean | Always 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
| Field | Type | Description |
|---|---|---|
id | string | Account vault ID (use this for transactions) |
paymentMethod | string | cc (credit card) or ach (bank account) |
lastFour | string | Last 4 digits of card/account number |
firstSix | string | First 6 digits (credit cards only) |
expDate | string | Expiration date MMYY (credit cards only) |
accountHolderName | string | Name on the account |
accountType | string | checking or savings (ACH only) |
title | string | Display label |
active | boolean | Whether the payment method is active |
isDefault | boolean | Whether this is the customer's default payment method (also surfaced as the top-level defaultPaymentMethodId) |
contactId | string | Opaque, 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. |
binInfo | object | Translated 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"
}'
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"
}
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:
- Create a payment intention to get a client token
- Render the hosted payment form using the token
- Customer enters card/bank details in the secure form
- PayConnect tokenizes the data and creates an account vault
See Hosted Payment Pages for the full integration guide.
Next Steps
- Invoices — send payment requests to customers
- Transactions — charge stored payment methods
- Subscriptions — set up recurring billing
- API Reference — full endpoint documentation