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

  1. Per-location balances — store A cannot spend store B’s funds
  2. Per-location keys / QR / checkout — devices and links tie to the right wallet
  3. Per-location settlement — payouts go to the right bank account
  4. LakiConnect masters — pay into a connected merchant’s specific location using the master API key + headers
ResourceIsolated?Notes
Wallet balanceYesSeparate balance per location
Deposits / withdrawals / settlementsYesTransactions carry the branch when set
Business bank accountsYesSettlement banks must match the branch
API keysOptionalKey can target main account or a specific branch
QR payment linksYesCreated under active JWT branch; pay inherits it
Hosted checkout sessionsYesStamped at create time
Team / auth groupsYesDashboard access is branch-scoped
LakiConnect fee configYesFee can differ per connected merchant and branch
Webhook callback logsYesAudit may include branch_id when set
ActorWhat they do
Merchant (STANDARD / MASTER)Create/list/update own branches; switch JWT; banks/QR/team under active branch
Connected merchantOwns branch rows; payments credit their branch wallets
LakiConnect masterManage connected branches; pay/withdraw/settle with X-Connected-Merchant-Id + optional X-Branch-Id
Super adminApprove / 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 actLocation used
Dashboard JWT with branch claim omittedMain account
Dashboard JWT switched to a branch UUIDThat secondary branch
API key created under main JWT contextMain account
API key created after switch-branchThat secondary branch
X-Branch-Id with X-Connected-Merchant-IdOverrides key branch for that Connect request

Branch object & status lifecycle

FieldTypeDescription
idUUIDUse in X-Branch-Id, switch-branch, fee config, settlements
merchant_idUUIDOwner (connected merchant id for Connect-created branches)
namestringDisplay name
is_defaultbooltrue = 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.
statusstringpending | approved | rejected | suspended
rejection_reasonstring | omittedSet when rejected / suspended
approved_byUUID | omittedAdmin user id
approved_attimestamp | omittedWhen approved
addressobject | omittedNested address when loaded
business_license_urlstringRequired when creating a non-default branch
created_at / updated_attimestampsLifecycle timestamps

Address payload

On create, at least one of these must be non-empty:

FieldJSON key
Regionregion
Citycity
Sub-citysub_city
Woredaworeda
Postal codepostal_code
Secondary phonesecondary_phone_number
Countrycountry (optional)

Status → payments

StatusDeposits / API withdrawals / checkout / QR into this branch
pendingBlocked (not approved for payments)
approvedAllowed
rejectedBlocked
suspendedBlocked
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 enabled

You create additional locations — you do not create the main account location via this API.

Authentication & how branch context is selected

Surfaces

SurfaceAuthHow branch is chosen
Merchant branch CRUDBearer JWT + merchant RBACPath/body for which branch; active ops use JWT branch
Admin statusAdmin Bearer JWTPath branch id
LakiConnect branch CRUD (API)Master X-API-KeyX-Connected-Merchant-Id + path
LakiConnect branch CRUD (dashboard)Master Bearer JWTPath connected merchant id
Direct / checkout create / API withdrawX-API-KeyAPI key branch; X-Branch-Id only if X-Connected-Merchant-Id is set
Checkout pay / public QR payPublic / sessionBranch from the stored checkout or QR link
QR mgmt / banks / dashboard withdrawBearer JWTActive JWT branch context
Connect settlements / fee-configMaster API key or JWTX-Branch-Id and/or body branch_id (header wins)

Headers

HeaderRequiredDescription
Authorization: Bearer …Dashboard / adminJWT
X-API-KeyPayment & Connect APIPUBLIC:SECRET key pair
X-Connected-Merchant-IdConnect contextUUID of connected merchant (txn/wallet owner)
X-Branch-IdOptionalUUID 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

  1. Start from the branch bound to the API key (or main account if the key has none).
  2. If X-Connected-Merchant-Id is present and X-Branch-Id is a valid UUID, use that branch instead for this request.
  3. The target merchant is the key’s merchant — or the connected merchant when that header is set.
  4. 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"
  }
}
FieldRequiredRules
nameYesNon-empty
business_license_urlYesValid http or https URL
addressYesAt 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; errors 400 invalid UUID, 404 not found / not owned
  • PUT /merchants/branches/{branchId} → 200 — metadata only (name, license URL, address). Cannot change status here.
{
  "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
}
statusWhat it means for you
approvedActivates the branch — it can now accept payments and appears in your team’s access controls for that location
rejected / suspendedBlocks payments; optional reason is stored
pendingReverts 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.

