Series MapLesson 11 / 64
Start HereOrdered learning track

Learn Java Payment Systems Part 011 Api Surface Design

19 min read3611 words
PrevNext
Lesson 1164 lesson track0112 Start Here

title: Build From Scratch: Large Production Grade Java Payment Systems - Part 011 description: Mendesain API surface payment platform enterprise: resource model, command semantics, idempotency, versioning, error contract, state exposure, webhook contract, async operation, HATEOAS-light, security boundary, dan invariant yang harus dijaga agar API tidak menciptakan double charge atau financial ambiguity. series: learn-java-payment-systems seriesTitle: Build From Scratch: Large Production Grade Java Payment Systems order: 11 partTitle: API Surface Design tags:

  • java
  • payments
  • api-design
  • payment-intent
  • idempotency
  • openapi
  • enterprise-architecture date: 2026-07-02

Part 011 — API Surface Design

API payment bukan sekadar kumpulan endpoint.

API payment adalah kontrak risiko antara client, merchant, payment platform, provider, finance, risk, operations, dan regulator.

Di aplikasi biasa, API yang buruk mungkin menghasilkan duplikasi data, UI error, atau request gagal.

Di payment system, API yang buruk bisa menghasilkan:

  • customer tertagih dua kali,
  • merchant menerima settlement yang salah,
  • refund melebihi captured amount,
  • reconciliation tidak bisa menjelaskan asal transaksi,
  • customer support tidak tahu status pembayaran,
  • finance tidak bisa menutup pembukuan,
  • fraud/risk tidak punya titik intervensi,
  • audit trail tidak defensible.

Karena itu API surface payment harus didesain seperti financial control plane, bukan seperti CRUD API.

Part ini membahas bagaimana mendesain API surface untuk large production-grade Java payment platform.

Kita belum menulis implementasi penuh.

Kita sedang menentukan bentuk kontrak yang nanti akan menjadi dasar:

  • OpenAPI schema,
  • service boundary,
  • idempotency behavior,
  • state machine enforcement,
  • ledger posting,
  • webhook processing,
  • reconciliation,
  • backoffice operations.

1. Mental Model: API Payment Mengontrol Intent, Bukan Langsung Menggerakkan Uang

Kesalahan umum engineer ketika pertama kali membuat payment API adalah membuat endpoint seperti ini:

POST /pay
POST /refund
POST /transfer

Secara tampilan sederhana.

Secara operasional berbahaya.

Kenapa?

Karena nama endpoint seperti /pay menyiratkan bahwa uang langsung berpindah saat request diterima.

Padahal dalam payment system, request API hanya memulai atau mengubah business intent.

Uang aktual bisa melalui beberapa tahap:

API yang benar harus memisahkan:

LayerPertanyaanContoh
IntentApa yang merchant/customer ingin lakukan?ingin membayar invoice 100000 IDR
AttemptDengan metode/provider apa dicoba?kartu Visa via acquirer A
Provider InteractionApa hasil eksternal?authorized, declined, timeout
Financial EffectApa dampak ke ledger?hold, capture, refund, fee
SettlementApakah uang benar-benar diterima/dibayarkan?settled batch 2026-07-02

API surface yang baik membuat perbedaan ini eksplisit.


2. API Surface Minimum untuk Payment Platform Enterprise

Payment platform enterprise biasanya membutuhkan API surface seperti ini:

Dalam seri ini, kita akan mulai dari API publik yang paling penting:

  • create payment intent,
  • retrieve payment intent,
  • confirm payment intent,
  • capture authorization,
  • cancel/void authorization,
  • create refund,
  • retrieve refund,
  • list payment events,
  • receive provider webhook,
  • deliver merchant webhook.

API internal akan dibahas bertahap di part orchestration, ledger, risk, reconciliation, settlement, dan backoffice.


3. Resource Utama: Payment Intent

PaymentIntent adalah resource yang merepresentasikan keinginan bisnis untuk menerima pembayaran.

Ia bukan attempt.

Ia bukan authorization.

Ia bukan capture.

Ia adalah aggregate yang menjawab:

“Untuk order/invoice ini, apakah kewajiban pembayaran customer sudah terpenuhi?”

Contoh create payment intent:

POST /v1/payment-intents
Idempotency-Key: pi_create_order_20260702_0001
Content-Type: application/json
{
  "merchantId": "mrc_01JZ8X1A2B3C4D5E6F7G8H9J0K",
  "referenceType": "ORDER",
  "referenceId": "ord_20260702_0001",
  "amount": {
    "currency": "IDR",
    "valueMinor": 15000000
  },
  "captureMethod": "AUTOMATIC",
  "allowedPaymentMethods": ["CARD", "BANK_TRANSFER", "QRIS"],
  "expiresAt": "2026-07-02T15:00:00+07:00",
  "metadata": {
    "cartId": "cart_abc123"
  }
}

Response:

{
  "id": "pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2",
  "status": "REQUIRES_PAYMENT_METHOD",
  "amount": {
    "currency": "IDR",
    "valueMinor": 15000000
  },
  "amountCapturable": {
    "currency": "IDR",
    "valueMinor": 0
  },
  "amountReceived": {
    "currency": "IDR",
    "valueMinor": 0
  },
  "captureMethod": "AUTOMATIC",
  "createdAt": "2026-07-02T08:00:01.123+07:00",
  "expiresAt": "2026-07-02T15:00:00+07:00",
  "links": {
    "self": "/v1/payment-intents/pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2",
    "confirm": "/v1/payment-intents/pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2/confirm"
  }
}

