Skip to main content

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_api is already multi-tenant: dual-source auth (psp.api_keys + partner.api_credentials HMAC), middleware attaches psp_role + psp_bank_code to the request context, and the /api/psp/v1/merchants/* handler automatically injects WHERE bank_code = $X for callers with role bank.

Tier 2 — External PSP / Bank (Mandiri, BRI, BNI, other acquirers) is now ready for onboarding via:

  1. Insert a row in partner.partners (psp_role='bank', psp_bank_code='BMRI'/'BRIN'/'BBNI'/etc).
  2. Insert a row in partner.api_credentials (auth_method='hmac', hmac_secret_encrypted via the psp-encrypt CLI, optional allowed_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 whose psp_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

Aspectkesles_merchantpayment.kesles.com
Data ownerMerchant profile, KYC, outlet, user, analytics, revenue, profitQRIS master transactions, settlement, QRIS payload, bank credentials
Public-facingMerchant mobile app + web dashboardH2H to bank, customer payment page
DB schemamerchant.*, psp.*, db_reference.*payment.* (own DB)
Public APIapi-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

  1. Idempotent — every operation can be repeated without duplicate side effects (via X-Idempotency-Key)
  2. Active-only exposure — kesles_merchant only exposes merchants with status = 'active' AND deleted_at IS NULL AND nmid IS NOT NULL to the PSP
  3. Audit log all requests — in psp.event_log and psp.outbound_requests
  4. Retry-safe — both services must handle retry with exponential backoff
  5. 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:

  1. Extract key ID (X-API-Key-ID or X-PSP-Key-ID) → look up the secret from psp.api_keys.hmac_secret_encrypted or partner.api_credentials.hmac_secret_encrypted (decrypt via AES)
  2. Check timestamp freshness — reject if now - timestamp > 300 seconds (prevent replay)
  3. Re-compute the HMAC from the received request, compare with the signature header using hmac.Equal() (constant-time, prevent timing attack)
  4. Check IP allowlist (optional but recommended): allowed_ip_ranges covers the caller's IP
  5. 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_at field on partner.api_credentials. Automated reminder in the admin UI is NOT yet implemented — operators must currently track expiry manually (calendar reminder ~7 days before expires_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)
  • 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:

  1. Generate a new master key: openssl rand -base64 32
  2. 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)
  3. Re-encrypt all rows in psp.api_keys with the new key
  4. 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:

  1. Admin generates an API key in the Admin UI (or manual SQL insert for MVP)
  2. The system outputs the plaintext secret ONCE — it cannot be recovered
  3. 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
  4. The payment.kesles.com team stores it in their env var (KESLES_MERCHANT_HMAC_SECRET)
  5. Rotation notification is sent 7 days before expiry (expires_at in psp.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:

ParamTypeDefaultDescription
limitint100Max 500
cursorstring-Opaque pagination cursor
updated_sincedatetime (ISO8601)-Delta sync — returns only merchants whose updated_at ≥ value
bank_codestring-Filter per PSP bank (BMRI, BCA, etc)
category_codestring-Filter MCC category
include_outletsboolfalseInclude 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 outlets
  • include_owners (bool, default false) — include owner/manager users
  • include_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 granular terminal_status on merchant are populated from a LATERAL ... LIMIT 1 join to merchant.payment_terminal_inventory (see migration 072), so the response carries at most one terminal even when a merchant has several. They are tagged omitempty in packages/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 on merchant.merchants but where no physical device has been registered in payment_terminal_inventory yet. 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):

FieldTypeExampleDescription
device_modelstring"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_statusenum active | inactive"active"Binary roll-up of the 9-value internal terminal status. activepayment_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 match
  • email — exact match (case-insensitive)
  • npwp — exact match
  • nmid — 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. pspListDevices routes via corePSPClientmerchant_core_api (which reads from inventory_service). The earlier backing view psp.v_active_merchant_devices was 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_statusInternal payment_terminal_inventory.status (mig 024 + 035)
activeactive, online
inactivestock, 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-device tid (and likely an outlet_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:

TierCaller (psp_role)Row scopingserial_number field
1internal (payment.kesles.com)none (all merchants)visible
2bankWHERE bank_code = caller_bank_codevisible
3none (Tier-3 partner / unauthenticated)per-contextempty (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 (the device_model field 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 corePSPClientmerchant_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_number empty.

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, format MRC-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)

payment-service — Port 8085

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).

Legacy Endpoints

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:

KondisiAlurAksi
nmid = NMID milik merchant (merchant.merchants.nmid)Flow A — customer membayar ke merchantINSERT payment.transactions + push notification ke merchant
nmid = ID9999999999999 (NMID korporat Kesles)Flow B — merchant membeli produk KeslesNotify 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):

  1. Verify HMAC signature (X-PSP-Key-ID + X-PSP-Timestamp + X-PSP-Signature)
  2. Check external_event_id against psp.event_log — if duplicate, return 200 without re-processing
  3. Determine flow based on nmid (see routing table above)
  4. Insert into payment.transactions
  5. Async: push notification to merchant via core_api /internal/notifications/merchant-event
  6. Log to psp.event_log
  7. Return 200

Server behavior for nmid.status_changed:

  1. Verify HMAC
  2. Lookup merchant by nmid
  3. Update merchant.merchants.status based on nmid_new_status
  4. Push notification to merchant owner
  5. Log to psp.event_log

Errors:

StatusCodeMeaning
401invalid_signatureHMAC mismatch
401timestamp_expiredTimestamp older than 5 minutes
401api_key_revokedAPI Key has been revoked
422invalid_payloadRequired field missing or wrong format
429rate_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

AspekLegacy (dashboard_api)New (payment-service)
Path transactionsPOST /api/psp/v1/payment-events/transactionPOST /psp/v1/events
Path settlementPOST /api/psp/v1/payment-events/settlementPOST /psp/v1/settlements
Path refundPOST /api/psp/v1/payment-events/refundPOST /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-statusPOST /api/psp/v1/payment-events/nmid-status-changedPOST /psp/v1/events (event_type: nmid.status_changed)
Port80828085
Header authX-API-Key-ID / X-Timestamp / X-SignatureX-PSP-Key-ID / X-PSP-Timestamp / X-PSP-Signature
Payload formatNested envelope {event_id, event_type, payload: {...}}Flat {external_event_id, event_type, nmid, gross_amount, ...}
Event ID fieldevent_idexternal_event_id
nmid-status fieldsold_status, new_status, reasonnmid_old_status, nmid_new_status, nmid_reason
signed_payload ordertimestamp\nmethod\npath\nbodymethod\npath\ntimestamp\nbody
StatusAktif ❌ DECOMMISSIONED Phase 4 2026-06-05Aktif (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 client PSPOutboundClient with 7 typed methods: RegisterMerchant, UpdateMerchant, SuspendMerchant, ReactivateMerchant, TerminateMerchant, InitiateRefund, LookupTransaction. HMAC signing via shared packages/psp_integration_go.Signer (same string-to-sign as inbound verifier).
  • services/dashboard_api/internal/app/psp_outbound_audit.go — wrapper over psp.outbound_requests table (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.go handleMerchantRegistrationApprove) → RegisterMerchant
    • merchant PATCH (dashboard_merchants.go updateMerchantHandler) → Suspend/Reactivate/Terminate/Update depending on status transition
    • merchant DELETE (dashboard_merchants.go deleteMerchantHandler) → TerminateMerchant
  • 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):

ModeSide effect
disabled (default)No-op: handler short-circuits on IsEnabled() check. Zero alloc, zero DB write, zero HTTP.
audit_onlySign + 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.
dispatchFull 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_apino 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 eventFiresPath (on payment.kesles.com)
Merchant registration approvedRegisterMerchantPOST /api/internal/merchants
PATCH status activesuspendedSuspendMerchantPOST /api/internal/merchants/{id}/suspend
PATCH status suspendedactiveReactivateMerchantPOST /api/internal/merchants/{id}/reactivate
PATCH status *inactiveTerminateMerchantPOST /api/internal/merchants/{id}/terminate
PATCH detail (non-status, e.g. NPWP/address/contact)UpdateMerchantPATCH /api/internal/merchants/{id}
DELETE merchant (soft-delete)TerminateMerchantPOST /api/internal/merchants/{id}/terminate
Refund operator action (handler TBD)InitiateRefundPOST /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/):

MethodPathPurpose
GET/statusReturns { 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}/replayRe-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 > 0 in /audit query to find replays.
  • Replay is mode-aware: in audit_only it writes a new row with dispatch_disabled note but still no HTTP; in dispatch it re-signs and re-sends.
  • Idempotency between attempts is the responsibility of the payment.kesles.com side (they should de-dupe by X-Request-ID within the 5-minute replay window).

Status grouping (used by the status= filter):

  • successresponse_status BETWEEN 200 AND 299
  • failedresponse_status non-2xx or NULL with error_message IS NOT NULL
  • pendingresponse_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

HTTPError CodeMeaning
400invalid_requestMalformed JSON or query param
400invalid_cursorInvalid cursor
400invalid_nmid_formatNMID does not match the BI format
401unauthorizedMissing auth headers
401invalid_signatureHMAC verification failed
401timestamp_expiredTimestamp older than 5 minutes
401api_key_revokedAPI Key has been revoked
403ip_not_allowedCaller IP is not in the allowlist
403endpoint_not_allowedAPI Key may not access this endpoint
404merchant_not_foundMerchant not found or non-active
404outlet_not_found
409duplicate_resourceUnique constraint violation
409nmid_already_assignedMerchant already has another NMID
422validation_failedInput validation failed
429rate_limit_exceededPer-API-key rate limit exceeded
500internal_errorUnexpected server error (detail logged, not returned)

7. Rate Limiting & Performance SLA

7.1 Per API Key Rate Limits

Endpoint PatternRate 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/search300 req/minute
GET /api/psp/v1/merchants/{id}/devices300 req/minute
GET /api/psp/v1/merchants/{id}/outlets300 req/minute
GET /api/psp/v1/merchants/{id}/owners300 req/minute
GET /api/psp/v1/reference/*100 req/minute
PATCH /api/psp/v1/merchants/{id}/nmid-assignment60 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/* (DECOMMISSIONED 2026-06-05)

Response headers:

X-RateLimit-Limit: 600
X-RateLimit-Remaining: 543
X-RateLimit-Reset: 1716452640

7.2 Performance SLA

Endpointp50p95p99
GET /merchants/by-nmid/{nmid}30ms50ms100ms
GET /merchants/by-mid/{mid}30ms50ms100ms
GET /merchants/by-code/{merchant_code}30ms50ms100ms
GET /merchants/by-serial/{serial_number}30ms50ms100ms
GET /merchants/{id}50ms100ms200ms
GET /merchants (list limit=100)150ms300ms500ms
GET /merchants/{id}/outlets50ms100ms150ms
PATCH /merchants/{id}/nmid-assignment80ms150ms300ms
POST /psp/v1/events (payment-service)80ms180ms400ms

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_at
  • qris_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 classification
  • business_scale_* (MIKRO/KECIL/MENENGAH) — internal analytics
  • average_monthly_revenue, employee_count — privacy + competitive info
  • referral_* — internal partner attribution
  • device_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-event with header X-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_tokens via FindActivePushTokensByMerchantID
    • Sends FCM HTTP v1 via push.Client.Send()
    • Auto-deactivates invalid/expired tokens (push.IsInvalidTokenError)
    • Returns push summary (sent_count, failed_count, invalidated_count)
  • 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

Payload mapping from payment-service event → core-api internal format:

payment-service fieldCore-api fieldNotes
reference_numberreferenceNo
status00/01responseCodesuccess=00, failed=01
nmid (resolved to merchant_id)merchantId
transaction_codepartnerReferenceNoPSP external id
gross_amount (string)amount
rrnapprovalCode
statusstatussuccess/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 TransitionOutbound Call
pendingactivePOST payment.kesles.com/api/psp/v1/merchants (register)
activesuspendedPOST .../merchants/{id}/suspend
activeinactivePOST .../merchants/{id}/deactivate
suspendedactivePOST .../merchants/{id}/reactivate
any → deletedPOST .../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 (schema psp + psp.api_keys + psp.event_log + psp.outbound_requests + view psp.v_active_merchants + NMID/QRIS columns on merchant.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 (extend psp.v_active_merchants)
  • Apply migration 087_partner_psp_role_and_hmac.sql (multi-tenant: partner.api_credentials.auth_method='hmac' + partners.psp_role for 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] services/dashboard_api/internal/app/psp_event_receiver.goDELETED 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_crypto.go — AES-256-GCM enc:v1: envelope for hmac_secret_encrypted
  • [x] services/dashboard_api/internal/app/psp_push_notifier.goDELETED Phase 4 2026-06-05. Functions hanya dipanggil dari psp_event_receiver.go (sudah dihapus).
  • services/dashboard_api/internal/app/psp_outbound_client.go — outbound HTTP client to payment.kesles.com, 7 typed methods. Mode-gated by PSP_OUTBOUND_MODE env (default disabled); 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 psp.v_active_merchant_devices (mig 126) — view di-DROP mig 101 (2026-06-09), pspListDevices sekarang route ke corePSPClientmerchant_core_api atau fallback langsung ke inventory_service; (3) reverse-lookup GET /api/psp/v1/merchants/by-serial/{serial_number} (§3.6.6); (4) include_devices=true param DONE 2026-06-09 — wired ke §3.2/§3.3/§3.4/§3.5 detail handlers; (5) Outlet.device_count DONE 2026-06-09 — real count via inventory_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 = ID9999999999999 dari payment.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-merchantDEPLOYED 2026-06-03, PAYMENT_SERVICE_BASE_URL=http://127.0.0.1:8085 aktif
  • Nginx rule /psp/v1/127.0.0.1:8085 aktif di api-merchant.kesles.comLIVE 2026-06-03
  • PSP API key provisioned di psp.api_keysDONE 2026-06-04: payment-kesles-com-prod (300/min) + banking-kesles-com-uat (60/min). psk-banking-kesles-uat REVOKED.
  • Refund push notification — tambah case transaction.refunded di merchant_event_messages.go
  • Phase 4: POST /api/psp/v1/payment-events/* di dashboard_api DECOMMISSIONED 2026-06-05psp_event_receiver.go + psp_push_notifier.go deleted, route returns 404
  • Phase 6: DROP legacy payment tables (mig 076) — APPLIED 2026-06-05merchant.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 replace directive from services/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.com team
  • 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.com team
  • 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.com vhost — see runbooks/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 (loopback curl /api/psp/v1/merchants401 missing_auth_headers ✅). But nginx proxy_pass config still wrong: trailing slash strips the /api/psp/v1 prefix before forwarding, so backend receives path /merchants and returns 404 route not registered: /merchants. Same issue for /api/partner/v1/. Root / still serves static HTML 200 instead of return 404. Fix: edit vhost to proxy_pass http://localhost:8082; (no trailing slash), proxy_pass http://localhost:8080; for Partner, replace location / body with return 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:

  1. payment.kesles.com endpoint spec — what endpoints will they expose? (for outbound kesles_merchant calls)
  2. payment.kesles.com IP — what static IP will be allowlisted?
  3. Error retry coordination — if kesles_merchant is down for 10 minutes, how long does PSP buffer events before dropping?
  4. Data reconciliation — how often (daily/weekly) will the data of both DBs be compared to detect drift?
  5. 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

ComponentStatusFile/Location
Database schema (2 DBs)Applied to stagingMigration 011, 012, 062, 063
Go shared package signing7 tests passpackages/psp_integration_go/
HMAC auth middleware (dashboard_api)4 scenarios testedpsp_auth_middleware.go
PSP lookup handlers (8)End-to-end testedpsp_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/
Event receiver — legacy (dashboard_api)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 encryptionIntegrated + testedpsp_crypto.go
CLI encrypt toolWorks (legacy path for psp.api_keys)cmd/psp-encrypt/main.go
Push notification forwarderService-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 lifecycleLive 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 browserdashboard_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 OutboundLive, super_admin only. 4 endpoints (status / audit list / audit detail / replay).dashboard_psp_outbound_admin.go
Postman collectionWith HMAC pre-request script/postman/kesles-psp-integration.postman_collection.json (di merchant_docs/api_docs/internal/static/postman/)

Pending (Required Before Full Production)

  1. FCM / APNs credentials SOLVED via service-to-service call — payment-service forwards events to merchant_core_api via POST /internal/notifications/merchant-event which resolves merchant → push tokens → FCM (live).

  2. Refund push notificationPOST /internal/notifications/merchant-event dengan event_type: "transaction.refunded" belum ada mapping di merchant_event_messages.go. Tambah case sebelum Phase 4.

  3. Production IP allowlist — currently NULL in dev. Set allowed_ip_ranges per API key pada deploy (static IP payment.kesles.com harus disediakan terlebih dahulu).

  4. Admin UI for API key managementDONE — Flutter admin panel live di Master Data → Partner → API Credentials (super_admin only).

  5. Master key rotation playbook — dual-key support during rotation (7-day grace period) belum diimplementasi.

  6. api-merchant.kesles.com nginx vhost — pending fix nginx proxy_pass trailing slash. Lihat §10.

  7. ⚠️ Server binding — BLOCKER untuk cross-VMRESOLVED 2026-06-03 — Nginx rule /psp/v1/127.0.0.1:8085 sudah ditambah di api-merchant.kesles.com. payment.kesles.com panggil https://api-merchant.kesles.com/psp/v1/events. TLS terminate di nginx, port 8085 tidak di-expose langsung.

  8. PSP API key untuk events endpoint belum di-provisionDONE 2026-06-04 — Dua key aktif di psp.api_keys: payment-kesles-com-prod (prod, 300/min) + banking-kesles-com-uat (UAT banking.kesles.com, 60/min). psk-banking-kesles-uat REVOKED karena allowed_endpoints: [] terlalu luas.

  9. Phase 4 cutoverDONE 2026-06-05psp_event_receiver.go + psp_push_notifier.go deleted. 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

Dua credential berbeda untuk dua endpoint berbeda
  • Events API (/psp/v1/events, /psp/v1/settlements — port 8085): gunakan psp.api_keys, provisioning via POST /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): gunakan partner.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:

EnvironmentURLStatus (per 2026-06-03)
Productionhttps://api-merchant.kesles.comDNS ✅ · cert ✅ · nginx vhost ✅ · backend live · nginx /psp/v1/:8085 ✅ LIVE 2026-06-03
Staginghttps://api-merchant-staging.kesles.combelum provisioned

Integration Steps untuk payment.kesles.com Team:

  1. Terima credentials via secure channel (dua set — Events + Lookup)
  2. Tambah nginx rule /psp/v1/ → port 8085 (koordinasi dengan ops kesles_merchant)
  3. Set env vars (lihat Langkah 4)
  4. Import shared Go package packages/psp_integration_go/ untuk HMAC signing
  5. Gunakan endpoint /psp/v1/events dengan format flat + header X-PSP-* (lihat §4.1). Jangan gunakan legacy /api/psp/v1/payment-events/*
  6. 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.