API conventions

The shape rules every Plugipay API endpoint follows. Understanding these once means you can read any endpoint's spec without re-learning the basics.

Base URL

https://api.plugipay.com

For staging (request access if you need it):

https://api-staging.plugipay.com

Test and live are differentiated by the key, not the URL. A pk_test_* key hitting api.plugipay.com lands in test mode; a pk_live_* key hits live.

Versioning

The current API version is v1. The version is in the path:

GET /v1/customers
POST /v1/checkout-sessions

When we ship a breaking change, it'll go to /v2/. Old versions stay supported for at least 12 months after a new one ships. We've never broken /v1 and don't plan to.

Content type

All requests with bodies use:

Content-Type: application/json

All responses are JSON. Even on errors. Even on 204 No Content (which returns {"data":null,"error":null,"meta":{...}}).

The response envelope

Every successful response is wrapped:

{
  "data": { /* the actual response payload */ },
  "error": null,
  "meta": {
    "requestId": "req_01H...",
    "timestamp": "2026-05-12T10:42:00.123Z"
  }
}
Field Type Notes
data object or array The response payload. null on error.
error object or null Set on error, never on success.
meta.requestId string Unique per request. Include in support tickets.
meta.timestamp ISO 8601 When the response was generated.

List endpoints add pagination to meta:

{
  "data": [/* items */],
  "error": null,
  "meta": {
    "requestId": "req_01H...",
    "timestamp": "...",
    "page": {
      "limit": 50,
      "hasMore": true,
      "nextCursor": "cur_..."
    }
  }
}

See Pagination.

Errors keep the envelope but populate error:

{
  "data": null,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "amount must be a positive integer",
    "field": "amount"
  },
  "meta": { "requestId": "...", "timestamp": "..." }
}

See Errors.

Always include meta.requestId when you file a support ticket. It lets us pull the exact request and response from our logs in seconds.

Object IDs

Plugipay object IDs use a typed prefix + a ULID:

cus_01HXxxxxxxxxxxxxxxxxxxxxxx
sess_01HYxxxxxxxxxxxxxxxxxxxxxx
pay_01HZxxxxxxxxxxxxxxxxxxxxxx
  • The prefix (cus_, sess_, pay_, etc.) tells you the type at a glance.
  • The body is a ULID — sortable by creation time, globally unique.
  • The total is always 30 characters (4 prefix + 26 ULID).

The full prefix list is in Concepts.

When passing IDs in URLs, pass the full ID:

GET /v1/customers/cus_01HXxxxxxxxxxxxxxxxxxxxxxx

Timestamps

All timestamps are ISO 8601 strings in UTC:

"createdAt": "2026-05-12T10:42:00.123Z"

The Z suffix is always present — we don't return timezone offsets.

For filter parameters, pass either ISO 8601 or Unix epoch seconds:

GET /v1/payments?since=2026-05-12T00:00:00Z
GET /v1/payments?since=1715472000

Both work; the response field is always the ISO form.

Money