Perhatikan hal penting:

  • amount menggunakan minor unit,
  • status awal tidak mengatakan PAID,
  • capture/cancel/refund bukan field yang bisa diubah sembarangan,
  • link action bergantung status,
  • idempotency diperlukan saat create.

4. Kenapa Bukan POST /payments Saja?

Bisa saja memakai /payments.

Tapi payment sering ambigu.

Dalam domain payment, kata payment bisa berarti:

  • niat membayar,
  • percobaan pembayaran,
  • transaksi kartu,
  • settlement item,
  • ledger movement,
  • customer-visible payment,
  • provider transaction,
  • bank transfer instruction.

Untuk enterprise-grade system, ambiguity seperti ini mahal.

Kita butuh naming yang memisahkan lifecycle.

NamaMakna yang lebih presisi
PaymentIntentkewajiban pembayaran bisnis
PaymentAttemptsatu percobaan menggunakan method/provider tertentu
Authorizationapproval hold dari card issuer/acquirer
Captureperintah mengambil dana authorized
Refundpengembalian dana ke customer
Payoutpengiriman dana ke merchant/beneficiary
Settlementfinalisasi net movement setelah clearing/report
LedgerJournalkebenaran internal finansial

Jika nama domain salah, API akan memaksa implementasi salah.


5. Payment Intent Status

Status intent harus merepresentasikan lifecycle bisnis, bukan status provider mentah.

Contoh status:

REQUIRES_PAYMENT_METHOD
REQUIRES_CONFIRMATION
REQUIRES_ACTION
PROCESSING
AUTHORIZED
PARTIALLY_CAPTURED
CAPTURED
SUCCEEDED
CANCELED
FAILED
EXPIRED
UNKNOWN

Namun jangan asal banyak status.

Status harus menjawab pertanyaan operasional.

StatusArtiFinancial effect
REQUIRES_PAYMENT_METHODbelum ada method validnone
REQUIRES_CONFIRMATIONmethod ada, belum dikirim ke providernone
REQUIRES_ACTIONperlu 3DS/OTP/redirect/customer actionnone atau auth pending
PROCESSINGprovider/bank sedang memprosesreservation/none tergantung rail
AUTHORIZEDdana di-hold, belum capturedpending receivable/hold
PARTIALLY_CAPTUREDsebagian auth sudah capturedpartial receivable
CAPTUREDcapture berhasil, settlement belum finalreceivable dari provider/acquirer
SUCCEEDEDpembayaran bisnis terpenuhisettled/recognized sesuai policy
CANCELEDintent dibatalkan sebelum finalrelease hold jika ada
FAILEDtidak bisa diproses laginone atau reversal needed
EXPIREDmelewati expiryrelease hold/reservation
UNKNOWNoutcome tidak pastimust repair/reconcile

Status UNKNOWN harus eksplisit.

Dalam payment, timeout tidak boleh otomatis dianggap gagal.


API harus membatasi action berdasarkan status.

Tidak semua endpoint boleh dipanggil kapan saja.

API action matrix:

ActionAllowed statusesNot allowed when
attach payment methodREQUIRES_PAYMENT_METHOD, REQUIRES_CONFIRMATIONsucceeded, canceled, failed
confirmREQUIRES_CONFIRMATION, sometimes REQUIRES_ACTIONalready processing unless idempotent replay
captureAUTHORIZED, PARTIALLY_CAPTUREDnot authorized, canceled, failed
cancel/voidAUTHORIZED, some PROCESSING railscaptured, settled, succeeded
refundCAPTURED, SUCCEEDED, sometimes partially captureduncaptured, voided, failed
retry attemptfailed attempt but open intentterminal intent
manual repairUNKNOWN, reconciliation breaknormal happy path

If an action is not legal, return a domain error, not generic 500.


7. Confirm API

confirm adalah action yang mengirim intent ke payment rail/provider.

POST /v1/payment-intents/{paymentIntentId}/confirm
Idempotency-Key: pi_confirm_order_20260702_0001
Content-Type: application/json
{
  "paymentMethod": {
    "type": "CARD",
    "token": "tok_card_01JZ8YCDEF",
    "billingDetails": {
      "email": "customer@example.com"
    }
  },
  "returnUrl": "https://merchant.example.com/payment/return",
  "clientContext": {
    "ipAddress": "203.0.113.10",
    "userAgent": "Mozilla/5.0",
    "deviceId": "dev_abc"
  }
}

Possible response:

{
  "id": "pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2",
  "status": "REQUIRES_ACTION",
  "nextAction": {
    "type": "REDIRECT",
    "redirectUrl": "https://acs.example.com/3ds/challenge/abc"
  },
  "latestAttempt": {
    "id": "pa_01JZ8YXYZ",
    "status": "REQUIRES_ACTION",
    "provider": "acquirer_a",
    "providerReference": "auth_123"
  }
}

Atau:

