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 id to track and no DELETE /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_startedinvitedregisteredlive), 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/onboarding endpoints — 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 the PUT that 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 secretKeyLast4 and configuredAt — don't try to read it back.
  • Re-connecting with the same secret refreshes configuredAt and clears lastErrorAt / 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 successful PUT of a kind.
  • adapter.updated — subsequent PUT (rotation, config change).
  • adapter.error — upstream gateway call failed; status flipped to error.
  • adapter.managed.kyb_changed, adapter.managed.capabilities_changed — managed-mode lifecycle transitions.

See Webhooks for delivery and payload shapes.

Next

Plugipay — Payments that don't tax your success