Customers

A customer is the persistent record of someone who can pay you — the address book that sits under every charge, subscription, and invoice in your workspace. Guest checkouts work without one; the moment you want a second charge, a stored payment method, or a clean receipt history under a single identity, you reach for a customer. The Go SDK exposes the four customer endpoints behind client.Customers; every method takes context.Context first and returns either (*plugipay.Customer, error) or (plugipay.Page[plugipay.Customer], error). For wire shapes and the full field table, see API → Customers.

Field on the Client

client.Customers — type *plugipay.CustomersResource. The namespace is installed on the *Client returned by NewClient; it shares the same *http.Client, base URL, key, and OnBehalfOf default as the parent. There is no per-namespace state — all methods are safe to call concurrently from many goroutines.

Methods

Create

Signature. func (r *CustomersResource) Create(ctx context.Context, in CustomerCreateInput) (*Customer, error)

Creates a customer in the workspace this key belongs to. Every field on CustomerCreateInput is optional — a zero-value CustomerCreateInput{} is a legal call and returns a customer with only ID and timestamps populated, which you can fill in later via Update. The SDK auto-mints an Idempotency-Key for you, so retries against transient network failures don't create duplicates.

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

email := "alice@example.com"
name := "Alice Tan"
extID := "user_19823"

cust, err := client.Customers.Create(ctx, plugipay.CustomerCreateInput{
    Email:      &email,
    Name:       &name,
    ExternalID: &extID,
    Metadata:   map[string]string{"segment": "enterprise"},
})
if err != nil {
    return err
}
log.Printf("created %s", cust.ID) // "cus_01HXAB7K3M9N2P5QRS8TVWXY3Z"

ExternalID is unique per workspace. If you re-use one that already exists, the server returns 409 conflict and the SDK surfaces *plugipay.Error{Code: "conflict"}. The find-or-create pattern below handles it cleanly.

Get

Signature. func (r *CustomersResource) Get(ctx context.Context, id string) (*Customer, error)

Fetches one customer by id. Returns *plugipay.Error{Status: 404, Code: "not_found"} for both missing-and cross-workspace ids — the server returns 404 (not 403) to avoid leaking existence.

cust, err := client.Customers.Get(ctx, "cus_01HXAB7K3M9N2P5QRS8TVWXY3Z")
if err != nil {
    var pe *plugipay.Error
    if errors.As(err, &pe) && pe.Code == "not_found" {
        return nil // treat as absent
    }
    return err
}
fmt.Println(cust.Email)

Get is naturally idempotent — safe to retry on any transient failure.

List

Signature. func (r *CustomersResource) List(ctx context.Context, params CustomerListParams) (Page[Customer], error)

Cursor-paginated. Limit is 1–100 (default 25), Cursor is opaque from a previous page, and Email filters by exact match. See Pagination for the cursor model — it's identical across every list method on the SDK.

limit := 50

page, err := client.Customers.List(ctx, plugipay.CustomerListParams{
    Limit: &limit,
})
if err != nil {
    return err
}
for _, c := range page.Data {
    fmt.Println(c.ID, deref(c.Email))
}
if page.HasMore {
    next, err := client.Customers.List(ctx, plugipay.CustomerListParams{
        Limit:  &limit,
        Cursor: page.Cursor,
    })
    _ = next
    _ = err
}

Filtering by email:

email := "alice@example.com"
page, err := client.Customers.List(ctx, plugipay.CustomerListParams{Email: &email})

Email is not server-side unique — you may legitimately have two customers with the same address. Treat the result as a list, not a single value.

Update

Signature. func (r *CustomersResource) Update(ctx context.Context, id string, patch CustomerUpdateInput) (*Customer, error)

PATCH semantics — only the fields you pass are touched. To "clear" a nullable field, pass a pointer to the empty string (the API treats "" as null on these columns). Update is not auto-idempotency-keyed (the operation is naturally safe to repeat); if you have a transactional source of truth and want belt-and-suspenders, drop to client.Do(...) with your own key.

newName := "Alice T. Wijaya"
newPhone := "+62811xxxxxxxx"

cust, err := client.Customers.Update(ctx, id, plugipay.CustomerUpdateInput{
    Name:  &newName,
    Phone: &newPhone,
})

The SDK does not surface Metadata on CustomerUpdateInput — the underlying endpoint treats metadata mutations as a separate concern. To touch metadata, use client.Do(...) directly.

Types

The returned shape:

type Customer struct {
    ID         string  `json:"id"`         // "cus_..."
    AccountID  string  `json:"accountId"`  // workspace
    Email      *string `json:"email"`
    Name       *string `json:"name"`
    Phone      *string `json:"phone"`
    ExternalID *string `json:"externalId"`
    CreatedAt  string  `json:"createdAt"`  // ISO-8601
    UpdatedAt  string  `json:"updatedAt"`
}

Pointer fields are nullable on the wire. Use a small helper if you find yourself dereferencing them often:

func deref(s *string) string {
    if s == nil { return "" }
    return *s
}

For per-field validation rules (length limits, email format), see API → Customers.

Common patterns

Find-or-create by external ID

Your CRM is the source of truth; Plugipay's ExternalID is a foreign key into it. The robust "find or create" uses the 409 conflict path:

func ensureCustomer(ctx context.Context, c *plugipay.Client, extID, email string) (*plugipay.Customer, error) {
    cust, err := c.Customers.Create(ctx, plugipay.CustomerCreateInput{
        ExternalID: &extID,
        Email:      &email,
    })
    if err == nil {
        return cust, nil
    }
    var pe *plugipay.Error
    if errors.As(err, &pe) && pe.Code == "conflict" {
        // Already exists — cache miss on our side. Store the cus_ id
        // when we first create the customer; this branch should be rare.
        return nil, fmt.Errorf("cache miss for externalId=%s", extID)
    }
    return nil, err
}

In practice, store the resulting cus_* id on your side at create-time, keyed by your own user id.

Walking the entire customer book

func allCustomers(ctx context.Context, c *plugipay.Client) ([]plugipay.Customer, error) {
    var out []plugipay.Customer
    limit := 100
    var cursor *string
    for {
        page, err := c.Customers.List(ctx, plugipay.CustomerListParams{
            Limit:  &limit,
            Cursor: cursor,
        })
        if err != nil {
            return out, err
        }
        out = append(out, page.Data...)
        if !page.HasMore {
            return out, nil
        }
        cursor = page.Cursor
    }
}

This is the canonical Go cursor loop: copy Page[T].Cursor back into the next call, break when HasMore is false. Wrap in your own retry helper if the dataset is large enough to hit a transient blip.

Cancellation-aware lookup

context.Context is honored on every call:

ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()
cust, err := client.Customers.Get(ctx, id) // returns timeout if slow

If the parent context is canceled, the SDK aborts the in-flight request and returns *plugipay.Error{Code: "canceled"} (transport status 0).

Errors

The error codes you'll see most often on this namespace:

Code Status Cause
validation_error 400 Bad email format, name too long, unknown field.
conflict 409 Duplicate ExternalID in this workspace.
not_found 404 Customer id doesn't exist or lives in another workspace.
insufficient_scope 403 Key lacks plugipay:customer:create (or read/update).

Branch with errors.As:

var pe *plugipay.Error
if errors.As(err, &pe) {
    switch pe.Code {
    case "conflict":      /* dup externalId */
    case "not_found":     /* missing id */
    case "validation_error":
        log.Printf("bad input: %s (requestId=%s)", pe.Message, pe.RequestID)
    }
}

Full mechanics: Errors. Full code catalog: API → Errors.

Next

Plugipay — Payments that don't tax your success