{
  "id": "pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2",
  "status": "SUCCEEDED",
  "amountReceived": {
    "currency": "IDR",
    "valueMinor": 15000000
  },
  "latestAttempt": {
    "id": "pa_01JZ8YXYZ",
    "status": "CAPTURED"
  }
}

Atau timeout:

{
  "id": "pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2",
  "status": "UNKNOWN",
  "latestAttempt": {
    "id": "pa_01JZ8YXYZ",
    "status": "UNKNOWN"
  },
  "resolution": {
    "strategy": "POLL_AND_RECONCILE",
    "nextCheckAfter": "2026-07-02T08:01:00+07:00"
  }
}

Jangan return 504 lalu meninggalkan client tanpa resource status.

Jika provider call ambiguous, resource harus mencatat unknown state.


8. Capture API

Capture hanya valid untuk authorization yang belum captured.

POST /v1/payment-intents/{paymentIntentId}/capture
Idempotency-Key: pi_capture_order_20260702_0001
Content-Type: application/json
{
  "amountToCapture": {
    "currency": "IDR",
    "valueMinor": 10000000
  },
  "finalCapture": false,
  "captureReference": "shipment_001"
}

Response:

{
  "id": "pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2",
  "status": "PARTIALLY_CAPTURED",
  "amountCapturable": {
    "currency": "IDR",
    "valueMinor": 5000000
  },
  "captures": [
    {
      "id": "cap_01JZ8Z111",
      "amount": {
        "currency": "IDR",
        "valueMinor": 10000000
      },
      "status": "SUCCEEDED"
    }
  ]
}

Capture invariant:

sum(successful_captures) <= authorized_amount

Jika finalCapture=true, sisa authorized amount harus dilepas atau tidak boleh dicapture lagi, tergantung payment rail/provider.


9. Cancel/Void API

Cancel/void membatalkan intent atau authorization sebelum capture final.

POST /v1/payment-intents/{paymentIntentId}/cancel
Idempotency-Key: pi_cancel_order_20260702_0001
Content-Type: application/json
{
  "reason": "CUSTOMER_REQUESTED",
  "comment": "Customer changed payment method before capture"
}

Response:

{
  "id": "pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2",
  "status": "CANCELED",
  "canceledAt": "2026-07-02T08:10:00+07:00",
  "cancellationReason": "CUSTOMER_REQUESTED"
}

Jangan mencampur cancel, void, dan refund.

OperationDigunakan ketikaMoney effect
cancelintent belum authorized/capturedno charge
voidauthorization ada, capture belum settledrelease authorization
refundcapture/sale sudah terjadimoney returned
reversalprovider/bank membalik transaksi tertentudepends on rail

API boleh memakai nama cancel, tetapi internal domain tetap harus tahu apakah efeknya cancel, void, atau reversal.


10. Refund API

Refund bukan sekadar negative payment.

Refund punya lifecycle sendiri.

POST /v1/refunds
Idempotency-Key: refund_order_20260702_0001
Content-Type: application/json
{
  "paymentIntentId": "pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2",
  "amount": {
    "currency": "IDR",
    "valueMinor": 5000000
  },
  "reason": "REQUESTED_BY_CUSTOMER",
  "referenceId": "return_001",
  "metadata": {
    "rma": "RMA-2026-0001"
  }
}

Response:

{
  "id": "rf_01JZ90123",
  "paymentIntentId": "pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2",
  "status": "PROCESSING",
  "amount": {
    "currency": "IDR",
    "valueMinor": 5000000
  },
  "createdAt": "2026-07-02T08:20:00+07:00"
}

Refund invariant:

sum(successful_refunds) + sum(processing_refund_reservations) <= refundable_amount

Refund should reserve refundable balance before provider call.

Otherwise two concurrent refund requests can both pass validation and over-refund.


11. Payment Attempt Resource

Payment attempt merepresentasikan satu percobaan teknis.

Satu intent bisa punya banyak attempt.

Contoh:

  • attempt pertama card gagal karena insufficient funds,
  • attempt kedua QRIS expired,
  • attempt ketiga virtual account berhasil.

Endpoint:

GET /v1/payment-intents/{paymentIntentId}/attempts

Response:

{
  "data": [
    {
      "id": "pa_01JZ8Y001",
      "paymentMethodType": "CARD",
      "provider": "acquirer_a",
      "status": "FAILED",
      "failureCode": "INSUFFICIENT_FUNDS",
      "createdAt": "2026-07-02T08:00:10+07:00"
    },
    {
      "id": "pa_01JZ8Y999",
      "paymentMethodType": "QRIS",
      "provider": "qris_switch_a",
      "status": "SUCCEEDED",
      "createdAt": "2026-07-02T08:03:44+07:00"
    }
  ]
}

Payment attempt perlu diekspos untuk debugging dan customer support, tapi hati-hati dengan data sensitif.

Jangan tampilkan:

  • PAN,
  • CVV,
  • raw provider credential,
  • full bank account number,
  • internal risk score detail,
  • raw sanction match data.

12. Payment Method API

Payment method bisa ephemeral atau reusable.

JenisContohPenyimpanan
Ephemeralone-time QR, VA number, redirect sessionbiasanya hanya untuk intent tertentu
Reusable tokencard token, wallet mandatetoken/vault, consent, lifecycle
External instructionbank transfer destinationinstruction + expiry

