Corporate Accounts & Employee Management
- Base path
/api/v1/wallet- Identity
- Ed25519 request signature —
X-Key-Id,X-Date,X-Nonce,Digest(when there is a body),X-Signature. ±5 min clock window, single-use nonce, optional per-key IP allow-list. - Authorization
- The key must carry the wallet product and the wallet permission the call requires; a call without it gets
403 WALLET_INSUFFICIENT_SCOPE. Permissions per endpoint: Key permissions. - Cross-surface
- These endpoints are on the s2s surface. The panel additionally exposes
GET corporate-accounts/:idandPATCH corporate-accounts/:id(wallet:enduser:read/wallet:enduser:manage); those have NO s2s equivalent.
Who performs these operations? Today, the program operator — the organisation running the wallet programme — does so from the panel or the s2s API. There is no self-service surface for the employer itself. If an employer wants to manage its own roster, the program operator must act on its behalf.
A corporate account is not a login identity: it carries no username, password or session. The customer portal belongs to the end user (the wallet holder); there is no employer-facing portal.
A corporate account groups an employer's employees under one roof. It is the carrier for meal-card, transport-allowance, per-diem and similar corporate wallet scenarios.
The corporate account itself holds no money. Money always sits in the employee's own wallet; the corporate account only carries membership and authority. That distinction is the entire reason for the "offboarding does not touch money" behaviour below.
No new permission family (wallet:corporate:*) was invented: corporate account management is part of end-user management and reuses the same permission codes.
On the s2s surface, all four corporate-account endpoints require the wallet:bulk:create permission on your key. See Key permissions.
Endpoints
Panel surface (Bearer JWT + RBAC):
POST corporate-accounts # create
GET corporate-accounts # programId, status, limit, cursor
GET corporate-accounts/:id # detail
PATCH corporate-accounts/:id # name / status
POST corporate-accounts/:id/employees/import # bulk add
POST corporate-accounts/:id/employees/offboard # bulk offboardServer-to-server (signed):
POST /api/v1/wallet/corporate-accounts
GET /api/v1/wallet/corporate-accounts
POST /api/v1/wallet/corporate-accounts/:id/employees/import
POST /api/v1/wallet/corporate-accounts/:id/employees/offboardThe s2s surface has no detail (GET :id) and no update (PATCH :id) endpoint — both exist on the panel only. Do not build an integration that reads a corporate account by id over s2s; use the list endpoint (GET corporate-accounts).
The three s2s mutations (POST corporate-accounts, employees/import, employees/offboard) require the Idempotency-Key header. See Authentication.
Defining a corporate account
| Field | Required | Description |
|---|---|---|
programId | ✅ | Which wallet program it belongs to — immutable |
code | ✅ | Employer code (1–64). Trimmed and upper-cased on write; unique within the program — immutable |
name | ✅ | Display name (1–160) |
status | — | ACTIVE | ARCHIVED (default ACTIVE) |
PATCH corporate-accounts/:id accepts only name and status.
code is normalised to upper case: acme and ACME are the same code, and the second one fails uniqueness. Assume this when generating codes on your side; otherwise you will hit a collision that looks like "I saved it but it isn't in the list".
The employee link
An end-user can have at most one employer (corporateAccountId).
- Single user at creation: the
corporateAccountIdfield inPOST end-users. - Bulk:
employees/import. - Roster listing:
GET end-users?corporateAccountId=<id>.
Importing a user already linked to a different employer is refused with WALLET_CORPORATE_LINK_CONFLICT — the link is never silently moved. If the employee changed jobs, offboard them from the old employer first, then import into the new one.
Bulk import
POST corporate-accounts/:id/employees/importThe body is a row array (rows, 1–1000 rows):
| Row field | Required | Rule |
|---|---|---|
phone | ✅ | E.164-like number, 8–20 characters (+, digits, space, parentheses, hyphen) |
displayName | — | Max 120 characters |
externalCustomerId | — | Max 128 characters — your own identifier |
kycRef | — | Max 128 characters |
CSV is parsed in the browser. The endpoint accepts a JSON row array, not CSV text; the four fields above are what your CSV columns map to. There is no accountType field and sending one is rejected: import always opens an individual (PERSONAL) account.
All-or-nothing
Import is atomic. If even a single row fails, no row is written — everything created up to that point, including users, wallets and accounts, is rolled back.
The response is then HTTP 422 with code: WALLET_BULK_IMPORT_REJECTED; the report is returned under details and every otherwise-valid row is marked ROLLED_BACK. There is no "some of it went through" state — fix the file and re-upload all of it.
Row statuses
| Status | Meaning |
|---|---|
CREATED | The number did not exist — user and default wallet created, linked to the employer |
LINKED_EXISTING | The user existed and had no employer — linked to this one |
ALREADY_LINKED | The user was already linked to this employer — nothing was written |
ERROR | The row was rejected (see codes below) |
ROLLED_BACK | The row was valid but not written, because another row failed |
Row error codes: WALLET_CORPORATE_LINK_CONFLICT (linked to another employer), WALLET_ENDUSER_CLOSED (user is closed), WALLET_VALIDATION (format), WALLET_ENDUSER_EXISTS (duplicate row inside the file).
Successful response
{
"status": "success",
"code": "SUCCESS",
"data": {
"applied": true,
"corporateAccountId": "…",
"totalRows": 120,
"createdCount": 94,
"linkedCount": 21,
"alreadyLinkedCount": 5,
"errorCount": 0,
"results": [
{ "index": 0, "phoneMasked": "+90 5** *** ** 41", "status": "CREATED", "endUserId": "…" }
]
}
}The response never carries a raw phone number, only phoneMasked. Use index or externalCustomerId to map a row back to your file.
totalRows is always the number of rows you submitted; on a rejected upload results may carry only the failing rows, so results.length and totalRows can differ.
Import is refused unless both the corporate account and the program are ACTIVE.
Offboarding an employee
POST corporate-accounts/:id/employees/offboard| Body field | Required | Rule |
|---|---|---|
rows[] | ✅ | 1–1000 rows |
rows[].endUserId | ◑ | UUID — exactly one of this and externalCustomerId |
rows[].externalCustomerId | ◑ | Max 128 characters |
reason | — | Max 240 characters — audit record only, it does not affect the decision |
Row statuses: OFFBOARDED · ALREADY_OFFBOARDED · ERROR · ROLLED_BACK.
Row error codes: WALLET_CORPORATE_NOT_MEMBER, WALLET_NOT_FOUND, WALLET_VALIDATION, WALLET_ENDUSER_EXISTS.
Offboarding is all-or-nothing as well; a rejected call returns HTTP 422 with code: WALLET_BULK_OFFBOARD_REJECTED.
Offboarding does NOT touch money
Offboarding is not a money operation. The employee's balance stays with the employee and they keep spending it. The only thing revoked is the employer's authority over that employee.
Concretely, offboarding updates only the corporate-link fields. After an offboard:
- The user's status stays
ACTIVE— they are not frozen. - Their wallets and accounts stay
ACTIVE. - Nothing is written to the ledger; the balance is not even read.
- Cards are not frozen.
This is deliberate: an e-money holder's right to redemption is unconditional, so the end of an employment relationship cannot justify seizing the holder's money. There is no endpoint that claws back, sweeps or transfers the balance to the employer, and none should be added.
Do not describe offboarding in your product copy or panel screens in a way that implies "the balance is taken back". The correct sentence is: "The employee is removed from the company roster; their existing balance stays with them."
If your commercial arrangement requires the employer's money to be recoverable, solve it on the funding side (e.g. periodic top-ups), not in the offboard endpoint.
Unlike import, offboarding does not require the corporate account or the program to be ACTIVE. Revoking an authority must not be blocked by the state of the container that granted it — an archived employer can still release its roster.
Offboarding the same employee twice is not an error: it returns ALREADY_OFFBOARDED.
Corporate spend policy
Spend rules specific to a company's employees (which category, which channel, which hours) are defined by a separate engine: Spend Policy.
Checklist
- Your
codegeneration assumes upper-case normalisation - You check
appliedin the import response and surfacedetails.resultsfrom the 422 body to the operator -
ROLLED_BACKrows are described to the user as "not written", not "failed" - Your offboard screen contains no misleading statement about balances
- For an employee changing jobs you offboard first, then import
- s2s mutations send
Idempotency-Key