PSP Integration Contract
Technical contract for PSPs integrating with the Kesles Merchant platform.
This document is only for parties with a signed NDA with Kesles. Do not distribute.
1. Core Principles
- Read-only — PSPs have GET-only access to merchant data via the Lookup API. Write endpoints (NMID assignment, payment events) require Tier-1 Internal access.
- Data isolation — each bank only sees merchants whose NMID is issued by that acquirer. Bank A cannot see Bank B's merchants.
- Active-only — only merchants with
status = active,deleted_at IS NULL, andnmid IS NOT NULLare returned. - Idempotent — all requests are safe to retry (use
X-Request-ID). - Eventual consistency — data may lag by ≤30 seconds.
2. Authentication — HMAC-SHA256
Auth headers differ depending on which service you are calling:
2.1 Lookup API — dashboard_api (port 8082)
Applies to: GET /api/psp/v1/merchants/*
X-API-Key-ID: <public key identifier — safe to log>
X-Timestamp: <unix seconds, current time>
X-Signature: hmac-sha256=<computed signature>
X-Request-ID: <uuid v4 for tracing>
2.2 Events API — payment-service (port 8085)
Applies to: POST /psp/v1/events, POST /psp/v1/settlements
X-PSP-Key-ID: <public key identifier — safe to log>
X-PSP-Timestamp: <RFC3339 timestamp, current time — e.g. 2026-06-08T10:30:00+07:00>
X-PSP-Signature: <computed signature — raw hex, no prefix>
X-Request-ID: <uuid v4 for tracing>
2.3 How to Compute the Signature
Lookup API — string-to-sign: {timestamp}\n{method}\n{path}\n{body}
Example for GET /api/psp/v1/merchants/by-nmid/ID102326873XXXX:
1716452580
GET
/api/psp/v1/merchants/by-nmid/ID102326873XXXX
(Body is empty for GET; the trailing newline remains.)
Events API — string-to-sign: {method}\n{path}\n{timestamp}\n{body}
Example for POST /psp/v1/events:
POST
/psp/v1/events
2026-06-08T10:30:00+07:00
{"external_event_id":"psp-evt-abc-123","event_type":"transaction.success",...}
Important: the field order in signed_payload differs between the two services. The Lookup API uses unix seconds for {timestamp}; the Events API uses RFC3339. Use the correct format for each target endpoint.
Compute HMAC-SHA256 (same for both services — emit raw hex; only the Lookup API header prepends hmac-sha256=):
// Go
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(stringToSign))
signature := hex.EncodeToString(mac.Sum(nil))
# Python
import hmac, hashlib
sig = hmac.new(secret.encode(), string_to_sign.encode(), hashlib.sha256)
signature = sig.hexdigest()
// Node.js
const crypto = require('crypto');
const signature = crypto.createHmac('sha256', secret).update(stringToSign).digest('hex');
2.4 Server-Side Validation
The server performs 5 checks before processing a request:
- Key ID (
X-API-Key-IDorX-PSP-Key-ID) is valid and registered - Timestamp freshness — rejected if
|now - timestamp| > 300 seconds(prevents replay attacks) - HMAC signature matches (constant-time comparison)
- Caller IP is in the allowlist (if configured)
- Rate limit per API Key ID
If any of these fails: the server returns 401 or 403 without specific details.
2.5 API Key Rotation
- Rotate every 90 days — schedule is agreed on at onboarding
- 7-day grace period — two keys are active simultaneously during rotation
- Instant revocation via the Kesles team — any request with the old key is immediately rejected with 401
- The secret is re-sent via a secure channel (1Password / Bitwarden), never via email
3. Base URL
Production : https://api-merchant.kesles.com
Staging : https://api-merchant-staging.kesles.com
4. Available Endpoints
4.1 Merchant Lookup (Lookup API — dashboard_api)
| Method | Path | Description |
|---|---|---|
GET | /api/psp/v1/merchants | List all active merchants (cursor paginated) |
GET | /api/psp/v1/merchants/{id} | Merchant detail by UUID |
GET | /api/psp/v1/merchants/by-nmid/{nmid} | Merchant lookup by NMID (hot endpoint) |
GET | /api/psp/v1/merchants/search | Search merchants by phone/email/npwp |
4.2 Payment Events (Events API — payment-service)
| Method | Path | Description |
|---|---|---|
POST | /psp/v1/events | Forward a payment event (transaction, refund, nmid-status-changed) |
POST | /psp/v1/settlements | Forward a daily settlement batch |
The following endpoints are restricted to Tier-1 Internal PSP and will return 403 for external bank PSPs:
PATCH /api/psp/v1/merchants/{id}/nmid-assignment
Error Responses
| Code | Meaning |
|---|---|
400 | Malformed request body or invalid field values |
401 | Missing or invalid authentication headers |
403 | Valid key but not authorized for this endpoint |
429 | Rate limit exceeded — implement exponential backoff |
500 | Transient infrastructure failure (DB timeout, service unavailable). PSPs MUST retry with exponential backoff for 500 responses. |
A duplicate external_event_id is not a 409 (or any error). The server is idempotent: it responds with HTTP 200 and body {"status":"duplicate_skipped"} (optionally a "message" field). Treat this as a successful, already-processed event — safe to ignore.
5xx retry guidance: the server returns
500for transient infrastructure failures. Do not drop the event on500— queue it for retry with exponential backoff (suggested: 1s, 2s, 4s, 8s, capped at 5 minutes). Reuse the sameX-Request-IDon retry so the server can deduplicate.
5. Rate Limits
| Endpoint | Limit |
|---|---|
GET /merchants | 60 req/min per API key |
GET /merchants/{id} | 600 req/min per API key |
GET /merchants/by-nmid/{nmid} | 1,000 req/min per API key |
GET /merchants/search | 120 req/min per API key |
POST /psp/v1/events | 6,000 req/min per API key |
POST /psp/v1/settlements | 600 req/min per API key |
Returns 429 if the limit is exceeded. Implement exponential backoff.
6. Standard Error Format
{
"error": "human readable message",
"code": "machine_readable_code"
}
See Error Codes for the complete list.