Contoh attach tokenized card:

POST /v1/payment-intents/{paymentIntentId}/payment-methods
Idempotency-Key: attach_pm_order_20260702_0001
{
  "type": "CARD",
  "token": "tok_card_01JZ8YABC",
  "saveForFutureUse": false
}

Untuk production system, payment method API harus mempertimbangkan PCI boundary.

Jika sistem tidak ingin masuk scope card data environment yang berat, API tidak boleh menerima PAN/CVV secara langsung.

Ia harus menerima token dari hosted fields, hosted checkout, network token, atau vault yang memenuhi kontrol keamanan.


13. API untuk Instruction-Based Payment

Tidak semua payment method langsung menghasilkan authorization.

Bank transfer, virtual account, dan QR sering menghasilkan instruction.

Contoh confirm VA:

{
  "id": "pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2",
  "status": "PROCESSING",
  "nextAction": {
    "type": "DISPLAY_BANK_TRANSFER_INSTRUCTION",
    "bankTransfer": {
      "bankCode": "BCA",
      "virtualAccountNumber": "1234567890123456",
      "amount": {
        "currency": "IDR",
        "valueMinor": 15000000
      },
      "expiresAt": "2026-07-02T15:00:00+07:00"
    }
  }
}

Contoh QR:

{
  "id": "pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2",
  "status": "REQUIRES_ACTION",
  "nextAction": {
    "type": "DISPLAY_QR_CODE",
    "qr": {
      "format": "EMVCO",
      "payload": "000201010212...",
      "imageUrl": "https://pay.example.com/qris/pi_.../qr.png",
      "expiresAt": "2026-07-02T08:15:00+07:00"
    }
  }
}

Instruction API harus punya expiry jelas.

Jika tidak, sistem akan memiliki “ghost payment” yang tidak jelas apakah masih bisa dibayar atau tidak.


14. nextAction sebagai Interface untuk Flow Berbeda

Payment rail berbeda punya user journey berbeda.

Daripada membuat endpoint berbeda untuk setiap flow, API bisa memakai nextAction.

{
  "nextAction": {
    "type": "REDIRECT",
    "redirectUrl": "https://..."
  }
}
{
  "nextAction": {
    "type": "DISPLAY_QR_CODE",
    "qr": {
      "payload": "000201..."
    }
  }
}
{
  "nextAction": {
    "type": "DISPLAY_BANK_TRANSFER_INSTRUCTION",
    "bankTransfer": {
      "virtualAccountNumber": "..."
    }
  }
}
{
  "nextAction": {
    "type": "NONE"
  }
}

Ini membuat API surface stabil, walaupun payment methods bertambah.

Namun jangan jadikan nextAction tempat membuang segala macam field tanpa schema.

Setiap action type harus punya schema spesifik.


15. Idempotency Contract di API Surface

Idempotency harus menjadi bagian eksplisit dari API contract.

Header:

Idempotency-Key: refund_order_20260702_0001

Tapi header saja tidak cukup.

Kontrak harus menjawab:

PertanyaanKeputusan desain
Apakah key required?Required untuk command yang punya financial effect
Scope key apa?merchant + endpoint + key
Berapa TTL?tergantung operation; payment/refund biasanya panjang
Request beda dengan key sama?reject dengan idempotency conflict
Response pertama gagal 500?simpan hanya jika side effect sudah dimulai atau kebijakan eksplisit
Concurrent same key?satu menang, lainnya wait/replay/409

Contoh error conflict:

{
  "error": {
    "type": "IDEMPOTENCY_ERROR",
    "code": "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST",
    "message": "The same idempotency key was used with a different request fingerprint.",
    "requestId": "req_01JZ9ABCDE"
  }
}

Rule praktis:

Every externally-triggered command that can create or change a financial effect must require idempotency.

Termasuk:

  • create payment intent,
  • confirm payment intent,
  • capture,
  • cancel/void,
  • refund,
  • payout,
  • backoffice adjustment,
  • settlement approval,
  • manual repair action.

16. API Error Contract

Payment API tidak boleh mengembalikan error asal-asalan.

Error harus membantu client dan operations mengambil tindakan yang benar.

Contoh base structure:

{
  "error": {
    "type": "INVALID_REQUEST",
    "code": "AMOUNT_CURRENCY_MISMATCH",
    "message": "Refund currency must match the original payment currency.",
    "field": "amount.currency",
    "requestId": "req_01JZ9ABCDE",
    "paymentIntentId": "pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2"
  }
}

Error taxonomy:

TypeHTTPMeaningRetry?
INVALID_REQUEST400request salah secara syntactic/semanticno
AUTHENTICATION_ERROR401API key/token invalidno until fixed
AUTHORIZATION_ERROR403merchant tidak punya permission/capabilityno until enabled
RESOURCE_NOT_FOUND404resource tidak ditemukan atau tidak accessibleno
IDEMPOTENCY_ERROR409key conflict/concurrent commanddepends
STATE_CONFLICT409action tidak legal untuk status saat inino or refresh
RATE_LIMIT_ERROR429terlalu banyak requestyes after delay
PROVIDER_UNAVAILABLE503dependency external bermasalahyes if safe
UNKNOWN_OUTCOME202/409/500 depending contractoutcome belum pastipoll/retrieve

