Branch Management
Multi-location payments under one merchant account. Base path: /api/v2.
What the branch feature is
A branch is a physical location (or business unit) under one merchant KYC identity. One merchant account can operate Main / HQ plus additional locations (Bole store, airport kiosk, franchise site, and so on).
Why it exists
- Per-location balances — store A cannot spend store B’s funds
- Per-location keys / QR / checkout — devices and links tie to the right wallet
- Per-location settlement — payouts go to the right bank account
- LakiConnect masters — pay into a connected merchant’s specific location using the master API key + headers
| Resource | Isolated? | Notes |
|---|---|---|
| Wallet balance | Yes | Separate balance per location |
| Deposits / withdrawals / settlements | Yes | Transactions carry the branch when set |
| Business bank accounts | Yes | Settlement banks must match the branch |
| API keys | Optional | Key can target main account or a specific branch |
| QR payment links | Yes | Created under active JWT branch; pay inherits it |
| Hosted checkout sessions | Yes | Stamped at create time |
| Team / auth groups | Yes | Dashboard access is branch-scoped |
| LakiConnect fee config | Yes | Fee can differ per connected merchant and branch |
| Webhook callback logs | Yes | Audit may include branch_id when set |
| Actor | What they do |
|---|---|
| Merchant (STANDARD / MASTER) | Create/list/update own branches; switch JWT; banks/QR/team under active branch |
| Connected merchant | Owns branch rows; payments credit their branch wallets |
| LakiConnect master | Manage connected branches; pay/withdraw/settle with X-Connected-Merchant-Id + optional X-Branch-Id |
| Super admin | Approve / reject / suspend branches (required before payments on new locations) |
A branch is not
- A separate legal merchant (beyond the branch
business_license_url) - Interchangeable with
merchant_id— connected accounts are merchants; branches sit under a merchant
How main account vs branch works
Practical rule
Your account’s main location is always addressed by leaving branch out entirely — never by an ID, even after you look one up in the dashboard. Use a branch ID only when you intentionally target a secondary location.
- Secondary branches must be approved before they can take payments
- Your main account can accept payments even before you create any additional branches
- Main and branch wallets never share balance — funds do not move between them automatically
| How you act | Location used |
|---|---|
| Dashboard JWT with branch claim omitted | Main account |
| Dashboard JWT switched to a branch UUID | That secondary branch |
| API key created under main JWT context | Main account |
| API key created after switch-branch | That secondary branch |
X-Branch-Id with X-Connected-Merchant-Id | Overrides key branch for that Connect request |
Branch object & status lifecycle
| Field | Type | Description |
|---|---|---|
| id | UUID | Use in X-Branch-Id, switch-branch, fee config, settlements |
| merchant_id | UUID | Owner (connected merchant id for Connect-created branches) |
| name | string | Display name |
| is_default | bool | true = this is your main location in the dashboard. To take payments on main, leave branch out of requests — do not send this row’s id as a branch target. |
| status | string | pending | approved | rejected | suspended |
| rejection_reason | string | omitted | Set when rejected / suspended |
| approved_by | UUID | omitted | Admin user id |
| approved_at | timestamp | omitted | When approved |
| address | object | omitted | Nested address when loaded |
| business_license_url | string | Required when creating a non-default branch |
| created_at / updated_at | timestamps | Lifecycle timestamps |
Address payload
On create, at least one of these must be non-empty:
| Field | JSON key |
|---|---|
| Region | region |
| City | city |
| Sub-city | sub_city |
| Woreda | woreda |
| Postal code | postal_code |
| Secondary phone | secondary_phone_number |
| Country | country (optional) |
Status → payments
| Status | Deposits / API withdrawals / checkout / QR into this branch |
|---|---|
| pending | Blocked (not approved for payments) |
| approved | Allowed |
| rejected | Blocked |
| suspended | Blocked |
| Main account (no secondary branch) | Allowed when the merchant itself is eligible |
Create (merchant or Connect master)
→ status = pending
→ Admin PUT .../status { "status": "approved" }
→ branch can accept payments
→ appears in team access for that location
→ Payments / withdrawals / settlements enabledYou create additional locations — you do not create the main account location via this API.
Authentication & how branch context is selected
Surfaces
| Surface | Auth | How branch is chosen |
|---|---|---|
| Merchant branch CRUD | Bearer JWT + merchant RBAC | Path/body for which branch; active ops use JWT branch |
| Admin status | Admin Bearer JWT | Path branch id |
| LakiConnect branch CRUD (API) | Master X-API-Key | X-Connected-Merchant-Id + path |
| LakiConnect branch CRUD (dashboard) | Master Bearer JWT | Path connected merchant id |
| Direct / checkout create / API withdraw | X-API-Key | API key branch; X-Branch-Id only if X-Connected-Merchant-Id is set |
| Checkout pay / public QR pay | Public / session | Branch from the stored checkout or QR link |
| QR mgmt / banks / dashboard withdraw | Bearer JWT | Active JWT branch context |
| Connect settlements / fee-config | Master API key or JWT | X-Branch-Id and/or body branch_id (header wins) |
Headers
| Header | Required | Description |
|---|---|---|
| Authorization: Bearer … | Dashboard / admin | JWT |
| X-API-Key | Payment & Connect API | PUBLIC:SECRET key pair |
| X-Connected-Merchant-Id | Connect context | UUID of connected merchant (txn/wallet owner) |
| X-Branch-Id | Optional | UUID of a branch under the target merchant. Invalid UUID → 400 |
Critical on payment routes
On /payment/direct, /payment/checkout, and /payment/withdrawal, X-Branch-Id is applied only when X-Connected-Merchant-Id is also present. For standard (non-Connect) merchants, bind the branch on the API key (switch JWT → create key). Sending X-Branch-Id alone does nothing.
How money routes pick a location
- Start from the branch bound to the API key (or main account if the key has none).
- If
X-Connected-Merchant-Idis present andX-Branch-Idis a valid UUID, use that branch instead for this request. - The target merchant is the key’s merchant — or the connected merchant when that header is set.
- The branch must belong to that merchant and be eligible (approved for secondary locations). Then the wallet for that location is credited or debited.
Merchant dashboard — branch CRUD
Auth: Bearer JWT with merchant permissions. Merchant context comes from the JWT as elsewhere.
Create branch
POST /merchants/branches → 201
{
"name": "Bole Branch",
"business_license_url": "https://cdn.example.com/licenses/bole.pdf",
"address": {
"region": "Addis Ababa",
"city": "Addis Ababa",
"sub_city": "Bole",
"woreda": "03",
"postal_code": "",
"secondary_phone_number": "",
"country": "ET"
}
}| Field | Required | Rules |
|---|---|---|
| name | Yes | Non-empty |
| business_license_url | Yes | Valid http or https URL |
| address | Yes | At least one address field non-empty |
Behavior: status=pending, is_default=false. Merchants cannot self-approve.
curl -sS -X POST "${API_BASE}/api/v2/merchants/branches" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"name": "Bole Branch",
"business_license_url": "https://cdn.example.com/licenses/bole.pdf",
"address": {
"region": "Addis Ababa",
"city": "Addis Ababa",
"sub_city": "Bole",
"woreda": "03",
"country": "ET"
}
}'Example response
{
"success": true,
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"merchant_id": "…",
"name": "Bole Branch",
"is_default": false,
"status": "pending",
"business_license_url": "https://cdn.example.com/licenses/bole.pdf",
"address": {},
"created_at": "…",
"updated_at": "…"
}
}List / get / update
GET /merchants/branches→200{ "success": true, "data": [ Branch, … ] }GET /merchants/branches/{branchId}→200; errors400invalid UUID,404not found / not ownedPUT /merchants/branches/{branchId}→200— metadata only (name, license URL, address). Cannot changestatushere.
{
"name": "Bole Branch Updated",
"business_license_url": "https://cdn.example.com/licenses/bole-v2.pdf",
"address": {
"city": "Addis Ababa",
"sub_city": "Bole"
}
}Admin — approve / reject / suspend
PUT /merchants/admin/branches/{branchId}/status — admin JWT with merchant admin update permission.
{
"status": "approved",
"rejection_reason": null
}| status | What it means for you |
|---|---|
| approved | Activates the branch — it can now accept payments and appears in your team’s access controls for that location |
| rejected / suspended | Blocks payments; optional reason is stored |
| pending | Reverts to pending (ops use) |
Admin list: GET /merchants/admin/branches/{merchantId}?status= (optional status filter).
Switch active branch (JWT)
Dashboard banks, QR management, team, and dashboard withdraws read the JWT branch context — not a branch field in the request body.
POST /auth/switch-branch — Bearer access token (must already include merchant).
{ "branch_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" }Replace stored tokens
Success 200 returns new access_token + refresh_token. Your client must replace stored tokens.
| HTTP | Meaning |
|---|---|
| 400 | Bad body / branch not for merchant / no merchant claim |
| 401 | Invalid token |
| 403 | No access to merchant |
Omitting branch from the JWT (or switching back to main) means subsequent dashboard calls use your main account.
LakiConnect — connected-account branches
Masters manage branches owned by a connected merchant. Same create validation as merchant CRUD. New branches are pending. Masters cannot approve — use the admin status endpoint.
API key routes
Auth: master X-API-Key with connected-accounts permissions. Required: X-Connected-Merchant-Id.
| Method | Path |
|---|---|
| GET / POST | /lakiconnect/connected-accounts/branches |
| GET / PUT | /lakiconnect/connected-accounts/branches/{branchId} |
curl -sS -X POST "${API_BASE}/api/v2/lakiconnect/connected-accounts/branches" \
-H "X-API-Key: ${MASTER_API_KEY}" \
-H "X-Connected-Merchant-Id: ${CONNECTED_MERCHANT_ID}" \
-H "Content-Type: application/json" \
-d '{
"name": "Bole",
"business_license_url": "https://cdn.example.com/licenses/bole.pdf",
"address": {
"region": "Addis Ababa",
"city": "Addis Ababa",
"sub_city": "Bole"
}
}'Errors: 400 missing/invalid connected id or body; 404 connected not linked / branch not found; 403 RBAC.
Dashboard JWT routes
Auth: master merchant Bearer JWT. Path id = connected merchant UUID.
GET|POST /lakiconnect/dashboard/connected-accounts/{id}/branchesGET|PUT /lakiconnect/dashboard/connected-accounts/{id}/branches/{branchId}
Same request/response bodies as the API-key routes.
API keys and branch binding
Create: POST /keys (merchant JWT). List/get: GET /keys, GET /keys/{id}.
Branch comes from JWT at create time
The branch on a key is taken from your active JWT branch context. A branch_id field in the create body is not trusted for binding.
POST /auth/switch-branchto the target branch (must be approved for payment use)POST /keyswith name and permissions (can_process_payment,can_withdrawal, etc.)
| Key binding | Payment behaviour |
|---|---|
| No branch on the key | Payments go to your main account |
| Key bound to a branch UUID | That secondary branch; must be approved to pay |
Responses include branch_id when the key is bound to a branch. Prefer branch-scoped keys for fixed POS devices so clients need not send X-Branch-Id.
Banks, wallets, team
| Method | Path | Branch source |
|---|---|---|
| POST | /merchants/banks | JWT branch stamped server-side — do not send branch in body |
| GET | /merchants/banks | Scoped to JWT branch |
| DELETE | /merchants/banks/{id} | Must belong to merchant (+ branch rules) |
Switch branch before creating a bank for a secondary location. Settlement business_bank_id must match the settlement’s branch.
Your main account and each approved branch each have their own balance; funds never move between them automatically. Secondary balances become available after admin approval.
Team groups and members are branch-scoped — manage them in the dashboard after switching to the location.
Direct payment
POST /payment/direct — X-API-Key + payment processing permission. Full guide: Direct Payments.
| Header | Standard merchant | LakiConnect |
|---|---|---|
| X-API-Key | Required | Required (usually master key) |
| X-Connected-Merchant-Id | Omit | Required for connected routing |
| X-Branch-Id | Ignored | Optional override of key branch |
{
"medium": "TELEBIRR",
"amount": 100.0,
"currency": "ETB",
"phone_number": "251911111111",
"reference": "order-1001",
"description": "Branch deposit",
"callback_url": "https://merchant.example/hooks/payment",
"merchant_pays_fee": false,
"details": {},
"redirects": {
"success": "https://merchant.example/ok",
"failed": "https://merchant.example/fail",
"cancelled": "https://merchant.example/cancel"
},
"bank_account_number": "",
"tag": null,
"expires_at": null
}| Field | Notes |
|---|---|
| medium | e.g. MPESA, TELEBIRR, CBE, AWASH, KACHA, OROMIA_BANK, GADAA_BANK, CYBERSOURCE |
| phone_number | 251 + 9 digits |
| bank_account_number | Required for some bank mediums |
| callback_url | Required URL |
| details / redirects | Required |
No branch_id in the payment body
Branch comes from your API key or Connect headers — not from the payment request itself.
Success (200)
{
"success": true,
"status": "PENDING",
"message": "Payment initiated successfully",
"payment_url": "…",
"reference_id": "…",
"lakipay_transaction_id": "…",
"merchant_pays_fee": false
}On provider SUCCESS, the target merchant’s location wallet is credited. For LakiConnect, fees can be configured per branch — see fee config.
Connect + branch example
curl -sS -X POST "${API_BASE}/api/v2/payment/direct" \
-H "X-API-Key: ${MASTER_API_KEY}" \
-H "X-Connected-Merchant-Id: ${CONNECTED_MERCHANT_ID}" \
-H "X-Branch-Id: ${BRANCH_ID}" \
-H "Content-Type: application/json" \
-d '{
"medium": "TELEBIRR",
"amount": 100,
"currency": "ETB",
"phone_number": "251911111111",
"reference": "order-1001",
"description": "Branch deposit",
"callback_url": "https://merchant.example/hooks/payment",
"merchant_pays_fee": false,
"details": {},
"redirects": {
"success": "https://merchant.example/ok",
"failed": "https://merchant.example/fail",
"cancelled": "https://merchant.example/cancel"
}
}'| Situation | Result |
|---|---|
X-Branch-Id not a UUID | 400 |
| Branch pending / rejected / suspended | Payment rejected (not eligible) |
| Branch not owned by target merchant | Error (not found / not owned) |
| Connect header without linked connected account | Error |
Hosted checkout
Also see Hosted Checkout.
Create
POST /payment/checkout — same branch resolution as direct payment, at create time.
{
"amount": 250.0,
"currency": "ETB",
"description": "Branch checkout",
"reference": "checkout-55",
"supported_mediums": ["TELEBIRR", "MPESA"],
"phone_number": "251911111111",
"callback_url": "https://merchant.example/hooks/checkout",
"merchant_pays_fee": false,
"accept_tip": false,
"redirects": {
"success": "https://merchant.example/ok",
"failed": "https://merchant.example/fail",
"cancelled": "https://merchant.example/cancel"
},
"expires_at": null
}curl -sS -X POST "${API_BASE}/api/v2/payment/checkout" \
-H "X-API-Key: ${MASTER_API_KEY}" \
-H "X-Connected-Merchant-Id: ${CONNECTED_MERCHANT_ID}" \
-H "X-Branch-Id: ${BRANCH_ID}" \
-H "Content-Type: application/json" \
-d '{ … checkout body … }'Customer pays
POST /checkout/makepayment — public checkout flow (no merchant API key / no X-Branch-Id). Branch comes from the hosted checkout row created above. Changing headers on make-payment does not re-scope the wallet.
Update
PATCH /payment/checkout/{id} — only while pending; does not change the branch stamp.
QR links & QR payment
| Method | Path | Auth |
|---|---|---|
| POST | /qr_mgmt/links | JWT + QR CREATE |
| GET | /qr_mgmt/links | JWT + QR READ |
| GET | /qr_mgmt/links/{id} | JWT + QR READ |
| PUT | /qr_mgmt/links/{id} | JWT + QR UPDATE |
| DELETE | /qr_mgmt/links/{id} | JWT + QR DELETE |
Branch is based on your active JWT branch context. Create stamps the link; list/get are scoped to the active branch. Relevant fields: id, merchant_id, type (STATIC/DYNAMIC), amount, supported_methods, tag, title, branch_id, is_active.
| Method | Path | Branch source |
|---|---|---|
| POST | /qr/payment/link/{id} | QR link’s stored branch |
| POST | /qr/payment/merchant | Merchant / request flow (not Connect X-Branch-Id) |
Customer payment credits the wallet matching the QR link’s stored branch. Create QR after the branch is approved, under the correct JWT context.
API withdrawals
POST /payment/withdrawal — same branch resolution as direct payment. Also: Withdrawals.
{
"amount": 50.0,
"currency": "ETB",
"medium": "TELEBIRR",
"phone_number": "251911111111",
"reference": "wd-9001",
"callback_url": "https://merchant.example/hooks/withdraw",
"account_number": "",
"merchant_pays_fee": false
}account_number— required for AWASH, OROMIA_BANK, GADAA_BANKphone_number— destination for mobile-money mediums
Effects: lock + debit the target merchant’s branch wallet. Pending branch → rejected. Insufficient branch balance → failure (main account balance is irrelevant).
curl -sS -X POST "${API_BASE}/api/v2/payment/withdrawal" \
-H "X-API-Key: ${MASTER_API_KEY}" \
-H "X-Connected-Merchant-Id: ${CONNECTED_MERCHANT_ID}" \
-H "X-Branch-Id: ${BRANCH_ID}" \
-H "Content-Type: application/json" \
-d '{ … withdrawal body … }'Dashboard withdraws & LakiConnect settlements
Merchant dashboard withdraws
Routes under /withdraws/ (merchant JWT). Branch from JWT only — omit branch_id from the body. Empty JWT branch claim → main account.
LakiConnect settlements
Bank payouts from a connected merchant’s branch wallet. Funds debit on admin SUCCESS, not at request time.
Required: X-API-Key, X-Connected-Merchant-Id. Optional: X-Branch-Id (omit = main account). Body branch_id allowed; header wins; mismatch → error.
| Method | Path | Purpose |
|---|---|---|
| GET | /lakiconnect/connected-accounts/settlements/banks | Banks eligible for that branch |
| POST | /lakiconnect/connected-accounts/settlements | Create PENDING settlement |
| GET | /lakiconnect/connected-accounts/settlements | List (optional branch filter) |
| GET | /lakiconnect/connected-accounts/settlements/{withdrawId} | Get one |
Create body — omit branch_id or set it to null to use your main account (when not overridden by X-Branch-Id):
{
"amount": 1000.0,
"business_bank_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"branch_id": null
}| Field | Required | Notes |
|---|---|---|
| amount | Yes | > 0 |
| business_bank_id | Yes | Must belong to connected merchant and match branch scope |
| branch_id | No | Overridden by X-Branch-Id when set |
Response fields include id, connected_merchant_id, branch_id (omitted for main), amount, status, business_bank, rejection_reason, proof, timestamps.
Dashboard JWT variants: /lakiconnect/dashboard/connected-accounts/{id}/settlements… with the same branch header rules.
LakiConnect fee config (branch-scoped)
Resolution order (most specific wins)
- Connected merchant + branch
- Connected merchant default (no branch on the fee row)
- Master default
GET /lakiconnect/connected-accounts/fee-config— optionalX-Branch-IdPUT /lakiconnect/connected-accounts/fee-config—X-Branch-Idand/or bodybranch_id
{
"connected_merchant_id": "…",
"branch_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"fee_model": "PERCENT",
"fee_value": 1.5
}| fee_model | Meaning |
|---|---|
| FREE | No master fee |
| PERCENT | Percent of connected merchant_net |
| FIXED | Fixed amount |
branch_id on upsert must belong to that connected merchant.
Transaction history & webhooks
GET / POST /transactions/history (merchant JWT): results are scoped to your active branch context. With no branch on the JWT, you see main-account transactions only (not secondary-branch rows). Each row may include branch_id when applicable.
Merchant callbacks: same URL and signature flow as Webhooks. Branch (when set) is on the transaction / audit log.
LakiConnect outbound webhooks (connected_account.*, settlement.*) use the Connect envelope; settlement events may carry branch on the withdraw row.
Error catalogue
Exact message strings may vary; treat type + HTTP as primary.
| HTTP | Typical cause |
|---|---|
| 400 | Invalid UUID (X-Branch-Id, path); invalid create/update body; missing business_license_url; address empty; header/body branch mismatch; branch not eligible; amount ≤ 0 |
| 401 | Missing/invalid JWT or API key |
| 403 | Missing RBAC (merchant, CONNECTED_ACCOUNTS, payment/withdrawal permission) |
| 404 | Branch not found / not owned; connected account not linked to master; merchant not found |
| 500 | Unexpected server error |
Payment-specific: pending branch → treat as failed initiate, not SUCCESS.
End-to-end recipes
A. Standard merchant — new store
1. POST /merchants/branches (+ license URL + address) → pending
2. Admin PUT /merchants/admin/branches/{id}/status { "status": "approved" }
3. POST /auth/switch-branch { "branch_id": "…" } → save tokens
4. POST /merchants/banks (bank for this branch)
5. POST /keys (key inherits JWT branch)
6. POST /payment/direct with that X-API-Key (no Connect headers)
7. Optional: POST /qr_mgmt/links under same JWTB. LakiConnect — connected location
1. Master creates connected account (existing Connect flow)
2. POST /lakiconnect/connected-accounts/branches
X-API-Key + X-Connected-Merchant-Id → pending
3. Admin approves branch
4. Optional: PUT fee-config with X-Branch-Id
5. POST /payment/direct|checkout|withdrawal
X-API-Key + X-Connected-Merchant-Id + X-Branch-Id
6. POST /lakiconnect/connected-accounts/settlements
same headers + business_bank_id for that branchC. Hosted checkout to branch wallet
1. Ensure branch approved 2. POST /payment/checkout with Connect + X-Branch-Id 3. Customer opens payment_url → POST /checkout/makepayment 4. SUCCESS → connected branch wallet credited
Integrator checklist & FAQ
- Document for your clients: only approved secondary branches accept money.
- Always send
business_license_urlwhen creating a non-default branch. - After approve, create bank + API key under the correct JWT branch (or use Connect headers).
- Connect payments:
X-Connected-Merchant-Id+X-Branch-Idtogether when targeting a location. - Remember
X-Branch-Idalone on payment routes does nothing without the Connect merchant header. - Hosted checkout & QR: branch is fixed at resource create time.
- Settle/withdraw against the same branch that received funds.
- Never expect main and secondary wallets to share balance.
- Your account’s main location is always addressed by leaving branch out entirely — never by an ID from the dashboard.
- Replace tokens after every
switch-branch.
Q: Can I pass branch_id in the direct payment JSON body?
A: No. Use API key binding and/or Connect headers.
Q: Does approving the merchant KYC auto-approve branches?
A: No. Each additional branch needs admin status approved.
Q: What if I omit X-Branch-Id on a Connect payment?
A: Uses the API key’s branch binding (often your main account if the key has none).
Q: Can a master approve a connected branch?
A: No. Use super-admin PUT /merchants/admin/branches/{branchId}/status.
Q: Is is_default: true the same as sending that UUID in X-Branch-Id?
A: No. Your main location is always addressed by leaving branch out entirely — never by sending that dashboard ID as a branch target.
Q: Are checkout and QR “Connect-aware” via headers at pay time?
A: Create-time yes (checkout create). Pay-time uses the stored resource branch, not a new X-Branch-Id.