Partner Access Setup — Operator Runbook
Audience: Admin / super_admin of the Kesles Merchant dashboard.
Prereq: Logged in to the admin dashboard, holding role admin / super_admin / partner_admin.
Architecture reference: partner-access-scope-plan.md.
This document is the operational guide for setting up merchant data access for partners: who can see which merchants, via which API, and how to disconnect/revoke access when the contract ends.
1. Quick Concepts
Partner in Kesles = an external entity that has read access to merchant data via the /api/partner/v1/* API. Each partner has:
- Identity — a row in
partner.partners(name, type, status). - Access Scope — visibility mode: which merchants may be seen.
- API Credentials — key/secret pair for API auth.
These three are configured independently. Admins can create the identity without immediately granting credentials (e.g. while still negotiating a contract), and can change the access scope at any time without regenerating credentials.
2. Decision Matrix — Access Scope Mode
Quick guide for picking the right mode based on the type of partnership.
| Partnership with… | Partner Type | Access Scope Mode | access_partner_ids |
|---|---|---|---|
| Regular sales/reseller acquiring merchants | sales, reseller, agency, affiliate, community | own_referral | empty |
| Bank / lender during merchant underwriting | bank, finance_lender | explicit | empty — granted manually per merchant |
| 3rd-party scoring bureau | scoring_bureau | explicit | empty |
| VC/angel investing in 1 partner (Joob ecosystem only) | venture_capital | partner_network | {<Joob uuid>} |
| Holding shareholder owning multi partner (A+B+C) | shareholder | partner_network | {<uuid A>, <uuid B>, <uuid C>} |
| Joint fee-share gateway across 2 ecosystems | agency / other | partner_network | {<uuid A>, <uuid B>} |
| Regulator (OJK/BI), Kesles shareholder, internal BI | regulator, shareholder, other | global (super_admin only) | — |
When in doubt: default to own_referral + empty list. The partner will not see anything until the admin actually sets a scope — this is safe, not a bug. Fail-closed.
3. Runbook 1 — Onboarding a New Partner
Scenario: Kesles signed an MoU with a new partner; now it needs to be configured in the dashboard.
Step 1 — Create the Partner Record
- Log in to the admin dashboard.
- Sidebar → Master Data → Partners.
- Click + Add Partner.
- Fill in the form:
- Partner Code — auto-generated (
PRT-<random hex>), no need to fill in. - Partner Name — display name (e.g. "PT Joob Global").
- Legal Name — official name per legal documents.
- Partner Type — pick per the Matrix §2.
- Entity Type —
company/individual/organization/community. - Status —
active. - Contact Person, Phone, Email — PIC contact for technical communication.
- Referral Code — fill in only if the partner recruits merchants; leave empty for viewers (VC, regulator, bank).
- Notes — MoU summary: contract number, date, scope expectations.
- Partner Code — auto-generated (
- Save Partner.
Result: a new row in partner.partners, default access_scope_mode='own_referral', access_partner_ids='{}'. This partner cannot hit the API yet because there are no credentials.
Step 2 — Set Access Scope
On the Partners page, find the newly created partner → click the shield icon 🛡️ in the Action column → the Access Control dialog appears.
In the dialog:
- Pick the mode per matrix §2.
own_referral— regular sales partner, no extra configuration needed.explicit— bank/lender, no configuration needed in this dialog; grants are filled in manually in thepartner.merchant_accesstable when consent is signed.partner_network— a checkbox list of all other partners appears. Tick the source partners this viewer is allowed to see.global— only available for super_admin. There is a red warning + double confirmation.
- Click Save.
- A confirmation modal appears with a Reason field (mandatory when the mode changes or when set to
global). - Fill in an informative reason for audit, e.g.:
- "Akbar Investor in Joob Global per MoU 2026-02-14"
- "Joint fee-share gateway with PT A and PT B per contract 2026-Q2-007"
- "OJK access for the 2026 annual audit per SE OJK 14/2026"
- Save again → a row is recorded in
partner.access_scope_changes(permanent audit trail).
Step 3 — Generate API Credential
In the same panel (Partners page), open the partner detail → tab API Credentials → click + Create Credential.
Modal:
- Label — name for internal identification (e.g. "Akbar — production", "Joob CRM integration"). Not exposed to the partner.
- Scopes — tick what is needed:
merchant.read— list of merchants + identity details.transaction.read— transactions per merchant.scoring.read— business scoring summary.risk-summary.read— alternative scoring (aliased).
- Expires — pick:
Never— permanent credential (not recommended for external partners).30 days/90 days/1 year— rotation required before that date.
- Click Create.
Reveal-once modal appears:
⚠️ Credential Secret — save now
credential_key: akb_pk_2026_a1b2c3
credential_secret: kls_sk_9f4e2a7d8b1c6e3f5a0b8d2c4e6f1a3b [📋 Copy]
Send to the partner via a secure channel.
IMPORTANT:
- The secret cannot be viewed again after the modal is closed. Only the hash is stored in the DB.
- If you forget to save it / don't copy in time → must revoke + create again.
- Send it to the partner via a secure channel: encrypted email, signed PDF, or a shared password manager. NOT via public chat / plain email.
Step 4 — Partner Verification
Ask the partner to test from their side:
# Step 1 — exchange credential for token
curl -X POST https://api.kesles.example/api/partner/v1/auth/token \
-H "Content-Type: application/json" \
-d '{"credential_key": "akb_pk_2026_a1b2c3",
"credential_secret": "kls_sk_9f4e..."}'
# Step 2 — hit portfolio endpoint
curl https://api.kesles.example/api/partner/v1/partners/<partner_id>/portfolio \
-H "Authorization: Bearer <access_token_from_step_1>"
Expected:
- Step 1 returns
access_token+expires_in. - Step 2 returns JSON with the number of merchants matching the configured scope.
If the partner reports "got no merchants at all" → see Troubleshooting §9.
4. Runbook 2 — Change Access Scope of an Existing Partner
Scenario: An existing partner needs additional visibility (e.g. Akbar now also invests in partner B, needs to be added to their network).
- Master Data → Partners → find the partner → click shield 🛡️.
- The dialog opens with the current mode + list.
- Tick the additional partner in the list (or untick those to revoke).
- Save → enter a new reason, e.g. "Add partner B per MoU addendum 2026-04-22".
- New audit row in
partner.access_scope_changeswith before/after.
Immediate effect: the next partner request immediately applies the new scope. No restart required.
To downgrade from global to partner_network:
Same. Fill in reason "revocation of global access per contract expiry". Super_admin is not required — even a regular admin can drop to a stricter mode.
To upgrade to global:
Super_admin only. The confirmation modal will warn in red. The server log will emit partner_access_scope.global_enabled for SRE alerting.
5. Runbook 3 — Revoking Partner Access
Scenario: The partnership has ended; the partner must lose access.
Option A — Full suspend (recommended)
- Master Data → Partners → edit the partner → Status =
inactiveorsuspended→ Save. - Backend rejects all token issuance for this partner's credential (401 Unauthorized).
Option B — Revoke credential (if you want the partner identity to remain active but API access revoked)
- Master Data → Partners → partner detail → tab API Credentials.
- Click Revoke on the active credential → confirm.
- The credential
statusis set torevokedin the DB; the credential cannot be used to obtain a new token. - Existing tokens remain valid until they expire (max 1 hour) — by design stateless JWT.
Option C — Downgrade scope without revoke
- Open the Access Control dialog.
- If mode
partner_network→ uncheck all partners; mode becomespartner_networkwith an empty list → backend fails closed, the partner gets 0 merchants even though the credential is still active. - Or switch to
explicitwithout granting any merchants.
Which to use?
- Contract expired / terminated → Option A.
- Credential rotation (security hygiene) → Option B, then issue a new credential.
- Temporary pause (e.g. active dispute) → Option C.
6. Credential vs Token — Important Note
These are often confused. Summary:
| Credential | Token | |
|---|---|---|
| Lifetime | Monthly / yearly | ~1 hour |
| Created by | Kesles admin (dashboard) | Partner (hits /auth/token) |
| Function | Permanent identity | Temporary access ticket |
| Stored by partner | Secret manager | In memory only |
| If leaked | Must revoke immediately | Wait for expiry (≤1 hour) |
| How to revoke | Set status = revoked in DB | Not needed — expires on its own |
See the full explanation in partner-access-scope-plan.md §6.
7. Audit — Who Changed What
Every access-scope change is permanently recorded in partner.access_scope_changes — minimum retention 5 years (compliance).
Check the last 10 changes for 1 partner
SELECT
created_at,
actor_role,
changed_by_user_id,
before_mode,
after_mode,
before_partner_ids,
after_partner_ids,
reason,
ip_address
FROM partner.access_scope_changes
WHERE partner_id = '<uuid>'
ORDER BY created_at DESC
LIMIT 10;
Check who has ever been set to global
SELECT
c.created_at,
p.partner_code, p.partner_name,
c.actor_role, c.changed_by_user_id,
c.reason
FROM partner.access_scope_changes c
JOIN partner.partners p ON p.id = c.partner_id
WHERE c.after_mode = 'global'
ORDER BY c.created_at DESC;
Check out-of-scope requests (indicator of credential theft)
SELECT
created_at, partner_id, request_path,
response_status, access_scope_mode
FROM partner.api_audit_logs
WHERE scope_violation = true
AND created_at >= now() - interval '7 days'
ORDER BY created_at DESC;
8. Credential Rotation (Security Hygiene)
Recommendation: rotate credentials every 90 days for active partners.
Flow:
- Notify the partner 7 days in advance.
- Generate a new credential via the Create Credential button (step 3 §3).
- Send to the partner via a secure channel.
- Partner switches their tooling to the new credential.
- Once the partner confirms the switch → Revoke the old credential.
Do not revoke before the partner switches — an overlap window is required.
9. Troubleshooting
"Partner says the API returns 0 merchants"
Step-by-step diagnosis:
-
Credential active?
SELECT id, credential_key, status, expires_atFROM partner.api_credentialsWHERE partner_id = '<uuid>';If
status = 'revoked'orexpires_at < now()→ credential is dead. -
Check the partner's access scope?
SELECT access_scope_mode, access_partner_idsFROM partner.partnersWHERE id = '<uuid>';- If mode is
partner_networkbutaccess_partner_idsis empty{}→ fail-closed, returns 0 merchants. Fix: open Access Control, tick the relevant partners. - If mode is
own_referralbut this partner truly has no referred merchants → returning 0 is correct.
- If mode is
-
Check whether any merchant matches the filter?
-- For mode own_referralSELECT COUNT(*) FROM merchant.merchantsWHERE referral_partner_id = '<uuid>' AND deleted_at IS NULL;-- For mode partner_networkSELECT COUNT(*) FROM merchant.merchantsWHERE referral_partner_id = ANY((SELECT access_partner_ids FROM partner.partners WHERE id = '<uuid>'))AND deleted_at IS NULL; -
Check the partner's most recent request audit log:
SELECT created_at, request_path, access_scope_mode,merchant_scope_count, response_status, scope_violationFROM partner.api_audit_logsWHERE partner_id = '<uuid>'ORDER BY created_at DESCLIMIT 5;
"Partner says response is 403 Forbidden"
The partner hit an endpoint for a merchant outside their scope. This is by design — the ScopeResolver helper filters at the query level, and the requested merchant does not match → 403 + scope_violation=true in the audit.
Action: No fix needed if it is genuinely out of scope. If it really should be allowed → add the merchant to the scope:
- Mode
own_referral/partner_network: add the merchant viapartner.merchant_accessgrants, or make sure the merchant'sreferral_partner_idis correct. - Mode
explicit: add a grant inpartner.merchant_access.
"Partner says response is 401 Unauthorized"
Bearer token expired or invalid. The partner must hit POST /api/partner/v1/auth/token again with their credential to get a new token.
If the credential is correct and it's still 401 → check the partner's status (might be suspended):
SELECT status, deleted_at FROM partner.partners WHERE id = '<uuid>';
"Admin cannot change the mode to global"
Mode global can only be set by the super_admin role. For regular admins → the global option is disabled in the dialog.
If genuinely needed → escalate to a super_admin or have the super_admin perform the change.
10. Core Steps Summary
1. Add Partner → Master Data → Partners → + Add Partner
2. Set Access Scope → click shield 🛡️ → pick mode → Save + reason
3. Create Credential → tab API Credentials → + Create Credential → copy secret
4. Send to partner → secure channel
5. Partner verifies → curl /auth/token + /portfolio
6. Monitor → audit log + SRE alerts for mode 'global'
7. Rotate → 90 days: create new, send, revoke old
8. Terminate → suspend partner or revoke credential
11. Revenue Share — Fee Type & Basis
The Revenue Share panel (🟩 percent icon in the partner row) has 2 fields that often confuse: Fee Type and Basis. They combine, they are not alternatives.
- Fee Type = the kind of money being shared. Answers "this fee comes from what transaction?"
- Basis = the way it is computed. Answers "computed based on what?"
11.1 Fee Type — where the money comes from
| Fee Type | Money being shared | When to use |
|---|---|---|
qris_mdr | A cut of the MDR (Merchant Discount Rate) from each merchant QRIS transaction | Fee-share with an acquirer / gateway / acquisition partner. The most common one. |
service_fee | Daily/monthly service fee charged to the merchant (e.g. Rp 2,000/day) | Partners receiving commission from device subscription fees |
subscription | Monthly SaaS/software subscription fee | If there is an additional subscription product outside QRIS |
settlement_fee | Fee per settlement (payout to the merchant's account) | Partners processing settlement payouts |
onboarding_fee | One-time fee paid when a merchant onboards | Sales partners getting an activation commission (e.g. Rp 100,000 per new merchant) |
other | Custom — use the notes field to explain | Catchall for non-standard agreements |
11.2 Basis — how it is computed
| Basis | Formula | Required input | Example |
|---|---|---|---|
gross_amount | gross × rate% | rate_bps (percent) | Partner gets 0.3% of merchant QRIS gross |
net_amount | (gross − MDR) × rate% | rate_bps (percent) | Partner gets 5% of net amount (after MDR is taken) |
transaction_count | tx_count × nominal | fixed_amount (IDR) | Partner gets Rp 100 per successful transaction |
fixed_per_merchant | active_merchant_count × nominal | fixed_amount (IDR/month) | Partner gets Rp 50,000/month per active merchant |
fixed_per_month | nominal (flat, independent of volume) | fixed_amount (IDR/month) | Partner gets a Rp 5,000,000/month retainer |
11.3 Reasonable Combinations
| Business case | Fee Type | Basis | Input |
|---|---|---|---|
| Sales partner gets 0.3% of every QRIS from their recruited merchant | qris_mdr | gross_amount | rate_bps = 30 (= 0.30%) |
| Bank partner gets 5 bps of net (after MDR) for settlement | settlement_fee | net_amount | rate_bps = 5 (= 0.05%) |
| Community leader gets Rp 100 per successful transaction | qris_mdr | transaction_count | fixed_amount = 100 |
| Acquirer gets Rp 10,000/month per active merchant | service_fee | fixed_per_merchant | fixed_amount = 10000 |
| Rp 5 million/month consulting retainer | other | fixed_per_month | fixed_amount = 5_000_000 |
| Onboarding commission Rp 150,000 per new merchant | onboarding_fee | fixed_per_merchant | fixed_amount = 150000 |
11.4 Unit Notes
- rate_bps in DB is stored in basis points: 100 bps = 1%. The UI converts — operators enter "0.50" in the form, the backend stores
50. - fixed_amount in DB: integer IDR. Type "50000" = Rp 50,000.
- The form validator rejects invalid combinations (e.g. percent basis but fixed_amount filled → 422 from backend, the form shows "basis gross_amount requires rate_bps").
11.5 Effective From / Effective To
-
Effective From is mandatory — the date the rule starts being effective.
-
Effective To is optional — if empty, the rule applies indefinitely.
-
Changing the rate mid-stream: do not edit an active row. The correct pattern:
- Edit the old row → set
Effective Toto the day before the change date. - Add a new Rule →
Effective Fromset to the change date, with the new rate.
The old row remains as a reference for reconciliation of the period before the change.
- Edit the old row → set
11.6 Source Partner
- Empty (— Across all visible sources —): rule applies to all merchants visible to this partner (per Access Scope). Suitable for global partners like the gateway acquirer.
- Pick 1 specific partner: rule only applies to merchants referred by that partner. Suitable for joint ecosystem fee-share with a different rate per ecosystem.
Example: Partner C fee-shares with Partner A (3 bps) and Partner B (5 bps) → create 2 rules:
- Rule 1: source = A, rate = 3 bps
- Rule 2: source = B, rate = 5 bps
12. Related Docs
- Partner Access Scope Plan — architecture + decision log + ship progress.
Partner API Reference— endpoint spec to share with the partner.DB Network Topology— VPN access to the DB for debugging audit logs.