Payment-specific error code:

CARD_DECLINED
INSUFFICIENT_FUNDS
AUTHENTICATION_REQUIRED
AUTHORIZATION_EXPIRED
AMOUNT_EXCEEDS_AUTHORIZED
AMOUNT_EXCEEDS_REFUNDABLE
PAYMENT_INTENT_EXPIRED
PAYMENT_METHOD_NOT_ALLOWED
PROVIDER_TIMEOUT_UNKNOWN_OUTCOME
DUPLICATE_PROVIDER_EVENT
SETTLEMENT_NOT_READY
MERCHANT_CAPABILITY_DISABLED
RISK_REJECTED
COMPLIANCE_HOLD

Jangan mengekspos raw provider code sebagai contract utama.

Provider code boleh ada sebagai diagnostic field internal atau normalized detail.


17. HTTP Status Code: Jangan Salah Makna

HTTP code bukan payment status.

Contoh:

HTTPPayment statusMeaning
200SUCCEEDEDrequest valid dan payment selesai
200REQUIRES_ACTIONrequest valid, customer perlu lanjut
200FAILEDrequest valid, provider decline definitif
202PROCESSINGrequest diterima, outcome async
400unchangedrequest invalid
409unchangedillegal transition/idempotency conflict
500unknown maybeinternal error; jangan sembunyikan side effect
503unknown maybedependency unavailable

Jangan berpikir:

HTTP 200 = paid
HTTP 500 = not paid

Itu asumsi berbahaya.

HTTP hanya menjelaskan hasil komunikasi API, bukan finalitas uang.


18. Retrieve API Harus Menjadi Source untuk Client Recovery

Client harus bisa recover dari timeout dengan retrieve.

GET /v1/payment-intents/{paymentIntentId}

Response harus cukup informatif:

{
  "id": "pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2",
  "status": "PROCESSING",
  "amount": {
    "currency": "IDR",
    "valueMinor": 15000000
  },
  "latestAttempt": {
    "id": "pa_01JZ8YXYZ",
    "status": "PROCESSING",
    "paymentMethodType": "BANK_TRANSFER"
  },
  "nextAction": {
    "type": "DISPLAY_BANK_TRANSFER_INSTRUCTION",
    "bankTransfer": {
      "bankCode": "BCA",
      "virtualAccountNumber": "1234567890123456",
      "expiresAt": "2026-07-02T15:00:00+07:00"
    }
  },
  "createdAt": "2026-07-02T08:00:01.123+07:00",
  "updatedAt": "2026-07-02T08:00:02.456+07:00"
}

Retrieve API harus stabil, cache-aware, dan aman untuk polling.

Tetapi jangan mendorong client polling brutal.

Berikan hints:

{
  "polling": {
    "recommendedAfterSeconds": 5,
    "maxRecommendedFrequencySeconds": 3
  }
}

19. Event API dan Merchant Webhook

Payment API tidak lengkap tanpa event delivery.

Merchant perlu tahu ketika payment berubah karena:

  • webhook provider datang setelah client redirect,
  • bank transfer dibayar beberapa menit kemudian,
  • settlement selesai di batch malam,
  • refund sukses async,
  • dispute dibuat oleh issuer/customer,
  • payout gagal.

Contoh event:

{
  "id": "evt_01JZ9EVENT",
  "type": "payment_intent.succeeded",
  "createdAt": "2026-07-02T08:05:10+07:00",
  "data": {
    "object": {
      "id": "pi_01JZ8X9FG2Z9V6VX4M1K8XJ5R2",
      "status": "SUCCEEDED",
      "amountReceived": {
        "currency": "IDR",
        "valueMinor": 15000000
      }
    }
  }
}

Webhook delivery headers:

Payment-Event-Id: evt_01JZ9EVENT
Payment-Signature: t=1782960310,v1=...
Payment-Delivery-Attempt: 1

Webhook contract:

RequirementReason
signed payloadmencegah spoofing
event id uniquededuplication
event type versionedcompatibility
retry with backoffmerchant endpoint bisa down
event replay APIrecovery
ordering not guaranteed globallydistributed reality
resource retrieval recommendedevent payload bisa stale/partial

Webhook bukan source of truth untuk merchant.

Webhook adalah notification.

Merchant sebaiknya retrieve resource setelah menerima event penting.


20. Provider Webhook Ingress API

Provider webhook berbeda dari merchant webhook.

Provider webhook masuk ke platform kita.

Endpoint biasanya:

POST /internal/provider-webhooks/{providerCode}

Atau public but protected:

POST /v1/provider-webhooks/acquirer-a

Design requirement:

  • verifikasi signature,
  • simpan raw payload secara aman,
  • deduplicate provider event,
  • normalize ke internal event,
  • jangan langsung percaya urutan,
  • jangan langsung finalize tanpa state validation,
  • jangan expose endpoint terlalu luas,
  • buat replay path yang diaudit.

Flow:

Important:

Return 200/202 ke provider setelah event diterima dan disimpan, bukan setelah semua downstream berhasil.

Kalau tidak, provider akan retry agresif dan menciptakan duplikasi.


Payment API perlu list/search untuk merchant, backoffice, dan support.

