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.requestIdwhen 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.0or2500.50— we'll reject withVALIDATION_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: nullfor 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
customeron 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 passIdempotency-Key.PATCH— partial update. Send only the fields you want to change.PUT— full replace. Rare; we preferPATCH.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 |