Adapters
An adapter is your workspace's connection to a payment provider — Xendit, Midtrans, PayPal, the bundled managed provider, or manual (offline bank transfer / static QRIS). Most workspaces have one or two; you can have all of them at once and route per-method.
This page covers the API for connecting and managing adapters. For per-provider setup details — webhook URLs, key generation, what each provider supports — see the provider pages:
Adapters are upserted by kind, not created by ID. A workspace has at most one adapter per kind (one Xendit, one Midtrans, etc.). Connecting again replaces the credentials in place. There is no
idto track and noDELETE /adapters/{id}— disconnect is a separate flow described below.
Endpoints
| Method | Path | What |
|---|---|---|
GET |
/v1/adapters |
List every adapter configured on this workspace |
PUT |
/v1/adapters/xendit |
Connect / re-connect Xendit (BYO) |
PUT |
/v1/adapters/midtrans |
Connect / re-connect Midtrans (BYO) |
PUT |
/v1/adapters/paypal |
Connect / re-connect PayPal (BYO) |
PUT |
/v1/adapters/manual |
Configure the manual / offline adapter |
GET |
/v1/adapters/managed/onboarding |
Fetch managed-mode onboarding state |
POST |
/v1/adapters/managed/onboarding |
Kick off managed-mode onboarding |
PUT calls require an Idempotency-Key header — see Idempotency.
List adapters
GET /v1/adapters
Returns every adapter currently configured on the workspace, keyed by kind. Adapters that have never been connected don't appear in the response.
plugipay_curl GET '/v1/adapters'
const adapters = await plugipay.adapters.list();
adapters = plugipay.adapters.list()
adapters, err := client.Adapters.List(ctx)
Response: an object keyed by kind. Adapters that have never been connected are omitted.
{
"data": {
"xendit": {
"kind": "xendit",
"status": "active",
"secretKeyLast4": "a1b2",
"publicConfig": { "callbackTokenFingerprint": "f3c1d8a0..." },
"configuredAt": "2026-05-12T10:42:00.123Z",
"lastErrorAt": null,
"lastErrorCode": null
},
"managed": { "kind": "managed", "status": "active", "...": "..." }
},
"error": null,
"meta": { "requestId": "req_01H...", "timestamp": "..." }
}
The managed entry is virtual — surfaced from the xenPlatform sub-account profile, not a stored credential row.
Connect Xendit
PUT /v1/adapters/xendit
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
secretKey |
string | yes | Xendit secret key. Must start with xnd_sk_ — we verify the prefix and reject otherwise with 422 gateway_error. |
callbackToken |
string | no | Xendit webhook verification token. Recommended; without it we can't verify inbound webhook authenticity. |
plugipay_curl PUT '/v1/adapters/xendit' \
'{"secretKey":"xnd_sk_live_xxx","callbackToken":"whtok_xxx"}'
await plugipay.adapters.updateXendit({
secretKey: 'xnd_sk_live_xxx',
callbackToken: 'whtok_xxx',
});
plugipay.adapters.update_xendit(
secret_key='xnd_sk_live_xxx',
callback_token='whtok_xxx',
)
_, err := client.Adapters.UpdateXendit(ctx, plugipay.XenditConfig{
SecretKey: "xnd_sk_live_xxx",
CallbackToken: "whtok_xxx",
})
Response is an adapter object with kind: "xendit". The webhook URL to register in Xendit's dashboard is documented in Xendit setup.
Connect Midtrans
PUT /v1/adapters/midtrans
| Field | Type | Required | Notes |
|---|---|---|---|
serverKey |
string | yes | Midtrans server key. Stored encrypted. |
clientKey |
string | yes | Public — stored in publicConfig. |
merchantId |
string | yes | Your Midtrans merchant ID. |
env |
"sandbox" | "production" |
no | Defaults to sandbox. |
await plugipay.adapters.updateMidtrans({
serverKey: 'SB-Mid-server-xxx',
clientKey: 'SB-Mid-client-xxx',
merchantId: 'M123456',
env: 'sandbox',
});
Connect PayPal
PUT /v1/adapters/paypal
| Field | Type | Required | Notes |
|---|---|---|---|
clientId |
string | yes | PayPal REST API client ID. |
secret |
string | yes | PayPal REST client secret. Stored encrypted. |
mode |
"live" | "sandbox" |
no | Defaults to live. |
await plugipay.adapters.updatePaypal({
clientId: 'AeA1QIZX...',
secret: 'ECCEnyJ...',
mode: 'live',
});
Configure the manual adapter
PUT /v1/adapters/manual
The manual adapter has no credentials — it carries bank account details and an optional static QR image, rendered verbatim on hosted checkout pages for offline "transfer to us" flows.
| Field | Type | Required | Notes |
|---|---|---|---|
bankAccounts |
array | no | Each: bankName, accountNumber, accountHolder (≤ 60/40/80 chars). |
staticQrImageUrl |
string | null | no | URL to a static QRIS image. ≤ 500 chars. |
instructions |
string | null | no | "How to pay" line shown above the details. ≤ 500 chars. |
await plugipay.adapters.updateManual({
bankAccounts: [{ bankName: 'BCA', accountNumber: '1234567890', accountHolder: 'PT Forjio' }],
staticQrImageUrl: 'https://cdn.example.com/qris.png',
instructions: 'Transfer and screenshot to support@forjio.com',
});
Managed onboarding
Managed mode is provisioned through an onboarding flow instead of credential paste.
GET /v1/adapters/managed/onboarding
POST /v1/adapters/managed/onboarding { "email": "..." }
GET returns current state — subAccountId, kybStatus, capabilitiesStatus, payoutsReady, onboardingUrl (the URL to redirect the merchant to). POST kicks off provisioning if it hasn't happened yet.
const state = await plugipay.adapters.managedOnboardingState();
if (state.kybStatus === 'not_started') {
await plugipay.adapters.startManagedOnboarding({ email: 'merchant@example.com' });
}
For the lifecycle (not_started → invited → registered → live), see Managed onboarding.
Disconnect
There is no DELETE. To stop using an adapter, deselect its methods in checkout settings; we won't route new sessions through it. To fully disable, open a support ticket — we'll mark status: "disabled". Hard-delete is intentionally not exposed because in-flight payments still reference the provider for refunds and reconciliation.
The adapter object
{
"kind": "xendit",
"status": "active",
"secretKeyLast4": "a1b2",
"publicConfig": { /* provider-specific */ },
"configuredAt": "2026-05-12T10:42:00.123Z",
"lastErrorAt": null,
"lastErrorCode": null
}
| Field | Type | Notes |
|---|---|---|
kind |
enum | One of xendit, midtrans, paypal, manual, managed. |
status |
enum | active, disabled, or error. error means the most recent gateway call failed; see lastErrorCode. |
secretKeyLast4 |
string | null | Last 4 characters of the connected secret — for "is this still the same key?" checks. null for manual and managed. |
publicConfig |
object | Non-secret config the provider needs. Schema is provider-specific (see below). |
configuredAt |
ISO 8601 | When this adapter was last PUT. |
lastErrorAt |
ISO 8601 | null | When we last saw a gateway error from this provider. |
lastErrorCode |
string | null | The error code from that last failure (e.g., gateway_error, invalid_credentials). |
publicConfig per kind
| Kind | Fields |
|---|---|
xendit |
callbackTokenFingerprint (sha-256 prefix of the webhook token) |
midtrans |
clientKey, merchantId, env |
paypal |
clientId, mode |
manual |
bankAccounts[], staticQrImageUrl, instructions |
managed |
subAccountId, env, kybStatus, capabilitiesStatus, payoutsReady, lastWebhookAt |
Capabilities
What each kind supports. For the full per-provider matrix see the provider pages.
| Method | Managed | Xendit | Midtrans | PayPal | Manual |
|---|---|---|---|---|---|
| Cards (Visa / MC) | yes | yes | yes | yes | no |
| Cards (AmEx / JCB) | select | depends | yes | no | no |
| Indonesian VA | yes | yes | yes | no | n/a |
| E-wallets (OVO, DANA, ShopeePay, GoPay) | yes | yes | yes | no | n/a |
| QRIS (dynamic) | yes | yes | yes | no | n/a |
| QRIS (static, scan-to-pay) | no | no | no | no | yes |
| Retail (Alfamart, Indomaret) | no | yes | yes | no | n/a |
| PayPal balance / Pay in 4 | no | no | no | yes | n/a |
| Apple / Google Pay | yes | no | no | no | n/a |
| Offline bank transfer | no | no | no | no | yes |
Modes: managed vs BYO
- Managed. Plugipay owns the provider relationship. One bill, one support contact, slightly higher per-transaction cost. Use the
managed/onboardingendpoints — no credentials to paste. - BYO. You bring your own Xendit / Midtrans / PayPal account; we orchestrate through it. Lower platform fee, you own provider support and settlement. Use
PUT /v1/adapters/{kind}.
The two coexist — e.g., managed for cards + Midtrans BYO for retail outlets. Per-method routing lives in checkout settings, not on the adapter. See Managed for the trade-offs.
Secret handling
Credentials are write-only. We encrypt secrets at rest (AES-256-GCM) and only ever return
secretKeyLast4. The full key is never returned by any endpoint — including the response to thePUTthat set it. If you lose the secret on your side, rotate it with the upstream provider and re-PUT.
- To check whether a connected key is the same one you have, compare
secretKeyLast4andconfiguredAt— don't try to read it back. - Re-connecting with the same secret refreshes
configuredAtand clearslastErrorAt/lastErrorCode. - Retries are safe via
Idempotency-Key; reusing a key within the retention window returns the cached response.
Errors
| Code | Status | When |
|---|---|---|
VALIDATION_ERROR |
400 | Required field missing or wrong shape (secretKey empty, bad env enum, etc.) |
gateway_error |
422 | The credentials look syntactically valid but failed an upstream sanity check (e.g., Xendit secret not starting with xnd_sk_). |
idempotency_required |
400 | PUT without an Idempotency-Key header. |
idempotency_conflict |
409 | Same Idempotency-Key reused with a different request body. |
insufficient_scope |
403 | Key lacks plugipay:adapter:write (for PUT/POST) or plugipay:adapter:read (for GET). |
See Errors for the full catalog.
Events
Adapter changes emit:
adapter.connected— first successfulPUTof a kind.adapter.updated— subsequentPUT(rotation, config change).adapter.error— upstream gateway call failed;statusflipped toerror.adapter.managed.kyb_changed,adapter.managed.capabilities_changed— managed-mode lifecycle transitions.
See Webhooks for delivery and payload shapes.
Next
- Checkout settings — route methods to specific adapters.
- Provider setup: Managed, Xendit, Midtrans, PayPal.