Contoh:

GET /v1/payment-intents?merchantId=mrc_...&createdFrom=2026-07-01T00:00:00+07:00&createdTo=2026-07-02T00:00:00+07:00&status=SUCCEEDED&limit=50

Response:

{
  "data": [
    {
      "id": "pi_...",
      "status": "SUCCEEDED",
      "amount": {
        "currency": "IDR",
        "valueMinor": 15000000
      },
      "createdAt": "2026-07-02T08:00:00+07:00"
    }
  ],
  "page": {
    "nextCursor": "cur_abc",
    "hasMore": true
  }
}

Gunakan cursor pagination, bukan offset untuk data besar.

Payment table akan besar, mutable pada beberapa kolom status, dan sering difilter berdasarkan waktu/merchant/status.

Offset pagination mahal dan bisa menghasilkan missing/duplicate item ketika data berubah.


22. Versioning

Payment API harus dirancang untuk kompatibilitas jangka panjang.

Ada beberapa level versioning:

LevelContohDigunakan untuk
URL version/v1/payment-intentsmajor API surface
media type versionapplication/vnd.company.payment.v1+jsonadvanced compatibility
event versionpayment_intent.succeeded.v1webhook evolution
schema versionschemaVersion: "2026-07-01"payload evolution
provider adapter versionacquirer_a:v3internal integration

Prinsip compatibility:

  • menambah optional field biasanya aman,
  • menghapus field breaking,
  • mengubah enum bisa breaking untuk client strict,
  • mengubah makna status sangat breaking,
  • mengubah idempotency semantics sangat breaking,
  • mengubah rounding/amount representation catastrophic.

Untuk enum, sediakan strategi unknown enum.

Client yang kuat harus bisa menangani value baru.


23. Security Boundary di API Surface

Payment API harus memisahkan audience.

Audience berbeda punya kontrol berbeda.

AudienceAuth modelRisk
Merchant serverAPI key/OAuth/mTLSunauthorized charge/refund
Customer browser/mobileephemeral client secret/sessiontampering, replay
Provider webhooksignature/IP allowlist/mTLS if availablespoofed event
Internal serviceworkload identity/mTLSlateral movement
Backoffice operatorSSO/RBAC/MFA/maker-checkermanual financial damage

Jangan menggunakan merchant secret di browser.

Untuk customer-side confirmation, gunakan client secret terbatas:

{
  "clientSecret": "pi_..._secret_...",
  "expiresAt": "2026-07-02T09:00:00+07:00",
  "allowedActions": ["CONFIRM", "COMPLETE_ACTION"]
}

Client secret tidak boleh bisa refund, capture, payout, atau membaca data merchant lain.


24. Amount Mutability Rules

API harus menentukan kapan amount boleh berubah.

StageAmount mutable?Reason
intent draftyes, with idempotent updateorder masih berubah
requires payment methodusually yesbelum ada provider instruction
instruction generatedusually noVA/QR amount sudah ditampilkan
authorizedno for auth amount; capture amount can varyissuer approved specific amount
capturednofinancial effect posted
settledneveraccounting finality

Jika amount berubah setelah instruction dibuat, lebih aman membuat intent baru atau attempt baru, bukan mengubah instruction lama.


25. Update API: Hati-Hati dengan PATCH

PATCH /payment-intents/{id} terlihat nyaman.

Tapi payment resource tidak boleh bebas diubah.

Update harus dibatasi.

Contoh allowed update sebelum confirm:

PATCH /v1/payment-intents/{paymentIntentId}
Idempotency-Key: pi_update_order_20260702_0001
{
  "metadata": {
    "cartId": "cart_abc123",
    "campaign": "july-sale"
  },
  "expiresAt": "2026-07-02T16:00:00+07:00"
}

Jangan allow:

{
  "status": "SUCCEEDED"
}

Status bukan field updateable.

Status berubah melalui command legal atau event verified.


26. API Boundary dengan Ledger

External API tidak boleh expose primitive ledger posting langsung.

Jangan buat:

POST /v1/ledger-entries

untuk merchant/client umum.

Ledger posting harus dipicu oleh domain command:

  • capture succeeded,
  • refund initiated/succeeded,
  • fee recognized,
  • payout created/succeeded,
  • adjustment approved.

Kenapa?

Karena ledger entry tanpa domain context tidak cukup untuk menjelaskan uang.

Benar:

Refund approved -> Refund domain event -> Ledger posting rule -> Journal entries

Salah:

Operator/API directly inserts ledger debit/credit without domain control

Backoffice adjustment boleh ada, tetapi harus:

  • punya reason code,
  • maker-checker,
  • attachment/evidence,
  • limit,
  • audit trail,
  • reconciliation link,
  • ledger posting rule.

27. Java API Model: Jangan Biarkan DTO Bocor ke Domain

Dalam Java implementation, pisahkan API DTO dan domain model.

Contoh DTO:

public record CreatePaymentIntentRequest(
    String merchantId,
    String referenceType,
    String referenceId,
    MoneyJson amount,
    CaptureMethod captureMethod,
    List<PaymentMethodType> allowedPaymentMethods,
    OffsetDateTime expiresAt,
    Map<String, String> metadata
) {}

public record MoneyJson(
    String currency,
    long valueMinor
) {}

