PSP Integration API Contract — kesles_merchant ↔ payment.kesles.com
Bi-directional API contract between two PT Kesles internal services:
kesles_merchant— owner of merchant data, mobile app + dashboard. Backend:services/dashboard_api(Go).payment.kesles.com— Kesles Payment Service Provider, H2H to bank. Backend: Go.
This document is defined by the kesles_merchant team (owner of merchant data).
Audience scope (as of 2026-04-30): this document was originally designed for Tier 1 — Internal PSP (payment.kesles.com). The Go implementation in
services/dashboard_apiis already multi-tenant: dual-source auth (psp.api_keys+partner.api_credentialsHMAC), middleware attachespsp_role+psp_bank_codeto the request context, and the/api/psp/v1/merchants/*handler automatically injectsWHERE bank_code = $Xfor callers with rolebank.Tier 2 — External PSP / Bank (Mandiri, BRI, BNI, other acquirers) is now ready for onboarding via:
- Insert a row in
partner.partners(psp_role='bank', psp_bank_code='BMRI'/'BRIN'/'BBNI'/etc).- Insert a row in
partner.api_credentials(auth_method='hmac',hmac_secret_encryptedvia the psp-encrypt CLI, optionalallowed_ip_ranges).The handler automatically filters per
bank_code— a bank will never see merchants from another acquirer. Tier-1-only endpoints (PATCH /api/psp/v1/merchants/{id}/nmid-assignment+ all/api/psp/v1/payment-events/*) reject with 403 (tier_internal_required) for callers whosepsp_role != 'internal'.The contract body below is still written from the Tier 1 perspective (payment.kesles.com). For full Tier 2 onboarding + sample request/response, see the checklist in
database/schema/views.md§4.5.
1. Architecture & Principles
1.1 Service Responsibilities
| Aspect | kesles_merchant | payment.kesles.com |
|---|---|---|
| Data owner | Merchant profile, KYC, outlet, user, analytics, revenue, profit | QRIS master transactions, settlement, QRIS payload, bank credentials |
| Public-facing | Merchant mobile app + web dashboard | H2H to bank, customer payment page |
| DB schema | merchant.*, psp.*, db_reference.* | payment.* (own DB) |
| Public API | api-merchant.kesles.com/api/psp/v1/* + /api/partner/v1/* (PSP & external Partner integrators). The merchant mobile app uses the internal route kesles.com/merchant/api/*, NOT the public subdomain. | (via payment.kesles.com, not in scope of this doc) |
1.2 Bi-Directional Communication
┌─────────────────────────────────────┐
│ │
│ payment.kesles.com │
│ (Go backend) │
│ │
└────────────┬─────────────▲──────────┘
│ │
outbound │ │ inbound
(lookup │ │ (event
merchant) │ │ forward)
▼ │
┌─────────────────────┐ ┌───────────── ──────────────┐
│ GET /api/psp/ │ │ POST /psp/v1/events │
│ v1/merchants/{id} │ │ POST /psp/v1/settlements │
│ /by-nmid │ │ (payment-service :8085) │
│ (dashboard_api │ │ │
│ :8082) │ │ Legacy (until Phase 4): │
│ │ │ POST /api/psp/v1/payment- │
│ │ │ events/* (dashboard_api) │
└──────────┬──────────┘ └──────────────┬────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────┐
│ │
│ kesles_merchant │
│ (dashboard-api :8082 + │
│ payment-service :8085) │
│ │
└────────────┬──────────────▲────────────┘
│ │
outbound │ │ inbound
(merchant │ │ (NMID
lifecycle) │ │ assignment)
▼ │
┌────────────────┐ ┌────────────────────┐
│ POST │ │ PATCH │
│ payment.kesles.│ │ /api/psp/v1/ │
│ com/api/... │ │ merchants/{id}/ │
│ /merchants │ │ nmid-assignment │
└────────────────┘ └────────────────────┘
1.3 Design Principles
- Idempotent — every operation can be repeated without duplicate side effects (via
X-Idempotency-Key) - Active-only exposure — kesles_merchant only exposes merchants with
status = 'active' AND deleted_at IS NULL AND nmid IS NOT NULLto the PSP - Audit log all requests — in
psp.event_logandpsp.outbound_requests - Retry-safe — both services must handle retry with exponential backoff
- Eventual consistency — data may lag ≤30 seconds across services
2. Authentication & Security
2.1 Method: HMAC-SHA256 + IP Allowlist
Auth headers differ depending on the target service:
Lookup API — dashboard_api (port 8082): /api/psp/v1/merchants/* and legacy /api/psp/v1/payment-events/*
X-API-Key-ID: <public identifier — safe to log>
X-Timestamp: <unix seconds, current>
X-Signature: hmac-sha256=<computed signature>
X-Request-ID: <uuid v4 for tracing>
Events API — payment-service (port 8085): /psp/v1/events and /psp/v1/settlements
X-PSP-Key-ID: <public identifier — safe to log>
X-PSP-Timestamp: <RFC3339 timestamp, current — e.g. 2026-05-23T10:02:15Z>
X-PSP-Signature: <computed signature — plain hex, no prefix>
X-Request-ID: <uuid v4 for tracing>
2.2 Signature Computation
String-to-sign format differs per service:
Lookup API (dashboard_api): {timestamp}\n{method}\n{path}\n{body}
Example for GET /api/psp/v1/merchants/abc-123?active=true:
1716452580
GET
/api/psp/v1/merchants/abc-123?active=true
(Body empty for GET, trailing newline still present).
Events API (payment-service): {method}\n{path}\n{timestamp}\n{body}
Example for POST /psp/v1/events:
POST
/psp/v1/events
2026-05-23T10:02:15Z
{"external_event_id":"evt-123","event_type":"transaction.created",...}
Important: signed_payload field order differs between the two services. Ensure the client uses the correct format for each endpoint.
HMAC-SHA256 with shared secret. Primitive-nya sama, tetapi stringToSign berbeda urutan per service (lihat §2.2), sehingga digest-nya BERBEDA — tidak bisa saling dipertukarkan. Format header signature juga berbeda:
// stringToSign dibangun sesuai skema endpoint (§2.2):
// Lookup API (dashboard_api): {timestamp}\n{method}\n{path}\n{body}
// Events API (payment-service): {method}\n{path}\n{timestamp}\n{body}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(stringToSign))
digest := hex.EncodeToString(mac.Sum(nil))
// Lookup API (dashboard_api) → X-Signature, dengan prefix algoritma:
signatureA := "hmac-sha256=" + digest
// Events API (payment-service) → X-PSP-Signature, plain hex tanpa prefix:
signatureB := digest
2.3 Server Verification (Critical)
Each server must perform 5 checks before executing the handler:
- Extract key ID (
X-API-Key-IDorX-PSP-Key-ID) → look up the secret frompsp.api_keys.hmac_secret_encryptedorpartner.api_credentials.hmac_secret_encrypted(decrypt via AES) - Check timestamp freshness — reject if
now - timestamp > 300 seconds(prevent replay) - Re-compute the HMAC from the received request, compare with the signature header using
hmac.Equal()(constant-time, prevent timing attack) - Check IP allowlist (optional but recommended):
allowed_ip_rangescovers the caller's IP - Rate limit per key ID
If any of these fails: return 401 or 403 without leaking specific info (prevent enumeration attack).
2.4 API Key Rotation
- Rotate every 90 days — tracked via the
expires_atfield onpartner.api_credentials. Automated reminder in the admin UI is NOT yet implemented — operators must currently track expiry manually (calendar reminder ~7 days beforeexpires_at). - Dual-key grace period: 7 days. Two keys active simultaneously, payment.kesles.com uses the new one as default and falls back to the old one if the new one is rejected
- Revocation: immediate via the admin dashboard, all requests with the old key will return 401
2.5 Secret Management — AES-256-GCM Encryption (IMPLEMENTED)
Implementation in services/dashboard_api/internal/app/psp_crypto.go:
-
The secret is generated as 32 random bytes, base64 encoded (44 chars) —
openssl rand -base64 32 -
The secret is encrypted with AES-256-GCM using the master key from env
KESLES_SECRET_ENCRYPTION_KEY -
Storage format in
psp.api_keys.hmac_secret_encrypted:enc:v1:<base64-nonce>:<base64-ciphertext-with-GCM-tag>- Prefix
enc:v1:= version identifier (future-proof for key rotation / algo change) - 12-byte random nonce per encryption (GCM requires unique nonce)
- Ciphertext + auth tag combined (GCM standard)
- Prefix
-
Dev mode fallback: A string without the
enc:v1:prefix is treated as plaintext + WARN log. Production MUST have everything encrypted — add a startup check to alert on plaintext rows. -
The plain secret is never logged, returned in an API response, or stored as plaintext in production DB.
Helper CLI to encrypt a new secret:
KESLES_SECRET_ENCRYPTION_KEY=<base64-master-key> \
go run ./cmd/psp-encrypt <plaintext-secret>
# Example output:
# enc:v1:nst1aCm9yZVrRKef:96DNrA1JZws4WX7hAy68P4LayJrQ1krcQP4RqXo36wDs4ZqNJw8+...
This output is what gets INSERTed into the hmac_secret_encrypted column.
2.6 Master Key Rotation
When the master key (KESLES_SECRET_ENCRYPTION_KEY) needs to be rotated:
- Generate a new master key:
openssl rand -base64 32 - Deploy the app with dual-key support — try the new key first, fallback to the old one (not covered in the current implementation — future enhancement)
- Re-encrypt all rows in
psp.api_keyswith the new key - After 7 days grace period, remove the old key from the env
For MVP: rotation remains manual (re-run the psp-encrypt CLI + UPDATE SQL).
2.7 Credential Handover for the payment.kesles.com Team
Handover flow to the external team:
- Admin generates an API key in the Admin UI (or manual SQL insert for MVP)
- The system outputs the plaintext secret ONCE — it cannot be recovered
- Admin shares the secret via a secure channel:
- 1Password Teams shared vault
- Encrypted Slack DM
- Bitwarden Organization
- NEVER via email, regular WhatsApp, or unencrypted documents
- The payment.kesles.com team stores it in their env var (
KESLES_MERCHANT_HMAC_SECRET) - Rotation notification is sent 7 days before expiry (
expires_atinpsp.api_keys)
2.8 Audit Log Requirements
All requests to /api/psp/v1/* and /psp/v1/* must be logged in psp.event_log or psp.outbound_requests (dashboard_api) / psp.event_log (payment-service) with:
- Timestamp
- Caller IP
- API Key ID (not the secret)
- Endpoint + method
- Request ID
- Response status
- Duration ms
- Error (if any)
Retention: 5 years (BI compliance for payment data).
3. Endpoint Reference — PSP Lookup API
Prefix: /api/psp/v1/*
Auth: HMAC (see §2)
Consumer: payment.kesles.com
3.1 GET /api/psp/v1/merchants
List all active merchants with assigned NMIDs. Cursor paginated.
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
limit | int | 100 | Max 500 |
cursor | string | - | Opaque pagination cursor |
updated_since | datetime (ISO8601) | - | Delta sync — returns only merchants whose updated_at ≥ value |
bank_code | string | - | Filter per PSP bank (BMRI, BCA, etc) |
category_code | string | - | Filter MCC category |
include_outlets | bool | false | Include outlets per merchant (expensive) |
Response 200:
{
"items": [
{
"id": "b8f3a2c1-uuid",
"merchant_name": "Warung Pak Budi",
"legal_name": "CV Sumber Rejeki",
"nmid": "ID102326873XXXX",
"bank_code": "BMRI",
"category_code": "5411",
"category_name": "Grocery Stores",
"phone": "081234567890",
"email": "budi@example.com",
"npwp": "01.234.567.8-123.000",
"address": {
"line": "Jl. Sudirman No. 123",
"city": "Makassar",
"province": "Sulawesi Selatan",
"postal_code": "90111",
"country": "ID"
},
"status": "active",
"kyc_status": "verified",
"kyc_verified_at": "2026-05-01T09:30:00Z",
"created_at": "2026-05-01T08:00:00Z",
"updated_at": "2026-05-23T14:23:45Z",
"psp_registration_id": "psp-reg-uuid-123"
}
],
"pagination": {
"next_cursor": "eyJpZCI6ImJmYTMifQ==",
"has_more": true
},
"meta": {
"returned_count": 100,
"active_only": true
}
}
Errors:
- 400
invalid_cursor— invalid cursor - 400
invalid_bank_code— bank_code is not in the reference - 401
unauthorized— auth failed - 429
rate_limit_exceeded
Rate limit: 60 req/minute per API key (bulk endpoint, do not call continuously)
Performance: p95 <300ms for limit=100
3.2 GET /api/psp/v1/merchants/{id}
Get a single merchant by internal merchant_id (UUID).
Path params:
id— merchant UUID
Query params:
include_outlets(bool, default false) — include outletsinclude_owners(bool, default false) — include owner/manager usersinclude_devices(bool, default false) — include the per-device list (array of Device objects, same shape as §3.6.5 response items). Implemented 2026-06-09.
Response 200:
{
"merchant": {
/* same shape as list item, plus the two device fields below */
"device_model": "QRIS Plus 191",
"device_status": "active"
},
"outlets": [ /* if include_outlets=true */ ],
"owners": [ /* if include_owners=true */ ],
"devices": [ /* if include_devices=true — Device objects, same shape as §3.6.5 */ ]
}
Known shape gap (verified 2026-05-06). The flat fields
terminal_code,device_id, and the granularterminal_statusonmerchantare populated from aLATERAL ... LIMIT 1join tomerchant.payment_terminal_inventory(see migration 072), so the response carries at most one terminal even when a merchant has several. They are taggedomitemptyinpackages/psp_integration_go/types.go, so a merchant with no terminal-inventory row at all returns the merchant payload without any of those fields — which is exactly what happens for newly-onboarded merchants whose acquirer-assigned MID/TID/NMID are denormalised onmerchant.merchantsbut where no physical device has been registered inpayment_terminal_inventoryyet. PSP cannot tell from this response how many devices the merchant actually has, what model they are, or whether any of them are usable.
Required additions on merchant (PENDING):
| Field | Type | Example | Description |
|---|---|---|---|
device_model | string | "QRIS Plus 191" | Human-readable device line. Composed in the view as terminal_type_name || ' ' || product_model_name (e.g., "QRIS Plus" || "Q161 Pro") — kolomnya hidup di merchant.payment_terminals (mig 024). Omitted when no terminal record exists. |
device_status | enum active | inactive | "active" | Binary roll-up of the 9-value internal terminal status. active ⇔ payment_terminal_inventory.status IN ('active','online'); everything else (inventory, assigned, offline, maintenance, suspended, returned, deleted) → inactive. Omitted when no terminal record exists; PSP can treat absent as inactive. |
When the merchant has multiple terminals, the inlined device_model / device_status follow the same LATERAL LIMIT 1 rule as the existing terminal_code snapshot (most recently activated wins). Use /devices (§3.6.5) for the full list with per-device device_status.
Errors:
- 404
merchant_not_found— id does not exist, is not active, or has no NMID - 401, 429 (same as above)
Rate limit: 600 req/minute per API key (hot endpoint, PSP calls this on every transaction)
Performance: p95 <100ms (cached 30s in Redis)
3.3 GET /api/psp/v1/merchants/by-nmid/{nmid}
Reverse lookup: find a merchant by NMID. The most frequently called by PSP (when a bank webhook arrives with an NMID).
Path params:
nmid— NMID string (e.g., "ID102326873XXXX")
Response 200: Same as 3.2
Errors:
- 404
merchant_not_found
Rate limit: 1000 req/minute
Performance: p95 <50ms (indexed + cached)
3.4 GET /api/psp/v1/merchants/search
Search merchant for duplicate detection or dispute resolution.
Query params (at least 1 required):
phone— exact matchemail— exact match (case-insensitive)npwp— exact matchnmid— exact match
Response 200:
{
"items": [ /* up to 10 merchant matches */ ],
"match_count": 2
}
Errors:
- 400
at_least_one_param_required
3.5 GET /api/psp/v1/merchants/{id}/outlets
List the merchant's outlets.
Response 200:
{
"items": [
{
"id": "outlet-uuid",
"merchant_id": "merchant-uuid",
"outlet_name": "Cabang Makassar Pusat",
"nmid_specific": "ID102326873XXXX-01",
"address": { /* ... */ },
"is_primary": true,
"device_count": 2,
"created_at": "...",
"updated_at": "..."
}
]
}
3.6 GET /api/psp/v1/merchants/{id}/owners
List the owner/manager users for critical notifications (refund approval, dispute).
Response 200:
{
"items": [
{
"user_id": "user-uuid",
"full_name": "Budi Santoso",
"phone_masked": "08**-****-5678",
"email_masked": "b***@example.com",
"role": "owner",
"notification_channels": ["whatsapp", "email"]
}
]
}
3.6.5 GET /api/psp/v1/merchants/{id}/devices
Status: IMPLEMENTED.
pspListDevicesroutes viacorePSPClient→merchant_core_api(which reads frominventory_service). The earlier backing viewpsp.v_active_merchant_deviceswas dropped in mig 101 (2026-06-09).
Return the full list of payment terminal devices attached to the merchant.
Why a separate endpoint instead of just adding fields:
- One merchant may own several terminals (chain restaurant, multi-counter retail). The single-terminal snapshot inlined into §3.1/§3.2 (
LATERAL LIMIT 1) only shows one — PSP needs all of them to reconcile a transaction routed via a specific TID. - Per-device fields (
last_seen_at,placement_name) are operational data that PSP consumes only when troubleshooting — not on every lookup.
Response 200 (binary device_status):
{
"items": [
{
"id": "device-uuid",
"merchant_id": "merchant-uuid",
"terminal_code": "TRM-0001",
"device_id": "DEV-9876",
"serial_number": "00087000668",
"device_model": "QRIS Plus Q161 Pro",
"device_status": "active",
"device_status_detail": "online",
"activated_at": "2026-05-07T16:50:11+07:00",
"last_seen_at": "2026-05-08T09:12:33+07:00",
"placement_name": "Kasir Lantai 1"
}
],
"summary": {
"total": 3,
"active": 2,
"inactive": 1
}
}
device_status is the single binary field (decision: 2026-05-08): active | inactive.
device_status | Internal payment_terminal_inventory.status (mig 024 + 035) |
|---|---|
active | active, online |
inactive | stock, allocated, in_transit, delivered, inventory, assigned, offline, maintenance, suspended, returned, deleted |
PSP doesn't need the granular enum for routing decisions. The full enum is exposed via device_status_detail for troubleshooting, but PSP must treat it as opaque debug info — the binary device_status is the contract.
device_model composition:
View column = concat_ws(' ', terminal_type_name, product_model_name) (NULL-safe). For the seeded default row (mig 024) that yields "QRIS Plus Q161 Pro". Frontend / PSP should treat this as opaque display text, not parse it.
NMID / MID / TID:
Not exposed in the per-device payload — they live on merchant.merchants (1 set per merchant) per current schema (mig 068, 096). All devices of the same merchant share the same identifiers. To get them, PSP looks up the parent merchant via /merchants/{id} (§3.2) or sends them in payload.MID/payload.NMID for transaction events.
Schema evolution note: a future multi-outlet schema (proposal:
docs/database/plans/multi-outlet-enterprise-schema-proposal.md) will move NMID/MID to outlet level and TID to device level. When that lands, this response will gain per-devicetid(and likely anoutlet_id). PSP should design clients tolerant to additive fields.
Optional fields kept (operational debug, devices endpoint only):
last_seen_at,activated_at— kapan device terakhir online / pertama aktif.serial_number— PII-gated (Tier 1 internal + Tier 2 bank only). Tier 3 caller receives the field empty (omitted).placement_name— kasir/outlet label for context.
Fields intentionally NOT exposed (kept internal to the dashboard, out of PSP contract):
firmware_version,connectivity_type,manufacturer_name,brand_name,terminal_type_code,payment_capabilities[],placement_address_line,assigned_at,metadata. These are operations data; PSP sees only what it needs to reconcile transactions and report to the merchant.
Tier behaviour:
| Tier | Caller (psp_role) | Row scoping | serial_number field |
|---|---|---|---|
| 1 | internal (payment.kesles.com) | none (all merchants) | visible |
| 2 | bank | WHERE bank_code = caller_bank_code | visible |
| 3 | none (Tier-3 partner / unauthenticated) | per-context | empty (omitempty) |
PII gating implemented via helper pspCallerSeesPII(ctx) in services/dashboard_api/internal/app/psp_merchants.go.
Rate limit: 300 req/minute (cold endpoint; PSP only calls when reconciling a specific terminal).
Errors:
- 404
merchant_not_found— merchant UUID tidak ada / non-active / di luar bank scope (Tier-2) - 401
unauthorized— HMAC fail - 405
method_not_allowed— bukan GET - 500
internal_error— DB error
3.6.6 GET /api/psp/v1/merchants/by-serial/{serial_number}
Status: IMPLEMENTED since 2026-05-10. Reverse lookup from device serial number to its parent merchant.
Resolve a hardware serial number to the merchant that owns the device. Useful when PSP receives an error / settlement row keyed only by SN and needs the Kesles merchant context to act on it.
Path params:
serial_number— manufacturer-issued hardware ID. Real Aisino Q161 format = 11-digit pure numeric (e.g.00087000668). Backend accepts broader pattern[A-Za-z0-9_\-]+with no length constraint to accommodate any operator-prefixed labels, but the default from manufacturer is raw numeric SN — not a compound string with device model SKU (thedevice_modelfield is separate).
Response 200:
{
"merchant": {
"id": "d0db70bd-bb29-46b3-8653-d59eed6f1fa2",
"merchant_code": "MRC-0174105647947242",
"merchant_name": "Makan Kini",
"nmid": "ID2313123123123",
"mid": "231312312312323",
"tid": "12321312",
"bank_code": "BMRI",
"...": "(full Merchant DTO, identical shape with §3.2)"
},
"device": {
"id": "device-uuid",
"merchant_id": "d0db70bd-bb29-46b3-8653-d59eed6f1fa2",
"terminal_code": "TRM-19E083F8195-80240268",
"device_id": "DEV-...",
"serial_number": "00087000668",
"device_model": "QRIS Plus Q161 Pro",
"device_status": "inactive",
"device_status_detail": "delivered",
"activated_at": null,
"last_seen_at": null,
"placement_name": "Kasir Lantai 1"
}
}
Implementation: resolved via corePSPClient → merchant_core_api (GET /internal/psp/merchant-devices/by-serial/{serial}), which reads the device + owning merchant from inventory_service. (The earlier direct backing view psp.v_active_merchant_devices was dropped in mig 101, 2026-06-09.)
Tier behaviour: same as §3.6.5.
- Tier-2 bank scoping enforced on both queries — bank A cannot fish a serial owned by bank B's merchant. Both view and merchant lookup append
WHERE bank_code = caller_bank_code. - Tier-3 caller receives
device.serial_numberempty.
Rate limit: 600 req/minute (lookup endpoint, similar weight to by-nmid/by-mid/by-code).
Errors:
- 404
device_not_found— SN tidak ada / merchant non-active / di luar bank scope - 404
merchant_not_found— race condition (device ada tapi merchant baru saja deleted/deactivated antara dua query) - 401
unauthorized— HMAC fail - 405
method_not_allowed— bukan GET - 500
internal_error
Use cases:
- PSP terima file batch settlement berisi SN gagal → lookup pemilik untuk hubungi
- Customer service PSP terima telepon "device SN xyz error" → butuh info merchant cepat
- Forensic / fraud check — "SN ini transaksi besar tiba-tiba, ini merchant mana?"
Tier-2 row scoping: when caller is psp_role='bank', view query filtered by bank_code = caller_bank_code so a bank cannot reverse-lookup a serial owned by a merchant on another acquirer.
3.6.7 GET /api/psp/v1/merchants/by-mid/{mid}
Status: IMPLEMENTED. Lookup merchant by MID (Merchant ID issued by acquirer, typically ~15 digits).
Path params:
mid— acquirer-issued merchant ID. Format: numeric string, typically 15 digits (e.g.000007100010926).
Response 200: same shape as §3.2 (MerchantDetailResponse). Does not support include_outlets / include_owners.
Tier behaviour: bank-scoped — Tier-2 caller can only lookup merchants under their bank_code.
Rate limit: 600 req/minute.
Errors:
- 404
merchant_not_found— MID tidak ada atau merchant non-active - 401
unauthorized— HMAC fail - 405
method_not_allowed— bukan GET
3.6.8 GET /api/psp/v1/merchants/by-code/{merchant_code}
Status: IMPLEMENTED. Lookup merchant by Kesles-internal merchant_code. This is the PSP's canonical reference to a Kesles merchant after registration.
Path params:
merchant_code— Kesles merchant code, formatMRC-XXXXXXXXXXXXXXXX(16-char hex suffix).
Query params (optional):
include_outlets=true— embed outlet list (same as §3.5)include_owners=true— embed owner list (same as §3.6)
Response 200: same shape as §3.2 (MerchantDetailResponse).
Tier behaviour: bank-scoped — same as §3.3/§3.6.7.
Rate limit: 600 req/minute.
Errors:
- 404
merchant_not_found - 401
unauthorized - 405
method_not_allowed
3.7 GET /api/psp/v1/reference/payment-service-providers
Get the list of PSPs from db_reference.ref_payment_service_provider.
Query params:
active_only(bool, default true)
Response 200:
{
"items": [
{
"psp_id": "uuid",
"psp_code": "BMRI",
"name": "Bank Mandiri",
"api_base_url": "https://api.bankmandiri.co.id/qris/v1",
"is_active": true,
"settlement_bank": "Mandiri",
"supported_qris_types": ["static", "dynamic"]
}
]
}
3.8 GET /api/psp/v1/reference/merchant-categories
MCC code lookup (for category standardization).
3.9 PATCH /api/psp/v1/merchants/{id}/nmid-assignment
PSP callback to kesles_merchant after the bank assigns an NMID to a merchant.
Request body:
{
"bank_code": "BMRI",
"nmid": "ID102326873XXXX",
"qris_static_payload": "00020101021226580013ID...",
"qris_static_url_image": "https://cdn.kesles.id/qr/merchant-uuid.png",
"psp_registration_id": "psp-reg-uuid",
"assigned_at": "2026-05-23T10:00:00Z"
}
Response 200: Updated merchant entity
Errors:
- 404
merchant_not_found - 409
nmid_already_assigned— this merchant already has an NMID (idempotent — if the request is identical, returns 200) - 422
invalid_nmid_format
4. Endpoint Reference — Event Receiver API (payment-service)
Endpoint /psp/v1/events dan /psp/v1/settlements berjalan di payment-service (port 8085). Auth headers menggunakan prefix X-PSP-*, bukan X-API-*. Signed_payload menggunakan format method\npath\ntimestamp\nbody (berbeda urutan dari lookup API).
POST /api/psp/v1/payment-events/* di dashboard_api (port 8082) masih aktif sampai Phase 4. Saat Phase 4 aktif, semua caller harus sudah migrasi ke endpoint payment-service di bawah.
Routing Internal — Flow A vs Flow B
Payment-service menentukan alur pemrosesan berdasarkan nmid yang diterima:
| Kondisi | Alur | Aksi |
|---|---|---|
nmid = NMID milik merchant (merchant.merchants.nmid) | Flow A — customer membayar ke merchant | INSERT payment.transactions + push notification ke merchant |
nmid = ID9999999999999 (NMID korporat Kesles) | Flow B — merchant membeli produk Kesles | Notify order-service, tidak insert ke payment.transactions merchant |
NMID korporat ID9999999999999 dikonfigurasi di db_kesles_merchant_payment.payment.qris_config.
4.1 POST /psp/v1/events
Forward satu payment event dari PSP ke payment-service.
Auth headers:
X-PSP-Key-ID: {key_id}
X-PSP-Timestamp: {rfc3339}
X-PSP-Signature: {plain_hex_no_prefix}
X-Request-ID: {uuid}
Content-Type: application/json
Request body — format FLAT (bukan nested envelope):
event_type: transaction.created
{
"external_event_id": "psp-evt-8b3a2c1f",
"event_type": "transaction.created",
"nmid": "ID102326873XXXX",
"gross_amount": 75000,
"transaction_code": "TRX-PSP-20260523-001",
"reference_number": "BI20260523142345XXXX",
"rrn": "301234567890",
"transaction_at": "2026-05-23T14:23:45Z",
"payer_bank_code": "014",
"payer_bank_name": "BCA",
"payer_name_masked": "Budi S.",
"mdr_fee_bank": 300,
"switching_fee": 150,
"platform_fee": 75,
"status": "success"
}
event_type: transaction.refunded
{
"external_event_id": "psp-evt-refund-456",
"event_type": "transaction.refunded",
"nmid": "ID102326873XXXX",
"gross_amount": 75000,
"original_transaction_code": "TRX-PSP-20260523-001",
"reference_number": "BI20260523142345REFUND",
"transaction_at": "2026-05-23T15:00:00Z",
"reason": "customer_request",
"status": "completed"
}
event_type: transaction.cancelled
{
"external_event_id": "psp-evt-cancel-789",
"event_type": "transaction.cancelled",
"nmid": "ID102326873XXXX",
"gross_amount": 75000,
"transaction_code": "TRX-PSP-20260523-001",
"external_reference": "BI20260523142345XXXX",
"transaction_at": "2026-05-23T14:30:00Z",
"reason": "customer_request",
"status": "cancelled"
}
event_type: transaction.reversed
{
"external_event_id": "psp-evt-reverse-321",
"event_type": "transaction.reversed",
"nmid": "ID102326873XXXX",
"gross_amount": 75000,
"transaction_code": "TRX-PSP-20260523-001",
"external_reference": "BI20260523142345XXXX",
"transaction_at": "2026-05-23T15:00:00Z",
"reason": "bank_initiated",
"status": "reversed"
}
event_type: nmid.status_changed
{
"external_event_id": "psp-evt-nmid-789",
"event_type": "nmid.status_changed",
"nmid": "ID102326873XXXX",
"nmid_new_status": "suspended",
"nmid_old_status": "active",
"nmid_reason": "fraud_detected",
"transaction_at": "2026-05-23T12:00:00Z"
}
Response 200 (first time):
{
"ack": true,
"event_id": "psp-evt-8b3a2c1f",
"processed_at": "2026-05-23T14:23:47Z",
"flow": "A",
"actions": [
"stored_to_payment_transactions",
"push_notification_queued"
]
}
For Flow B, flow field contains "B" and actions contains ["order_service_notified"].
Response 200 (duplicate, idempotent):
{
"ack": true,
"event_id": "psp-evt-8b3a2c1f",
"duplicate": true,
"original_processed_at": "2026-05-23T14:23:47Z"
}
Server behavior (Flow A):
- Verify HMAC signature (
X-PSP-Key-ID+X-PSP-Timestamp+X-PSP-Signature) - Check
external_event_idagainstpsp.event_log— if duplicate, return 200 without re-processing - Determine flow based on
nmid(see routing table above) - Insert into
payment.transactions - Async: push notification to merchant via core_api
/internal/notifications/merchant-event - Log to
psp.event_log - Return 200
Server behavior for nmid.status_changed:
- Verify HMAC
- Lookup merchant by
nmid - Update
merchant.merchants.statusbased onnmid_new_status - Push notification to merchant owner
- Log to
psp.event_log
Errors:
| Status | Code | Meaning |
|---|---|---|
| 401 | invalid_signature | HMAC mismatch |
| 401 | timestamp_expired | Timestamp older than 5 minutes |
| 401 | api_key_revoked | API Key has been revoked |
| 422 | invalid_payload | Required field missing or wrong format |
| 429 | rate_limit_exceeded | > 6,000 req/minute per API key |
4.2 POST /psp/v1/settlements
Forward daily settlement batch from the bank. Separate endpoint from /psp/v1/events.
Auth headers: same as §4.1 (X-PSP-*)
Request body:
{
"external_event_id": "psp-sett-20260524-001",
"settlement_id": "psp-sett-uuid",
"nmid": "ID102326873XXXX",
"bank_code": "BMRI",
"batch_number": "SETT-20260524-001",
"transaction_count": 48,
"gross_amount": 3240000,
"mdr_total": 22680,
"net_amount": 3217320,
"bank_account_masked": "1234-****-5678",
"settlement_date": "2026-05-24",
"completed_at": "2026-05-24T08:00:00Z"
}
Response 200:
{
"ack": true,
"settlement_id": "psp-sett-uuid",
"processed_at": "2026-05-24T08:00:05Z",
"actions": ["stored_to_payment_settlements"]
}
4.3 Perbandingan: Legacy vs New Endpoint
| Aspek | Legacy (dashboard_api) | New (payment-service) |
|---|---|---|
| Path transactions | POST /api/psp/v1/payment-events/transaction | POST /psp/v1/events |
| Path settlement | POST /api/psp/v1/payment-events/settlement | POST /psp/v1/settlements |
| Path refund | POST /api/psp/v1/payment-events/refund | POST /psp/v1/events (event_type: transaction.refunded) |
| Path cancelled | — (tidak ada di legacy) | POST /psp/v1/events (event_type: transaction.cancelled) |
| Path reversed | — (tidak ada di legacy) | POST /psp/v1/events (event_type: transaction.reversed) |
| Path nmid-status | POST /api/psp/v1/payment-events/nmid-status-changed | POST /psp/v1/events (event_type: nmid.status_changed) |
| Port | 8082 | 8085 |
| Header auth | X-API-Key-ID / X-Timestamp / X-Signature | X-PSP-Key-ID / X-PSP-Timestamp / X-PSP-Signature |
| Payload format | Nested envelope {event_id, event_type, payload: {...}} | Flat {external_event_id, event_type, nmid, gross_amount, ...} |
| Event ID field | event_id | external_event_id |
| nmid-status fields | old_status, new_status, reason | nmid_old_status, nmid_new_status, nmid_reason |
| signed_payload order | timestamp\nmethod\npath\nbody | method\npath\ntimestamp\nbody |
| Status | Aktif (production) |
5. Outbound Client — kesles_merchant → payment.kesles.com
Endpoints that kesles_merchant calls to payment.kesles.com. The spec is defined together with the payment.kesles.com team, but here is the contract draft from the kesles_merchant side.
5.0 Implementation Status (Phase 1–3 complete, dispatch OFF by default)
Built (2026-05-20):
services/dashboard_api/internal/app/psp_outbound_client.go— singleton clientPSPOutboundClientwith 7 typed methods:RegisterMerchant,UpdateMerchant,SuspendMerchant,ReactivateMerchant,TerminateMerchant,InitiateRefund,LookupTransaction. HMAC signing via sharedpackages/psp_integration_go.Signer(same string-to-sign as inbound verifier).services/dashboard_api/internal/app/psp_outbound_audit.go— wrapper overpsp.outbound_requeststable (InsertOutboundRequest+MarkOutboundResult). Table exists since mig 062 but only now written to.services/dashboard_api/internal/app/psp_outbound_triggers.go— lifecycle dispatcher that decides which outbound method to fire on merchant state transition. Wired into:- approve flow (
dashboard_merchant_registration.gohandleMerchantRegistrationApprove) →RegisterMerchant - merchant PATCH (
dashboard_merchants.goupdateMerchantHandler) →Suspend/Reactivate/Terminate/Updatedepending on status transition - merchant DELETE (
dashboard_merchants.godeleteMerchantHandler) →TerminateMerchant
- approve flow (
services/dashboard_api/internal/app/dashboard_psp_outbound_admin.go— 4 super-admin observability endpoints (see §5.8 below).
Mode switch (env PSP_OUTBOUND_MODE, default disabled):
| Mode | Side effect |
|---|---|
disabled (default) | No-op: handler short-circuits on IsEnabled() check. Zero alloc, zero DB write, zero HTTP. |
audit_only | Sign + insert row in psp.outbound_requests with error_message="dispatch_disabled (audit_only mode)". No HTTP to payment.kesles.com. Useful for pre-prod simulation. |
dispatch | Full production: sign + audit row + HTTP POST/PATCH/GET to payment.kesles.com + write response to audit row. |
Plug-and-play activation (when the payment.kesles.com team is ready):
# In services/dashboard_api/.env.<env>
PSP_OUTBOUND_MODE=dispatch
PSP_OUTBOUND_BASE_URL=https://payment.kesles.com
PSP_OUTBOUND_HMAC_KEY_ID=<key id minted by payment.kesles.com>
PSP_OUTBOUND_HMAC_SECRET=<paired secret>
PSP_OUTBOUND_TIMEOUT=15s
Restart dashboard_api — no code change required. The same trigger points that have been silently no-op since launch will now fire real outbound calls. Verify in admin panel Integrations → PSP Outbound (see §5.8).
Trigger map (when fired in production):
| Source event | Fires | Path (on payment.kesles.com) |
|---|---|---|
| Merchant registration approved | RegisterMerchant | POST /api/internal/merchants |
PATCH status active→suspended | SuspendMerchant | POST /api/internal/merchants/{id}/suspend |
PATCH status suspended→active | ReactivateMerchant | POST /api/internal/merchants/{id}/reactivate |
PATCH status *→inactive | TerminateMerchant | POST /api/internal/merchants/{id}/terminate |
| PATCH detail (non-status, e.g. NPWP/address/contact) | UpdateMerchant | PATCH /api/internal/merchants/{id} |
| DELETE merchant (soft-delete) | TerminateMerchant | POST /api/internal/merchants/{id}/terminate |
| Refund operator action (handler TBD) | InitiateRefund | POST /api/internal/refunds |
All triggers fire async via goroutine + context.Background() (30 s timeout) so user response is not blocked, and goroutine survives HTTP connection close. Failures are best-effort: logged + audit row error_message, but no DB rollback (state-of-truth lives in kesles_merchant; payment.kesles.com is reconciled via Replay admin tool — see §5.8).
5.1 POST payment.kesles.com/api/psp/v1/merchants
Register a new merchant to PSP (after KYC verified in kesles_merchant).
Request body:
{
"kesles_merchant_id": "merchant-uuid",
"merchant_name": "Warung Pak Budi",
"legal_name": "CV Sumber Rejeki",
"category_code": "5411",
"phone": "081234567890",
"email": "budi@example.com",
"npwp": "01.234.567.8-123.000",
"address": { /* ... */ },
"requested_bank_code": "BMRI",
"kyc_documents": {
"ktp_url": "...",
"nib_url": "...",
"selfie_url": "..."
}
}
Response 201 (sync): registered + NMID assigned
Response 202 (async): accepted, callback via /api/psp/v1/merchants/{id}/nmid-assignment when done
5.2 PATCH payment.kesles.com/api/psp/v1/merchants/{id}
Update merchant profile in PSP (when the merchant edits in the mobile app).
5.3 POST payment.kesles.com/api/psp/v1/merchants/{id}/suspend
Suspend the merchant at the bank (from an admin action in kesles_merchant).
5.4 POST payment.kesles.com/api/psp/v1/merchants/{id}/reactivate
Reactivate.
5.5 POST payment.kesles.com/api/psp/v1/merchants/{id}/terminate
Terminate the NMID permanently (deleted merchant).
5.6 POST payment.kesles.com/api/psp/refunds
Request a refund for a transaction.
5.7 GET payment.kesles.com/api/psp/transactions/{reference_number}
Query transaction status (reconciliation).
5.8 Admin Panel — Integrations → PSP Outbound
Super-admin only (role superadmin). Backed by dashboard_psp_outbound_admin.go.
Routes (mounted in server.go under /api/dashboard/integrations/psp-outbound/):
| Method | Path | Purpose |
|---|---|---|
| GET | /status | Returns { mode, is_enabled, last_24h: {pending, success, failed} } for health card. |
| GET | /audit?merchant_id=&request_type=&status=success|failed|pending&limit=&offset=&page= | Paginated list of psp.outbound_requests rows. Default limit=50 (max 200). |
| GET | /audit/{id} | Single row drill-down (request_payload + response_body as raw JSON). |
| POST | /audit/{id}/replay | Re-fire the original request. Writes a new audit row with retry_count = original.retry_count + 1 and triggered_by_user_id = current operator. 503 when mode=disabled. |
Replay semantics:
- Audit history is multi-row — every attempt inserts a new row; the original is never overwritten. Filter
retry_count > 0in/auditquery to find replays. - Replay is mode-aware: in
audit_onlyit writes a new row withdispatch_disablednote but still no HTTP; indispatchit re-signs and re-sends. - Idempotency between attempts is the responsibility of the payment.kesles.com side (they should de-dupe by
X-Request-IDwithin the 5-minute replay window).
Status grouping (used by the status= filter):
success—response_status BETWEEN 200 AND 299failed—response_statusnon-2xx or NULL witherror_message IS NOT NULLpending—response_status IS NULL AND error_message IS NULL(in-flight or never dispatched)
6. Error Response Format
All errors from kesles_merchant /api/psp/v1/* (lookup) and payment-service /psp/v1/* (events) use a consistent format:
{
"error": "error_code_snake_case",
"message": "Human-readable message",
"details": { "optional": "field" },
"request_id": "echo of X-Request-ID from the request"
}
Error Code Reference
| HTTP | Error Code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed JSON or query param |
| 400 | invalid_cursor | Invalid cursor |
| 400 | invalid_nmid_format | NMID does not match the BI format |
| 401 | unauthorized | Missing auth headers |
| 401 | invalid_signature | HMAC verification failed |
| 401 | timestamp_expired | Timestamp older than 5 minutes |
| 401 | api_key_revoked | API Key has been revoked |
| 403 | ip_not_allowed | Caller IP is not in the allowlist |
| 403 | endpoint_not_allowed | API Key may not access this endpoint |
| 404 | merchant_not_found | Merchant not found or non-active |
| 404 | outlet_not_found | |
| 409 | duplicate_resource | Unique constraint violation |
| 409 | nmid_already_assigned | Merchant already has another NMID |
| 422 | validation_failed | Input validation failed |
| 429 | rate_limit_exceeded | Per-API-key rate limit exceeded |
| 500 | internal_error | Unexpected server error (detail logged, not returned) |
7. Rate Limiting & Performance SLA
7.1 Per API Key Rate Limits
| Endpoint Pattern | Rate Limit |
|---|---|
GET /api/psp/v1/merchants (bulk list) | 60 req/minute |
GET /api/psp/v1/merchants/{id} | 600 req/minute |
GET /api/psp/v1/merchants/by-nmid/{nmid} | 1000 req/minute |
GET /api/psp/v1/merchants/by-mid/{mid} | 600 req/minute |
GET /api/psp/v1/merchants/by-code/{merchant_code} | 600 req/minute |
GET /api/psp/v1/merchants/by-serial/{serial_number} | 600 req/minute |
GET /api/psp/v1/merchants/search | 300 req/minute |
GET /api/psp/v1/merchants/{id}/devices | 300 req/minute |
GET /api/psp/v1/merchants/{id}/outlets | 300 req/minute |
GET /api/psp/v1/merchants/{id}/owners | 300 req/minute |
GET /api/psp/v1/reference/* | 100 req/minute |
PATCH /api/psp/v1/merchants/{id}/nmid-assignment | 60 req/minute |
POST /psp/v1/events (payment-service) | 6,000 req/minute (100/sec) |
POST /psp/v1/settlements (payment-service) | 600 req/minute |
POST /api/psp/v1/payment-events/* | — |
Response headers:
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 543
X-RateLimit-Reset: 1716452640
7.2 Performance SLA
| Endpoint | p50 | p95 | p99 |
|---|---|---|---|
GET /merchants/by-nmid/{nmid} | 30ms | 50ms | 100ms |
GET /merchants/by-mid/{mid} | 30ms | 50ms | 100ms |
GET /merchants/by-code/{merchant_code} | 30ms | 50ms | 100ms |
GET /merchants/by-serial/{serial_number} | 30ms | 50ms | 100ms |
GET /merchants/{id} | 50ms | 100ms | 200ms |
GET /merchants (list limit=100) | 150ms | 300ms | 500ms |
GET /merchants/{id}/outlets | 50ms | 100ms | 150ms |
PATCH /merchants/{id}/nmid-assignment | 80ms | 150ms | 300ms |
POST /psp/v1/events (payment-service) | 80ms | 180ms | 400ms |
7.3 Uptime SLA
- Year 1: 99.5% (≤3.6 hours downtime/month)
- Year 2+: 99.9% (≤43 minutes downtime/month)
7.4 Retry Strategy (for the payment.kesles.com client)
For transient errors (500, 502, 503, 504, timeout):
- Exponential backoff: 1s, 2s, 4s, 8s (max 4 retries)
- Max timeout per attempt: 10 seconds
- If still failing: enqueue to dead letter → alert on-call
For event forwarding (/psp/v1/events) which is critical:
- Durable queue on PSP side (Redis Streams or NATS JetStream)
- Retry indefinitely until payment-service returns 200
- The idempotency key prevents double-processing
7.5 Response Field Exclusions — Internal-Only Fields
merchant_tier is NOT exposed in the PSP API response. This field is internal-only for kesles_merchant analytics + admin dashboard — not for PSP consumption.
Rationale:
- payment.kesles.com has its own merchant_tier DB (master MDR logic)
- Double source of truth = risk of drift + inconsistent billing
- kesles_merchant only trusts the MDR amounts that PSP sends via
/psp/v1/events
Fields AVAILABLE in the PSP response:
merchant_name,nmid,bank_code,category_code(MCC reference)phone,email(contact info for critical notifications)address.*(fulfillment + verification)status,kyc_status,kyc_verified_atqris_static_payload,qris_static_url_image(in case of re-display)
Fields NOT exposed to PSP:
merchant_tier(REGULER/MICRO/EDUCATION/etc) — internal BI tier classificationbusiness_scale_*(MIKRO/KECIL/MENENGAH) — internal analyticsaverage_monthly_revenue,employee_count— privacy + competitive inforeferral_*— internal partner attributiondevice_price_amount,shipping_fee_amount,promo_amount— commercial pricing
If PSP needs the above information for a specific use case, coordinate via product request → evaluate case-by-case.
7.6 Push Notification Flow (Event Receiver → Mobile App)
payment-service does not hold Firebase credentials. Push notifications are handled via the existing infrastructure in merchant_core_api (the active runtime backend that already has Firebase Admin SDK credentials + device token store + FCM HTTP v1 client).
3-hop architecture:
payment-service (8085) merchant_core_api (8080) FCM
│ │ │
│── POST /psp/v1/events │ │
│ (from PSP) │ │
│ │ │
│── INSERT payment.transactions│ │
│ UPSERT psp.event_log │ │
│ │ │
│──POST /internal/ ►│ │
│ notifications/ │ │
│ merchant-event │ │
│ (X-Internal-API-Key) │ │
│ │── Upsert gateway │
│ │ status │
│ │── Lookup device │
│ │ tokens │
│ │── push.Client.Send │
│ │ │──► FCM API
│ │ │ to mobile
│◄── 202 Accepted + summary │ │
│ (payment-service returns │ │
│ BEFORE push completes — │ │
│ call to core-api is async│ │
│ in goroutine) │ │
Implementation details:
-
payment-service forwarder: calls
POST {CORE_API_BASE_URL}/internal/notifications/merchant-eventwith headerX-Internal-API-Key: {CORE_API_INTERNAL_KEY}— async via goroutine with 10s timeout. -
Core-api push handler:
merchant_core_api/internal/httpapi/internal_merchant_event_handler.go- Endpoint
POST /internal/notifications/merchant-event(handleSendMerchantEvent, existing, production) - Looks up active push tokens from
notification.fcm_push_tokensviaFindActivePushTokensByMerchantID - Sends FCM HTTP v1 via
push.Client.Send() - Auto-deactivates invalid/expired tokens (
push.IsInvalidTokenError) - Returns push summary (sent_count, failed_count, invalidated_count)
- Endpoint
-
FCM config (existing in
merchant_core_api):- Env:
FIREBASE_PROJECT_ID,FIREBASE_CLIENT_EMAIL,FIREBASE_PRIVATE_KEY - Service account JSON:
kesles-merchant-firebase-adminsdk-fbsvc-66b18e8bf0.json
- Env:
Payload mapping from payment-service event → core-api internal format:
| payment-service field | Core-api field | Notes |
|---|---|---|
reference_number | referenceNo | |
status → 00/01 | responseCode | success=00, failed=01 |
nmid (resolved to merchant_id) | merchantId | |
transaction_code | partnerReferenceNo | PSP external id |
gross_amount (string) | amount | |
rrn | approvalCode | |
status | status | success/refunded/failed |
8. Active-Only Filter Rules
Critical: All /api/psp/v1/* endpoints that return merchant data ONLY expose merchants matching the filter:
WHERE status = 'active'
AND deleted_at IS NULL
AND nmid IS NOT NULL
Merchants with status pending, inactive, suspended, deleted are not visible to PSP — endpoints return 404 merchant_not_found.
Handling Status Transitions
The event receiver (/psp/v1/events) still accepts events for non-active merchants but:
- Logs the event to
psp.event_log - Does not insert into
payment.transactions(treated as orphan) - Does not forward push notifications to mobile
- Returns 200 OK (ack) + field
action: "logged_only", reason: "merchant_not_active"
This handles the race condition: a merchant just got suspended, but a late-arrival transaction is still forwarded by the PSP.
Outbound Events from kesles_merchant
Each status change triggers an outbound call to payment.kesles.com:
| Status Transition | Outbound Call |
|---|---|
pending → active | POST payment.kesles.com/api/psp/v1/merchants (register) |
active → suspended | POST .../merchants/{id}/suspend |
active → inactive | POST .../merchants/{id}/deactivate |
suspended → active | POST .../merchants/{id}/reactivate |
any → deleted | POST .../merchants/{id}/terminate |
9. Event Idempotency
9.1 Idempotency Key
The event receiver must check external_event_id from the payload body against psp.event_log.external_event_id:
SELECT id, processing_status, processed_at
FROM psp.event_log
WHERE external_event_id = $1
LIMIT 1;
If it exists → return 200 with the flag duplicate: true. Do not re-process.
Note: X-Request-ID header is used for distributed tracing, but the deduplication key is external_event_id in the body.
9.2 Storage
INSERT INTO psp.event_log (
external_event_id,
event_type,
nmid,
payload,
signature_verified,
received_at,
processing_status
) VALUES ($1, $2, $3, $4, $5, now(), 'pending')
ON CONFLICT (external_event_id) DO UPDATE
SET processing_status = 'duplicate_skipped'
RETURNING id;
9.3 Processing Status Flow
pending ──► success (happy path)
╲─► failed (handler error, retry)
╲─► duplicate_skipped (event was already processed)
10. Implementation Checklist
Verified 2026-05-06 against actual repo + production probe. Migration numbers below are the actual ones in
merchant_database/db_kesles_merchant/migrations/— earlier draft listed 066/067/068 which are unrelated migrations.
kesles_merchant Backend (dashboard_api — lookup API)
- Apply migration
062_psp_integration_schema.sql(schemapsp+psp.api_keys+psp.event_log+psp.outbound_requests+ viewpsp.v_active_merchants+ NMID/QRIS columns onmerchant.merchants) - Apply migration
068_add_mid_tid_and_tighten_nmid.sql(MID/TID columns + NMID NOT NULL hardening) - Apply migration
069_nmid_prefix_km.sql,070_psp_view_with_terminal_and_qris_codes.sql,072_psp_view_add_bank_account_fields.sql(extendpsp.v_active_merchants) - Apply migration
087_partner_psp_role_and_hmac.sql(multi-tenant:partner.api_credentials.auth_method='hmac'+partners.psp_rolefor Tier 2 PSP External) - Apply migration
092_chk_merchant_nmid_bank_pair.sql+096_drop_terminal_psp_duplicates.sql(data-integrity guards) -
services/dashboard_api/internal/app/psp_auth_middleware.go— HMAC verify, dual-source lookup (psp.api_keys+partner.api_credentials), IP allowlist, 60 s in-memory cache -
services/dashboard_api/internal/app/psp_merchants.go— lookup endpoints (list, search, by-nmid, by-mid, detail, outlets, owners, nmid-assignment) [x]— DELETED Phase 4 2026-06-05. Legacy event endpoints (transaction, settlement, refund, nmid-status-changed) sudah didecommission — route returns 404.services/dashboard_api/internal/app/psp_event_receiver.go-
services/dashboard_api/internal/app/psp_crypto.go— AES-256-GCMenc:v1:envelope forhmac_secret_encrypted [x]— DELETED Phase 4 2026-06-05. Functions hanya dipanggil dariservices/dashboard_api/internal/app/psp_push_notifier.gopsp_event_receiver.go(sudah dihapus).-
services/dashboard_api/internal/app/psp_outbound_client.go— outbound HTTP client topayment.kesles.com, 7 typed methods. Mode-gated byPSP_OUTBOUND_MODEenv (defaultdisabled); see §5.0 for full mode table. -
services/dashboard_api/internal/app/psp_outbound_audit.go+psp_outbound_triggers.go+dashboard_psp_outbound_admin.go— audit store, lifecycle dispatcher, 4 super-admin observability endpoints - Per-device list — fully done (2026-06-09): (1) endpoint
GET /api/psp/v1/merchants/{id}/devices(§3.6.5); (2)view— view di-DROP mig 101 (2026-06-09),psp.v_active_merchant_devices(mig 126)pspListDevicessekarang route kecorePSPClient→merchant_core_apiatau fallback langsung keinventory_service; (3) reverse-lookupGET /api/psp/v1/merchants/by-serial/{serial_number}(§3.6.6); (4)include_devices=trueparam DONE 2026-06-09 — wired ke §3.2/§3.3/§3.4/§3.5 detail handlers; (5)Outlet.device_countDONE 2026-06-09 — real count viainventory_service HTTP. - Multi-outlet schema (Phoenixdart enterprise use case) — proposal at
docs/database/plans/multi-outlet-enterprise-schema-proposal.md. - Routes registered in
services/dashboard_api/internal/app/server.go - Admin UI in dashboard to manage API Keys (create, list, revoke, rotate) — today operators rotate keys via SQL +
cmd/psp-encrypt - Unit tests + integration tests with at least 70 % coverage
- Load test — verify p95 target
payment-service Backend (port 8085 — events API)
-
POST /psp/v1/events— unified event receiver (transaction, refund, nmid.status_changed) dengan flat payload format -
POST /psp/v1/settlements— settlement receiver - HMAC auth:
X-PSP-Key-ID/X-PSP-Timestamp/X-PSP-Signature - signed_payload:
method\npath\ntimestamp\nbody - Flow A / Flow B routing via
nmid(Flow B:nmid = ID9999999999999daripayment.qris_config) - Idempotency via
psp.event_log.external_event_id - Async push notification ke core_api
POST /internal/notifications/merchant-event - Phase 2 dual-write ke legacy
db_kesles_merchant(soak mode,POSTGRES_DSN_LEGACY) — DEPLOYED 2026-06-03 - Phase 3 reader cutover dashboard_api:
/internal/daily-summary,/internal/transactions/by-merchant/{id},/internal/revenue-by-merchant— DEPLOYED 2026-06-03,PAYMENT_SERVICE_BASE_URL=http://127.0.0.1:8085aktif - Nginx rule
/psp/v1/→127.0.0.1:8085aktif diapi-merchant.kesles.com— LIVE 2026-06-03 - PSP API key provisioned di
psp.api_keys— DONE 2026-06-04:payment-kesles-com-prod(300/min) +banking-kesles-com-uat(60/min).REVOKED.psk-banking-kesles-uat - Refund push notification — tambah case
transaction.refundeddimerchant_event_messages.go - Phase 4:
POST /api/psp/v1/payment-events/*di dashboard_api DECOMMISSIONED 2026-06-05 —psp_event_receiver.go+psp_push_notifier.godeleted, route returns 404 - Phase 6: DROP legacy payment tables (mig 076) — APPLIED 2026-06-05 —
merchant.transactions,merchant.settlements,merchant.refunds,merchant.transaction_daily_fees, 2 views dropped CASCADE
Shared Go Package
- Create
packages/psp_integration_go/ -
signing.go— HMAC signing + verification -
types.go— shared DTOs (Merchant, Transaction, Settlement, etc.) -
client.go— HTTP client wrapper with auto-signing - Unit tests (
signing_test.go) — signing only; client + types still untested - Consumed via go.mod
replacedirective fromservices/dashboard_api/go.mod(monorepo pattern — git-tag publishing N/A for internal-only consumers)
Documentation
- This file (
psp-integration-contract.md) - Postman collection (
merchant_docs/api_docs/internal/static/postman/kesles-psp-integration.postman_collection.json+kesles-psp-env-local.postman_environment.json) - Onboarding guide for the
payment.kesles.comteam - Operational runbooks:
runbooks/master-key-rotation.md(AES master key),runbooks/secret-rotation.md(per-key rotation),runbooks/incident-response.md,runbooks/setup-api-merchant-subdomain.md
Admin Operations
- Script to generate + distribute the first API key to the
payment.kesles.comteam - Monitoring dashboard for rate limit + signature failure rate
- Alerting for anomalies (401 spike, 5xx spike, rate limit hit)
Production exposure (subdomain)
- [~] Deploy
api-merchant.kesles.comvhost — seerunbooks/setup-api-merchant-subdomain.md. Re-probed 2026-05-18: vhost & TLS cert are now correct (CN=api-merchant.kesles.com, Let's Encrypt E7), HTTP→HTTPS 301 OK, backend handler live on port 8082 (loopbackcurl /api/psp/v1/merchants→401 missing_auth_headers✅). But nginxproxy_passconfig still wrong: trailing slash strips the/api/psp/v1prefix before forwarding, so backend receives path/merchantsand returns404 route not registered: /merchants. Same issue for/api/partner/v1/. Root/still serves static HTML 200 instead ofreturn 404. Fix: edit vhost toproxy_pass http://localhost:8082;(no trailing slash),proxy_pass http://localhost:8080;for Partner, replacelocation /body withreturn 404 '{"error":"not_found"}', reload nginx, re-probe 8 checks.
11. Open Questions for the payment.kesles.com Team
Before implementation, align with the payment.kesles.com team:
- payment.kesles.com endpoint spec — what endpoints will they expose? (for outbound kesles_merchant calls)
- payment.kesles.com IP — what static IP will be allowlisted?
- Error retry coordination — if kesles_merchant is down for 10 minutes, how long does PSP buffer events before dropping?
- Data reconciliation — how often (daily/weekly) will the data of both DBs be compared to detect drift?
- Monitoring access — is there a shared observability tool (Grafana/Datadog) for both services?
11.5 Integration Status & Handover (updated 2026-05-31)
Production-Ready Components
| Component | Status | File/Location |
|---|---|---|
| Database schema (2 DBs) | Applied to staging | Migration 011, 012, 062, 063 |
| Go shared package signing | 7 tests pass | packages/psp_integration_go/ |
| HMAC auth middleware (dashboard_api) | 4 scenarios tested | psp_auth_middleware.go |
| PSP lookup handlers (8) | End-to-end tested | psp_merchants.go |
| Event receiver — payment-service (new) | LIVE port 8085, 30 endpoints. Phase 1+2+3 DEPLOYED 2026-06-03. PSP nginx rule + API key live. | services/payment_service/ |
DECOMMISSIONED Phase 4 2026-06-05. psp_event_receiver.go + psp_push_notifier.go deleted. Route /api/psp/v1/payment-events/* returns 404. | psp_event_receiver.go | |
| AES-256-GCM secret encryption | Integrated + tested | psp_crypto.go |
| CLI encrypt tool | Works (legacy path for psp.api_keys) | cmd/psp-encrypt/main.go |
| Push notification forwarder | Service-to-service to merchant_core_api /internal/notifications/merchant-event (FCM is live) | payment-service forwarder |
| Multi-tenant credential auth (Tier 1 internal + Tier 2 bank + Tier 3 partner) | Live, dual-source (psp.api_keys + partner.api_credentials) | psp_auth_middleware.go §lookupSecretFromPartnerCredential |
| Admin UI for credential lifecycle | Live in dashboard at Master Data → Partner → API Credentials (super_admin only) | dashboard_partner_credentials.go (list / create / rotate / revoke) |
| Dashboard PSP Endpoint Tester (loopback) | Live, signs HMAC server-side, no secret in browser | dashboard_dev_psp_tester.go |
| PSP Outbound client (kesles_merchant → payment.kesles.com) | Live, dispatch OFF by default (mode=disabled). | psp_outbound_client.go, psp_outbound_audit.go, psp_outbound_triggers.go |
| Admin panel — Integrations → PSP Outbound | Live, super_admin only. 4 endpoints (status / audit list / audit detail / replay). | dashboard_psp_outbound_admin.go |
| Postman collection | With HMAC pre-request script | /postman/kesles-psp-integration.postman_collection.json (di merchant_docs/api_docs/internal/static/postman/) |
Pending (Required Before Full Production)
-
FCM / APNs credentialsSOLVED via service-to-service call — payment-service forwards events tomerchant_core_apiviaPOST /internal/notifications/merchant-eventwhich resolves merchant → push tokens → FCM (live). -
Refund push notification —
POST /internal/notifications/merchant-eventdenganevent_type: "transaction.refunded"belum ada mapping dimerchant_event_messages.go. Tambah case sebelum Phase 4. -
Production IP allowlist — currently NULL in dev. Set
allowed_ip_rangesper API key pada deploy (static IP payment.kesles.com harus disediakan terlebih dahulu). -
Admin UI for API key management✅ DONE — Flutter admin panel live di Master Data → Partner → API Credentials (super_admin only). -
Master key rotation playbook — dual-key support during rotation (7-day grace period) belum diimplementasi.
-
api-merchant.kesles.comnginx vhost — pending fix nginxproxy_passtrailing slash. Lihat §10. -
⚠️ Server binding — BLOCKER untuk cross-VM✅ RESOLVED 2026-06-03 — Nginx rule/psp/v1/→127.0.0.1:8085sudah ditambah diapi-merchant.kesles.com. payment.kesles.com panggilhttps://api-merchant.kesles.com/psp/v1/events. TLS terminate di nginx, port 8085 tidak di-expose langsung. -
PSP API key untuk events endpoint belum di-provision✅ DONE 2026-06-04 — Dua key aktif dipsp.api_keys:payment-kesles-com-prod(prod, 300/min) +banking-kesles-com-uat(UAT banking.kesles.com, 60/min).REVOKED karenapsk-banking-kesles-uatallowed_endpoints: []terlalu luas. -
Phase 4 cutover✅ DONE 2026-06-05 —psp_event_receiver.go+psp_push_notifier.godeleted. Route/api/psp/v1/payment-events/*returns 404. Legacy tables (merchant.transactions,merchant.settlements,merchant.refunds,merchant.transaction_daily_fees) dropped via mig 076.
Handover Info for the payment.kesles.com Team
- Events API (
/psp/v1/events,/psp/v1/settlements— port 8085): gunakanpsp.api_keys, provisioning viaPOST /internal/psp/api-keys(lihat langkah di bawah). Header:X-PSP-Key-ID/X-PSP-Timestamp/X-PSP-Signature. - Lookup API (
/api/psp/v1/merchants/*— port 8082): gunakanpartner.api_credentials, provisioning via dashboard UI Master Data → Partner → API Credentials (super_admin only). Header:X-API-Key-ID/X-Timestamp/X-Signature.
Langkah 1 — Provision API key untuk Events endpoint
# Mint API key di payment_service (hanya bisa dari VM, pakai INTERNAL_API_KEY)
curl -X POST http://127.0.0.1:8085/internal/psp/api-keys \
-H "X-Internal-API-Key: QM4bYDFaeG8O2JI4flhlvJsSddxpxIK6WwnWLhpkOp1CEF3T" \
-H "Content-Type: application/json" \
-d '{
"label": "payment.kesles.com production",
"allowed_ip_ranges": ["<IP_STATIC_PAYMENT_KESLES_COM>/32"]
}'
# Response berisi key_id dan hmac_secret (tampil SEKALI — simpan segera)
# {
# "key_id": "psk_xxxxxxxxxxxxxxxx",
# "hmac_secret": "plaintext-secret-copy-immediately",
# "label": "payment.kesles.com production"
# }
Langkah 2 — Provision API key untuk Lookup endpoint
Dashboard UI: Master Data → Partner → Buat Partner KESLES-PAYMENT (psp_role=internal) → API Credentials → Tambah Credential
Langkah 3 — Konfigurasi nginx untuk Events endpoint (cross-VM)
Karena payment_service bind ke 127.0.0.1:8085, payment.kesles.com tidak bisa langsung reach port tersebut. Tambah di nginx api-merchant.kesles.com:
location /psp/v1/ {
proxy_pass http://127.0.0.1:8085;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_read_timeout 30s;
}
Setelah reload nginx, payment.kesles.com panggil:
POST https://api-merchant.kesles.com/psp/v1/events
POST https://api-merchant.kesles.com/psp/v1/settlements
Langkah 4 — Konfigurasi payment.kesles.com
# Env vars yang perlu di-set di payment.kesles.com
KESLES_MERCHANT_EVENTS_BASE_URL=https://api-merchant.kesles.com
KESLES_MERCHANT_PSP_KEY_ID=psk_xxxxxxxxxxxxxxxx # dari Langkah 1
KESLES_MERCHANT_PSP_SECRET=plaintext-secret # dari Langkah 1
KESLES_MERCHANT_LOOKUP_BASE_URL=https://api-merchant.kesles.com
KESLES_MERCHANT_KEY_ID=kesles-payment-internal-xxx # dari Langkah 2
KESLES_MERCHANT_HMAC_SECRET=xxx # dari Langkah 2
Credential delivery template (kirim via 1Password/Bitwarden — JANGAN email/WhatsApp biasa)
=== EVENTS API (payment-service :8085) ===
endpoint_base: https://api-merchant.kesles.com
key_id: psk_xxxxxxxxxxxxxxxx
hmac_secret: <plaintext — copy immediately, never retrievable again>
header_prefix: X-PSP-*
signed_payload: METHOD\nPATH\nTIMESTAMP\nBODY
allowed_ip: <IP payment.kesles.com yang didaftarkan>
=== LOOKUP API (dashboard_api :8082) ===
endpoint_base: https://api-merchant.kesles.com
key_id: kesles-payment-internal-<hex>
hmac_secret: <plaintext>
header_prefix: X-API-*
signed_payload: TIMESTAMP\nMETHOD\nPATH\nBODY
scopes: merchants:read, qris_identifiers:read, kyc_pii:read
expires_at: <90 hari dari tanggal issuance>
Public base URLs:
| Environment | URL | Status (per 2026-06-03) |
|---|---|---|
| Production | https://api-merchant.kesles.com | DNS ✅ · cert ✅ · nginx vhost ✅ · backend live · nginx /psp/v1/ → :8085 ✅ LIVE 2026-06-03 |
| Staging | https://api-merchant-staging.kesles.com | belum provisioned |
Integration Steps untuk payment.kesles.com Team:
- Terima credentials via secure channel (dua set — Events + Lookup)
- Tambah nginx rule
/psp/v1/→ port 8085 (koordinasi dengan ops kesles_merchant) - Set env vars (lihat Langkah 4)
- Import shared Go package
packages/psp_integration_go/untuk HMAC signing - Gunakan endpoint
/psp/v1/eventsdengan format flat + headerX-PSP-*(lihat §4.1). Jangan gunakan legacy/api/psp/v1/payment-events/* - Test dengan
scripts/test_payment_webhook.py(ada di repo kesles_merchant) — minta koordinasi untuk run di VM staging
12. Versioning & Deprecation
12.1 Version Strategy
- Current:
/api/psp/v1/*(explicit v1, established 2026-05-07) - Breaking change:
/api/v2/psp/*with a 6-month deprecation notice - Additive changes (new fields) do not require a new version
12.2 Communication
Contract changes must be announced in:
- The changelog doc in the repo
- The shared Slack channel between the two teams
- Formal email if breaking
13. References
- Mobile API contract:
merchant-mobile-api-contract.md - Mobile app spec:
apps/mobile_user/mvp-spec.md(merchant_docs/docs/apps/mobile_user/mvp-spec.md) - Analytics spec:
merchant-financial-analytics-spec.md(to be created) - Migration 066-068: to be created
- Shared Go package:
packages/psp_integration_go/(to be created) - HMAC standard: RFC 2104
- Stripe webhook signing (reference pattern): https://stripe.com/docs/webhooks/signatures
Summary
A bi-directional API contract covering:
- Lookup API (dashboard_api, port 8082): 9 GET endpoints for merchant data
- Events API (payment-service, port 8085):
POST /psp/v1/events+POST /psp/v1/settlements - NMID Assignment callback:
PATCH /api/psp/v1/merchants/{id}/nmid-assignment - Outbound lifecycle: 7 typed methods kesles_merchant → payment.kesles.com
Security: HMAC-SHA256 + IP allowlist + audit log. Active-only filter for all merchant data going to PSP. Idempotent + retry-safe. Flow A/B routing in payment-service based on NMID (merchant vs corporate NMID).
Phase 4 action: deprecate legacy POST /api/psp/v1/payment-events/* in dashboard_api once all callers have migrated to payment-service.