Customers
A customer is the persistent record of someone who can pay you — the address book that sits under every payment, subscription, and invoice in your workspace. Guest checkouts work without one, but the moment you want a second charge, a stored card, or a clean receipt history hanging off one identity, you want a customer object. See Concepts → Customer for the data model and Portal → Customers for the dashboard equivalent of these endpoints.
All requests on this page must be signed — see Authentication for the HMAC recipe — and follow the response envelope, ID, and money conventions in Conventions.
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/customers |
Create a customer |
GET |
/v1/customers |
List customers |
GET |
/v1/customers/:id |
Retrieve one customer |
PATCH |
/v1/customers/:id |
Update a customer |
Delete is not currently exposed via the API. See Delete (or archive) for the reasoning and the supported workaround.
Create a customer
POST /v1/customers
Creates a customer in the workspace the API key belongs to (or the workspace named by X-Plugipay-On-Behalf-Of, if you're a platform admin). All fields are optional — a bare {} body returns a customer with just an id and timestamps; you add data via PATCH later.
An Idempotency-Key header is required — see Idempotency.
| Required scope |
|---|
plugipay:customer:create |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
externalId |
string (1–255) | no | Your own ID for this person — e.g. your CRM's user_id. Unique per workspace; a duplicate fails with 409 Conflict. |
email |
string (RFC 5321, ≤320) | no | Primary contact email. Used on receipts. Not enforced unique — see Email uniqueness. |
name |
string (1–255) | no | Display name on receipts and the dashboard. |
phone |
string (1–64) | no | Phone number. Used by some providers (OVO, GoPay) for account lookup. Free-form — we don't normalize. |
taxId |
string (1–64) | no | NPWP / VAT number. Appears on invoices when set. |
defaultPaymentTokenId |
string (pt_…) |
no | Stored payment token to charge by default. Must exist in the same workspace; we don't validate ownership at create time — an invalid value won't fail the create but will fail later at charge time. |
metadata |
object | no | Up to 50 string–to–string entries (keys ≤40 chars, values ≤500 chars). Pass null to clear. |
Unknown fields are rejected with 400 VALIDATION_ERROR. Don't try to set id, accountId, arn, createdAt, etc. — those are server-assigned.
Response — 201 Created
{
"data": {
"id": "cus_01HXAB7K3M9N2P5QRS8TVWXY3Z",
"arn": "arn:plugipay:acc_01HX...:customer/cus_01HXAB7K3M9N2P5QRS8TVWXY3Z",
"accountId": "acc_01HXxxxxxxxxxxxxxxxxxxxxxx",
"externalId": "user_19823",
"email": "alice@example.com",
"name": "Alice Tan",
"phone": "+62811234567",
"taxId": null,
"defaultPaymentTokenId": null,
"metadata": { "segment": "enterprise" },
"createdAt": "2026-05-12T10:42:00.123Z",
"updatedAt": "2026-05-12T10:42:00.123Z"
},
"error": null,
"meta": {
"requestId": "req_01HX...",
"timestamp": "2026-05-12T10:42:00.124Z"
}
}
Errors specific to this endpoint
| Status | error.code |
When |
|---|---|---|
400 |
VALIDATION_ERROR |
Field shape wrong, unknown field present, email not RFC–valid, defaultPaymentTokenId doesn't start with pt_. |
409 |
IDEMPOTENCY_KEY_REUSED |
Same Idempotency-Key used with a different body. See Idempotency. |
409 |
CONFLICT |
externalId already exists in this workspace. Message: externalId already exists for this account. |
403 |
INSUFFICIENT_SCOPE |
Key lacks plugipay:customer:create. |
Other 4xx/5xx errors follow the standard table in Errors.
Examples
// Node
import { Plugipay } from '@plugipay/sdk';
const pp = new Plugipay({ keyId: process.env.PLUGIPAY_KEY_ID, secret: process.env.PLUGIPAY_KEY_SECRET });
const customer = await pp.customers.create({
email: 'alice@example.com',
name: 'Alice Tan',
externalId: 'user_19823',
metadata: { segment: 'enterprise' },
});
console.log(customer.id); // cus_01HX...
# Python
from plugipay import Plugipay
pp = Plugipay(key_id=os.environ['PLUGIPAY_KEY_ID'], secret=os.environ['PLUGIPAY_KEY_SECRET'])
customer = pp.customers.create(
email='alice@example.com',
name='Alice Tan',
external_id='user_19823',
metadata={'segment': 'enterprise'},
)
print(customer.id) # cus_01HX...
// Go
import plugipay "github.com/hachimi-cat/saas-plugipay/sdk/go"
client := plugipay.New(os.Getenv("PLUGIPAY_KEY_ID"), os.Getenv("PLUGIPAY_KEY_SECRET"))
customer, err := client.Customers.Create(ctx, &plugipay.CustomerCreateParams{
Email: plugipay.String("alice@example.com"),
Name: plugipay.String("Alice Tan"),
ExternalID: plugipay.String("user_19823"),
Metadata: map[string]string{"segment": "enterprise"},
})
# curl (assumes the plugipay_curl helper from /docs/api/authentication)
plugipay_curl POST '/v1/customers' \
'{"email":"alice@example.com","name":"Alice Tan","externalId":"user_19823"}'
Retrieve a customer
GET /v1/customers/:id
Returns the customer with the given id. The id must include the cus_ prefix.
| Required scope |
|---|
plugipay:customer:read |
Path parameters
| Param | Type | Description |
|---|---|---|
id |
string (cus_…) |
The customer ID. 30 characters total (4-char prefix + 26-char ULID). |
Query parameters
| Param | Type | Description |
|---|---|---|
expand |
string | Comma-separated list of related objects to inline. Supported: defaultPaymentToken. See Expansion. |
Response — 200 OK
The same shape as the customer object.
Errors
| Status | error.code |
When |
|---|---|---|
404 |
NOT_FOUND |
The customer doesn't exist, or exists in a different workspace. Plugipay returns 404 (not 403) for cross-workspace IDs to avoid leaking their existence. |
// Node
const customer = await pp.customers.get('cus_01HXAB7K3M9N2P5QRS8TVWXY3Z');
# Python
customer = pp.customers.retrieve('cus_01HXAB7K3M9N2P5QRS8TVWXY3Z')
// Go
customer, err := client.Customers.Get(ctx, "cus_01HXAB7K3M9N2P5QRS8TVWXY3Z")
# curl
plugipay_curl GET '/v1/customers/cus_01HXAB7K3M9N2P5QRS8TVWXY3Z'
List customers
GET /v1/customers
Returns customers in the workspace, newest first by default. Cursor-paginated.
| Required scope |
|---|
plugipay:customer:read |
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
limit |
integer | 20 |
Page size. Clamped to [1, 100]. |
cursor |
string | — | Opaque cursor returned in meta.page.nextCursor of a previous response. |
order |
asc | desc |
desc |
Sort by createdAt. desc returns newest first. |
email |
string | — | Exact match on email. Case-sensitive on the wire. Useful for "does this person exist?" checks before creating. |
externalId |
string | — | Exact match on externalId. Combined with the per-workspace uniqueness guarantee, this is the safe dedupe lookup. |
createdAfter |
ISO 8601 or epoch seconds | — | Only customers created strictly after this time. |
There is no partial-match or substring filter on name or email. If you need fuzzy search, pull pages and filter client-side, or wait for the search endpoint on the backlog.
externalIdis the right dedupe key. It's unique per workspace and enforced at insert time. Use it as the primary lookup before creating;
Response — 200 OK
{
"data": [
{
"id": "cus_01HXAB7K3M9N2P5QRS8TVWXY3Z",
"email": "alice@example.com",
"name": "Alice Tan",
"externalId": "user_19823",
"phone": null,
"taxId": null,
"defaultPaymentTokenId": null,
"metadata": {},
"createdAt": "2026-05-12T10:42:00.123Z",
"updatedAt": "2026-05-12T10:42:00.123Z",
"arn": "arn:plugipay:acc_01HX...:customer/cus_01HXAB7K3M9N2P5QRS8TVWXY3Z",
"accountId": "acc_01HXxxxxxxxxxxxxxxxxxxxxxx"
}
],
"error": null,
"meta": {
"requestId": "req_01HX...",
"timestamp": "2026-05-12T10:42:00.124Z",
"page": {
"limit": 20,
"hasMore": true,
"nextCursor": "cur_eyJjcmVhdGVkQXQiOiIyMDI2LTA1LTEy..."
}
}
}
See Pagination for the cursor protocol.
// Node — paginate to end
let cursor;
do {
const { data, meta } = await pp.customers.list({ limit: 100, cursor });
for (const c of data) handle(c);
cursor = meta.page.nextCursor;
} while (cursor);
# Python — auto-paginator
for customer in pp.customers.list(limit=100).auto_paging_iter():
handle(customer)
// Go
iter := client.Customers.List(ctx, &plugipay.CustomerListParams{Limit: plugipay.Int(100)})
for iter.Next() {
handle(iter.Current())
}
if err := iter.Err(); err != nil { return err }
# curl — first page filtered by email
plugipay_curl GET '/v1/customers?limit=50&email=alice@example.com'
Update a customer
PATCH /v1/customers/:id
Partial update — send only the fields you want to change. Omitted fields are left untouched. To clear a field, send it as null (see Null vs missing fields).
An Idempotency-Key header is required so retries are safe.
| Required scope |
|---|
plugipay:customer:write |
Path parameters
| Param | Type | Description |
|---|---|---|
id |
string (cus_…) |
The customer to update. |
Request body — the same shape as create, all fields optional:
| Field | Type | Description |
|---|---|---|
externalId |
string | Reassign your external ID. Still unique per workspace. |
email |
string | Update the primary email. |
name |
string | Update the display name. |
phone |
string | Update the phone. |
taxId |
string | Update or set the tax ID. |
defaultPaymentTokenId |
string (pt_…) |
Change the default token. |
metadata |
object | null |
Replace the metadata map. Sending null clears it. Sending {} also clears. To remove one key, send the full map without that key — we don't support deep merge. |
Response — 200 OK. The full updated customer object.
Errors
| Status | error.code |
When |
|---|---|---|
400 |
VALIDATION_ERROR |
Field shape wrong or unknown field. |
404 |
NOT_FOUND |
Customer doesn't exist or is in another workspace. |
409 |
CONFLICT |
New externalId collides with an existing customer. |
409 |
IDEMPOTENCY_KEY_REUSED |
Same Idempotency-Key with a different body. |
// Node
const updated = await pp.customers.update('cus_01HXAB7K3M9N2P5QRS8TVWXY3Z', {
name: 'Alice Tan (Acme)',
phone: null, // clears the phone
});
# Python
updated = pp.customers.update(
'cus_01HXAB7K3M9N2P5QRS8TVWXY3Z',
name='Alice Tan (Acme)',
phone=None, # clears the phone
)
// Go
updated, err := client.Customers.Update(ctx, "cus_01HXAB7K3M9N2P5QRS8TVWXY3Z", &plugipay.CustomerUpdateParams{
Name: plugipay.String("Alice Tan (Acme)"),
Phone: plugipay.Null[string](),
})
# curl
plugipay_curl PATCH '/v1/customers/cus_01HXAB7K3M9N2P5QRS8TVWXY3Z' \
'{"name":"Alice Tan (Acme)","phone":null}'
Delete (or archive)
Not currently exposed via the API. There is no DELETE /v1/customers/:id — hard delete would cascade dangerously through payments, invoices, refunds, and ledger entries.
Workarounds:
- Soft-archive. PATCH
metadata.archived = "true"and filter client-side. - GDPR / erasure. PATCH to clear
email,name,phone, and identifyingmetadata, then email support@plugipay.com with the customer ID — our redaction job scrubs the same fields from associated payments, invoices, and stored webhook payloads. The record stays as a tombstone to keep historical payments queryable, but carries no personal data afterwards.
Merging two customers isn't supported — payments stay on whichever customer they were created against; we don't reparent them.
The customer object
| Field | Type | Nullable | Description | Example |
|---|---|---|---|---|
id |
string | no | Plugipay ID. Always cus_ + 26-char ULID. Stable forever. |
cus_01HXAB7K3M9N2P5QRS8TVWXY3Z |
arn |
string | no | Fully-qualified Forjio resource name. Useful for cross-service references. | arn:plugipay:acc_01HX...:customer/cus_01HX... |
accountId |
string | no | The workspace this customer belongs to. Always acc_ + ULID. |
acc_01HXxxxxxxxxxxxxxxxxxxxxxx |
externalId |
string | yes | Your own ID for this person. Unique per workspace. | user_19823 |
email |
string | yes | Primary contact email. Not unique — see pitfalls. | alice@example.com |
name |
string | yes | Display name. | Alice Tan |
phone |
string | yes | Free-form phone string. We don't normalize. | +62811234567 |
taxId |
string | yes | NPWP / VAT identifier. Appears on invoices when set. | 01.234.567.8-901.000 |
defaultPaymentTokenId |
string | yes | The stored payment token to charge by default. Always pt_…. |
pt_01HXxxxxxxxxxxxxxxxxxxxxxx |
metadata |
object | yes | Up to 50 string–to–string entries. Pass-through to webhooks. | { "segment": "enterprise" } |
createdAt |
string (ISO 8601 UTC) | no | When the customer was created. | 2026-05-12T10:42:00.123Z |
updatedAt |
string (ISO 8601 UTC) | no | Last mutation timestamp. | 2026-05-12T11:03:14.456Z |
The same shape comes back from every endpoint on this page, from auto-creation during checkout, and inside webhook payloads.
Email uniqueness
Plugipay does not enforce email uniqueness. You can have two customers with the same email — some businesses serve multiple legal entities through one inbox. Treat email as a soft hint, never as a key. Use externalId (which we do enforce unique per workspace) for dedupe.
To enforce uniqueness yourself, GET /v1/customers?email=… before POST /v1/customers and skip the create on match.
Events
State changes on a customer emit outbox events that fire to your registered webhook endpoints. The payload's data field is the full customer object as above.
| Event type | Fires on | Notes |
|---|---|---|
plugipay.customer.created.v1 |
POST /v1/customers succeeds, or a checkout auto-creates one. |
Emitted in the same transaction as the create, delivered at-least-once. |
customer.updated and customer.archived are on the roadmap but not currently emitted. If you need to mirror customer mutations into another system today, poll GET /v1/customers?createdAfter=… and reconcile by updatedAt, or subscribe to the downstream events (payment.succeeded, invoice.paid) that already carry the customer object inline.
See Webhooks for the event envelope, retry policy, and signature recipe.
Next
- Plans and Prices — what a customer pays for on a schedule.
- Checkout sessions — how a customer pays you the first time.
- Payments — one-off charges tied to a customer.
- Subscriptions — recurring billing tied to a customer.
- Idempotency — required on
POSTandPATCH. - Errors — the full error code catalog.