Domain command:

public record CreatePaymentIntentCommand(
    MerchantId merchantId,
    BusinessReference reference,
    Money amount,
    CaptureMethod captureMethod,
    Set<PaymentMethodType> allowedPaymentMethods,
    Instant expiresAt,
    Metadata metadata,
    IdempotencyContext idempotency
) {}

Mapping layer:

public final class PaymentIntentApiMapper {
    public CreatePaymentIntentCommand toCommand(
        CreatePaymentIntentRequest request,
        RequestContext context
    ) {
        return new CreatePaymentIntentCommand(
            new MerchantId(request.merchantId()),
            new BusinessReference(request.referenceType(), request.referenceId()),
            Money.ofMinor(request.amount().currency(), request.amount().valueMinor()),
            request.captureMethod(),
            Set.copyOf(request.allowedPaymentMethods()),
            request.expiresAt().toInstant(),
            Metadata.from(request.metadata()),
            context.idempotency()
        );
    }
}

DTO bisa mengikuti OpenAPI.

Domain model harus menjaga invariant.


28. Request Context

Setiap API command harus membawa request context.

public record RequestContext(
    RequestId requestId,
    MerchantId merchantId,
    Actor actor,
    IdempotencyContext idempotency,
    Instant receivedAt,
    String remoteIp,
    String userAgent,
    CorrelationId correlationId
) {}

Context ini penting untuk:

  • audit trail,
  • idempotency scope,
  • risk scoring,
  • debugging,
  • trace correlation,
  • rate limiting,
  • legal evidence.

Jangan hanya melewatkan HttpServletRequest ke domain service.

Ambil data relevan, normalisasi, lalu pass sebagai immutable context.


29. API Endpoint Sketch dengan JAX-RS

Contoh skeleton JAX-RS/Jersey:

@Path("/v1/payment-intents")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public final class PaymentIntentResource {

    private final PaymentIntentApplicationService service;
    private final RequestContextFactory contextFactory;
    private final PaymentIntentApiMapper mapper;

    public PaymentIntentResource(
        PaymentIntentApplicationService service,
        RequestContextFactory contextFactory,
        PaymentIntentApiMapper mapper
    ) {
        this.service = service;
        this.contextFactory = contextFactory;
        this.mapper = mapper;
    }

    @POST
    public Response create(CreatePaymentIntentRequest request) {
        RequestContext context = contextFactory.fromCurrentRequest();
        CreatePaymentIntentCommand command = mapper.toCommand(request, context);
        PaymentIntentView view = service.create(command);
        return Response.status(Response.Status.CREATED).entity(view).build();
    }

    @GET
    @Path("/{id}")
    public Response get(@PathParam("id") String id) {
        RequestContext context = contextFactory.fromCurrentRequest();
        PaymentIntentView view = service.get(new PaymentIntentId(id), context);
        return Response.ok(view).build();
    }

    @POST
    @Path("/{id}/confirm")
    public Response confirm(
        @PathParam("id") String id,
        ConfirmPaymentIntentRequest request
    ) {
        RequestContext context = contextFactory.fromCurrentRequest();
        ConfirmPaymentIntentCommand command = mapper.toConfirmCommand(id, request, context);
        PaymentIntentView view = service.confirm(command);
        return Response.ok(view).build();
    }
}

Resource layer tipis.

Ia tidak melakukan business logic.

Ia melakukan:

  • parse request,
  • build request context,
  • call application service,
  • map response/error.

30. Error Mapping di Java

Domain exception harus dimapping konsisten.

public final class PaymentExceptionMapper implements ExceptionMapper<PaymentException> {
    @Override
    public Response toResponse(PaymentException exception) {
        ApiError error = ApiError.from(exception);
        return Response
            .status(exception.httpStatus())
            .entity(new ErrorResponse(error))
            .type(MediaType.APPLICATION_JSON_TYPE)
            .build();
    }
}

Contoh domain exception:

public final class IllegalPaymentStateTransitionException extends PaymentException {
    public IllegalPaymentStateTransitionException(
        PaymentIntentId id,
        PaymentIntentStatus currentStatus,
        PaymentAction attemptedAction
    ) {
        super(
            409,
            "STATE_CONFLICT",
            "PAYMENT_ACTION_NOT_ALLOWED",
            "Action " + attemptedAction + " is not allowed when payment intent is " + currentStatus,
            Map.of("paymentIntentId", id.value())
        );
    }
}

Error mapping bukan kosmetik.

Ia menentukan apakah client akan retry, refresh, contact support, atau berhenti.


31. Rate Limit dan Abuse Control

Payment API bisa diserang dengan:

  • card testing,
  • refund abuse,
  • payout abuse,
  • webhook replay,
  • brute force client secret,
  • merchant integration bug yang retry agresif.

Rate limit harus berbeda per operation.

OperationRate limit strategy
create intentmerchant-level + order reference uniqueness
confirm cardmerchant + card fingerprint + IP/device velocity
retrievehigher limit, cacheable
refundlower limit, strong idempotency
payoutapproval/limit-based, not simple QPS
webhook ingressprovider-specific allowlist/signature + dedup

Rate limit response:

{
  "error": {
    "type": "RATE_LIMIT_ERROR",
    "code": "TOO_MANY_CONFIRM_ATTEMPTS",
    "message": "Too many confirmation attempts for this payment intent.",
    "retryAfterSeconds": 60,
    "requestId": "req_..."
  }
}

32. API Observability Contract

Setiap response harus punya request id.

Payment-Request-Id: req_01JZ9ABCDE

Dalam payload error:

{
  "error": {
    "requestId": "req_01JZ9ABCDE"
  }
}

Correlation fields penting:

FieldPurpose
request iddebugging satu API call
idempotency keyduplicate/replay analysis
payment intent idbusiness flow
attempt idprovider interaction
provider referenceexternal lookup
trace iddistributed tracing
merchant referencemerchant support

Support engineer harus bisa menjawab:

“Customer bilang sudah bayar, kenapa order belum paid?”

Tanpa correlation model yang kuat, pertanyaan sederhana ini menjadi investigasi manual berjam-jam.


33. API Design Anti-Patterns

Anti-pattern 1: POST /pay yang langsung charge

Masalah:

  • tidak ada intent resource,
  • client timeout tidak recoverable,
  • retry bisa double charge,
  • support sulit mencari status.

Lebih baik:

POST /payment-intents
POST /payment-intents/{id}/confirm
GET  /payment-intents/{id}

Anti-pattern 2: status provider langsung diekspos

Masalah:

  • provider A dan B punya status berbeda,
  • client merchant jadi tergantung provider,
  • migration sulit.

Lebih baik normalize ke status internal.

Anti-pattern 3: refund sebagai update amount negatif

Masalah:

  • refund punya lifecycle sendiri,
  • bisa async,
  • bisa partial,
  • bisa gagal,
  • butuh ledger entries sendiri.

Lebih baik resource Refund.

Anti-pattern 4: endpoint admin bisa mengubah status

Masalah:

  • audit buruk,
  • ledger tidak sinkron,
  • reconciliation rusak.

Lebih baik command spesifik: approve_adjustment, resolve_unknown, mark_reconciled_with_evidence.

Anti-pattern 5: response tidak menyimpan unknown outcome

Masalah:

  • client menganggap gagal,
  • customer bisa membayar ulang,
  • provider ternyata berhasil.

Lebih baik explicit UNKNOWN dan resolution workflow.


34. Minimal API Contract untuk Capstone

Untuk capstone nanti, kita akan membangun API minimal seperti ini:

POST   /v1/payment-intents
GET    /v1/payment-intents/{id}
POST   /v1/payment-intents/{id}/confirm
POST   /v1/payment-intents/{id}/capture
POST   /v1/payment-intents/{id}/cancel
GET    /v1/payment-intents/{id}/attempts
POST   /v1/refunds
GET    /v1/refunds/{id}
POST   /v1/provider-webhooks/{provider}
GET    /v1/events
POST   /v1/webhook-endpoints

Internal later:

POST   /internal/ledger/postings
POST   /internal/risk/evaluate
POST   /internal/reconciliation/imports
POST   /internal/settlement/batches
POST   /internal/backoffice/actions

Tapi internal API akan dijaga dengan service identity dan tidak diekspos ke merchant.


35. Checklist API Surface Design

Gunakan checklist ini sebelum menganggap API payment siap:

  • Apakah command financial effect punya idempotency key required?
  • Apakah request fingerprint disimpan dan dibandingkan?
  • Apakah timeout menghasilkan recoverable resource status?
  • Apakah retrieve API cukup untuk client recovery?
  • Apakah HTTP status tidak dicampur dengan payment status?
  • Apakah state transition dibatasi oleh domain, bukan UI?
  • Apakah refund/capture/cancel punya resource/action sendiri?
  • Apakah provider raw status dinormalisasi?
  • Apakah API tidak menerima card PAN/CVV jika tidak sengaja masuk PCI scope?
  • Apakah webhook signed dan deduplicated?
  • Apakah error code memberi instruksi retry yang benar?
  • Apakah event delivery punya replay path?
  • Apakah amount immutable setelah financial effect?
  • Apakah API punya request id dan correlation id?
  • Apakah backoffice action tidak bisa bypass ledger invariant?
  • Apakah enum evolution dipikirkan?
  • Apakah pagination memakai cursor?
  • Apakah rate limit berbeda per operation risk?

36. What We Built in This Part

Kita belum menulis OpenAPI spec penuh.

Tapi kita sudah menentukan API surface yang masuk akal untuk production-grade payment platform:

  • PaymentIntent sebagai resource utama,
  • PaymentAttempt sebagai percobaan teknis,
  • Refund sebagai resource lifecycle sendiri,
  • command API untuk confirm/capture/cancel,
  • explicit nextAction,
  • idempotency contract,
  • error taxonomy,
  • webhook contract,
  • provider webhook ingestion,
  • retrieve API untuk recovery,
  • security boundary,
  • Java resource skeleton.

Part berikutnya akan mengubah desain ini menjadi OpenAPI & Schema-First Payment APIs.

Kita akan membuat contract yang bisa dipakai untuk:

  • code generation,
  • request validation,
  • contract testing,
  • API governance,
  • backward compatibility,
  • merchant documentation,
  • simulator.

References

Lesson Recap

You just completed lesson 11 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.