X-Api-Key header. Keys are created in the developer portal and are scoped to specific operations.-H "X-Api-Key: wb_live_xxxxxxxxxxxxxxxxxxxxxxxx"
API keys are tied to a single environment — Sandbox or Production. A Sandbox key cannot call production endpoints, and vice versa.
Requests using revoked, expired, or invalid keys return 401 Unauthorized. Requests where the key's scopes do not include the required permission return 403 Forbidden.
Scopes & Permissions
Each API key is assigned one or more scopes at creation time. Scopes are permanent — to change them you must revoke the key and create a new one.
| Scope | Grants access to |
|---|---|
COMPANY_READ | List and retrieve companies |
COMPANY_WRITE | Create and update companies |
EMPLOYEE_READ | List and retrieve employees |
EMPLOYEE_WRITE | Register employees, manage company links |
BENEFIT_READ | List company benefit enrollments |
BENEFIT_WRITE | Enroll and update company benefits |
EWA_READ | View EWA settings, requests, and employee data |
EWA_WRITE | Approve/reject requests, update EWA settings |
PAYMENT_READ | View payment history and balances |
PAYMENT_WRITE | Initialize repayments and EWA transfers |
Environments
| Environment | Key prefix | Base URL |
|---|---|---|
| Sandbox | wb_test_ | https://api-v2.workbenefits.app |
| Production | wb_live_ | https://api-v2.workbenefits.app |
Sandbox and Production share the same base URL. The key prefix determines which environment your request runs in. Sandbox data is isolated from production data.
Ownership
You act on behalf of the company admins and employees you onboard. You can only access companies you created and employees you registered — requests for anything else return 404, exactly as if the record did not exist.
Pagination
List endpoints accept page (default 1) and pageSize (default 20, max 100) query parameters and return a paginated envelope inside data:
"status": "success",
"message": "...",
"data": {
"total": 57,
"page": 1,
"pageSize": 20,
"totalPages": 3,
"data": [ ... ]
}
}
Hosted Flows
Some steps must be completed by the employee directly and are never handled by your integration. For these, the API returns a short-lived hosted url — open it in a browser or web view for the employee.
| Flow | Started by |
|---|---|
| PIN setup | POST /employees/:employeeId/pin/initialize |
| EWA withdrawal | POST /employees/:employeeId/payments/ewa/transfer/initialize |
Response Format
All responses are JSON. Successful responses follow this envelope:
"status": "success",
"message": "Human-readable message",
"data": { ... } // or an array
}
Rate Limits
Requests are limited to 100 per minute per client IP address, across all endpoints. The window is a fixed 60 seconds. Sandbox and Production keys share the same limit.
Every response includes rate limit headers so you can track usage:
| Header | Meaning |
|---|---|
RateLimit-Policy | The policy in force, e.g. 100;w=60 (100 requests per 60 seconds) |
RateLimit-Limit | Maximum requests allowed in the current window |
RateLimit-Remaining | Requests left in the current window |
RateLimit-Reset | Seconds until the window resets |
When the limit is exceeded the API returns 429 Too Many Requests:
"status": "error",
"message": "Too many requests"
}
RateLimit-Reset seconds have passed before retrying. Avoid tight polling loops — for example, when waiting on a repayment or withdrawal, poll no more than every few seconds.Error Codes
| Status | Meaning |
|---|---|
400 | Bad request — validation failed or business rule violated |
401 | Missing, invalid, or expired API key |
403 | API key exists but lacks the required scope, or developer account is not active |
404 | Resource not found, or belongs to another developer |
409 | Conflict — duplicate record or violated unique constraint |
429 | Rate limit exceeded — see Rate Limits |
500 | Unexpected server error |
Error responses include a message field describing what went wrong.
"status": "error",
"message": "API key does not have the COMPANY_READ scope"
}
/api/public/v1/companies.404, exactly as if the resource did not exist.List endpoints are paginated with page and pageSize (default 20, max 100) and return this envelope inside data:
"status": "success",
"message": "…",
"data": {
"total": 57,
"page": 1,
"pageSize": 20,
"totalPages": 3,
"data": [ ... ]
}
}
Owner role. A temporary password is generated and emailed to the admin as an invitation — it is never returned in the response. The company starts as PENDING until Work Benefit reviews it."name": "Acme Corp",
"emailDomain": "acmecorp.com",
"isEmailDomainEnforced": true,
"admin": {
"firstName": "Jane",
"lastName": "Doe",
"email": "jane@acmecorp.com",
"phoneNumber": "+233201234567",
"position": "HR Manager"
}
}
| Field | Type | Notes | |
|---|---|---|---|
name | string | required | 1–200 characters |
emailDomain | string | required | Up to 100 characters, stored lowercase, e.g. acmecorp.com |
isEmailDomainEnforced | boolean | required | If true, employees must use an email on this domain to link to the company |
admin.firstName | string | required | 1–100 characters |
admin.lastName | string | required | 1–100 characters |
admin.email | string | required | Valid email, stored lowercase. Must not already belong to a company admin |
admin.phoneNumber | string | optional | Include the country code, e.g. +233201234567. Must not already belong to a company admin |
admin.position | string | optional | Up to 100 characters. Defaults to HR |
Profile details (industry, pay cycle, website, etc.) are not accepted here — set them afterwards with Update Company.
"status": "success",
"message": "Company created successfully",
"data": {
"company": {
"id": 42,
"name": "Acme Corp",
"code": "A1B2C3D4",
"emailDomain": "acmecorp.com",
"isEmailDomainEnforced": true,
"status": "PENDING",
"createdAt": "2026-04-17T10:00:00.000Z",
"updatedAt": "2026-04-17T10:00:00.000Z",
"profile": null
},
"admin": { "email": "jane@acmecorp.com", "firstName": "Jane", "lastName": "Doe" }
}
}
| Status | When |
|---|---|
400 | Validation failed |
409 | A company admin with this email or phone number already exists |
| Query param | Type | Notes |
|---|---|---|
page | integer | Page number, starting at 1. Default 1 |
pageSize | integer | Items per page, 1–100. Default 20 |
search | string | Matches part of the company name (max 200 characters) |
status | string | PENDING, ACCEPTED or REJECTED |
industrySector | string | One of the industry sector values listed under Update Company |
from / to | ISO date | Only companies created on/after from and on/before to |
"status": "success",
"message": "Companies fetched successfully",
"data": {
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"data": [{
"id": 42,
"name": "Acme Corp",
"code": "A1B2C3D4",
"emailDomain": "acmecorp.com",
"isEmailDomainEnforced": true,
"status": "PENDING",
"createdAt": "2026-04-17T10:00:00.000Z",
"updatedAt": "2026-04-17T10:00:00.000Z",
"profile": {
"industrySector": "TECHNOLOGY",
"employeeCountRange": "FIFTY_ONE_TO_TWO_HUNDRED",
"hasPhysicalOffice": true,
"payrollSystem": "Sage",
"incorporationCountry": "Ghana",
"website": "https://acmecorp.com",
"linkedIn": null,
"facebook": null
}
}]
}
}
profile is null until a profile field has been set. totalPages is 0 when nothing matches.
"status": "success",
"message": "Company fetched successfully",
"data": {
"id": 42,
"name": "Acme Corp",
"code": "A1B2C3D4",
"emailDomain": "acmecorp.com",
"isEmailDomainEnforced": true,
"status": "PENDING",
"createdAt": "2026-04-17T10:00:00.000Z",
"updatedAt": "2026-04-17T10:00:00.000Z",
"profile": {
"industrySector": "TECHNOLOGY",
"employeeCountRange": "FIFTY_ONE_TO_TWO_HUNDRED",
"hasPhysicalOffice": true,
"payrollSystem": "Sage",
"incorporationCountry": "Ghana",
"incorporationCertUrl": null,
"logoUrl": null,
"website": "https://acmecorp.com",
"linkedIn": null,
"facebook": null,
"workingDayStartDay": 1,
"workingDayEndDay": 31,
"cutoffDay": 25,
"repaymentDay": 31,
"maxSalaryExposurePercentage": 60,
"contactAdminId": null
},
"locations": [
{ "id": 1, "location": "Airport City, Accra", "ghanaPostGps": "GA-123-4567", "isVerified": true, "isPrimary": true }
],
"_count": { "employeeLinks": 120, "companyBenefits": 3 }
}
}
profile is null until a profile field has been set; locations is [] when none exist.
profile creates the profile. code and status cannot be changed."name": "Acme Corporation",
"profile": {
"industrySector": "TECHNOLOGY",
"employeeCountRange": "FIFTY_ONE_TO_TWO_HUNDRED",
"payrollSystem": "Sage",
"cutoffDay": 20,
"repaymentDay": 28,
"maxSalaryExposurePercentage": 50,
"contactAdminId": 7
}
}
| Field | Type | Notes |
|---|---|---|
name | string | 1–200 characters |
emailDomain | string | Up to 100 characters, stored lowercase. Applies to future employee links only |
isEmailDomainEnforced | boolean | Applies to future employee links only |
profile | object | At least one field when present |
profile.industrySector | string | See values below |
profile.employeeCountRange | string | See values below |
profile.hasPhysicalOffice | boolean | |
profile.payrollSystem | string | Up to 100 characters |
profile.incorporationCountry | string | Up to 100 characters |
profile.website | string | URL, up to 500 characters |
profile.linkedIn | string | URL, up to 500 characters |
profile.facebook | string | URL, up to 500 characters |
profile.logoUrl | string | URL, up to 500 characters |
profile.incorporationCertUrl | string | URL, up to 500 characters |
profile.workingDayStartDay | integer | 1–31. First day of the pay cycle (default 1) |
profile.workingDayEndDay | integer | 1–31. Last day of the pay cycle (default 31) |
profile.cutoffDay | integer | 1–31. Monthly cutoff for EWA/payroll (default 25) |
profile.repaymentDay | integer | 1–31. Day the company repays each month (default 31) |
profile.maxSalaryExposurePercentage | integer | 0–100. Cap on benefit repayments plus earned wage as a share of salary (default 60) |
profile.contactAdminId | integer | Must be the id of an admin of this company |
| Industry sector values |
|---|
AGRICULTURE CONSTRUCTION EDUCATION ENERGY FINANCE HEALTHCARE HOSPITALITY MANUFACTURING MEDIA MINING RETAIL TECHNOLOGY TELECOMMUNICATIONS TRANSPORTATION OTHER |
| Employee count range values |
|---|
ONE_TO_FIVE SIX_TO_TWENTY TWENTY_ONE_TO_FIFTY FIFTY_ONE_TO_TWO_HUNDRED TWO_HUNDRED_PLUS |
Returns the updated company in the same shape as Get Company.
"status": "success",
"message": "Company updated successfully",
"data": {
"id": 42,
"name": "Acme Corporation",
"code": "A1B2C3D4",
"emailDomain": "acmecorp.com",
"isEmailDomainEnforced": true,
"status": "PENDING",
"createdAt": "2026-04-17T10:00:00.000Z",
"updatedAt": "2026-04-17T10:15:00.000Z",
"profile": {
"industrySector": "TECHNOLOGY",
"employeeCountRange": "FIFTY_ONE_TO_TWO_HUNDRED",
"hasPhysicalOffice": true,
"payrollSystem": "Sage",
"incorporationCountry": "Ghana",
"incorporationCertUrl": null,
"logoUrl": null,
"website": "https://acmecorp.com",
"linkedIn": null,
"facebook": null,
"workingDayStartDay": 1,
"workingDayEndDay": 31,
"cutoffDay": 20,
"repaymentDay": 28,
"maxSalaryExposurePercentage": 50,
"contactAdminId": 7
},
"locations": [
{ "id": 1, "location": "Airport City, Accra", "ghanaPostGps": "GA-123-4567", "isVerified": true, "isPrimary": true }
],
"_count": { "employeeLinks": 120, "companyBenefits": 3 }
}
}
| Status | When |
|---|---|
400 | Validation failed, empty body, or contactAdminId is not an admin of this company |
404 | Company not found |
Benefits — /api/public/v1/companies/:companyId/benefits
A company benefit attaches a benefit from the catalogue (GET /api/public/v1/benefits) to a company. It has its own id, distinct from the catalogue benefitId: pass benefitId when adding a benefit, and the company benefit id when updating one.
"status": "success",
"message": "Company benefits fetched successfully",
"data": [{
"id": 3,
"status": "ACTIVE",
"paymentOption": "PAYROLL_DEDUCTION",
"createdAt": "2026-04-01T09:00:00.000Z",
"updatedAt": "2026-04-17T10:00:00.000Z",
"benefit": {
"id": 1,
"name": "Flexible Rent Payment Options",
"code": "RENT",
"description": "Rent Financing helps individuals take control of their housing costs…",
"tag": "POPULAR",
"fixedPaymentOption": null
},
"enrolledCount": 45
}]
}
benefit.tag is POPULAR, NEW or null. benefit.fixedPaymentOption is null, or the payment option the benefit is locked to (PAYROLL_DEDUCTION / EMPLOYEE_SELF_PAYMENT).
ACTIVE (or WAITLISTED if the benefit is not yet generally available) with payment option PAYROLL_DEDUCTION. When active, all approved employees are enrolled and notified."benefitId": 1
}
| Field | Type | Notes | |
|---|---|---|---|
benefitId | integer | required | Catalogue benefit id |
"status": "success",
"message": "Company benefit created successfully",
"data": {
"id": 3,
"companyId": 42,
"benefitId": 1,
"status": "ACTIVE",
"paymentOption": "PAYROLL_DEDUCTION",
"createdAt": "2026-04-17T10:00:00.000Z",
"updatedAt": "2026-04-17T10:00:00.000Z"
}
}
| Status | When |
|---|---|
404 | Company or catalogue benefit not found |
409 | The company already has this benefit |
:id is the company benefit id from List Benefits. Send at least one field. Activating a benefit enrols all approved employees and notifies them."status": "ACTIVE",
"paymentOption": "EMPLOYEE_SELF_PAYMENT"
}
| Field | Type | Notes |
|---|---|---|
status | string | ACTIVE or INACTIVE |
paymentOption | string | PAYROLL_DEDUCTION or EMPLOYEE_SELF_PAYMENT. Rejected with 400 if the benefit's fixedPaymentOption is set |
| Benefit status | Description |
|---|---|
ACTIVE | Live — enrolled employees can use it |
INACTIVE | Disabled |
WAITLISTED | Set automatically when the benefit is not yet available; employees are not enrolled yet |
SUSPENDED | Set by Work Benefit only — cannot be set or changed through the API |
| Payment option | Description |
|---|---|
PAYROLL_DEDUCTION | Company repays and deducts from employee salary |
EMPLOYEE_SELF_PAYMENT | Employee pays directly |
"status": "success",
"message": "Company benefit updated successfully",
"data": {
"id": 3,
"status": "ACTIVE",
"paymentOption": "EMPLOYEE_SELF_PAYMENT",
"createdAt": "2026-04-01T09:00:00.000Z",
"updatedAt": "2026-04-17T10:15:00.000Z",
"benefit": {
"id": 1,
"name": "Flexible Rent Payment Options",
"code": "RENT",
"description": "Rent Financing helps individuals take control of their housing costs…",
"tag": "POPULAR",
"fixedPaymentOption": null
},
"enrolledCount": 45
}
}
status to INACTIVE on a WAITLISTED benefit removes it from the company. The response is still 200 and returns the removed record (id, companyId, benefitId, status, paymentOption, timestamps).| Status | When |
|---|---|
400 | Validation failed, or payment option change on a fixed-option benefit |
403 | The benefit is SUSPENDED |
404 | Company or company benefit not found |
Earned Wage Access — /api/public/v1/companies/:companyId/ewa
pendingApprovals counts all pending requests; the other values cover the current pay cycle (based on the company's workingDayStartDay / workingDayEndDay)."status": "success",
"message": "EWA company stats fetched successfully",
"data": {
"pendingApprovals": 3,
"approvedThisMonth": 12,
"declinedThisMonth": 1,
"totalWithdrawalsThisMonth": 4500
}
}
"status": "success",
"message": "EWA company settings fetched successfully",
"data": {
"id": 5,
"companyId": 42,
"enabledAutoApprovalForAllEmployees": false,
"hasPercentageOfAccruedWage": false,
"hasPermanentOrConfirmedEmployeeStatus": false,
"hasRequestedLessThanMaxAmountPerCycle": false,
"hasMadeLessThanMaxRequestsPerMonth": false,
"hasPendingPaymentFromPriorCycles": false,
"enabledBetweenTimePeriods": false,
"percentageOfAccruedWage": 50,
"maxAmountPerCycle": "100000",
"smartAutoApprovalStartTime": "08:00",
"smartAutoApprovalEndTime": "17:00",
"smartAutoApprovalDays": "WEEKDAYS"
}
}
has… / enabled… toggle switches on the matching limit, which can be set independently."enabledAutoApprovalForAllEmployees": true,
"hasPercentageOfAccruedWage": true,
"percentageOfAccruedWage": 40,
"enabledBetweenTimePeriods": true,
"smartAutoApprovalStartTime": "09:00",
"smartAutoApprovalEndTime": "16:30",
"smartAutoApprovalDays": "WEEKDAYS"
}
| Field | Type | Default | Notes |
|---|---|---|---|
enabledAutoApprovalForAllEmployees | boolean | false | Master switch for auto-approval |
hasPercentageOfAccruedWage | boolean | false | Enforce percentageOfAccruedWage |
percentageOfAccruedWage | integer | 50 | 0–100. Max share of accrued wage per request |
hasPermanentOrConfirmedEmployeeStatus | boolean | false | Only auto-approve permanent/confirmed employees |
hasRequestedLessThanMaxAmountPerCycle | boolean | false | Enforce maxAmountPerCycle |
maxAmountPerCycle | number | 100000 | ≥ 0. Max total withdrawn per pay cycle |
hasMadeLessThanMaxRequestsPerMonth | boolean | false | Enforce each employee's maxRequestsPerMonth |
hasPendingPaymentFromPriorCycles | boolean | false | Block auto-approval while prior cycles are unpaid |
enabledBetweenTimePeriods | boolean | false | Only auto-approve within the time window below |
smartAutoApprovalStartTime | string | 08:00 | 24-hour HH:MM |
smartAutoApprovalEndTime | string | 17:00 | 24-hour HH:MM |
smartAutoApprovalDays | string | WEEKDAYS | WEEKDAYS, WEEKENDS or EVERYDAY |
"status": "success",
"message": "EWA company settings updated successfully",
"data": {
"id": 5,
"companyId": 42,
"enabledAutoApprovalForAllEmployees": true,
"hasPercentageOfAccruedWage": true,
"hasPermanentOrConfirmedEmployeeStatus": false,
"hasRequestedLessThanMaxAmountPerCycle": false,
"hasMadeLessThanMaxRequestsPerMonth": false,
"hasPendingPaymentFromPriorCycles": false,
"enabledBetweenTimePeriods": true,
"percentageOfAccruedWage": 40,
"maxAmountPerCycle": "100000",
"smartAutoApprovalStartTime": "09:00",
"smartAutoApprovalEndTime": "16:30",
"smartAutoApprovalDays": "WEEKDAYS"
}
}
| Query param | Type | Notes |
|---|---|---|
status | string | PENDING, APPROVED or REJECTED |
search | string | Matches employee first or last name |
dateRange | string | today, this_week, this_month or this_year |
from / to | ISO date | Explicit request-date range, used when dateRange is not given |
page | integer | Page number, starting at 1. Default 1 |
pageSize | integer | Items per page, 1–100. Default 20 |
"status": "success",
"message": "EWA requests fetched successfully",
"data": {
"total": 25,
"page": 1,
"pageSize": 20,
"totalPages": 2,
"data": [{
"id": 7,
"reference": "EWA-20260417-8F3A1C",
"linkId": 5,
"status": "PENDING",
"employeeName": "Kwame Mensah",
"earnedWage": 1200,
"amount": 400,
"percentageOfWage": 33.33,
"requestDate": "2026-04-17T09:00:00.000Z"
}]
}
}
earnedWage is the employee's estimated earned wage so far this cycle (0 if their salary is unknown); percentageOfWage is the request as a share of it (null if unknown). linkId identifies the employee for Update Employee.
"status": "success",
"message": "EWA request details fetched successfully",
"data": {
"id": 7,
"reference": "EWA-20260417-8F3A1C",
"status": "PENDING",
"employeeName": "Kwame Mensah",
"requestDate": "2026-04-17T09:00:00.000Z",
"summary": {
"estimatedEarnedSalary": 1200,
"requestAmount": 400,
"percentageOfAccrued": 33.33,
"maxWithdrawalPercentage": 50,
"withinRuleLimit": true
},
"withdrawalHistory": [
{ "amount": 200, "requestDate": "2026-03-10T08:00:00.000Z" }
]
}
}
withinRuleLimit compares percentageOfAccrued with the employee's maxWithdrawalPercentage; it is null when either is unknown, as are estimatedEarnedSalary and percentageOfAccrued when the salary is unknown.
PENDING EWA request and notifies the employee. No request body."status": "success",
"message": "EWA request approved successfully",
"data": {
"id": 7,
"reference": "EWA-20260417-8F3A1C",
"companyLinkId": 5,
"amount": "400",
"serviceCharge": "8",
"status": "APPROVED",
"requestDate": "2026-04-17T09:00:00.000Z",
"cutOffDate": "2026-04-25T00:00:00.000Z",
"repaymentDate": "2026-04-30T00:00:00.000Z",
"approvedById": null,
"createdAt": "2026-04-17T09:00:00.000Z",
"updatedAt": "2026-04-17T10:05:00.000Z"
}
}
amount and serviceCharge are decimal strings. approvedById is always null for requests approved through the API.
| Status | When |
|---|---|
400 | The request is not PENDING (e.g. Request is already approved) |
404 | Company or request not found |
PENDING EWA request and notifies the employee. No request body. Returns 400 if the request has already been processed."status": "success",
"message": "EWA request rejected successfully",
"data": {
"id": 7,
"reference": "EWA-20260417-8F3A1C",
"companyLinkId": 5,
"amount": "400",
"serviceCharge": "8",
"status": "REJECTED",
"requestDate": "2026-04-17T09:00:00.000Z",
"cutOffDate": "2026-04-25T00:00:00.000Z",
"repaymentDate": "2026-04-30T00:00:00.000Z",
"approvedById": null,
"createdAt": "2026-04-17T09:00:00.000Z",
"updatedAt": "2026-04-17T10:06:00.000Z"
}
}
sortBy is given.| Query param | Type | Notes |
|---|---|---|
search | string | Matches employee first or last name |
sortBy | string | name, maxWithdrawalPercentage or approvalMode |
sortOrder | string | asc (default) or desc |
page | integer | Page number, starting at 1. Default 1 |
pageSize | integer | Items per page, 1–100. Default 20 |
"status": "success",
"message": "EWA employees fetched successfully",
"data": {
"total": 120,
"page": 1,
"pageSize": 20,
"totalPages": 6,
"data": [{
"employeeName": "Kwame Mensah",
"maxWithdrawalPercentage": 50,
"approvalMode": "MANUAL",
"maxRequestsPerMonth": 5,
"enrolled": true,
"linkId": 5
}]
}
}
Settings fields are null until they have been set for that employee. enrolled is true when the employee's EWA benefit is active.
:linkId comes from List Employees or List Requests. Send at least one field."enrolled": true,
"maxWithdrawalPercentage": 40,
"approvalMode": "AUTOMATIC",
"maxRequestsPerMonth": 3
}
| Field | Type | Notes |
|---|---|---|
enrolled | boolean | Turns the employee's EWA benefit on or off. A suspended EWA benefit is not changed |
maxWithdrawalPercentage | integer | 0–50. Max share of accrued wage the employee may withdraw |
approvalMode | string | MANUAL or AUTOMATIC |
maxRequestsPerMonth | integer | 1–50 |
"status": "success",
"message": "EWA employee settings updated successfully",
"data": {
"maxWithdrawalPercentage": 40,
"approvalMode": "AUTOMATIC",
"maxRequestsPerMonth": 3,
"enrolled": true
}
}
| Status | When |
|---|---|
400 | Validation failed or empty body |
404 | Company or employee not found |
Payments — /api/public/v1/companies/:companyId/payments
…/owed endpoints show what the company owes for payroll-deduction benefits (EWA, Rent & Pay Monthly, Shop & Pay Monthly). …/employees/… endpoints show what self-paying employees owe for their own rent/shop instalments.| Query param | Type | Notes |
|---|---|---|
page | integer | Page number, starting at 1. Default 1 |
pageSize | integer | Items per page, 1–100. Default 20 |
search | string | Matches employee name (EWA entries) or company name (repayments) |
from / to | ISO date | Date range |
"status": "success",
"message": "Payment history retrieved",
"data": {
"total": 50,
"page": 1,
"pageSize": 20,
"totalPages": 3,
"data": [
{ "reference": "EWA-20260417-8F3A1C", "date": "2026-04-17T09:00:00.000Z", "name": "Kwame Mensah", "amount": 400, "benefitType": "Earned Wage Access", "paymentMethod": null, "status": "APPROVED" },
{ "reference": "cpr-20260415-3f9c2a7b1d4e6f80", "date": "2026-04-15T14:00:00.000Z", "name": "Acme Corp", "amount": 1000, "benefitType": null, "paymentMethod": "Paystack", "status": "COMPLETED" }
]
}
}
status=PAID.| Query param | Type | Notes |
|---|---|---|
status | string | UNPAID, PARTIAL or PAID |
search | string | Matches the month name, e.g. March |
from / to | ISO date | Matched against each month's due date |
"status": "success",
"message": "Owed balance retrieved",
"data": {
"breakdown": [{
"month": "March",
"year": 2026,
"ewaOwed": 2400,
"ewaPaid": 800,
"rentOwed": 600,
"rentPaid": 0,
"shopOwed": 200,
"shopPaid": 0,
"dueDate": "2026-03-31T00:00:00.000Z",
"owed": 3200,
"paid": 800,
"status": "PARTIAL"
}],
"totals": { "owed": 3200, "paid": 800 }
}
}
owed is the remaining balance for the month (sum of ewaOwed, rentOwed, shopOwed); paid is what has been paid so far.
:year is e.g. 2026, :month is 1–12. Employees appear once per benefit type."status": "success",
"message": "Owed balance retrieved",
"data": {
"month": 3,
"year": 2026,
"employees": [
{ "employeeId": 101, "name": "Kwame Mensah", "owed": 400, "type": "Earned Wage Access", "dueDate": "2026-03-31T00:00:00.000Z" },
{ "employeeId": 102, "name": "Ama Owusu", "owed": 600, "type": "Rent & Pay Monthly", "dueDate": "2026-03-28T00:00:00.000Z" }
],
"totals": { "owed": 1000, "paid": 800 }
}
}
type is Earned Wage Access, Rent & Pay Monthly or Shop & Pay Monthly. totals.paid is the amount the company has already repaid for that month.
authorization_url, then call Verify Repayment when they return to your callbackUrl. The Paystack receipt goes to the company's primary admin email."month": 3,
"year": 2026,
"amount": 1000,
"callbackUrl": "https://yourapp.com/callback"
}
"months": [
{ "month": 3, "year": 2026 },
{ "month": 4, "year": 2026 }
],
"amount": 2500,
"callbackUrl": "https://yourapp.com/callback"
}
| Field | Type | Notes | |
|---|---|---|---|
month | integer | required | 1–12 (single-month form) |
year | integer | required | ≥ 2000 (single-month form) |
months | array | required | At least one { month, year } (multiple-month form, instead of month/year) |
amount | number | required | > 0 |
callbackUrl | string | required | URL Paystack redirects to after payment |
The amount is applied to the listed months oldest first, up to what is still owed on each. Anything left over is recorded as an overpayment credit and returned as overpayment.
"status": "success",
"message": "Repayment initialized",
"data": {
"authorization_url": "https://checkout.paystack.com/abc123",
"access_code": "abc123",
"reference": "cpr-20260417-3f9c2a7b1d4e6f80",
"amount": 1000,
"transactionId": 55,
"allocations": [
{ "year": 2026, "month": 3, "amount": 1000, "benefitId": 4 }
]
}
}
| Status | When |
|---|---|
400 | Validation failed, the company has no outstanding EWA balance, or Paystack could not start the payment |
404 | Company not found, or the company has no admin |
400."status": "success",
"message": "Repayment verified",
"data": {
"success": true,
"reference": "cpr-20260417-3f9c2a7b1d4e6f80",
"amount": 1000,
"months": [
{ "month": 3, "year": 2026 }
]
}
}
"status": "success",
"message": "Repayment verified",
"data": {
"success": false,
"reference": "cpr-20260417-3f9c2a7b1d4e6f80"
}
}
"status": "success",
"message": "Repayment verified",
"data": {
"success": false,
"pending": true,
"reference": "cpr-20260417-3f9c2a7b1d4e6f80",
"status": "ongoing"
}
}
When pending is true nothing has been settled yet; status is the Paystack status. Call verify again later.
| Status | When |
|---|---|
400 | Transaction has already been processed |
404 | Transaction not found |
direction is CREDIT for repayments and DEBIT for EWA withdrawals."status": "success",
"message": "Transaction details retrieved",
"data": {
"reference": "cpr-20260415-3f9c2a7b1d4e6f80",
"amount": 1000,
"direction": "CREDIT",
"status": "COMPLETED",
"date": "2026-04-15T14:00:00.000Z",
"name": "Acme Corp",
"benefitType": "Earned Wage Access",
"paymentMethod": "Company Benefit epayment"
}
}
Transaction status is PENDING, COMPLETED or FAILED; EWA withdrawals return APPROVED. For EWA withdrawals paymentMethod is null.
| Status | When |
|---|---|
404 | No transaction with this reference for the company |
| Query param | Type | Notes |
|---|---|---|
page | integer | Page number, starting at 1. Default 1 |
pageSize | integer | Items per page, 1–100. Default 20 |
search | string | Matches employee name |
"status": "success",
"message": "Employee amount owed retrieved",
"data": {
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"data": [{
"employeeId": 101,
"name": "Kwame Mensah",
"amountOwed": 1500,
"monthsOwed": [
"March",
"April"
],
"benefit": "Flexible Rent Payment Options",
"status": "UNPAID"
}],
"totals": { "owed": 1500, "paid": 0 }
}
}
status is UNPAID if any month has no payment yet, otherwise PARTIALLY_PAID. totals.owed covers every matching employee, not just the current page.
| Query param | Type | Notes |
|---|---|---|
status | string | UNPAID, PARTIAL or PAID — filters individual instalments |
search | string | Matches the month name |
from / to | ISO date | Matched against the instalment due date |
"status": "success",
"message": "Employee owed balance retrieved",
"data": {
"breakdown": [
{ "month": "April", "year": 2026, "owed": 1500, "totalPaid": 200, "status": "PARTIAL", "dueDate": "2026-04-30T00:00:00.000Z" }
],
"totals": { "owed": 1500, "paid": 200 }
}
}
:year is e.g. 2026, :month is 1–12. Employees appear once per benefit type."status": "success",
"message": "Employee owed balance retrieved",
"data": {
"month": 4,
"year": 2026,
"employees": [
{ "employeeId": 101, "name": "Kwame Mensah", "type": "Rent & Pay Monthly", "owed": 800, "paid": 200, "status": "PARTIAL" }
],
"totals": {
"owed": 800,
"paid": 200,
"rentOwed": 800,
"rentPaid": 200,
"shopOwed": 0,
"shopPaid": 0
}
}
}
404. All employee endpoints share the base path /api/public/v1/employees unless noted otherwise.Onboarding flow
Onboarding is not fully automated — it needs the employee. Verification codes (4-digit OTPs) are sent directly to the employee's phone by SMS and to their work email inbox. You cannot read these codes: the employee receives them and gives them back to you (for example, through your own UI), and you submit them to the API. Build a step into your flow to collect these codes from the employee.
| Step | Endpoint | Notes |
|---|---|---|
| 1. Register | POST /employees | Creates the employee and sends a phone OTP by SMS. Optionally links a company in the same call (also sends a work-email OTP). |
| 2. Verify phone | POST /employees/:id/verify-phone | Submit the SMS code the employee received. |
| 3. Upload selfie | POST /employees/:id/selfie | Required for KYC. Only allowed after the phone is verified. |
| 4. Link company | POST /employees/:id/companies | Skip if you linked a company at registration. Sends a work-email OTP. |
| 5. Verify work email | POST /employees/:id/verify-work-email | Submit the code the employee received at their work email. |
Phone numbers are accepted in international format with the country code, e.g. +233558371654. Local Ghana numbers such as 0558371654 are also accepted. Numbers are validated, stored and returned in E.164 format (+233558371654). An invalid number returns 400.
companyId and workEmail, the employee is linked to that company in the same call and a work-email code is sent."firstName": "Kwame",
"lastName": "Mensah",
"phoneNumber": "+233201234567",
"companyId": 42, // optional, with workEmail
"workEmail": "kwame@acmecorp.com" // optional, with companyId
}
| Field | Type | Notes | |
|---|---|---|---|
firstName | string | required | 1–100 characters |
lastName | string | required | 1–100 characters |
phoneNumber | string | required | Valid phone number with country code (see phone format). Must be unique across the platform. |
companyId | integer | optional | A company owned by your developer account. Must be sent together with workEmail. |
workEmail | string | optional | Valid email, lowercased. Must be sent together with companyId. Must use the company's domain if the company enforces its email domain. |
gender and dateOfBirth are not accepted at registration — set them afterwards with Update Profile. Unknown fields are rejected with 400."status": "success",
"message": "Employee registered successfully",
"data": {
"id": 101, "firstName": "Kwame", "lastName": "Mensah",
"phoneNumber": "+233201234567",
"gender": null, "dateOfBirth": null,
"createdAt": "2026-09-25T10:00:00.000Z",
"companyLinks": [{
"id": 5, "workEmail": "kwame@acmecorp.com", "emailVerified": false,
"status": "PENDING", "createdAt": "2026-09-25T10:00:00.000Z",
"company": { "id": 42, "name": "Acme Corp", "code": "A1B2C3D4", "status": "ACCEPTED" }
}]
}
}
companyLinks is an empty array when no company was linked. Errors: 400 validation or work-email domain mismatch, 404 company not found, 409 phone number already registered.
| Query param | Notes |
|---|---|
search | Matches first name, last name or phone number. Max 200 characters. |
page / pageSize | Defaults: 1 / 20 (max 100) |
"status": "success",
"message": "Employees fetched successfully",
"data": {
"total": 1, "page": 1, "pageSize": 20, "totalPages": 1,
"data": [{
"id": 101, "firstName": "Kwame", "lastName": "Mensah",
"phoneNumber": "+233201234567", "gender": "MALE",
"dateOfBirth": "1995-06-15T00:00:00.000Z", "createdAt": "2026-09-25T10:00:00.000Z"
}]
}
}
404 if the employee was not registered by your developer account."status": "success",
"message": "Employee fetched successfully",
"data": {
"id": 101, "firstName": "Kwame", "lastName": "Mensah",
"phoneNumber": "+233201234567", "gender": "MALE",
"dateOfBirth": "1995-06-15T00:00:00.000Z",
"createdAt": "2026-09-25T10:00:00.000Z",
"companyLinks": [{
"id": 5, "workEmail": "kwame@acmecorp.com", "emailVerified": true,
"status": "APPROVED", "createdAt": "2026-09-25T10:00:00.000Z",
"company": { "id": 42, "name": "Acme Corp", "code": "A1B2C3D4", "status": "ACCEPTED" }
}]
}
}
| Field | Type | Notes | |
|---|---|---|---|
code | string | required | Exactly 4 characters |
Errors: 409 if the code is invalid or expired, or the phone number is already verified. Each wrong code uses up one of the 3 attempts.
"status": "success",
"message": "Verification code resent successfully",
"data": { "phoneNumber": "+233201234567" }
}
Errors: 409 if the phone number is already verified.
PENDING link and emails a 4-digit code to the workEmail. The employee relays the code to you for Verify Work Email.| Field | Type | Notes | |
|---|---|---|---|
companyId | integer | required | A company owned by your developer account |
workEmail | string | required | Valid email, lowercased. Must use the company's domain if the company enforces its email domain. |
| Linking rule | Result |
|---|---|
| An employee can have only one active company link. An existing approved, suspended, on-hold, or verified-pending link must be revoked first. | 409 |
| A link that was rejected by the same company cannot be attempted again. | 409 |
| A work email already verified and active at the company cannot be reused. | 409 |
The company enforces its email domain and workEmail does not match it. | 400 |
| Unverified pending links, and any earlier link to the same company, are replaced automatically. | — |
"status": "success",
"message": "Employee linked to company successfully",
"data": {
"id": 5, "companyId": 42, "workEmail": "kwame@acmecorp.com",
"status": "PENDING", "otpSent": true, "createdAt": "2026-09-25T10:05:00.000Z"
}
}
APPROVED and the company's active benefits become available to the employee. Otherwise (only possible when the company does not enforce its domain) the link stays PENDING until a company admin approves it. Company admins are notified either way.| Field | Type | Notes | |
|---|---|---|---|
companyId | integer | required | The company the employee is linking to |
code | string | required | Exactly 4 characters |
"status": "success",
"message": "Work email verified successfully",
"data": { "id": 5, "companyId": 42, "workEmail": "kwame@acmecorp.com", "status": "APPROVED", "createdAt": "2026-09-25T10:05:00.000Z" }
}
Errors: 400 domain mismatch on a company that enforces its domain, 404 no link to this company, 409 invalid or expired code, or email already verified.
| Field | Type | Notes | |
|---|---|---|---|
companyId | integer | required | The company of the pending link |
"status": "success",
"message": "Work email verification code resent successfully",
"data": { "companyId": 42, "workEmail": "kwame@acmecorp.com" }
}
Errors: 404 no link to this company, 409 email already verified, link no longer pending, or code expired.
| Link status | Description |
|---|---|
PENDING | Work email not yet verified, or verified and awaiting company admin approval (check emailVerified) |
APPROVED | Active — the employee has access to the company's benefits |
REJECTED | Rejected by a company admin. The employee cannot re-link to the same company. |
SUSPENDED | Temporarily suspended by the company |
HOLD | Placed on hold by the company |
REVOKED | Link ended. The employee can link to a new company. |
Profile — /api/public/v1/employees/:employeeId/profile
gender and dateOfBirth are set. Send only the fields you want to change; at least one field is required."firstName": "Kwame", "lastName": "Asante",
"dateOfBirth": "1995-06-15", "gender": "MALE"
}
| Field | Type | Notes | |
|---|---|---|---|
firstName | string | optional | Non-empty |
lastName | string | optional | Non-empty |
dateOfBirth | string | optional | ISO 8601 date, e.g. 1995-06-15 |
gender | string | optional | MALE or FEMALE |
"status": "success",
"message": "Profile updated successfully",
"data": {
"id": 101, "firstName": "Kwame", "lastName": "Asante",
"phoneNumber": "+233201234567", "isPhoneVerified": true, "isActive": true,
"dateOfBirth": "1995-06-15T00:00:00.000Z", "gender": "MALE",
"selfie": "https://...",
"personalEmail": { "email": "kwame.personal@gmail.com", "isVerified": true },
"salary": { "amount": "5000.00", "isVerified": false },
"createdAt": "2026-09-25T10:00:00.000Z"
}
}
selfie, personalEmail and salary are null until set. salary.amount is a decimal string.
| Field | Type | Notes | |
|---|---|---|---|
email | string | required | Valid email address |
"status": "success",
"message": "Verification code sent to email",
"data": { "email": "kwame.personal@gmail.com" }
}
Errors: 409 if this email is already verified for the employee, or is in use by another employee.
| Field | Type | Notes | |
|---|---|---|---|
code | string | required | Exactly 4 characters |
"status": "success",
"message": "Email verified successfully",
"data": { "email": "kwame.personal@gmail.com" }
}
Errors: 409 if there is no pending verification, or the code is invalid or expired.
Selfie — /api/public/v1/employees/:employeeId/selfie
multipart/form-data with the image in a file field named selfie. The image is checked for face quality before it is saved; uploading again replaces the previous selfie.| Field | Type | Notes | |
|---|---|---|---|
selfie | file | required | JPEG or PNG image, max 5 MB. Must show exactly one clear, well-lit face. |
400.-H "X-Api-Key: wb_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-F "selfie=@/path/to/selfie.jpg"
"status": "success",
"message": "Selfie uploaded successfully",
"data": { "selfie": "https://...", "previousSelfie": null }
}
Errors: 400 if no image is sent, the phone is not verified, or the image fails the face check (no face, more than one face, too blurry, too dark or too bright — the message says which). 415 for an unsupported file type.
Emergency Contact — /api/public/v1/employees/:employeeId/emergency-contact
404 if none has been saved yet."status": "success",
"message": "Emergency contact fetched successfully",
"data": {
"id": 3, "firstName": "Abena", "lastName": "Mensah",
"relationship": "Sister", "phoneNumber": "+233201112233",
"residentialAddress": "123 Osu Street, Accra", "createdAt": "2026-09-25T09:00:00.000Z"
}
}
"firstName": "Abena", "lastName": "Mensah",
"relationship": "Sister", "phoneNumber": "+233201112233",
"residentialAddress": "123 Osu Street, Accra"
}
| Field | Type | Notes | |
|---|---|---|---|
firstName | string | required | |
lastName | string | required | |
relationship | string | required | Free text, e.g. Sister |
phoneNumber | string | required | Valid phone number with country code (see phone format) |
residentialAddress | string | required |
"status": "success",
"message": "Emergency contact saved successfully",
"data": { "id": 3, "firstName": "Abena", "lastName": "Mensah", "relationship": "Sister", "phoneNumber": "+233201112233", "residentialAddress": "123 Osu Street, Accra", "createdAt": "2026-09-25T09:00:00.000Z" }
}
Identity Verification — /api/public/v1/employees/:employeeId/identity-verification
"status": "success",
"message": "Identity verifications fetched successfully",
"data": [{
"id": 2,
"documentType": "GHANA_CARD", "documentId": "GHA-123456789-0",
"frontImage": "https://...", "backImage": "https://...",
"createdAt": "2026-09-25T09:00:00.000Z"
}]
}
multipart/form-data. If a document of the same documentType already exists, it is replaced (new images and document number); otherwise a new record is created.| Field | Type | Notes | |
|---|---|---|---|
documentType | text | required | GHANA_CARD or PASSPORT |
documentId | text | required | The document number, max 100 characters |
frontImage | file | required | Front of the document. Image (JPEG, PNG, WebP, GIF) or PDF, max 5 MB. |
backImage | file | optional | Back of the document (e.g. for a Ghana Card). Same format and size limits. |
-H "X-Api-Key: wb_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-F "documentType=GHANA_CARD" \
-F "documentId=GHA-123456789-0" \
-F "frontImage=@/path/to/front.jpg" \
-F "backImage=@/path/to/back.jpg"
"status": "success",
"message": "Identity document uploaded successfully",
"data": {
"id": 2,
"documentType": "GHANA_CARD", "documentId": "GHA-123456789-0",
"frontImage": "https://...", "backImage": "https://...",
"createdAt": "2026-09-25T09:00:00.000Z"
}
}
backImage is null when not supplied. Errors: 400 invalid documentType, missing documentId or missing frontImage; 415 unsupported file type.
Earned Wage Access — /api/public/v1/employees/:employeeId/ewa
"status": "success",
"message": "Earned wage details fetched successfully",
"data": {
"earnedWage": 2450,
"availableToUse": 850,
"maxAmountAllowed": 1250,
"availableToWithdraw": 400,
"serviceChargePercentage": 0.049,
"maximumWithdrawalPercentage": 25,
"totalRequestedAmount": 400,
"totalRequests": 1,
"linkId": 5,
"repaymentDate": "2026-09-30T00:00:00.000Z",
"cutoffDate": "2026-09-25T00:00:00.000Z",
"message": "You can access up to GHS 850.00 of your earned salary at no interest fee"
}
}
| Field | Description |
|---|---|
earnedWage | Salary earned so far in the current pay cycle |
availableToUse | Amount the employee can still request this cycle (the upper limit for Submit Request) |
maxAmountAllowed | Cycle cap, based on the employee's withdrawal limit and the company's salary-exposure limit |
availableToWithdraw | Approved EWA balance that can be paid out now (the upper limit for a withdrawal) |
serviceChargePercentage | Service charge as a fraction of the requested amount (0.049 = 4.9%) |
totalRequestedAmount / totalRequests | Pending and approved requests in the current cycle |
cutoffDate / repaymentDate | Last day to request in this cycle / date the advance is recovered from salary |
0, linkId, cutoffDate and repaymentDate are null, and the response also includes minimumRequestAmount. Returns 400 if no salary has been set (see Update Income).| Query param | Notes | |
|---|---|---|
status | optional | PENDING, APPROVED or REJECTED |
page | optional | Integer ≥ 1. Default 1 |
pageSize | optional | Integer 1–100. Default 20 |
"status": "success",
"message": "EWA requests fetched successfully",
"data": {
"total": 3,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"data": [
{
"id": 7,
"reference": "EWA-A1B2C3D4",
"status": "APPROVED",
"amount": 400,
"serviceCharge": 19.6,
"requestDate": "2026-09-10T09:00:00.000Z",
"cutOffDate": "2026-09-25T00:00:00.000Z",
"repaymentDate": "2026-09-30T00:00:00.000Z",
"companyName": "Acme Corp"
}
]
}
}
PENDING until a company admin reviews it. Approved amounts become available to withdraw via Initialize Withdrawal.| Field | Type | Notes | |
|---|---|---|---|
amount | number | required | ≥ 1 and ≤ availableToUse from EWA Details |
"status": "success",
"message": "Earned wage request submitted successfully",
"data": {
"id": 7,
"reference": "EWA-A1B2C3D4",
"companyLinkId": 5,
"amount": "400",
"serviceCharge": "19.6",
"status": "PENDING",
"requestDate": "2026-09-10T09:00:00.000Z",
"cutOffDate": "2026-09-25T00:00:00.000Z",
"repaymentDate": "2026-09-30T00:00:00.000Z",
"approvedById": null,
"createdAt": "2026-09-10T09:00:00.000Z",
"updatedAt": "2026-09-10T09:00:00.000Z",
"autoApproved": false
}
}
autoApproved: true means the request was approved immediately and status is APPROVED. In this response amount and serviceCharge are decimal strings.| Error | When |
|---|---|
400 | The employee already has a PENDING request, the cycle's cut-off date has passed, or amount exceeds availableToUse |
403 | Employee is deactivated, EWA is not active for the company, the employee is not enrolled in EWA, or their salary is not yet verified |
404 | Employee not found for this developer, or not linked to an active company |
accountName is looked up live and is null if it cannot be resolved."status": "success",
"message": "Recipients fetched successfully",
"data": [
{
"id": 12,
"employeeId": 101,
"transferType": "BANK",
"accountNumber": "1234567890",
"bankCode": "030100",
"recipientCode": "RCP_abc123",
"isSaved": true,
"createdAt": "2026-09-10T10:00:00.000Z",
"accountName": "KWAME MENSAH"
}
]
}
transferType is BANK or MOBILE_MONEY (manually processed payouts appear as MANUAL_BANK / MANUAL_MOBILE_MONEY). For mobile money, accountNumber is the phone number and bankCode the network (MTN, VOD, ATL).isSaved: true.| Field | Type | Notes | |
|---|---|---|---|
isSaved | boolean | required |
"status": "success",
"message": "Recipient updated successfully",
"data": { "id": 12, "employeeId": 101, "transferType": "BANK", "accountNumber": "1234567890", "bankCode": "030100", "recipientCode": "RCP_abc123", "isSaved": true, "createdAt": "2026-09-10T10:00:00.000Z" }
}
404 if the recipient does not belong to this employee. accountName is not included in this response.Payments — /api/public/v1/employees/:employeeId/payments
url; the PIN entered on that page is what authorises the withdrawal.| Step | What happens |
|---|---|
| 1. Initialize | Call Initialize PIN Setup or Initialize Withdrawal. The response contains url and expiresAt. |
| 2. Send the employee | Redirect the employee's browser to url, or open it in a new tab / in-app browser / WebView. Do not fetch it server-side. |
| 3. Employee completes | PIN setup: the employee enters and confirms a new PIN. Withdrawal: the employee chooses a bank (and account number) or mobile money network (and phone number), then enters their PIN. |
| 4. Return | If you passed redirectUrl, the page sends the employee there on success. For withdrawals, ?reference=<ref>&status=success is appended. Without redirectUrl, a success screen is shown on the hosted page. |
expiresAt). Opening a used or expired link shows an error page — create a new session. Errors such as a wrong PIN are shown on the page and the employee can retry until the session is used or expires. Five wrong PINs lock the PIN for 15 minutes.redirectUrl is a plain browser redirect with no signature. Confirm the outcome from your server with PIN Status or Transaction History (match the withdrawal reference) rather than trusting the query string.| Query param | Notes | |
|---|---|---|
type | optional | requests, withdrawals, rent or shop. Omit to return all groups. rent and shop both return the external list (each item tagged with benefitType) |
page | optional | Integer ≥ 1. Default 1 |
pageSize | optional | Integer 1–100. Default 20 |
"status": "success",
"message": "Transaction history retrieved",
"data": {
"requests": [
{ "id": 7, "reference": "EWA-A1B2C3D4", "status": "APPROVED", "amount": "400", "serviceCharge": "19.6", "requestDate": "2026-09-10T09:00:00.000Z", "cutOffDate": "2026-09-25T00:00:00.000Z", "repaymentDate": "2026-09-30T00:00:00.000Z" }
],
"withdrawals": [
{ "id": 20, "reference": "WD-9F8E7D6C", "status": "COMPLETED", "transferType": "BANK", "amount": "200", "transferFee": "7", "createdAt": "2026-09-11T10:00:00.000Z" }
],
"external": [
{ "id": 5521, "paymentPurpose": "RENT", "paymentChannel": "MOMO", "amountPaid": 1500, "transactionReference": "TXN-55210", "datePaymentWasMade": "2026-09-01", "paymentMonth": "September", "paymentTitle": "Rent payment", "benefitType": "RENT" }
],
"total": 3,
"page": 1,
"pageSize": 20
}
}
page / pageSize apply to requests and withdrawals separately; external is always returned in full. total is the combined count across all three groups. Withdrawal status is PROCESSING, COMPLETED or FAILED. Decimal amounts in requests and withdrawals are strings.| Query param | Notes | |
|---|---|---|
accountType | required | BANK or MOBILE_MONEY |
amount | required | Positive number |
"status": "success",
"message": "Transfer details retrieved",
"data": { "fee": 7, "requestedAmount": 200, "receivedAmount": 193, "minimumAmount": 10 }
}
receivedAmount = requestedAmount − fee. The minimum withdrawal is GHS 10.url, where they choose the destination account and confirm with their PIN (see Hosted sessions). No money moves until the employee confirms on the hosted page."amount": 200,
"accountType": "BANK",
"redirectUrl": "https://yourapp.com/withdrawals/done"
}
| Field | Type | Notes | |
|---|---|---|---|
amount | number | required | ≥ 1, at most 2 decimal places (e.g. 150.75). Fixed for the session |
accountType | string | required | BANK or MOBILE_MONEY. Decides which destination form the hosted page shows |
redirectUrl | string | optional | Valid URI. Where the employee is sent after a successful withdrawal, with reference and status=success appended |
"status": "success",
"message": "Withdrawal session created",
"data": {
"url": "https://api-v2.workbenefits.app/hosted/withdrawal/3f9c1e...b7a2",
"expiresAt": "2026-09-25T10:30:00.000Z"
}
}
hasPin is false. When the employee confirms, the withdrawal is checked against the GHS 10 minimum, the employee's availableToWithdraw balance and a daily limit of GHS 2,500; any failure is shown on the hosted page.| Error | When |
|---|---|
400 | Validation failed |
403 | Employee account is deactivated |
404 | Employee not found for this developer |
PIN — /api/public/v1/employees/:employeeId/pin
"status": "success",
"message": "PIN status fetched successfully",
"data": { "hasPin": false }
}
url to choose and confirm their PIN (see Hosted sessions). The PIN itself is never sent to or returned by the API.| Field | Type | Notes | |
|---|---|---|---|
redirectUrl | string | optional | Valid URI. Where the employee is sent after the PIN is created. The body may be empty |
"status": "success",
"message": "PIN setup session created",
"data": {
"url": "https://api-v2.workbenefits.app/hosted/pin/8a41d0...c93e",
"expiresAt": "2026-09-25T10:30:00.000Z"
}
}
Income — /api/public/v1/employees/:employeeId/income
data is null if no salary has been set."status": "success",
"message": "Income fetched successfully",
"data": { "amount": "5000", "isVerified": false }
}
amount is a decimal string.| Field | Type | Notes | |
|---|---|---|---|
salary | number | required | 800 – 1,000,000 |
"status": "success",
"message": "Income updated successfully",
"data": { "amount": "5000", "isVerified": false, "previousAmount": 4500 }
}
previousAmount is null when no salary was set before. Returns 409 if the salary is already verified.rfes) and the document verification fee status."status": "success",
"message": "Income verification fetched successfully",
"data": {
"id": 3,
"employeeId": 101,
"verificationType": "STANDARD",
"status": "PENDING",
"incomeAmount": "5000",
"verifiedAmount": "0",
"approvedById": null,
"approvedAt": null,
"isUpdatedAfterRFE": false,
"createdAt": "2026-09-01T09:00:00.000Z",
"updatedAt": "2026-09-01T09:00:00.000Z",
"deletedAt": null,
"documents": [
{ "id": 14, "documentType": "BANK_STATEMENT", "bank": "GCB", "name": "statement-aug.pdf", "url": "https://…/statement-aug.pdf", "isActive": true, "createdAt": "2026-09-01T09:00:00.000Z" }
],
"rfes": [
{ "id": 2, "adminComment": "Please upload your latest payslip", "commentedAt": "2026-09-03T12:00:00.000Z" }
],
"controllerDetail": null,
"documentVerificationFee": "unpaid",
"salaryReview": null
}
}
| Field | Description |
|---|---|
status | PENDING, APPROVED or REJECTED |
incomeAmount / verifiedAmount | Declared monthly income / amount confirmed by review (decimal strings) |
documents[].documentType | BANK_STATEMENT, MOMO_STATEMENT, PAY_SLIP, OFFER_LETTER, BUSINESS_REGISTRATION or OTHER |
documentVerificationFee | paid, unpaid or waived |
salaryReview | When an approved verification exists and a newer submission is pending, the pending submission (same shape); otherwise null |
controllerDetail: null, documentVerificationFee and salaryReview: null are returned."monthlyIncome": 5000,
"bank": [
{ "name": "statement-aug.pdf", "bankName": "GCB", "password": null, "file": "JVBERi0xLjcK…" }
],
"momo": [],
"otherFiles": [
{ "type": "PAY_SLIP", "file": "JVBERi0xLjcK…" }
]
}
| Field | Type | Notes | |
|---|---|---|---|
monthlyIncome | number | required | ≥ 0 |
bank | array | optional | Bank statements. Each item: name (string, required — include the file extension, e.g. .pdf), file (base64 string, required), bankName (string, optional), password (string, optional — for password-protected PDFs) |
momo | array | optional | Mobile money statements. Same item shape as bank |
otherFiles | array | optional | Supporting documents. Each item: type (required — PAY_SLIP, OFFER_LETTER, BUSINESS_REGISTRATION or OTHER), file (base64 string, required) |
"status": "success",
"message": "Income verification submitted successfully",
"data": {
"id": 3,
"employeeId": 101,
"verificationType": "STANDARD",
"status": "PENDING",
"incomeAmount": "5000",
"verifiedAmount": "0",
"approvedById": null,
"approvedAt": null,
"isUpdatedAfterRFE": false,
"createdAt": "2026-09-01T09:00:00.000Z",
"updatedAt": "2026-09-01T09:00:00.000Z",
"deletedAt": null,
"documents": [
{ "id": 14, "documentType": "BANK_STATEMENT", "bank": "GCB", "name": "statement-aug.pdf", "url": "https://…/statement-aug.pdf", "isActive": true, "createdAt": "2026-09-01T09:00:00.000Z" },
{ "id": 15, "documentType": "PAY_SLIP", "bank": null, "name": null, "url": "https://…/payslip.pdf", "isActive": true, "createdAt": "2026-09-01T09:00:00.000Z" }
],
"controllerDetail": null
}
}
409 if a pending verification already exists — use Update Verification instead."monthlyIncome": 5200,
"otherFiles": [
{ "type": "PAY_SLIP", "file": "JVBERi0xLjcK…" }
]
}
| Field | Type | Notes | |
|---|---|---|---|
monthlyIncome | number | optional | ≥ 0 |
bank | array | optional | Replaces all current bank statements. Item shape as in Submit Verification |
momo | array | optional | Replaces all current mobile money statements |
otherFiles | array | optional | Replaces current documents of the same type (other types are kept) |
"status": "success",
"message": "Income verification updated successfully",
"data": {
"id": 3,
"employeeId": 101,
"verificationType": "STANDARD",
"status": "PENDING",
"incomeAmount": "5200",
"verifiedAmount": "0",
"approvedById": null,
"approvedAt": null,
"isUpdatedAfterRFE": true,
"createdAt": "2026-09-01T09:00:00.000Z",
"updatedAt": "2026-09-04T11:00:00.000Z",
"deletedAt": null,
"documents": [
{ "id": 14, "documentType": "BANK_STATEMENT", "bank": "GCB", "name": "statement-aug.pdf", "url": "https://…/statement-aug.pdf", "isActive": true, "createdAt": "2026-09-01T09:00:00.000Z" },
{ "id": 16, "documentType": "PAY_SLIP", "bank": null, "name": null, "url": "https://…/payslip-sep.pdf", "isActive": true, "createdAt": "2026-09-04T11:00:00.000Z" }
]
}
}
404 if there is no pending verification. documents lists only the active documents after the update.Rent — /api/public/v1/employees/:employeeId/rent
Rent financing lets an employee have their rent paid up front and repay it in monthly instalments. You manage the whole application on the employee's behalf. Every endpoint below only works for employees that belong to your developer account; any other employee returns 404.
Each application has a stage that moves forward as the employee completes each step:
| Step | What you do | Stage afterwards |
|---|---|---|
| 1. Check | Estimate with Calculate and Rent Limit, then run Check Qualification. | — |
| 2. Apply | Create Application. Add or correct landlord and property details with Update Application. | CREATED_APPLICATION |
| 3. Inspection fee | Inspection Payment — the employee pays on a checkout page. WorkBenefit then inspects the property and proposes a payment plan. | PAID_PHYSICAL_INSPECTION |
| 4. Sign agreement | Share the agreement (Agreement Link or Email), then Sign Agreement. | AGREED_TO_PAYMENT_PLAN |
| 5. Move-in deposit | Deposit Payment — the employee pays the move-in deposit on a checkout page. | PAID_INITIAL_DEPOSIT |
| 6. Repay | Show the Payment Plan, collect instalments with Pay Rent, and review Payment History. | COMPLETED_RENT_PAYMENT once the balance is fully paid |
checkout_url. Send the employee to it to pay. When they finish, they land on a WorkBenefit confirmation page and the application's stage updates. Read the application again with Get Application to see the new stage. Stages can also change from WorkBenefit's side (for example after the inspection), so always read the current stage before deciding the next step."status": "success",
"message": "Physical inspection fees fetched successfully",
"data": { "standardFee": 130, "expressFee": 250 }
}
premiumRate × paybackDuration is added to the amount. Values are rounded to whole numbers. This is an estimate only; it does not check the employee's eligibility.| Query param | Type | Notes | |
|---|---|---|---|
amount | number | required | Total rent amount to finance (GHS), ≥ 1 |
paybackDuration | integer | required | Repayment period in months, ≥ 1 |
premiumRate | number | optional | Monthly premium rate, ≥ 0. Defaults to 0.025 (2.5% per month) |
"status": "success",
"message": "Rent calculated successfully",
"data": { "totalPayback": 13800, "monthlyRent": 2300 }
}
404 if the employee has no salary on file."status": "success",
"message": "Rent limit fetched successfully",
"data": { "maxMonthlyPayment": 1950, "maxTotalPayback": 18000, "maxPaybackPeriod": 12, "premuimRate": 0.025 }
}
premuimRate. This matches what the API returns.404 if the employee has no salary on file.| Query param | Type | Notes | |
|---|---|---|---|
amount | number | required | Total rent amount to finance (GHS), ≥ 1 |
paybackDuration | integer | required | Repayment period in months, ≥ 1 |
"status": "success",
"message": "Rent qualification checked successfully",
"data": { "results": "P" }
}
"status": "success",
"message": "Rent qualification checked successfully",
"data": { "results": "F", "message": "Requested amount exceeds the affordable limit" }
}
results is "P" (pass) or "F" (fail). A fail includes a message with the reason. Both outcomes return HTTP 200.| Field | Type | Notes | |
|---|---|---|---|
monthlyRentBudget | number | required | Monthly rent (GHS), ≥ 1 |
durationOfRent | integer | required | Length of the tenancy in months, ≥ 1 |
rentPaybackPeriod | integer | required | Months to repay, ≥ 1 |
fullNameOfLandlord | string | optional | |
phoneNumberOfLandlord | string | optional | Phone number with country code, e.g. +233201234567 |
propertyGpsAddress | string | optional | Ghana GPS address, e.g. GA-123-4567 |
404 otherwise), and a monthly salary of at least GHS 1,500 (400 otherwise)."monthlyRentBudget": 1500,
"durationOfRent": 12,
"rentPaybackPeriod": 6,
"fullNameOfLandlord": "John Landlord",
"phoneNumberOfLandlord": "+233201234567",
"propertyGpsAddress": "GA-123-4567"
}
snake_case. Keep id: it is the applicationId for every later call. A shortened example:"status": "success",
"message": "Rent application created successfully",
"data": {
"id": 5821,
"client_information_id": 3310,
"application_date": "2026-09-25",
"monthly_rent_value": "1500",
"rent_duration": 12,
"payback_duration": 6,
"name_of_landlord": "John Landlord",
"phone_number_of_landlord": "+233201234567",
"gps_address": "GA-123-4567",
"tenant_has_signed": "no",
"payment_schedules": [],
…
}
}
404 if the employee has never applied."status": "success",
"message": "Rent applications fetched successfully",
"data": [
{ "applicationId": 5821, "stage": "PAID_PHYSICAL_INSPECTION", "propertyGpsAddress": "GA-123-4567", "nameOfLandlord": "John Landlord", "phoneOfLandlord": "+233201234567", "locationOfLandlord": "East Legon, Accra", "hasPaymentPlan": true, "hasSigned": "no", "applicationDate": "2026-09-25", "monthlyPayment": 1725, "paybackDuration": 6, "rentDuration": 12, "monthlyRentValue": 1500 }
]
}
| Field | Notes |
|---|---|
applicationId | Use as :applicationId in the endpoints below |
stage | CREATED_APPLICATION, PAID_PHYSICAL_INSPECTION, AGREED_TO_PAYMENT_PLAN, PAID_INITIAL_DEPOSIT or COMPLETED_RENT_PAYMENT |
hasPaymentPlan | true once a payment plan has been proposed |
hasSigned | "yes" once the tenancy agreement is signed, otherwise "no" |
monthlyPayment | Monthly repayment (GHS), or null until a plan is set |
monthlyRentValue | Monthly rent (GHS), or null |
paymentSchedule is empty until WorkBenefit proposes a plan. Its items use the rent provider's snake_case fields."status": "success",
"message": "Rent application fetched successfully",
"data": { "applicationId": 5821, "propertyGpsAddress": "GA-123-4567", "nameOfLandlord": "John Landlord", "phoneOfLandlord": "+233201234567", "locationOfLandlord": "East Legon, Accra", "stage": "AGREED_TO_PAYMENT_PLAN", "paymentSchedule": [{ "id": 90211, "rent_month": "October", "expected_payment_date": "2026-10-28", "amount_due": "1725", "amount_paid": "0", "amount_remaining": "1725", "amount_fined": "0", "payment_status_words": "Unpaid" }], "moveInDepositBreakdown": { "monthlyRent": 1725, "refundableRentSecurity": 1500, "serviceFee": 150, "extraPayment": 0, "proRatedFirstMonth": 0, "totalMoveInDeposit": 3375 } }
}
| Field | Notes |
|---|---|
moveInDepositBreakdown.totalMoveInDeposit | Amount the employee pays with Deposit Payment |
moveInDepositBreakdown.* | All amounts in GHS; 0 until a plan is set |
| Field | Type | Notes | |
|---|---|---|---|
monthlyRentBudget | number | optional | ≥ 1 |
durationOfRent | integer | optional | Months, ≥ 1 |
rentPaybackPeriod | integer | optional | Months, ≥ 1 |
fullNameOfLandlord | string | optional | |
phoneNumberOfLandlord | string | null | optional | Phone number with country code; null or "" clears it |
locationOfLandlord | string | optional | |
locationOfProperty | string | null | optional | |
propertyGpsAddress | string | null | optional | Ghana GPS address |
"fullNameOfLandlord": "John Landlord",
"locationOfLandlord": "East Legon, Accra"
}
"status": "success",
"message": "Rent application updated successfully",
"data": { "applicationId": 5821, "propertyGpsAddress": "GA-123-4567", "nameOfLandlord": "John Landlord", "phoneOfLandlord": "+233201234567", "locationOfLandlord": "East Legon, Accra", "stage": "CREATED_APPLICATION" }
}
checkout_url to pay. Once the payment is confirmed, the stage moves to PAID_PHYSICAL_INSPECTION.| Field | Type | Notes | |
|---|---|---|---|
expressServiceForPhysicalInspection | boolean | optional | true for express inspection (higher fee). Default: standard |
dateAndTimeForVerification | string | optional | Preferred inspection date, format YYYY-MM-DD (date only) |
"expressServiceForPhysicalInspection": false,
"dateAndTimeForVerification": "2026-10-02"
}
"status": "success",
"message": "Payment initialized successfully",
"data": { "checkout_url": "https://checkout.paystack.com/abc123xyz" }
}
"status": "success",
"message": "Tenancy agreement link generated successfully",
"data": { "link": "https://api-v2.workbenefits.app/api/v2/employee/rent/tenancy-agreement/eyJhbGciOi..." }
}
| Field | Type | Notes | |
|---|---|---|---|
email | string | required | Valid email address |
"email": "kwame.mensah@example.com"
}
"status": "success",
"message": "Tenancy agreement email sent successfully",
"data": null
}
AGREED_TO_PAYMENT_PLAN. No request body."status": "success",
"message": "Tenancy agreement signed successfully",
"data": { "message": "Tenancy agreement signed successfully" }
}
totalMoveInDeposit (see Get Application). Send the employee to checkout_url to pay. Once the payment is confirmed, the stage moves to PAID_INITIAL_DEPOSIT and repayments begin. No request body.400 if the deposit amount has not been set yet, which means WorkBenefit has not proposed a payment plan."status": "success",
"message": "Initial deposit payment initialized successfully",
"data": { "checkout_url": "https://checkout.paystack.com/def456uvw" }
}
"status": "success",
"message": "Payment plan fetched successfully",
"data": [
{ "applicationId": 5821, "amount": 1725, "fines": 0, "amountPaid": 1725, "totalAmountDue": 0, "paymentStatus": "Paid", "rentMonth": "October", "rentYear": 2026, "expectedPaymentDate": "2026-10-28", "isLate": false, "daysDelayed": 0 },
{ "applicationId": 5821, "amount": 1725, "fines": 0, "amountPaid": 0, "totalAmountDue": 1725, "paymentStatus": "Unpaid", "rentMonth": "November", "rentYear": 2026, "expectedPaymentDate": "2026-11-28", "isLate": false, "daysDelayed": 0 }
]
}
| Field | Notes |
|---|---|
amount | Instalment due for the month |
fines | Late fines added to the month |
totalAmountDue | What is still owed for the month, including fines |
paymentStatus | e.g. Paid, Partial, Unpaid |
isLate | true when fines have been added |
daysDelayed | Days past the expected payment date |
checkout_url to pay. The amount can cover part of an instalment, one instalment or several.| Field | Type | Notes | |
|---|---|---|---|
amount | number | required | Amount to pay (GHS), ≥ 1 |
"amount": 1725
}
"status": "success",
"message": "Rent payment initialized successfully",
"data": { "checkout_url": "https://checkout.paystack.com/ghi789rst" }
}
"status": "success",
"message": "Payment history fetched successfully",
"data": [
{ "id": 7712, "paymentPurpose": "RENT", "paymentChannel": "mobile_money", "amountPaid": 1725, "transactionReference": "T482915736201", "datePaymentWasMade": "2026-10-27", "paymentMonth": "October", "paymentTitle": "Monthly Rent Payment" },
{ "id": 7650, "paymentPurpose": "INITIAL_DEPOSIT", "paymentChannel": "card", "amountPaid": 3375, "transactionReference": "T482901118845", "datePaymentWasMade": "2026-09-30", "paymentMonth": null, "paymentTitle": "Initial Deposit" }
]
}
paymentPurpose is PHYSICAL_VERIFICATION_FEE, INITIAL_DEPOSIT or RENT. paymentMonth is set only for rent repayments.bankName on bank statements when you submit income verification."status": "success",
"message": "Banks fetched successfully",
"data": [
"Absa Bank Ghana LTD",
"Access Bank (Ghana) Plc",
"Agricultural Development Bank Plc",
...,
"Zenith Bank (Ghana) Limited"
]
}
id when enrolling a company in a benefit."status": "success",
"message": "Benefits fetched successfully",
"data": [{
"id": 4,
"name": "Earned Wage Access",
"code": "EWA",
"description": "Real-time access to a portion of earned but unpaid wages before payday...",
"readMore": "",
"status": "ACTIVE",
"tag": null,
"fixedPaymentOption": "EMPLOYEE_SELF_PAYMENT"
}]
}
| Field | Notes |
|---|---|
code | Stable identifier, e.g. RENT, BNPL, CAR_FINANCING, EWA, GROUP_LIFE_INSURANCE, GROUP_HEALTH_INSURANCE |
status | ACTIVE, INACTIVE, WAITLIST or ARCHIVED |
tag | POPULAR, NEW or null |
fixedPaymentOption | If set, the only repayment option for this benefit: PAYROLL_DEDUCTION or EMPLOYEE_SELF_PAYMENT. null means the company can choose. |