Amounts are integers in the smallest currency unit:

  • USD: cents. $1.00 = 100
  • IDR: rupiah. IDR 1000 = 1000 (IDR doesn't have a sub-unit)
  • JPY: yen. ¥100 = 100

A currency field always accompanies amount fields:

{
  "amount": 250000,
  "currency": "IDR"
}

Currencies are uppercase ISO 4217 codes (USD, IDR, EUR, etc.).

No floats, ever. All money is integer. Don't try to send 250000.0 or 2500.50 — we'll reject with VALIDATION_ERROR.

Field naming

Fields use camelCase in JSON:

{
  "createdAt": "...",
  "customerId": "cus_...",
  "amountRefunded": 50000
}

This holds in both requests and responses.

Some SDKs translate to language-idiomatic naming (snake_case in Python, PascalCase in Go) — that's a client-side translation, not what's on the wire.

Null vs missing fields

We distinguish:

  • null — the field exists but has no value (e.g., endedAt: null for an active subscription).
  • Missing — the field isn't present in the response for this object type.

On PATCH requests, sending a field as null explicitly clears it; omitting it leaves the current value untouched.

PATCH /v1/customers/cus_xxx
{
  "phone": null         // clears the phone
}
PATCH /v1/customers/cus_xxx
{
  "name": "Alice Updated"
                        // phone is unchanged
}

Metadata

Most resource types accept a metadata field for your own arbitrary data:

{
  "metadata": {
    "internalUserId": "u_42",
    "campaign": "spring-2026",
    "ranking": "premium"
  }
}

Constraints:

  • Up to 50 keys.
  • Keys are strings, max 40 characters each.
  • Values are strings, max 500 characters each.
  • Numeric or boolean values get coerced to strings.

We don't index metadata for search; it's there for your reconciliation, not for filtering API queries. (Some endpoints support a metadata[<key>]=<value> exact-match filter — see each resource page.)

Metadata appears in the corresponding webhook event payload, so you can route events to your systems without an extra lookup.

Expansion (related objects)

By default, foreign-key fields return as IDs:

{
  "id": "pay_xxx",
  "customerId": "cus_xxx",
  "checkoutSessionId": "sess_xxx"
}

Pass expand to inline the related objects:

GET /v1/payments/pay_xxx?expand=customer,checkoutSession

Response:

{
  "id": "pay_xxx",
  "customer": { "id": "cus_xxx", "email": "...", "name": "..." },
  "checkoutSession": { "id": "sess_xxx", "amount": 250000, "..." }
}

You can expand up to 3 levels deep: ?expand=customer.defaultPaymentMethod.

Don't over-expand. Every expansion is a database join. For list endpoints, expanding customer on a 100-item list does 100 customer lookups. Use expansion when you genuinely need the data; otherwise fetch separately.

Workspaces and X-Plugipay-On-Behalf-Of

By default, an API key operates on its own workspace — the one it was minted in.

Platform partners (Storlaunch, Fulkruma, integrators) can have keys that operate on behalf of multiple merchant workspaces. To target a specific merchant:

X-Plugipay-On-Behalf-Of: acc_01HXxxx...

Only platform-admin keys can use this header. Regular merchant keys ignore it. See Authentication for the security context.

HTTP methods

  • GET — read. Safe and idempotent.
  • POST — create. Not idempotent unless you pass Idempotency-Key.
  • PATCH — partial update. Send only the fields you want to change.
  • PUT — full replace. Rare; we prefer PATCH.
  • DELETE — delete or archive. Many resources are "archived" rather than hard-deleted.

HTTP status codes

Status When
200 OK Successful read or update
201 Created Successful resource creation
204 No Content Successful deletion (envelope still returned with data: null)
400 Bad Request Malformed request body or query
401 Unauthorized Missing/invalid signature or expired timestamp
403 Forbidden Authenticated but lacks scope/role
404 Not Found Resource doesn't exist or isn't visible to this key
409 Conflict Idempotency-key mismatch or state conflict
422 Unprocessable Entity Business-rule violation (e.g., refund exceeds payment)
429 Too Many Requests Rate limited
500 Internal Server Error Our problem. Includes requestId — ping us.
502 Bad Gateway Upstream provider error (Xendit/Midtrans timeout, etc.)
503 Service Unavailable Maintenance window or systemic outage

The envelope error.code provides a more specific error code on 4xx and 5xx responses. See Errors.

Response headers

Header What
X-Request-Id Same as meta.requestId. Convenient for log correlation.
X-RateLimit-Limit Bucket size for this endpoint class
X-RateLimit-Remaining Requests remaining in the window
X-RateLimit-Reset Epoch seconds when the bucket refills
Retry-After On 429, seconds to wait
Plugipay-Mode test or live, echoed from your key

Next

Plugipay — Payments that don't tax your success