HTTPMeaning
400Bad body / branch not for merchant / no merchant claim
401Invalid token
403No 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.

MethodPath
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}/branches
  • GET|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.

  1. POST /auth/switch-branch to the target branch (must be approved for payment use)
  2. POST /keys with name and permissions (can_process_payment, can_withdrawal, etc.)
Key bindingPayment behaviour
No branch on the keyPayments go to your main account
Key bound to a branch UUIDThat 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

MethodPathBranch source
POST/merchants/banksJWT branch stamped server-side — do not send branch in body
GET/merchants/banksScoped 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.

HeaderStandard merchantLakiConnect
X-API-KeyRequiredRequired (usually master key)
X-Connected-Merchant-IdOmitRequired for connected routing
X-Branch-IdIgnoredOptional 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
}
FieldNotes
mediume.g. MPESA, TELEBIRR, CBE, AWASH, KACHA, OROMIA_BANK, GADAA_BANK, CYBERSOURCE
phone_number251 + 9 digits
bank_account_numberRequired for some bank mediums
callback_urlRequired URL
details / redirectsRequired

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"
    }
  }'
SituationResult
X-Branch-Id not a UUID400
Branch pending / rejected / suspendedPayment rejected (not eligible)
Branch not owned by target merchantError (not found / not owned)
Connect header without linked connected accountError

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

MethodPathAuth
POST/qr_mgmt/linksJWT + QR CREATE
GET/qr_mgmt/linksJWT + 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.

MethodPathBranch source
POST/qr/payment/link/{id}QR link’s stored branch
POST/qr/payment/merchantMerchant / 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_BANK
  • phone_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.

MethodPathPurpose
GET/lakiconnect/connected-accounts/settlements/banksBanks eligible for that branch
POST/lakiconnect/connected-accounts/settlementsCreate PENDING settlement
GET/lakiconnect/connected-accounts/settlementsList (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
}
FieldRequiredNotes
amountYes> 0
business_bank_idYesMust belong to connected merchant and match branch scope
branch_idNoOverridden 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)

  1. Connected merchant + branch
  2. Connected merchant default (no branch on the fee row)
  3. Master default
  • GET /lakiconnect/connected-accounts/fee-config — optional X-Branch-Id
  • PUT /lakiconnect/connected-accounts/fee-config — X-Branch-Id and/or body branch_id
{
  "connected_merchant_id": "…",
  "branch_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "fee_model": "PERCENT",
  "fee_value": 1.5
}
fee_modelMeaning
FREENo master fee
PERCENTPercent of connected merchant_net
FIXEDFixed 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.

HTTPTypical cause
400Invalid UUID (X-Branch-Id, path); invalid create/update body; missing business_license_url; address empty; header/body branch mismatch; branch not eligible; amount ≤ 0
401Missing/invalid JWT or API key
403Missing RBAC (merchant, CONNECTED_ACCOUNTS, payment/withdrawal permission)
404Branch not found / not owned; connected account not linked to master; merchant not found
500Unexpected 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 JWT

B. 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 branch

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

  1. Document for your clients: only approved secondary branches accept money.
  2. Always send business_license_url when creating a non-default branch.
  3. After approve, create bank + API key under the correct JWT branch (or use Connect headers).
  4. Connect payments: X-Connected-Merchant-Id + X-Branch-Id together when targeting a location.
  5. Remember X-Branch-Id alone on payment routes does nothing without the Connect merchant header.
  6. Hosted checkout & QR: branch is fixed at resource create time.
  7. Settle/withdraw against the same branch that received funds.
  8. Never expect main and secondary wallets to share balance.
  9. Your account’s main location is always addressed by leaving branch out entirely — never by an ID from the dashboard.
  10. 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.