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.

Response201 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.

Response200 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.

externalId is the right dedupe key. It's unique per workspace and enforced at insert time. Use it as the primary lookup before creating; email will return multiple records and may be empty (auto-created customers from guest checkouts often start with no email).

Response200 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.

Response200 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 identifying metadata, 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

Plugipay — Payments that don't tax your success