# Create crypto deposit address
Source: https://docs.cadanapay.com/api-reference/payments/balances/create-crypto-deposit-address
/openapi/embedded-payments.yaml post /v1/businesses/{businessId}/crypto-deposit-addresses
Creates a crypto deposit address for the business on the given chain. Fails if an address already exists for that currency and chain.
# Get account balances
Source: https://docs.cadanapay.com/api-reference/payments/balances/get-account-balances
/openapi/embedded-payments.yaml get /v1/businesses/{businessId}/balances
Get a business's account balances
# Get funding details
Source: https://docs.cadanapay.com/api-reference/payments/balances/get-funding-details
/openapi/embedded-payments.yaml get /v1/businesses/{businessId}/funding-details
Returns the business's funding details.
# Create
Source: https://docs.cadanapay.com/api-reference/payments/beneficiaries/create
/openapi/embedded-payments.yaml post /v1/beneficiaries
Create a new beneficiary i.e employee/contractor's payout account
# Create upload URL
Source: https://docs.cadanapay.com/api-reference/payments/beneficiaries/create-upload-url
/openapi/embedded-payments.yaml post /v1/files/upload-url
Reserve a file ID and receive a short-lived HTTPS **PUT** URL.
After the client uploads the bytes to that URL, it can reference
`fileId` in later API calls (e.g. Beneficiary creation KYC).
# Delete
Source: https://docs.cadanapay.com/api-reference/payments/beneficiaries/delete
/openapi/embedded-payments.yaml delete /v1/beneficiaries/{beneficiaryId}
Delete an existing beneficiary
# Get
Source: https://docs.cadanapay.com/api-reference/payments/beneficiaries/get
/openapi/embedded-payments.yaml get /v1/beneficiaries/{beneficiaryId}
Get an existing beneficiary
# Get file details
Source: https://docs.cadanapay.com/api-reference/payments/beneficiaries/get-file-details
/openapi/embedded-payments.yaml get /v1/files/{fileId}
Get details and a download URL for a file by its fileId
# List
Source: https://docs.cadanapay.com/api-reference/payments/beneficiaries/list
/openapi/embedded-payments.yaml get /v1/beneficiaries
Retrieve beneficiaries
# Create FX Quote
Source: https://docs.cadanapay.com/api-reference/payments/fx/create-fx-quote
/openapi/embedded-payments.yaml post /v1/fx-quotes
Create an fx quote
# Create
Source: https://docs.cadanapay.com/api-reference/payments/payouts/create
/openapi/embedded-payments.yaml post /v1/payouts
Create a new payout
# Get
Source: https://docs.cadanapay.com/api-reference/payments/payouts/get
/openapi/embedded-payments.yaml get /v1/payouts/{payoutId}
Get an existing payout
# List
Source: https://docs.cadanapay.com/api-reference/payments/payouts/list
/openapi/embedded-payments.yaml get /v1/payouts
Retrieve payouts
# Get payment methods
Source: https://docs.cadanapay.com/api-reference/payments/resources/get-payment-methods
/openapi/embedded-payments.yaml get /v1/payment-methods
Get a list of available payment methods for a given country code
# Get payout providers
Source: https://docs.cadanapay.com/api-reference/payments/resources/get-payout-providers
/openapi/embedded-payments.yaml get /v1/providers
Get a list of providers for a given country code
# Payment corridor requirements
Source: https://docs.cadanapay.com/api-reference/payments/resources/payment-corridor-requirements
/openapi/embedded-payments.yaml get /v1/payment-requirements
Returns a Draft-07 JSON-Schema fragment that specifies every field, enum, regex and conditional rule for the requested `countryCode` + `currency` + `paymentMethod`.
# Resolve bank provider
Source: https://docs.cadanapay.com/api-reference/payments/resources/resolve-bank-provider
/openapi/embedded-payments.yaml get /v1/providers/resolve
Resolves a bank code, SWIFT code, or ACH routing number to the corresponding bank name and details. The behaviour depends on the `paymentMethod` parameter:
- **swift** — resolves an 8 or 11-character SWIFT/BIC code
- **ach** — resolves a 9-digit US ACH routing number
- **bank** — resolves against Cadana's internal bank provider list
for the given currency
# Create Virtual Account
Source: https://docs.cadanapay.com/api-reference/payments/virtual-accounts/create-virtual-account
/openapi/embedded-payments.yaml post /v1/users/{userId}/virtual-accounts
Create a new virtual account for a user
# Get Virtual Accounts
Source: https://docs.cadanapay.com/api-reference/payments/virtual-accounts/get-virtual-accounts
/openapi/embedded-payments.yaml get /v1/users/{userId}/virtual-accounts
Retrieve virtual accounts for a user
# Simulate virtual account deposit
Source: https://docs.cadanapay.com/api-reference/payments/virtual-accounts/simulate-virtual-account-deposit
/openapi/embedded-payments.yaml post /v1/sandbox/users/{userId}/virtual-accounts/{virtualAccountId}/deposit
Simulate a virtual account deposit for an active virtual account
# Get user wallets balances
Source: https://docs.cadanapay.com/api-reference/wallets/balances/get-user-wallets-balances
/openapi/embedded-consumer-wallets.yaml get /v1/users/{userId}/wallets
Get balances for the user wallets
# Create beneficiary
Source: https://docs.cadanapay.com/api-reference/wallets/beneficiaries/create-beneficiary
/openapi/embedded-consumer-wallets.yaml post /v1/users/{userId}/beneficiaries
Create a new beneficiary for a user
# Delete beneficiary
Source: https://docs.cadanapay.com/api-reference/wallets/beneficiaries/delete-beneficiary
/openapi/embedded-consumer-wallets.yaml delete /v1/users/{userId}/beneficiaries/{beneficiaryId}
Delete an existing beneficiary for a user
# Get beneficiary
Source: https://docs.cadanapay.com/api-reference/wallets/beneficiaries/get-beneficiary
/openapi/embedded-consumer-wallets.yaml get /v1/users/{userId}/beneficiaries/{beneficiaryId}
Get an existing beneficiary for a user
# List beneficiaries
Source: https://docs.cadanapay.com/api-reference/wallets/beneficiaries/list-beneficiaries
/openapi/embedded-consumer-wallets.yaml get /v1/users/{userId}/beneficiaries
Retrieve beneficiaries for a user
# Create transaction
Source: https://docs.cadanapay.com/api-reference/wallets/transactions/create-transaction
/openapi/embedded-consumer-wallets.yaml post /v1/users/{userId}/transactions
Create a new transaction for a user
# Get transaction
Source: https://docs.cadanapay.com/api-reference/wallets/transactions/get-transaction
/openapi/embedded-consumer-wallets.yaml get /v1/users/{userId}/transactions/{transactionId}
Get an existing transaction for a user
# Get user fees
Source: https://docs.cadanapay.com/api-reference/wallets/transactions/get-user-fees
/openapi/embedded-consumer-wallets.yaml get /v1/users/{userId}/fees
Retrieve the payout catalog for a user — fees, delivery timelines, and transfer limits per corridor.
`data.payouts` is the full, unfiltered catalog (the user's rate card) and is always returned.
Supply `paymentMethod` together with `destinationCurrency` to also receive `data.estimate` — a per-transaction fee, delivery timeline, and transfer limits for the specified corridor. Add `destinationCountry` when several countries share a currency (e.g. USD sent to Ecuador vs. USD sent to El Salvador) so the estimate resolves to the right country. Add `sourceAmount` to resolve percentage-based fees into a concrete amount.
# List transactions
Source: https://docs.cadanapay.com/api-reference/wallets/transactions/list-transactions
/openapi/embedded-consumer-wallets.yaml get /v1/users/{userId}/transactions
Retrieve transactions for a user
# Create
Source: https://docs.cadanapay.com/api-reference/workforce/persons/create
/openapi/workforce-management.yaml post /v1/persons
Create a new employee/contractor
# Get
Source: https://docs.cadanapay.com/api-reference/workforce/persons/get
/openapi/workforce-management.yaml get /v1/persons/{personId}
Fetch a person's details
# Get tax profile
Source: https://docs.cadanapay.com/api-reference/workforce/persons/get-tax-profile
/openapi/workforce-management.yaml get /v1/persons/{personId}/taxProfile
Retrieve a person's tax profile. Returns the shape accepted by PUT /v1/persons/{personId}/taxProfile plus a server-derived `countryCode` field — remove `countryCode` before resubmitting via PUT.
# List
Source: https://docs.cadanapay.com/api-reference/workforce/persons/list
/openapi/workforce-management.yaml get /v1/persons
Get all persons
# Update basic information
Source: https://docs.cadanapay.com/api-reference/workforce/persons/update-basic-information
/openapi/workforce-management.yaml put /v1/persons/{personId}/basicInfo
Update a person's basic information
# Update personal information
Source: https://docs.cadanapay.com/api-reference/workforce/persons/update-personal-information
/openapi/workforce-management.yaml put /v1/persons/{personId}/personalInfo
Update a person's personal information including identity documents, date of birth, gender, nationality, and address
# Update tax profile
Source: https://docs.cadanapay.com/api-reference/workforce/persons/update-tax-profile
/openapi/workforce-management.yaml put /v1/persons/{personId}/taxProfile
Update a person's tax profile with country-specific tax fields such as marital status and number of dependents
# Authentication
Source: https://docs.cadanapay.com/authentication
API keys, token types, and how to authenticate requests
All Cadana API requests require authentication via a Bearer token in the `Authorization` header:
```bash Bash theme={null}
curl -X GET 'https://dev-api.cadanapay.com/v1/balances' \
-H 'Authorization: Bearer YOUR_API_KEY'
```
* All requests must be made over **HTTPS**
* API keys are environment-specific — sandbox keys don't work in production and vice versa
* Missing or invalid tokens return `401 Unauthorized`
***
## Token Types
Cadana offers three token types for different integration scenarios:
| Token Type | Scope | Expires | Use Case |
| :- | :- | :- | :- |
| **Org Token** | Single business | No\* | Backend integrations for one business |
| **Platform Token** | Multiple businesses | No\* | Multi-tenant platforms serving multiple companies |
| **User Token** | Single user | 1 hour | Frontend and mobile apps |
\*Can be manually revoked or regenerated at any time.
### Org Tokens
The default for most integrations. An Org token gives your backend full access to your organization's resources — persons, users, payrolls, payments, and settings.
```bash Bash theme={null}
curl -X GET 'https://api.cadanapay.com/v1/persons' \
-H 'Authorization: Bearer ORG_TOKEN'
```
Create Org tokens in the [Dashboard](https://app.cadanapay.com) under **Settings** → **Developers**. You can optionally bind tokens to specific IP addresses for added security. Never expose Org tokens in client-side code.
### Platform Tokens
These tokens are not restricted to a particular organization. They grant access to the primary organization and any additional businesses created.
When using a Platform API Key for business specific actions you must include the `X-MultiTenantKey` header to specify which business you're accessing:
```bash Bash theme={null}
curl -X GET 'https://api.cadanapay.com/v1/users' \
-H 'Authorization: Bearer PLATFORM_TOKEN' \
-H 'X-MultiTenantKey: {tenantKey}'
```
The tenant key is returned when you [create a business](/api-reference/workforce/platform/create). Omitting it will scope the request to the default platform business.
Platform API access requires special enablement. Contact your account manager to request access.
### User Tokens
User tokens are scoped to an individual end-user — whether an admin, employee, or contractor. They grant access only to that user's resources, making them safe for frontend applications (browser or mobile).
* Valid for **1 hour**, after which a new token must be obtained
* Obtained through Cadana's built-in authentication or a [custom authentication](/white-label/custom-authentication) flow (SSO via your own JWT provider)
***
## Which Token Do I Need?
| Scenario | Token | Headers |
| :- | :- | :- |
| Backend for one business | Org | `Authorization: Bearer {token}` |
| Backend for multiple businesses | Platform | `Authorization: Bearer {token}` + `X-MultiTenantKey: {key}` |
| Frontend / mobile for a user | User | `Authorization: Bearer {token}` |
***
## Verify Your Token
[`GET /v1/me`](/api-reference/workforce/businesses/get-current-identity) returns the calling key's access level and the business the request resolved to, including its `businessId` and tenant key:
```bash Bash theme={null}
curl -X GET 'https://api.cadanapay.com/v1/me' \
-H 'Authorization: Bearer YOUR_API_KEY'
```
With a Platform token, include `X-MultiTenantKey` and the response shows which sub-business your requests operate on — useful for confirming tenant scoping before making changes.
***
## Common Errors
| Error | Cause | Solution |
| :- | :- | :- |
| `401 Unauthorized` | Missing or invalid token | Check your API key is correct |
| `401 Unauthorized` | Wrong environment | Use sandbox key with `dev-api.cadanapay.com` |
| `403 Forbidden` | Tenant key doesn't belong to your platform | Verify the tenant key matches a business you created |
| `403 Forbidden` | Insufficient permissions | Check your token type and scope |
***
## Next Steps
Full guide to Platform API integration
SSO and custom JWT integration
# Fund Your Account
Source: https://docs.cadanapay.com/fund-your-account
Add funds to your business account via bank transfer, ACH direct debit, or crypto
Before you can run payroll or send payouts, your business account needs funds. Cadana supports three funding methods: bank transfer, ACH direct debit, and crypto deposits.
***
## Check Your Balance
View your current account balances at any time.
***
## Sandbox Funding
In sandbox, use the deposit endpoint to add test funds instantly. No bank linking required.
Returns `204` on success. The deposit is processed asynchronously — the balance reflects it within a few seconds.
Amounts are in the **lowest denomination** of the currency. For USD, `10000000` = \$100,000.00.
***
## Bank Transfer
Transfer funds from your bank to your Cadana business account using the account details provided by Cadana. Supports domestic ACH, wire (USD), and international SWIFT transfers.
### Get Your Funding Details
Retrieve the bank account details to send funds to. These are also available in the [Dashboard](https://app.cadanapay.com) under **Business > Account > Top up Account**.
The response returns one or more funding accounts depending on the currencies enabled for your business. Pass the optional `currency` query parameter to filter by a specific currency.
**Example — USD:**
For USD, the response includes three transfer methods: ACH, wire, and SWIFT.
```json theme={null}
{
"data": [
{
"type": "ach",
"currency": "USD",
"bankName": "Column NA",
"bankAddress": "123 Main St, San Francisco, CA 94100",
"beneficiaryAddress": "442 10th Ave, New York City, NY 10001",
"accountName": "Cadana Inc",
"accountNumber": "000000000000000",
"accountType": "Checking",
"routingNumber": "000000000"
},
{
"type": "wire",
"currency": "USD",
"bankName": "Column NA",
"bankAddress": "123 Main St, San Francisco, CA 94100",
"beneficiaryAddress": "442 10th Ave, New York City, NY 10001",
"accountName": "Cadana Inc",
"accountNumber": "000000000000000",
"routingNumber": "000000000"
},
{
"type": "swift",
"currency": "USD",
"bankName": "Column NA",
"bankAddress": "123 Main St, San Francisco, CA 94100",
"beneficiaryAddress": "442 10th Ave, New York City, NY 10001",
"accountName": "Cadana Inc",
"accountNumber": "000000000000000",
"swiftCode": "XXXXUS00"
}
]
}
```
**Example — EUR:**
```json theme={null}
{
"data": [
{
"type": "iban",
"currency": "EUR",
"bankName": "Banking Circle S.A.",
"branchName": "Copenhagen",
"bankAddress": "123 Main St, Copenhagen, Denmark",
"accountName": "Cadana Inc",
"accountNumber": "DK0000000000000000",
"swiftCode": "XXXXDKKK",
"country": "DK"
}
]
}
```
Use these details to initiate a transfer from your bank. Funds are credited to your Cadana balance once the transfer settles.
| Transfer Type | Currency | Typical Settlement |
| :- | :- | :- |
| ACH | USD | 1–3 business days |
| Wire | USD | Same day or next business day |
| SWIFT | Multiple | 1–5 business days |
***
## ACH Direct Debit (USD only)
Link an external US bank account and pull funds directly into your Cadana account.
### Connect a Bank Account
There are two ways to link a bank account:
**Via Dashboard:** Go to **Settings > Payment Methods > Add payment method** and follow the on-screen flow.
**Via hosted link (for platforms):** Generate a hosted link that opens the bank connection flow. The user completes it in their browser and is redirected to your app.
**Response:**
```json theme={null}
{
"url": "https://app.cadanapay.com/external-accounts/link/..."
}
```
Redirect the user to the returned `url` — this opens Cadana's hosted **link-out experience**, where the user selects their bank and authorizes the connection. When they finish or exit, they're redirected back to your `returnURL` (or `refreshURL` if the link has expired).
The `mode` parameter controls where the hosted flow starts:
| `mode` | Behavior |
| :- | :- |
| `standard` (default) | Shows the entry page with an **Add payment method** button before the bank-selection flow. |
| `setup` | Skips the entry page and opens bank selection directly. |
| `verification` | Opens a focused verification view for a single `needs_confirmation` account. Requires `externalAccountId` — see [Resume Verification with a Verification Link](#resume-verification-with-a-verification-link). |
Inside the link-out experience, the user connects their account one of two ways:
* **Instant verification** — the user signs in to their bank and the account is verified immediately. It comes back with status `active` and is ready to use.
* **Manual entry + micro-deposit** — the user types their account and routing numbers. This is also the fallback when instant connection isn't available for their bank or fails. The account is created with status `needs_confirmation` and must be verified with a micro-deposit before it can be used — see [Complete Micro-Deposit Verification](#complete-micro-deposit-verification).
Your business must have completed KYB verification before you can connect an external bank account. If KYB is not yet verified, the connection will be rejected. See [KYB Requirements](/platform/kyb-requirements) for details.
### Check the Account Status
After the user returns from the link-out experience, list external accounts to check the connection status.
Each account carries one of these statuses:
| Status | Meaning |
| :- | :- |
| `active` | Verified and ready to use for [deposits](#create-a-deposit). |
| `needs_confirmation` | Linked with micro-deposits and awaiting verification — not yet usable. |
| `restricted` | Verified, but direct debits from this account are currently restricted by the bank partner, usually after the business's bank rejected a debit as unauthorized. It can't be used for [deposits](#create-a-deposit) until the restriction is lifted, at which point it returns to `active`. |
A restriction applies to every verified account linked through the same bank partner, including accounts linked while the restriction is in place. Subscribe to the [`external-account.restricted`](/reference/events#external-account-restricted) and `external-account.restriction_lifted` events to be told when it starts and ends.
**Response:**
```json theme={null}
[
{
"id": "d43eaff3-bc75-44f6-ac37-3a305192ead6",
"last4": "0000",
"bankName": "Example Bank",
"status": "active"
}
]
```
### Complete Micro-Deposit Verification
When an account is linked with micro-deposits, it stays in `needs_confirmation` until the user confirms a small test deposit sent to their bank.
1. One or two small test deposits (under **\$1**) are sent to the linked bank account. These can take 1–2 business days to appear.
2. The business also receives a [Micro-Deposit Verification Required](/white-label/all-notification-kinds#account) email with the expected arrival date and step-by-step instructions.
3. The user completes verification back in the hosted **link-out experience** — immediately after micro-deposit linking is initiated, the flow redirects them to the link-out page, where they enter the deposit amounts or the code.
4. Once confirmed, the account transitions to `active` and can be used for deposits.
#### Tell the User What to Look For
There are two verification methods, and the bank decides which one applies — do not assume one. A pending account carries a `microdepositInfo` object, both on [the account list](#check-the-account-status) and on the [`external-account.added`](/reference/events#external-account-added) event, so you can render the right instructions without waiting for the user to reach the link-out page:
| `type` | What the user will see | What to tell them |
| - | - | - |
| `amounts` | Two separate deposits under $1, for example $0.32 and \$0.45 | Ask them to note **both amounts** in the order they appear |
| `descriptor_code` | One \$0.01 deposit whose description contains a 6-character code | Ask them to read the **code from the transaction description**, not the amount |
`arrivalDate` is a Unix timestamp for when the deposit is expected to land — use it for copy like "expect this by 11 August" instead of a vague "in a few days".
```json JSON theme={null}
{
"id": "1f2e3d4c-5678-9012-cdef-345678901234",
"last4": "6789",
"bankName": "First National Bank - Checking",
"status": "needs_confirmation",
"microdepositInfo": {
"type": "amounts",
"arrivalDate": 1715583600
}
}
```
`microdepositInfo` is absent once the account is `active`.
#### Resume Verification with a Verification Link
The test deposits take 1–2 business days, so users typically leave the flow before they can verify. To bring a user back to finish, generate a link in `verification` mode, scoped to the pending account:
Redirect the user to the returned `url`. It opens a focused verification page for that account only — the user can complete verification there but cannot add or remove payment methods, which makes it safe to trigger from an automated flow.
Take `externalAccountId` from [the account list](#check-the-account-status). Generating a verification link requires the account to be awaiting verification: the request fails with `404` for an unknown account and `400` if the account is already `active`.
There is no API endpoint to submit the micro-deposit code — verification is completed in the hosted link-out experience only. Poll [the account status](#check-the-account-status), or subscribe to the [`external-account.added`](/reference/events#external-account-added) event, to detect when the account becomes `active`. Only `active` accounts can be used to [create a deposit](#create-a-deposit).
### Remove a Linked Account
Delete an external account that is no longer needed.
A successful deletion returns a `204 No Content` response.
### Create a Deposit
Initiate an ACH debit from the linked bank account. Also available in the Dashboard under **Business > Account > Top up Account > ACH Direct Debit**.
**Response:**
```json theme={null}
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef0123456789"
}
```
Only `active` accounts can be debited. Otherwise the request fails with `400`:
| Account status | Error message |
| :- | :- |
| `restricted` | `direct debits are restricted on this external account` |
| `needs_confirmation` | `external account is not verified; complete verification or add the bank account again` |
ACH deposits typically take 1–3 business days to settle. Your balance updates once the transfer completes.
***
## Crypto Funding
Fund your account by sending USDC or USDT to your Cadana crypto wallet address.
Crypto funding is not enabled by default. Contact your account manager to set it up.
### Supported Chains
| Token | Supported Chains |
| :- | :- |
| USDC | Ethereum, Solana, Arbitrum, Base, Polygon |
| USDT | Ethereum, Solana, Tron |
### Create a Deposit Address
Deposit addresses are created per currency and chain — they don't exist by default. Create one for each chain you want to receive on:
**Response:**
```json theme={null}
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef0123456789",
"currency": "USDC",
"chain": "ethereum",
"address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"
}
```
Creating an address fails with `400` if crypto deposits aren't enabled for the business, or if an address already exists for that currency and chain.
### Get Your Crypto Wallet Address
Once created, call the funding details endpoint with `currency=USDC` or `currency=USDT`. The response includes a `cryptoWallets` array with your addresses for each chain.
**Example response:**
```json theme={null}
{
"cryptoWallets": [
{
"currency": "USDC",
"address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
"chain": "ethereum"
},
{
"currency": "USDC",
"address": "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs",
"chain": "solana"
}
]
}
```
Always verify the `chain` matches the network you're sending from. Sending tokens on the wrong chain will result in lost funds.
If a `memo` is returned, you **must** include it in your transaction. Deposits without the required memo may not be credited.
***
## Webhook Notification
When funds arrive in your business account, Cadana emits a [`transaction.succeeded`](/reference/events#transaction-succeeded) event with `type: "DEPOSIT"`. Subscribe to this event to programmatically detect when your account has been funded.
```json JSON theme={null}
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"eventType": "transaction.succeeded",
"version": "1.0",
"timestamp": "2025-06-15T14:30:00Z",
"data": {
"id": "d7e8f9a0-b1c2-3456-def0-123456789abc",
"amount": {
"currency": "USD",
"amount": 500000
},
"type": "DEPOSIT",
"reference": "dep-ref-001",
"recipientId": "acct-9bd99534-8c7f-4b2a",
"recipientType": "BUSINESS",
"description": "Invoice 28278FC-155",
"paymentInfo": {
"preferredMethod": "bank",
"momo": {
"accountName": "",
"provider": "",
"providerCode": "",
"phoneNumber": { "countryCode": "", "number": "" }
},
"bank": {
"accountName": "ACME TRADING LTD",
"accountNumber": "****2061",
"bankCode": "",
"bankName": "",
"branchName": "",
"sortCode": "108800"
}
},
"tenantKey": "cad95193904"
}
}
```
See [Webhooks](/reference/webhooks) for setup instructions.
***
## Insufficient Funds During Payroll
If you approve a payroll without sufficient balance, it moves to `awaiting funds` status. Once you fund the account, the payroll proceeds automatically — no need to re-approve. See [Payroll Lifecycle](/workforce/payroll-lifecycle) for details.
***
Reserves allow you to lock a portion of your business balance for obligations like security deposits, severance, or notice period payments. Locked funds are deducted from your available balance and held until released.
To view reserve balances, pass `include=reserves`:
**Lock reserves:**
**Release reserves:**
**Reserve types:** `SECURITY_DEPOSIT`, `SEVERANCE`, `NOTICE_PERIOD`
# Getting Started
Source: https://docs.cadanapay.com/getting-started
Get from zero to your first API call in minutes
Cadana provides APIs for global payroll, cross-border payments, and multi-currency wallets. This guide gets you set up and making your first API call.
***
## 1. Get Your API Key
1. [Sign in](https://app.cadanapay.com) to the Cadana Dashboard (or [request access](https://cadanapay.com/book-demo))
2. Go to **Settings** → **Developers**
3. Create a new API key and copy it — it won't be shown again
Cadana supports different [token types](/authentication#token-types) depending on your integration. Most integrations start with an **Org API key**.
Keep your API key secret. Never expose it in client-side code or public repositories.
***
## 2. Environments
| Environment | Base URL | Purpose |
| :- | :- | :- |
| Sandbox | `https://dev-api.cadanapay.com` | Development and testing |
| Production | `https://api.cadanapay.com` | Live transactions |
Sandbox and production use separate API keys and data. Start in sandbox.
***
## 3. Make Your First Request
Test your API key by asking the API [who you are](/api-reference/workforce/businesses/get-current-identity):
```bash Bash theme={null}
curl -X GET 'https://dev-api.cadanapay.com/v1/me' \
-H 'Authorization: Bearer YOUR_API_KEY'
```
A successful `200` response confirms your key is working and returns your key's access level and your business — including the `businessId` and tenant key that other endpoints ask for:
```json JSON theme={null}
{
"apiKey": {
"id": "ba51f42c-0adc-4a77-8dd1-a03dd4fb955f",
"accessLevel": "FULL",
"status": "active",
"description": "Sandbox key"
},
"business": {
"id": "1f7b33d4-45c6-4a66-b58f-2f6b3d3e5a10",
"tenantKey": "acme-us",
"name": "Acme Inc.",
"country": "US"
}
}
```
If you get `401 Unauthorized`, check that you're using a sandbox API key with the sandbox URL.
***
## Next Steps
Send payouts to bank accounts and mobile wallets globally
Pay employees and contractors via payroll
Token types, multi-tenant setup, and security
Test your integration with simulated data
# Introduction
Source: https://docs.cadanapay.com/introduction
Learn how to integrate Cadana's APIs and pre-built UI components for global payroll, cross-border payments, and embedded wallets.
Make your first request in minutes. Learn how to get started with Cadana.
Explore endpoints, schemas, and try requests live
***
## Products
Use Cadana's workforce management solution to build and launch a global payroll for your business.
Integrate Cadana's payment infrastructure to send money globally.
Add multi-currency digital wallets to your platform that empower your customers with borderless finance.
Stateful payroll tax calculations with full breakdowns, legislative citations, and year-to-date tracking across 90+ countries.
File statutory returns with government authorities across jurisdictions.
Remit payroll taxes and contributions to government authorities with automatic FX conversion.
***
## Integration Paths
Call REST endpoints directly. Full control over UI and workflows.
Embed pre-built payroll and payment screens under your own brand.
Manage multiple businesses from a single integration with tenant isolation.
Drop in a salary calculator widget with a few lines of code.
***
## Environments
| Environment | Base URL | Purpose |
| :- | :- | :- |
| **Sandbox** | `https://dev-api.cadanapay.com` | Development and testing |
| **Production** | `https://api.cadanapay.com` | Live transactions |
Start in sandbox to test your integration without moving real money. [Learn more about sandbox](/reference/sandbox).
***
## Resources
Supported countries, virtual account types, and onboarding tiers
Subscribe to events for payment status, payroll updates, and KYC
***
# Making a Payment
Source: https://docs.cadanapay.com/payments/making-a-payment
Step-by-step guide to sending a payout through the Payments API
This guide walks through the complete payout flow — from discovering corridor requirements to tracking delivery.
## Prerequisites
* A Cadana business account with an **Org API key**
* Review [Authentication](/authentication) for API key setup
***
## Step 0: Fund Your Wallet
Payouts are sent from your Cadana wallet balance. Before you can send a payment, make sure your wallet is funded.
### Check Your Balance
**Response:**
```json theme={null}
{
"data": [
{
"id": "517075a2-17db-469f-9481-eb8347cb920c",
"currency": "USD",
"balance": 703448,
"available": 703448,
"processing": 0
}
]
}
```
Use the `available` field to determine how much you can send — `processing` reflects amounts currently in transit. All values are in the lowest denomination (e.g., cents).
### Get Funding Instructions
To add funds, call [`GET /v1/funding-details`](/api-reference/payments/balances/get-funding-details) to get the bank details for depositing into your wallet:
This returns bank account details you can use to transfer funds into your Cadana wallet. The response varies by currency — for example, USD returns ACH, wire, and SWIFT details, while EUR returns IBAN details.
In sandbox, use [`POST /v1/sandbox/business-deposits`](/api-reference/workforce/sandbox/deposit) to add test funds to your wallet.
***
## Step 1: Discover Requirements
Before creating a beneficiary, query the corridor requirements and available providers.
### Get Payment Requirements
Returns the required fields for a given country, currency, and payment method:
The response is a JSON schema describing mandatory fields, validation patterns, and allowed values. See [Payment Requirements](/payments/payment-requirements) for how to interpret the schema and build dynamic forms from it.
### Get Supported Providers
Returns banks and mobile money providers you can send payments to:
**Response:**
```json theme={null}
{
"data": [
{
"code": "01",
"name": "Example Bank",
"countryCode": "KE",
"currency": "KES",
"type": "bank"
}
]
}
```
Use the returned `code` as the `bankCode` when creating beneficiaries.
### Resolve a Bank Provider
If you already have a bank code, SWIFT code, or ACH routing number and need to verify the bank name and details, use [`GET /v1/providers/resolve`](/api-reference/payments/resources/resolve-bank-provider):
***
## Step 2: Create a Beneficiary
A beneficiary stores the recipient's payment details. Create it once, then reference it for every payout to that recipient.
### Upload KYC Document
Beneficiary creation requires a KYC identity document. Upload it first:
**2a.** Get an upload URL:
**Response:**
```json theme={null}
{
"fileId": "517075a2-17db-469f-9481-eb8347cb920c",
"putUrl": "https://cadana-kyc.s3.amazonaws.com/tmp/file_9c52fa2e?X-Amz-Algorithm=...",
"expiresIn": 900
}
```
**2b.** Upload the file to the `putUrl` before it expires:
```bash Bash theme={null}
curl -X PUT 'RETURNED_PUT_URL' \
-H 'Content-Type: image/jpeg' \
--data-binary @passport-front.jpg
```
### Create the Beneficiary
**Response:**
```json theme={null}
{
"id": "7a7f80a6-1665-4f64-9ef3-d5f90f8f309b",
}
```
Save the beneficiary `id`. You'll use it every time you pay this recipient.
### Managing Beneficiaries
Once created, you can list, retrieve, and delete beneficiaries:
| Operation | Endpoint |
| :- | :- |
| List all | [`GET /v1/beneficiaries`](/api-reference/payments/beneficiaries/list) |
| Get one | [`GET /v1/beneficiaries/{beneficiaryId}`](/api-reference/payments/beneficiaries/get) |
| Delete | [`DELETE /v1/beneficiaries/{beneficiaryId}`](/api-reference/payments/beneficiaries/delete) |
***
## Step 3: Get an FX Quote
Lock the exchange rate before creating the payout. The returned quote `id` is required when submitting the payout.
**Response:**
```json theme={null}
{
"id": "57c74c5d-38a0-41e7-a19e-161f16dc4898",
"from": "USD",
"to": "KES",
"rate": "129.50",
"expirationTimestamp": "2024-01-15T10:45:00Z"
}
```
Quotes expire in \~2 minutes. Create the payout before the quote expires or it will be rejected.
***
## Step 4: Create the Payout
Check your balance first, then execute:
### Check Balance
### Generate a Reference
Include a unique `reference` to identify the payout in your system. References are useful for:
* **Tracking** — look up payouts by reference via `GET /v1/payouts?reference=INV-2024-001`
* **Preventing duplicates** — submitting the same reference twice returns a `400` error, preventing accidental double payments
References are optional but recommended. Max 128 characters.
### Execute Payout
The `amount` value is in the lowest denomination of the currency (e.g., cents for USD). `100000` = \$1,000.00 USD.
**Response:**
```json theme={null}
{
"id": "d35d2bd6-188b-4e82-9a16-71442dad7375"
}
```
### Retrying a Failed Payout
If a payout fails, you can retry it by creating a new payout with the same beneficiary. You'll need a fresh FX quote (the original may have expired) and a **new reference**, since the original reference is already recorded.
```
1. Get a new FX quote → POST /v1/fx-quotes
2. Create a new payout → POST /v1/payouts (same beneficiaryId, new quoteId, new reference)
```
***
## Step 5: Track the Payment
### Webhooks (Recommended)
Subscribe to [transaction events](/reference/events#transactions) for real-time status updates. See [Webhooks](/reference/webhooks) to configure your endpoint.
Each status change emits a webhook with the following envelope:
```json theme={null}
{
"id": "evt_abc123",
"eventType": "transaction.initiated",
"version": "1.0",
"timestamp": 1681007225,
"data": { ... }
}
```
The `data` payload varies by event:
Payout created, compliance checks in progress.
```json theme={null}
{
"id": "d35d2bd6-188b-4e82-9a16-71442dad7375",
"amount": {
"currency": "USD",
"amount": 100000
},
"type": "PAYOUT",
"reference": "INV-2024-001",
"recipientId": "7a7f80a6-1665-4f64-9ef3-d5f90f8f309b",
"recipientType": "BUSINESS",
"tenantKey": "your-tenant-key",
"timestamp": 1681007225
}
```
Delivered to recipient's account.
```json theme={null}
{
"id": "d35d2bd6-188b-4e82-9a16-71442dad7375",
"amount": {
"currency": "USD",
"amount": 100000
},
"type": "PAYOUT",
"reference": "INV-2024-001",
"recipientId": "7a7f80a6-1665-4f64-9ef3-d5f90f8f309b",
"recipientType": "BUSINESS",
"tenantKey": "your-tenant-key",
"timestamp": 1681007225
}
```
Payment failed — check the `message` field for details. If `isReturned` is `true`, the payment was previously successful but returned by the recipient's bank.
```json theme={null}
{
"id": "d35d2bd6-188b-4e82-9a16-71442dad7375",
"message": "Account inactive",
"isReturned": false,
"amount": {
"currency": "USD",
"amount": 100000
},
"type": "PAYOUT",
"reference": "INV-2024-001",
"recipientId": "7a7f80a6-1665-4f64-9ef3-d5f90f8f309b",
"recipientType": "BUSINESS",
"tenantKey": "your-tenant-key",
"timestamp": 1681007225
}
```
### Status Lifecycle
```
initiated → processing → succeeded
↓ ↓
failed returned (failed with isReturned: true)
```
### Polling
Alternatively, poll the payout endpoint for status updates:
**Response:**
```json theme={null}
{
"id": "d35d2bd6-188b-4e82-9a16-71442dad7375",
"beneficiaryId": "7a7f80a6-1665-4f64-9ef3-d5f90f8f309b",
"quoteId": "57c74c5d-38a0-41e7-a19e-161f16dc4898",
"reference": "INV-2024-001",
"amount": {
"amount": 100000,
"currency": "USD"
},
"sourceAmount": {
"amount": 100000,
"currency": "USD"
},
"feeAmount": {
"amount": 500,
"currency": "USD"
},
"fxRate": "129.50",
"status": "succeeded",
"createdTimestamp": 1748478276,
"lastUpdatedTimestamp": 1748478276
}
```
### List Payouts
Retrieve all payouts with optional filters:
The endpoint has three modes depending on which parameters you pass:
| Mode | When | Behavior |
| :- | :- | :- |
| **Reference lookup** | `reference` is set | Returns the single matching payout. All other params are ignored. |
| **Paginated list** | Default | Returns payouts in pages (default 20). Supports `status`, `startDate`, `endDate`, `order` (`asc`/`desc`, default `desc`), `cursor`, and `limit`. |
| **CSV export** | `format=csv` | Returns all matching payouts as a CSV file. Requires `startDate` and `endDate`. Order is always `desc`. |
***
## Complete Example
```go theme={null}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const baseURL = "https://dev-api.cadanapay.com"
func main() {
apiKey := os.Getenv("CADANA_API_KEY")
// 1. Upload KYC document
fmt.Println("Uploading KYC document...")
upload := post(apiKey, "/v1/files/upload-url", map[string]string{
"purpose": "kyc-id-front",
"contentType": "image/jpeg",
})
fileID := upload["fileId"].(string)
putURL := upload["putUrl"].(string)
uploadFile(putURL, "id-front.jpg", "image/jpeg")
// 2. Create beneficiary
fmt.Println("Creating beneficiary...")
ben := post(apiKey, "/v1/beneficiaries", map[string]any{
"name": "Jane Wanjiku",
"email": "jane@example.com",
"countryCode": "KE",
"currency": "KES",
"paymentDetails": map[string]any{
"preferredMethod": "bank",
"bank": map[string]string{
"accountName": "Jane Wanjiku",
"accountNumber": "1234567890",
"bankCode": "01",
"bankName": "Example Bank",
},
},
"kyc": map[string]string{
"firstName": "Jane",
"lastName": "Wanjiku",
"dateOfBirth": "1990-08-15",
"countryCode": "KE",
"idType": "NATIONAL_ID",
"idNumber": "12345678",
"idFileFront": fileID,
},
})
benID := ben["id"].(string)
fmt.Printf("Beneficiary: %s\n", benID)
// 3. Get FX quote
fmt.Println("Getting FX quote...")
quote := post(apiKey, "/v1/fx-quotes", map[string]string{
"from": "USD",
"to": "KES",
})
quoteID := quote["id"].(string)
fmt.Printf("Quote: %s (rate: %v)\n", quoteID, quote["rate"])
// 4. Create payout
fmt.Println("Creating payout...")
payout := post(apiKey, "/v1/payouts", map[string]any{
"beneficiaryId": benID,
"quoteId": quoteID,
"amount": map[string]any{"amount": 100000, "currency": "USD"},
"reference": fmt.Sprintf("test-%d", time.Now().Unix()),
})
payoutID := payout["id"].(string)
fmt.Printf("Payout: %s\n", payoutID)
// 5. Check status
time.Sleep(3 * time.Second)
fmt.Println("Checking status...")
status := get(apiKey, "/v1/payouts/"+payoutID)
fmt.Printf("Status: %s\n", status["status"])
}
func post(apiKey, path string, body any) map[string]any {
data, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", baseURL+path, bytes.NewReader(data))
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
return doRequest(req)
}
func get(apiKey, path string) map[string]any {
req, _ := http.NewRequest("GET", baseURL+path, nil)
req.Header.Set("Authorization", "Bearer "+apiKey)
return doRequest(req)
}
func doRequest(req *http.Request) map[string]any {
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
return result
}
func uploadFile(putURL, filePath, contentType string) {
file, _ := os.Open(filePath)
defer file.Close()
req, _ := http.NewRequest("PUT", putURL, file)
req.Header.Set("Content-Type", contentType)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
}
```
***
## Next Steps
Supported methods and required fields per corridor
Handling compliance holds and document requests
Simulate payment scenarios in sandbox
Full endpoint documentation
# Overview
Source: https://docs.cadanapay.com/payments/overview
Integrate Cadana's payment infrastructure to simplify international payroll across 100+ countries
Cadana's Payments API lets you pay employees and contractors through local payment rails and SWIFT across a broad network of countries.
***
## Features
Access real-time foreign exchange rates and lock quotes before sending payments.
Create and manage reusable payment destinations to streamline repeat payouts.
Initiate payouts to bank accounts and mobile wallets with built-in compliance checks.
Monitor the status of every payment in real-time via API polling or webhooks.
Send payments in multiple currencies through local rails and SWIFT across a broad network of countries.
***
## How It Works
Every payout follows four steps:
```mermaid theme={null}
flowchart LR
S1["Discover Requirements
GET /v1/payment-requirements
GET /v1/providers"] --> S2["Create Beneficiary
POST /v1/files/upload-url
POST /v1/beneficiaries"]
S2 --> S3["Create Payout
GET /v1/balances
POST /v1/fx/quotes
POST /v1/payouts"]
S3 --> S4["Track Status
GET /v1/payouts/:id
Webhooks: transaction.*"]
S1:::blue
S2:::orange
S3:::red
S4:::purple
classDef blue fill:#e6f7ff,stroke:#1890ff,color:#000
classDef orange fill:#fff7e6,stroke:#fa8c16,color:#000
classDef red fill:#ffeded,stroke:#f5222d,color:#000
classDef purple fill:#f9f0ff,stroke:#722ed1,color:#000
```
Call [`GET /v1/payment-requirements`](/api-reference/payments/resources/payment-corridor-requirements) to get the required fields for a country and payment method. Call [`GET /v1/providers`](/api-reference/payments/resources/get-payout-providers) to list supported banks and mobile money providers.
Store the recipient's payment details with [`POST /v1/beneficiaries`](/api-reference/payments/beneficiaries/create). Beneficiaries are reusable — create once, pay many times.
KYC documents (e.g., a government-issued ID) are required for beneficiaries. Upload documents via [`POST /v1/files/upload-url`](/api-reference/payments/beneficiaries/create-upload-url) before creating the beneficiary. See [Making a Payment](/payments/making-a-payment) for the full flow.
Check your balance, lock an FX rate with [`POST /v1/fx/quotes`](/api-reference/payments/fx/create-fx-quote), then execute the payout with [`POST /v1/payouts`](/api-reference/payments/payouts/create) using the returned quote `id`.
Poll [`GET /v1/payouts/:id`](/api-reference/payments/payouts/get) or subscribe to [webhooks](/reference/events#transactions) for real-time status updates.
***
## API Endpoints
### Beneficiaries
| Method | Endpoint | Description |
| :- | :- | :- |
| POST | [`/v1/beneficiaries`](/api-reference/payments/beneficiaries/create) | Create a beneficiary |
| GET | [`/v1/beneficiaries`](/api-reference/payments/beneficiaries/list) | List beneficiaries |
| GET | [`/v1/beneficiaries/{id}`](/api-reference/payments/beneficiaries/get) | Get a beneficiary |
| DELETE | [`/v1/beneficiaries/{id}`](/api-reference/payments/beneficiaries/delete) | Delete a beneficiary |
### Payouts
| Method | Endpoint | Description |
| :- | :- | :- |
| POST | [`/v1/payouts`](/api-reference/payments/payouts/create) | Create a payout |
| GET | [`/v1/payouts`](/api-reference/payments/payouts/list) | List payouts |
| GET | [`/v1/payouts/{id}`](/api-reference/payments/payouts/get) | Get payout status |
### FX
| Method | Endpoint | Description |
| :- | :- | :- |
| POST | [`/v1/fx-quotes`](/api-reference/payments/fx/create-fx-quote) | Get an FX quote |
### Balances
| Method | Endpoint | Description |
| :- | :- | :- |
| GET | [`/v1/balances`](/api-reference/payments/balances/get-account-balances) | Get wallet balances |
| GET | [`/v1/funding-details`](/api-reference/payments/balances/get-funding-details) | Get funding instructions for your wallet |
### Discovery
| Method | Endpoint | Description |
| :- | :- | :- |
| GET | [`/v1/payment-requirements`](/api-reference/payments/resources/payment-corridor-requirements) | Get required fields for a corridor |
| GET | [`/v1/providers`](/api-reference/payments/resources/get-payout-providers) | List supported banks and providers |
| GET | [`/v1/payment-methods`](/api-reference/payments/resources/get-payment-methods) | List available payment methods |
***
## Webhook Events
Subscribe to these events to track payment lifecycle changes:
| Event | Description |
| :- | :- |
| `transaction.initiated` | Payout created and compliance checks in progress |
| `transaction.processing` | Payout passed compliance checks and is being processed |
| `transaction.rfi.updated` | Additional information was requested or provided for a payout under review |
| `transaction.succeeded` | Delivered to recipient |
| `transaction.failed` | Payment failed. If `isReturned` is `true`, the payment was previously successful but returned by the recipient's bank. |
See [Webhooks](/reference/webhooks) to configure your endpoint.
***
## Next Steps
Step-by-step integration guide
Supported methods and required fields per corridor
Handling compliance holds and document requests
# Payment Methods & Requirements
Source: https://docs.cadanapay.com/payments/payment-requirements
Supported payment methods and required fields for each payment corridor
Cadana supports multiple payment methods depending on the destination country and currency. Each method has specific field requirements for creating beneficiaries. Use the discovery endpoints to determine what's available for a given corridor at runtime.
Available corridors are configured per business. If you need access to a corridor that isn't enabled on your account, contact the Cadana team.
***
## Discovering Available Methods
Call [`GET /v1/payment-methods`](/api-reference/payments/resources/get-payment-methods) to list payment methods available for a country:
**Response:**
```json theme={null}
{
"country": "BR",
"paymentMethods": [
{ "type": "bank", "currency": "BRL", "status": "active" },
{ "type": "proxy", "currency": "BRL", "status": "active" },
{ "type": "swift", "currency": "USD", "status": "active" }
]
}
```
Call [`GET /v1/providers`](/api-reference/payments/resources/get-payout-providers) to list supported banks and mobile money providers:
**Response:**
```json theme={null}
{
"data": [
{
"code": "01",
"name": "Example Bank",
"countryCode": "KE",
"currency": "KES",
"type": "bank"
}
]
}
```
Use the returned `code` as the `bankCode` (or `providerCode` for mobile money) when creating beneficiaries.
### Resolve a Bank Provider
If you have a bank code, SWIFT code, or ACH routing number and need to look up the corresponding bank name and details, use [`GET /v1/providers/resolve`](/api-reference/payments/resources/resolve-bank-provider):
The `paymentMethod` parameter determines how the code is resolved:
| `paymentMethod` | Resolves |
| :- | :- |
| `swift` | 8 or 11-character SWIFT/BIC code |
| `ach` | 9-digit US ACH routing number |
| `bank` | Bank code from Cadana's provider list for the given currency |
***
## Using the Requirements API
The requirements endpoint returns a [JSON Schema (Draft-07)](https://json-schema.org/draft-07) describing all mandatory and optional fields for a given corridor:
### Understanding the Response
Here's an example response for Colombia (COP, bank transfer):
```json theme={null}
{
"type": "object",
"additionalProperties": false,
"required": ["accountName", "accountNumber", "bankCode", "bankName", "accountType", "beneficiaryId", "address", "email"],
"properties": {
"accountName": { "type": "string", "maxLength": 60 },
"accountNumber": {
"type": "string",
"pattern": "^[0-9]{9,16}$",
"description": "9 - 16-digit Colombian account number"
},
"accountType": { "type": "string", "enum": ["Checking", "Saving"] },
"bankCode": {
"type": "string",
"pattern": "^[A-Za-z0-9]{1,11}$",
"description": "1-11 alphanumeric bank code"
},
"bankName": { "type": "string", "maxLength": 255 },
"email": { "type": "string", "format": "email", "maxLength": 100 },
"beneficiaryId": {
"type": "object",
"additionalProperties": false,
"required": ["type", "value"],
"properties": {
"type": { "type": "string", "enum": ["NIT", "CC", "CE", "TI", "PASS"] },
"value": { "type": "string" }
},
"allOf": [
{
"if": { "properties": { "type": { "const": "NIT" } } },
"then": { "properties": { "value": { "pattern": "^\\d{9,11}$" } } }
},
{
"if": { "properties": { "type": { "const": "CC" } } },
"then": { "properties": { "value": { "pattern": "^\\d{6,10}$" } } }
},
{
"if": { "properties": { "type": { "const": "CE" } } },
"then": { "properties": { "value": { "pattern": "^[A-Za-z0-9]{5,12}$" } } }
},
{
"if": { "properties": { "type": { "const": "TI" } } },
"then": { "properties": { "value": { "pattern": "^\\d{8,11}$" } } }
},
{
"if": { "properties": { "type": { "const": "PASS" } } },
"then": { "properties": { "value": { "pattern": "^[A-Za-z0-9]{5,15}$" } } }
}
]
},
"address": {
"type": "object",
"additionalProperties": false,
"required": ["line1", "city", "countryCode"],
"properties": {
"line1": { "type": "string", "maxLength": 70 },
"line2": { "type": "string", "maxLength": 70 },
"city": { "type": "string", "maxLength": 50 },
"postalCode": { "type": "string", "maxLength": 10 },
"state": { "type": "string", "maxLength": 50 },
"countryCode": { "const": "CO" }
}
}
}
}
```
Key parts of the schema:
| Keyword | What it tells you |
| :- | :- |
| `required` | Fields that must be present |
| `properties` | Available fields with their types and constraints |
| `pattern` | Regex the value must match (e.g., `^[0-9]{9,16}$` for a 9-16 digit number) |
| `enum` | Allowed values (e.g., `["Checking", "Saving"]`) |
| `const` | Fixed value (e.g., `"CO"` for country code) |
| `maxLength` | Maximum string length |
| `format` | Semantic format hint (e.g., `email`) |
| `additionalProperties` | When `false`, no extra fields beyond those in `properties` are accepted |
| `allOf` / `if-then` | Conditional rules — e.g., if `beneficiaryId.type` is `"NIT"`, then `value` must be 9-11 digits |
### Building Dynamic Forms
You can use these schemas to dynamically generate beneficiary creation forms that automatically adapt to each corridor's requirements — no hardcoded field logic needed.
```
1. User selects country, currency, and payment method
2. Your app calls GET /v1/payment-requirements
3. Render form fields from the schema (required, types, enums, validation)
4. Validate input client-side against the schema before submitting
5. POST /v1/beneficiaries with the validated data
```
This approach means your UI automatically adapts when Cadana adds new corridors or updates field requirements, without code changes. Standard JSON Schema libraries (like [Ajv](https://ajv.js.org/) for JavaScript or [jsonschema](https://pypi.org/project/jsonschema/) for Python) can handle validation directly from the response.
Always use [`GET /v1/payment-requirements`](/api-reference/payments/resources/payment-corridor-requirements) as the source of truth. The examples below illustrate common patterns to help you plan your integration — they are not exhaustive.
***
## Bank Transfer
Sends funds directly to a recipient's bank account through local clearing networks.
Set `preferredMethod` to `"bank"` and provide bank details in the `bank` object:
```json theme={null}
{
"paymentDetails": {
"preferredMethod": "bank",
"bank": {
"accountName": "John Doe",
"accountNumber": "0123456789",
"bankCode": "058",
"bankName": "Example Bank"
}
}
}
```
Required fields vary by corridor. Bank transfer corridors range from 4 required fields to 10+, depending on the country's regulatory and clearing requirements.
### Simple — Nigeria (NGN)
Many corridors (e.g., NG, KE, GH, MX) require only basic bank details:
| Field | Required | Format | Notes |
| :- | :- | :- | :- |
| `accountName` | Yes | String (max 60) | Must match bank records |
| `accountNumber` | Yes | String (max 255) | Bank-specific format (e.g., 10-digit NUBAN for Nigeria) |
| `bankCode` | Yes | String (max 255) | Bank code — use [`GET /v1/providers`](/api-reference/payments/resources/get-payout-providers) |
| `bankName` | Yes | String (max 255) | Bank name |
### Medium — United Kingdom (GBP)
Some corridors add fields like `sortCode`, `routingNumber`, or `iban` depending on the local clearing system:
| Field | Required | Format | Notes |
| :- | :- | :- | :- |
| `accountName` | Yes | String (max 60) | Account holder name |
| `accountNumber` | Yes | 1-8 digits | UK account number |
| `bankCode` | Yes | String (max 255) | Bank code |
| `bankName` | Yes | String (max 255) | Bank name |
| `sortCode` | Yes | 6 digits | Sort code |
Other examples of this pattern: SEPA (adds `iban`, `beneficiaryAddress`), Canada (adds `sortCode`, `address`).
### Complex — Colombia (COP)
Corridors like Colombia, India, South Africa, and Peru require a combination of beneficiary identification, address, contact details, and conditional validation:
| Field | Required | Format | Notes |
| :- | :- | :- | :- |
| `accountName` | Yes | String (max 60) | Account holder name |
| `accountNumber` | Yes | 9-16 digits | Colombian account number |
| `bankCode` | Yes | String (1-11 chars) | Alphanumeric bank code |
| `bankName` | Yes | String (max 255) | Bank name |
| `accountType` | Yes | `Checking` or `Saving` | Account type |
| `beneficiaryId.type` | Yes | `NIT`, `CC`, `CE`, `TI`, or `PASS` | ID type |
| `beneficiaryId.value` | Yes | String | ID number (format depends on type — see schema `allOf` rules) |
| `email` | Yes | Email (max 100) | Recipient email |
| `address.line1` | Yes | String (max 70) | Street address |
| `address.city` | Yes | String (max 50) | City |
| `address.countryCode` | Yes | `CO` | Country code |
India (INR) follows this pattern: the corridor schema puts the IFSC code in `sortCode`, requires `ownerType` as `Individual` or `Business` (capitalized, per the schema's enum), and requires `email` and `phoneNumber`.
***
## ACH
Send funds to US bank accounts via the Automated Clearing House network.
Set `preferredMethod` to `"ach"` and provide details in the `ach` object:
```json theme={null}
{
"paymentDetails": {
"preferredMethod": "ach",
"ach": {
"accountName": "Jane Smith",
"accountNumber": "123456789",
"routingNumber": "000000000",
"accountType": "Checking",
"bankName": "Example Bank",
"address": {
"line1": "123 Main St",
"city": "New York",
"state": "NY",
"postalCode": "10001",
"countryCode": "US"
}
}
}
}
```
***
## Mobile Money
Send funds directly to mobile money wallets. Widely used across Sub-Saharan African markets.
Set `preferredMethod` to `"momo"` and provide details in the `momo` object:
```json theme={null}
{
"paymentDetails": {
"preferredMethod": "momo",
"momo": {
"accountName": "John Doe",
"phoneNumber": {
"number": "700000000",
"countryCode": "254"
},
"providerCode": "MPESA"
}
}
}
```
Use [`GET /v1/providers`](/api-reference/payments/resources/get-payout-providers) to get the available providers for a given country.
| Field | Required | Format | Notes |
| :- | :- | :- | :- |
| `accountName` | Yes | String | Account holder name |
| `phoneNumber.number` | Yes | String | Phone number |
| `phoneNumber.countryCode` | Yes | String | Country dialing code |
| `provider` | Yes | String | Provider name (e.g., `mpesa`) |
| `providerCode` | Yes | String | Provider code — use [`GET /v1/providers`](/api-reference/payments/resources/get-payout-providers) |
***
## Proxy
Proxy-based payment methods use an identifier (email, phone, or national ID) instead of traditional bank details. Used for corridors like Brazil Pix.
Set `preferredMethod` to `"proxy"` and provide details in the `proxy` object:
```json theme={null}
{
"paymentDetails": {
"preferredMethod": "proxy",
"proxy": {
"type": "email",
"value": "recipient@example.com"
}
}
}
```
| Field | Required | Format | Notes |
| :- | :- | :- | :- |
| `type` | Yes | `email`, `mobile`, or `other` | Proxy key type |
| `value` | Yes | String (max 255) | The proxy identifier |
Some corridors require additional fields like `beneficiaryId` or `address`. Use [`GET /v1/payment-requirements`](/api-reference/payments/resources/payment-corridor-requirements) to check the exact requirements for each corridor.
***
## SWIFT
For corridors without local payment rails, Cadana routes payments via SWIFT. Covers 180+ countries (USD and EUR).
Set `preferredMethod` to `"swift"` and provide details in the `swift` object. SWIFT payments typically require more information than local transfers, including the SWIFT/BIC code, recipient address, and either `iban` or `accountNumber` depending on the destination.
| Field | Required | Format | Notes |
| :- | :- | :- | :- |
| `accountName` | Yes | String (max 60) | Account holder name |
| `accountNumber` | Yes | String (max 255) | Account number |
| `swiftCode` | Yes | 8 or 11 chars | SWIFT/BIC code |
| `bankName` | Yes | String (max 255) | Bank name |
| `currency` | Yes | `USD` or `EUR` | Payment currency |
| `iban` | Yes | String | International Bank Account Number |
| `address` | Yes | Object | Recipient address (`line1`, `city`, `postalCode`, `countryCode`) |
***
## Next Steps
Step-by-step payout integration
Full list of supported countries and payment rails
# RFI & Compliance
Source: https://docs.cadanapay.com/payments/rfi-compliance
Understanding and responding to requests for information during payment processing
An RFI (Request for Information) is a compliance check that requires additional documentation to validate a payout before it can proceed. RFIs are triggered automatically based on regulatory requirements and risk signals during payment processing.
***
## What Triggers an RFI?
Several factors can trigger a compliance review on a payout:
* **Country regulations** — Destination countries may require additional documentation for certain payment types
* **Transaction amount** — Payments exceeding corridor-specific thresholds
* **Transaction purpose** — Certain payment purposes require supporting documentation
* **Flow type** — B2B payments may have different requirements than individual payouts
***
## RFI Workflow
| Phase | Description |
| :- | :- |
| **Compliance triggered** | Risk factors detected during payment processing — the payout is placed on hold |
| **Documents requested** | Cadana requests supporting documentation (invoice, contract, proof of funds, etc.) |
| **Document review** | Cadana's compliance team reviews the submitted documents |
| **Outcome** | Payment is either **approved** and proceeds to delivery, or **rejected** if documentation is insufficient |
***
## Responding to an RFI
When a payout is held, Cadana's compliance team will reach out with the specific documents needed. Once you provide them, the team reviews and either approves or rejects the payout.
For questions about a compliance hold, contact [support@cadanapay.com](mailto:support@cadanapay.com).
***
## Next Steps
Simulate payment scenarios in sandbox
Configure webhook endpoints
# Testing
Source: https://docs.cadanapay.com/payments/testing
Test your payment integration in the sandbox environment
Cadana's sandbox environment lets you test your payment integration without processing real transactions. You can fund your wallet with test money, simulate payment failures, and verify your webhook handling.
**Sandbox host:** `https://dev-api.cadanapay.com`
Funding your wallet, simulating payment failures, and test values
Configure webhook endpoints to receive payment status updates
# KYB Requirements
Source: https://docs.cadanapay.com/platform/kyb-requirements
Business verification requirements for Cadana
Know Your Business (KYB) verification is required before a business can process payments through Cadana. This page covers all requirements and the verification process.
**KYB vs KYC:** KYB verifies business entities. KYC verifies individual users. See [KYC Verification](/platform/kyc-verification) for user verification.
***
## KYB Process Flow
```mermaid theme={null}
flowchart LR
A["Create Business"] --> B["Submit KYB"]
B -->|All requirements met| C["pending-review"]
B -->|Requirements outstanding| G["incomplete"]
G --> B
C -->|Approved| D["complete"]
C -->|Needs info| E["needs-additional-info"]
C -->|Rejected| F["rejected"]
E -->|"Fix & resubmit"| C
```
### KYB Statuses
| Status | Description | Can Process Payments? |
| :- | :- | :- |
| `initialized` | KYB record created (e.g. by generating a hosted link), nothing submitted yet | No |
| `incomplete` | Partially submitted — required fields are still outstanding (see `requirements.currentlyDue`) | No |
| `pending-review` | All requirements submitted, under review | No |
| `awaiting-principal-kyc` | Submitted, waiting on one or more principals to complete identity verification | No |
| `needs-additional-info` | Review complete, more information required | No |
| `rejected` | Business verification rejected (terminal) | No |
| `complete` | Business verified | Yes |
Before the first submission (or hosted-link generation), `GET /v1/businesses/{businessId}/kyb` returns `404`.
***
## Submit KYB
You can submit KYB in two ways:
* **Hosted form** — generate a branded link and send it to your customer. The business owner completes KYB through a hosted UI — no need to build your own collection form.
* **API submission** — collect the verification data in your own UI and submit it directly via API.
### Hosted KYB Form
If you prefer to delegate KYB data collection to the business owner, generate a hosted form link and share it with them. The business owner visits the link and completes verification through a branded UI.
**Response:**
```json theme={null}
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"url": "https://app.cadanapay.com/onboarding/business/?token=eyJhbGciOi...",
"expiresAt": 1765459752
}
```
| Field | Description |
| :- | :- |
| `id` | Unique identifier for the KYB link |
| `url` | The hosted form URL — send this to the business owner. White-label platforms get the link on their own domain. |
| `expiresAt` | Unix timestamp when the link expires |
Links expire 24 hours after creation by default; pass `expiresIn` (seconds, maximum 72 hours) to choose a different lifetime. The resolved expiry is returned as `expiresAt`. If the owner visits an expired link, they are redirected to your `refreshURL` where you can generate a new one. Progress is saved as the owner completes each step, so a new link resumes where they left off.
Prefer a short `expiresIn` when you redirect the owner straight into the form from your own product, and the default when you email the link.
Webhook events (`business.kyb.reviewed`, `business.kyb.completed`) work the same regardless of whether KYB was submitted via the hosted form or the API.
### API Submission
Submit business and principal information for verification. You can submit all data at once or start with a partial submission and add more information later.
The two write endpoints have distinct roles:
| | [`POST /kyb`](/api-reference/workforce/kyb/submit-kyb) | [`PATCH /kyb`](/api-reference/workforce/kyb/update-kyb) |
| :- | :- | :- |
| **Purpose** | Provide **new** information — the initial submission, and any change to the set of principals | **Update** information already provided |
| **Principals** | Always the **full list** — each POST replaces the previously submitted list | Only the principals being updated — each **must** include its `id` |
| **Adding a principal** | Yes — re-POST all existing principals (with their `id`s) plus the new one | No — a missing or unknown `id` fails with `400` |
| **Documents** | Replaces | Appends |
**Incremental submission:** You can start with just the business name and address and submit the rest over time. The status stays `incomplete` — with the outstanding fields listed in `requirements.currentlyDue` — until all requirements are met, then moves to `pending-review`.
Returns `204` on success. The business is now under review.
### Updating KYB Information
Use PATCH to update information already provided — filling in outstanding fields or correcting flagged ones. Only the fields you include are updated — everything else is left unchanged. Document arrays (`articlesOfIncorporation`, `bankStatements`, etc.) are **appended to**, not replaced.
When updating principals in a PATCH request, every principal — including individuals nested inside an [entity principal's](#entity-principals) `entity.individuals` — **must** include its `id` (returned from [GET KYB](#check-kyb-status)). PATCH can only update existing principals: a missing or unknown `id` fails the whole request with `400` (`"principal not found"`), and PATCH cannot add new principals. To add a principal, re-`POST` the full submission.
PATCH is also how you respond after a review returns `needs-additional-info`. Parse the `currentlyDue` array from the [`business.kyb.reviewed`](#webhook-events) webhook, collect the required data or documents, and PATCH them in. The status automatically moves back to review once all requirements are satisfied.
You cannot POST or PATCH while the KYB is in `pending-review`, `complete`, or `rejected` status — the request returns `400`.
### Business Owner Emails
When a review returns `needs-additional-info`, Cadana emails the business's admin users with a link back to the [hosted form](#hosted-kyb-form) so they can supply what is outstanding. The link expires 24 hours after the review. Unlike a link you generate yourself, it carries no `refreshURL`, so an admin who arrives after it has expired has to come back through you.
If nothing has been submitted 3 days after that review, Cadana sends the admin users one reminder email with a fresh hosted-form link, again valid for 24 hours. Each review earns a single reminder: a later review that again returns `needs-additional-info` starts a new 3-day clock. No reminder goes out if the business has already responded or if the email notifications are suppressed.
If your platform owns the customer relationship, suppress both emails and drive the follow-up yourself from the [`business.kyb.reviewed`](#webhook-events) webhook. Generating the hosted link at the moment your customer is ready to act on it keeps the link fresh, and keeps every message to your customer in your own product's voice.
Set `suppressEmailNotification` on either write endpoint:
`POST /kyb` always writes this field, so a later POST that omits it resets it to `false`. PATCH changes it only when the field is present. Either include `suppressEmailNotification: true` in every POST, or set it once via PATCH after your final POST.
### Check KYB Status
Retrieve the full KYB record at any time, including the current `status` and any outstanding requirements. `requirements.currentlyDue` lists the exact field paths still needed — the same paths used in the [`business.kyb.reviewed`](#webhook-events) webhook — so you can poll this endpoint instead of (or in addition to) listening for webhooks.
**Response (truncated):**
```json theme={null}
{
"id": "7c74cc95-d7c6-4537-b388-6d57ed020b34",
"tenantKey": "cad95193904",
"entityName": "Acme Corporation",
"status": "incomplete",
"principals": [
{
"id": "c4874dfd-50ef-429a-afbe-3c8adc0ecbe9",
"firstName": "Jane",
"lastName": "Founder",
"identifier": { "type": "ssn", "value": "****" }
}
],
"requirements": {
"currentlyDue": [
"entityType",
"principals.0.identifier.identityFileId"
],
"errors": []
}
}
```
***
## Business Requirements
| Requirement | API Field | Rules |
| :- | :- | :- |
| Legal business name | `entityName` | Must exactly match the registered name on government records |
| Entity type | `entityType` | `corporation`, `c-corporation`, `s-corporation`, `partnership`, `sole proprietorship`, `limited liability company`, `non profit`, `other` |
| Business address | `address` | Must be a valid physical address; PO Boxes not allowed |
| Phone number | `phoneNumber` | Must be a reachable business phone |
| EIN / Tax ID | `taxIdentificationNumber` | Must be a valid IRS-issued EIN |
| Company registration number | `registrationNumber` | Businesses incorporated in the UK only — see below |
| Incorporation address | `incorporation.address` | Must match corporate records |
| Incorporation date | `incorporation.date` | Must be a valid date |
| Website URL | `websiteURL` | Must be a valid website |
### UK businesses
A business whose `incorporation.address.countryCode` is `GB` must also provide `registrationNumber`, its Companies House company number (for example `12345678`). Until it's provided, `registrationNumber` appears in `requirements.currentlyDue`.
The operating address doesn't matter: a business incorporated outside the UK isn't asked for it, even if its `address.countryCode` is `GB`.
The requirement only appears once a `GB` incorporation address is on the KYB. If you submit the incorporation address in a later request, check `requirements.currentlyDue` afterwards, or send `registrationNumber` together with it.
If compliance finds the number incorrect, the review returns `needs-additional-info` with `registrationNumber` in `currentlyDue` and the reason in `errors`. Send the corrected value with [PATCH](#updating-kyb-information).
***
## Principal / UBO Requirements
A principal can be an **individual** or a **business entity** (see [Entity Principals](#entity-principals) below). You must provide information for principals who:
* Own **25% or more** of the business (`ownershipPercentage >= 0.25`), or
* Hold significant managerial responsibility (`isExecutive: true`)
At least one executive/representative is required, and ownership percentages must sum to at most 1 at each level.
The following fields apply to individual principals:
| Requirement | API Field | Rules |
| :- | :- | :- |
| First name | `principals[].firstName` | Must match ID document |
| Last name | `principals[].lastName` | Must match ID document |
| Date of birth | `principals[].dateOfBirth` | Must be valid and 18+ |
| Email | `principals[].email` | Valid email address |
| Phone number | `principals[].phoneNumber` | Reachable phone |
| Residential address | `principals[].address` | Physical address; PO Boxes not allowed |
| Identifier | `principals[].identifier` | Accepted `type` depends on the principal's country — see [Providing a principal ID](#providing-a-principal-id) |
| Ownership % | `principals[].ownershipPercentage` | 0-1 scale (1 = 100%) |
| Executive flag | `principals[].isExecutive` | Required for control person |
| Executive title | `principals[].title` | Required for control person |
| ID document (front) | `principals[].identifier.identityFileId` | Required if ID number verification fails, or for the passport fallback below |
| ID document (back) | `principals[].identifier.identityBackPageFileId` | Required if ID number verification fails |
| Address proof | `principals[].identifier.addressFileId` | Required if address verification fails |
Upload documents using the [file upload flow](/reference/working-with-files) and pass the returned `fileId` values.
### Providing a principal ID
Which ID a principal can provide depends on their country. Follow this flow:
```mermaid theme={null}
flowchart TD
A[Providing identity for a UBO] --> B{Do you have an ID number
or document for this UBO?}
B -->|No| F[Opt into direct verification —
set optInForDirectVerification: true
so we email the UBO a KYC link]
B -->|Yes| C{Is that ID type accepted
for the UBO's country?}
C -->|Yes| D[Send the ID type and number]
C -->|No| E{Is it a passport with both
its number and a front-page image?}
E -->|Yes| G[Send the passport number and image]
E -->|No| F
D --> H([ID accepted — KYB continues to review])
G --> H
F --> I([UBO completes KYC themselves;
KYB waits in awaiting-principal-kyc until done])
click C "#supported-id-types-by-country" "See the ID types accepted per country"
```
**Fields for each outcome:**
| Diagram step | What to send |
| :- | :- |
| Send the ID type and number | `identifier.type` + `identifier.value` |
| Passport fallback | `identifier.type: passport`, `identifier.value` (the number) and `identifier.identityFileId` (front-page image) |
| Direct verification | `optInForDirectVerification: true` — omit the `identifier` object |
On a PATCH, the passport fallback lets you supply either half — the number now if the image is already stored, or the image now if the number is already stored.
| Country | Code | Supported `type` values |
| :- | :- | :- |
| Argentina | `AR` | `national_id`, `tax_id` |
| Brazil | `BR` | `national_id` |
| Cambodia | `KH` | `national_id` |
| Canada | `CA` | `national_id` |
| Chile | `CL` | `national_id` |
| China | `CN` | `national_id`, `passport` |
| Colombia | `CO` | `national_id`, `ppt` |
| Costa Rica | `CR` | `national_id` |
| Côte d'Ivoire | `CI` | `national_id` |
| Denmark | `DK` | `national_id` |
| Ecuador | `EC` | `national_id` |
| Ghana | `GH` | `ghana_card` |
| Hong Kong | `HK` | `national_id` |
| India | `IN` | `national_id`, `voter_id`, `driver_license`, `tax_id`, `passport` |
| Indonesia | `ID` | `national_id` |
| Italy | `IT` | `national_id` |
| Kenya | `KE` | `national_id`, `passport`, `alien_card`, `kra_pin` |
| Malaysia | `MY` | `national_id` |
| Mexico | `MX` | `national_id`, `tax_id` |
| Nigeria | `NG` | `bvn`, `nin`, `voter_id` |
| Peru | `PE` | `national_id`, `tax_id` |
| Qatar | `QA` | `national_id` |
| Singapore | `SG` | `national_id` |
| South Africa | `ZA` | `national_id` |
| Spain | `ES` | `national_id` |
| Sweden | `SE` | `national_id` |
| Thailand | `TH` | `national_id` |
| United Arab Emirates | `AE` | `national_id` |
| United States | `US` | `ssn` |
### Entity Principals
When a stake of 25% or more is held by another company rather than a person, submit that company as an **entity principal**: set `isEntity: true` and provide the `entity` object instead of the individual fields (`firstName`, `lastName`, `dateOfBirth`, and `identifier` do not apply).
| Requirement | API Field | Rules |
| :- | :- | :- |
| Entity flag | `principals[].isEntity` | Must be `true` for entity principals |
| Company name | `principals[].entity.companyName` | Registered legal name (required) |
| Entity type | `principals[].entity.entityType` | Same values as the business [`entityType`](#business-requirements) |
| Tax ID | `principals[].entity.taxIdentificationNumber` | Required |
| Registered address | `principals[].entity.address` | Required; physical address |
| Email | `principals[].entity.email` | Contact email address |
| Website URL | `principals[].entity.websiteURL` | Must be a valid website |
| Incorporation | `principals[].entity.incorporation` | `date` and `address` of incorporation |
| Ownership document | `principals[].entity.ownershipDocumentFileId` | File ID for a document evidencing the entity's stake in the business |
| Owners | `principals[].entity.individuals` | The entity's own principals — see below |
The ownership document is the only document required by default — a certificate of incorporation (`certificateOfIncorporationFileId`) and proof of address (`proofOfAddressFileId`) can also be attached; see the [API reference](/api-reference/workforce/kyb/submit-kyb). Unlike individual principals, no phone number is collected for an entity principal.
`entity.individuals` lists the entity's own principals, in the same shape as top-level principals: everyone holding **25% or more of that entity**, plus at least one **controlling person** — an executive such as the CEO, submitted with `isExecutive: true` and a `title`. If no individual crosses the 25% ownership threshold, the controlling person alone satisfies the requirement. An owner that is itself a company is another entity principal — nesting continues as deep as the ownership chain requires, until every branch ends at individuals. Identity verification (KYC) runs against those individuals, not the entity itself.
Entity principals follow the same [POST vs PATCH rules](#api-submission) as individuals: add or remove them by re-`POST`ing the full principals list, and update them via PATCH by referencing their `id` — including the `id` of each nested individual inside `entity.individuals`.
***
## Required Documents (Conditional)
These documents are only required when automated verification fails.
| Verification failure | Required upload | Accepted documents |
| :- | :- | :- |
| EIN / Tax ID unverified | `taxDocuments` | IRS EIN confirmation letter (SS-4 or 147C) — must show exact legal name + EIN |
| Business registration mismatch | `articlesOfIncorporation` | Articles of Incorporation or Certificate of Formation |
| Business address unverified | `proofOfAddress` | Utility bill, lease, or bank statement |
| Principal identity unverified | `principals[].identifier.identityFileId` and `principals[].identifier.identityBackPageFileId` | Government-issued photo ID (front and back) |
| Principal address unverified | `principals[].identifier.addressFileId` | Utility bill, lease, or bank statement |
| Compliance review | `bankStatements` | Recent bank statements (if requested by Compliance) |
***
## Webhook Events
### business.kyb.reviewed
Sent after KYB has been reviewed. May indicate approval or that additional information is needed.
```json theme={null}
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"eventType": "business.kyb.reviewed",
"version": "1.0",
"timestamp": 1765359752,
"data": {
"id": "7c74cc95-d7c6-4537-b388-6d57ed020b34",
"tenantKey": "cad95193904",
"status": "needs-additional-info",
"timestamp": 1765359752,
"businessId": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"businessName": "Acme Corporation",
"requirements": {
"currentlyDue": [
"principals.0.firstName",
"principals.0.identifier.identityFileId",
"entityName"
],
"errors": [
{
"field": "entityName",
"reason": "Only english letters are allowed"
},
{
"field": "principals.0.firstName",
"reason": "Name doesn't match ID document"
}
]
}
}
}
```
| Field | Description |
| :- | :- |
| `status` | Current KYB status |
| `requirements.currentlyDue` | Fields that need to be submitted or corrected |
| `requirements.errors` | Specific validation errors with reasons |
When you receive `needs-additional-info`:
1. Parse the `currentlyDue` array to identify required fields
2. Check `errors` array for specific issues
3. Collect the required information/documents from the business owner
4. Resubmit KYB with corrected data
By default Cadana also emails the business owner at this point. See [Business Owner Emails](#business-owner-emails) to suppress it and own the follow-up yourself.
### business.kyb.rejected
Sent when a KYB submission has been permanently rejected. This is a terminal state — the business cannot resubmit.
```json theme={null}
{
"id": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"eventType": "business.kyb.rejected",
"version": "1.0",
"timestamp": 1765359752,
"data": {
"id": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"businessName": "Acme Corporation",
"reason": "Business does not meet compliance requirements",
"status": "rejected",
"tenantKey": "cad95193904",
"timestamp": 1765359752
}
}
```
### business.kyb.completed
Sent when business verification is successful. After receiving this event, the business can process payments.
```json theme={null}
{
"id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
"eventType": "business.kyb.completed",
"version": "1.0",
"timestamp": 1765359752,
"data": {
"id": "7c74cc95-d7c6-4537-b388-6d57ed020b34",
"businessId": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"businessName": "Acme Corporation",
"status": "complete",
"timestamp": 1765359752,
"tenantKey": "cad95193904"
}
}
```
See [Events](/reference/events) for all event types and [Webhooks](/reference/webhooks) to configure your endpoint.
***
## Timeline
| Step | Duration |
| :- | :- |
| Submit KYB | Immediate |
| Review (once all requirements provided) | Up to 3 business days |
| Reminder email after `needs-additional-info` with no response | 3 days after the review |
***
## Sandbox Testing
Use test values to simulate different KYB outcomes without waiting for real verification.
| Test value | Field | Result |
| :- | :- | :- |
| `000-CAD-AUTO-APPROVE` | `taxIdentificationNumber` | KYB automatically approved |
| `000-CAD-AUTO-REJECT` | `taxIdentificationNumber` | KYB automatically rejected (terminal) |
| `000-CAD-AUTO-FLAG` | `taxIdentificationNumber` | Status set to `needs-additional-info`, requests all business documents |
| `000-11-2222` | `principals[].identifier.value` (SSN) | Requires identity documents and address files for the principal |
| `000-11-3333` | `principals[].identifier.value` (SSN) | Status set to `needs-additional-info`, principal receives KYC requirement email |
These test values only work in the sandbox environment and are silently ignored in production. You can't combine `000-CAD-AUTO-APPROVE` with a sentinel SSN in the same submission — the request returns `400`.
See [Sandbox & Testing](/reference/sandbox#kyb-testing) for all test values.
***
## Next Steps
User identity verification
Platform integration guide
Configure webhook endpoints
Test values and simulated scenarios
# KYC Verification
Source: https://docs.cadanapay.com/platform/kyc-verification
Submit and track Know Your Customer verification for workers
Know Your Customer (KYC) verification is required before a user can receive payments through Cadana. You submit identity documents and personal information via the API, and Cadana handles automated verification. This page covers the submission requirements, status tracking, and webhook events.
***
## KYC Flow
```mermaid theme={null}
flowchart LR
A[not started] -->|Submit KYC| B[processing]
B -->|Auto-verified| C[approved]
B -->|Needs manual review| E[pending]
B -->|Failed| D[rejected]
E -->|Review complete| C
E -->|Review complete| D
D -->|Resubmit| B
C -->|Renew expiring document| B
```
| Status | Meaning | User can transact? |
| :- | :- | :- |
| `not started` | KYC has not been submitted | No |
| `processing` | Documents submitted, awaiting a result from the verification provider | No |
| `pending` | The provider returned an inconclusive result and a manual review is underway | No |
| `approved` | Verification successful | Yes |
| `rejected` | Verification failed — resubmission needed | No |
Most verifications complete automatically within minutes. `pending` means automated checks could not verify the user, so a reviewer has to decide — allow 1-2 business days for it to settle into `approved` or `rejected`.
***
## Submit KYC
Submit identity information and documents for a user. Upload document images first using the [file upload flow](/reference/working-with-files), then pass the returned `fileId` values.
Returns `204` on success. The user's KYC status moves to `processing`.
### Personal Information
| Field | API Field | Required | Rules |
| :- | :- | :- | :- |
| First name | `firstName` | Yes | Must match government-issued ID exactly |
| Last name | `lastName` | Yes | Must match government-issued ID exactly |
| Date of birth | `dob` | Yes | Format: `YYYY-MM-DD` |
| Nationality | `nationality` | Yes | 2-letter ISO country code (e.g., `US`, `NG`, `KE`) |
### Identity Document
| Field | API Field | Required | Rules |
| :- | :- | :- | :- |
| Document type | `idDetails.type` | Yes | See supported types below |
| Document number | `idDetails.number` | Yes | Unique identifier on the document |
| Issuing authority | `idDetails.issuedBy` | Yes | Authority that issued the document |
| Issue date | `idDetails.issuedDate` | Yes | Format: `YYYY-MM-DD` |
| Expiration date | `idDetails.expirationDate` | Yes | Format: `YYYY-MM-DD`. Must not be expired |
| Issuing country | `idDetails.issuedCountryCode` | Yes | 2-letter ISO country code |
| Front of ID | `idDetails.frontFileId` | Yes | `fileId` from [file upload](/reference/working-with-files) |
| Back of ID | `idDetails.backFileId` | No | Required for driver's licenses and some national IDs |
| Selfie | `idDetails.selfieFileId` | Yes | Photo of user holding the document |
**Supported document types:**
| Document | API Value |
| :- | :- |
| Passport | `passport` |
| Driver's license | `driver_license` |
| National ID card | `national_id` |
| ID card | `id_card` |
| Voter ID card | `voter_id_card` |
| PAN card | `pan_card` |
Images must be clear and readable with all text visible. Supported formats: JPEG, PNG.
### Address (Optional)
If provided, all required address fields must be completed.
| Field | API Field | Required |
| :- | :- | :- |
| Street address | `address.line1` | Yes |
| Address line 2 | `address.line2` | No |
| City | `address.city` | Yes |
| State / Province | `address.state` | Yes |
| Postal code | `address.postalCode` | Yes |
| Country | `address.countryCode` | Yes (2-letter ISO code) |
An address proof document can be provided via `addressProofFileId` (utility bill, bank statement, or government document dated within the last 3 months).
***
## Check KYC Status
Retrieve a user's current KYC status.
**Response:**
```json theme={null}
{
"firstName": "John",
"lastName": "Doe",
"dob": "1990-01-15",
"identity": "approved",
"address": "not started",
"identityDocumentStatus": "about-to-expire",
"idDetails": {
"country": "US",
"type": "Drivers license"
},
"userAddress": {
"line1": "123 Main Street",
"line2": "Apt 4B",
"city": "San Francisco",
"state": "CA",
"postalCode": "94102",
"countryCode": "US"
},
"createdTimestamp": "2026-07-17 00:49:40 +0000 UTC",
"lastUpdatedTimestamp": "2026-07-17 00:50:04 +0000 UTC",
"lastIdentitySubmissionDate": "2026-07-17 00:49:40 +0000 UTC",
"lastIdentityStatusUpdate": "2026-07-17 00:50:04 +0000 UTC",
"lastAddressSubmissionDate": "",
"lastAddressStatusUpdate": ""
}
```
Identity and address verification are tracked separately. A user needs at minimum `identity: "approved"` to receive payments.
Note that `address` is the verification *status* of the address, while `userAddress` holds the address itself.
### Identity document status
`identityDocumentStatus` reports where the user's identity document sits in its expiry window. It is separate from `identity`, which is the verification result, and it is what tells you whether [Renew an Expiring Document](#renew-an-expiring-document) is available for this user.
| Value | Meaning |
| :- | :- |
| *(field absent)* | The identity has never been approved — a first verification that is still processing, or was rejected, has no document status yet |
| `valid` | Identity is approved and the document is not near expiry. A successful renewal returns the user here |
| `about-to-expire` | The document expires in 14 to 60 days |
| `in-grace-period` | The document has expired but is still within the 60-day grace period, so `identity` stays `approved` and the user can keep transacting. A grace-period extension granted by Cadana also lands here |
| `out-of-grace` | The grace period has run out. `identity` is `rejected` by this point — the stage the [`user.kyc.expiry`](#user-kyc-expiry) webhook reports as `expired` |
Renewal is accepted from `about-to-expire` onwards, so a platform that polls rather than waiting on the webhook can read this field to decide when to collect a replacement document. See [When renewal is allowed](#when-renewal-is-allowed).
An approved identity always carries a status, so an absent field means the user has not completed
verification yet — not that something went wrong. Check `identity` to see where they are.
### Timestamps
The six timestamp fields are formatted as `YYYY-MM-DD HH:MM:SS +0000 UTC`, always in UTC. This is not RFC 3339, so parse it with an explicit layout rather than a standard ISO 8601 parser.
| Field | Meaning |
| :- | :- |
| `createdTimestamp` | When the KYC record was first created |
| `lastUpdatedTimestamp` | When the KYC record last changed |
| `lastIdentitySubmissionDate` | When identity documents were last submitted |
| `lastIdentityStatusUpdate` | When the identity status last changed |
| `lastAddressSubmissionDate` | When address information was last submitted |
| `lastAddressStatusUpdate` | When the address status last changed |
Any of these is an empty string when the corresponding event has not happened — for example, `lastAddressSubmissionDate` stays `""` until the user submits an address.
***
## Resubmit KYC
If a user's identity or address verification is rejected, use the PATCH endpoint to resubmit corrected information. You can resubmit identity, address, or both in a single request.
**Resubmission rules:**
| Section | Can resubmit when |
| :- | :- |
| Identity | Status is `rejected` |
| Address | Status is `not started` or `rejected` |
At least one of `identity` or `address` must be provided. When a section is included, all of its fields are required.
PATCH is the **rejection** route. If the user's identity document is simply expiring, you do not
have to wait for it to be rejected — use [Renew an Expiring Document](#renew-an-expiring-document)
to replace it while the verification is still approved.
### Resubmit identity only
### Resubmit address only
Returns `204` on success. Only the resubmitted section(s) move to `processing` — approved sections remain unchanged.
***
## Renew an Expiring Document
Identity documents expire. When one does, the user's verification eventually reverts to `rejected` and they stop being able to transact. The renewal endpoint lets you get ahead of that: submit a replacement identity document while the current verification is still good, without waiting for a rejection.
Renewal covers **identity documents only**. `type` is carried on the request so an address variant
can be added later, but `identity` is the only value accepted today.
Returns `204` on success. Identity moves to `processing`, and the result arrives asynchronously on the [`user.kyc.updated`](#user-kyc-updated) webhook — the endpoint does not return a verification outcome.
### When renewal is allowed
Renewal is only accepted once the identity document has entered its expiry window. Cadana tracks that window as a document stage on the KYC record, and the same stage drives the [`user.kyc.expiry`](#user-kyc-expiry) webhook — though the webhook renames the last one:
| Document stage | `user.kyc.expiry` reports it as | Renewal allowed? |
| :- | :- | :- |
| `valid` | — (no event) | No — nothing to renew yet |
| `about-to-expire` | `about-to-expire` | Yes |
| `in-grace-period` | `in-grace-period` | Yes |
| `out-of-grace` | `expired` | Yes |
The endpoint validates against the internal stage, so **a platform that sees `"stage": "expired"` on the webhook is still eligible to call renewal** — `expired` and `out-of-grace` are the same stage under two names.
The current stage is also readable at any time as `identityDocumentStatus` on [Check KYC Status](#identity-document-status), so you do not have to have received the webhook to know whether renewal is available.
Calling renewal while the document is still `valid` returns a `400`: `kyc can only be renewed when the identity document is expiring or expired`. Wait for the first `user.kyc.expiry` event, or drive your own schedule off the `expiryDate` it carries.
Once a document is `out-of-grace`, the user's identity status is already `rejected`, so
[`PATCH /v1/users/{userId}/kyc`](/api-reference/workforce/users/resubmit-kyc) can replace it there
too. Before that point the identity is still `approved`, so PATCH is not available — renewal is the
only route.
### Request body
| Field | Required | Rules |
| :- | :- | :- |
| `type` | Yes | Must be `identity`. Any other value is a `400`: `type must be identity` |
| `identity` | Yes | Same shape as the identity section of [Resubmit KYC](#resubmit-kyc). Omitting it is a `400`: `identity is required` |
`identity` takes the full personal-information and document block — `firstName`, `lastName`, `dob`, `nationality`, and `idDetails`. Field rules are identical to [Submit KYC](#submit-kyc), with two extra constraints on the replacement document:
| Rule | `400` message |
| :- | :- |
| `idDetails.expirationDate` must be later than today | `expirationDate must be in the future` |
| `idDetails.expirationDate` must differ from the current document's | `expirationDate is the same as the current document` |
The second check exists to catch the common mistake of re-uploading the same expiring document and assuming the clock has been reset.
### Document files
The `fileId` values come from the same [file upload flow](/reference/working-with-files) the other KYC endpoints use: reserve a file ID with `POST /v1/files/upload-url`, `PUT` the bytes to the returned pre-signed URL, then pass the `fileId`. Use the `kyc-id-front`, `kyc-id-back`, and `kyc-selfie` purposes.
Cadana verifies that each `fileId` resolves to a file you actually uploaded, so upload fresh files for the renewal. Reusing the `fileId` values from the original submission just re-submits the expiring document.
***
## Webhook Events
### user.kyc.updated
Fired on every KYC status change — processing, pending, approved, or rejected. The `type` field indicates which verification component changed.
```json theme={null}
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"eventType": "user.kyc.updated",
"version": "1.0",
"timestamp": 1765359752,
"data": {
"userId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"status": "approved",
"type": "identity",
"tenantKey": "cad95193904"
}
}
```
| Field | Description |
| :- | :- |
| `userId` | The user whose KYC status changed |
| `status` | New status: `processing`, `pending`, `approved`, or `rejected` |
| `type` | Verification component: `identity` or `address` |
For rejected verifications, have the user resubmit the affected section with the [PATCH endpoint](/api-reference/workforce/users/resubmit-kyc).
### user.kyc.expiry
Fired when a user's identity document is approaching expiry or has expired.
```json theme={null}
{
"id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
"eventType": "user.kyc.expiry",
"version": "1.0",
"timestamp": 1765359752,
"data": {
"userId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"stage": "about-to-expire",
"documentType": "Passport",
"expiryDate": "2026-03-15",
"tenantKey": "cad95193904",
"timestamp": 1765359752
}
}
```
| Stage | Meaning |
| :- | :- |
| `about-to-expire` | Document expires within 2 months |
| `in-grace-period` | Expired but within the 2-month grace period — the user's verification stays approved and their account is unrestricted |
| `expired` | Past grace period — verification reverts to rejected and the user must re-verify |
`documentType` is the document's display name as stored on the KYC record: `Passport`, `Drivers license`, `National ID Card`, `Voter's Card`, or `PAN Card`.
The event fires **once per stage**, when the document first enters it — not repeatedly while it
stays there. Expect three events per document: one when it enters `about-to-expire`, one on the
expiry date, and one when the grace period runs out. To remind users as the date approaches, drive
your own schedule off the `expiryDate` in the payload rather than waiting for another event.
To act on the event, submit a replacement document with [Renew an Expiring Document](#renew-an-expiring-document) — that works from the first `about-to-expire` event onwards. If you let the document reach `expired`, the user's verification has already reverted to `rejected`, and [`PATCH /v1/users/{userId}/kyc`](/api-reference/workforce/users/resubmit-kyc) becomes available as well.
See [Events](/reference/events) for all event types and payload details.
***
## Handling Rejections
When KYC is rejected, the user needs to resubmit. Common rejection reasons:
| Reason | User action |
| :- | :- |
| Document expired | Submit valid, non-expired document |
| Image unreadable | Retake photo with good lighting |
| Document damaged | Submit undamaged document |
| Info mismatch | Verify name and DOB match the document exactly |
| Unsupported document | Submit an accepted document type |
To resubmit, call [`PATCH /v1/users/{userId}/kyc`](/api-reference/workforce/users/resubmit-kyc) with corrected information for the rejected section(s). The status resets to `processing`.
***
## Sandbox Testing
Use sentinel values to simulate KYC approval or rejection in the sandbox environment without waiting for real verification. These sentinels work with both initial submission (`POST`) and resubmission (`PATCH`).
In sandbox, a submission without a sentinel never gets a result on its own — the user stops at `pending`. `PATCH` cannot move them, because resubmission only works after a rejection. So either use a sentinel, [approve them yourself](#testing-document-transfer), or [delete the sandbox user](/reference/sandbox#resetting-sandbox-users) and start over.
### Identity Verification
Set `idDetails.number` to a sentinel value:
| Test Value | Field | Result |
| :- | :- | :- |
| `auto-approve` | `idDetails.number` | Identity automatically approved |
| `auto-reject` | `idDetails.number` | Identity automatically rejected |
### Address Verification
Set `address.line2` to a sentinel value:
| Test Value | Field | Result |
| :- | :- | :- |
| `auto-approve` | `address.line2` | Address automatically approved |
| `auto-reject` | `address.line2` | Address automatically rejected |
You can combine both sentinels in a single request to auto-resolve identity and address independently. For example, `idDetails.number: "auto-approve"` with `address.line2: "auto-reject"` will approve identity and reject address.
When a sentinel is used, the document file IDs (`frontFileId`, `selfieFileId`, `addressProofFileId`) can be omitted — no file upload is needed. If you do pass them, they must reference files you actually [uploaded](/reference/working-with-files); made-up IDs are rejected with a `404`. Without a sentinel, file IDs are required, as in production.
### Testing the Resubmission Flow
To test the full rejection-to-resubmission cycle in sandbox:
1. Submit KYC with `idDetails.number: "auto-reject"` to get a rejected identity
2. Resubmit via PATCH with `idDetails.number: "auto-approve"` to approve it
These test values only work in the sandbox environment and are silently ignored in production. Sentinel values are case-insensitive.
### Testing Document Transfer
A sentinel tells Cadana the answer up front, so your uploaded files are never opened. That is quick, but it means a sentinel cannot prove your document upload works.
To test the upload itself, submit the way your production code would — real ID number, real `fileId` values, no sentinel. Cadana stores the documents and sends them on for verification. Sandbox has no real reviewer, so the user stops at `pending` and waits. Approve them yourself to finish:
Set `action` to `approve` or `reject`, and `type` to `identity` or `address`. Each call decides one check, so approving both takes two calls. It works from any status — including `pending`, which resubmission cannot rescue — and it fires `user.kyc.updated` just like a real result.
For the full step-by-step version of this flow, plus what to do when a test user gets stuck, see [Sandbox & Testing](/reference/sandbox#reviewing-kyc-directly).
***
## Next Steps
Upload identity documents before submitting KYC
Business verification requirements
Create Person and User records
Test values and simulated scenarios
# Multi-Tenant Setup
Source: https://docs.cadanapay.com/platform/multi-tenant-setup
Set up a multi-tenant integration with Cadana's Platform API
This guide walks through the full lifecycle of a business on your platform:
```mermaid theme={null}
flowchart LR
A["1. Create business"] --> B["2. Complete KYB"]
B --> C["3. Fund the account"]
C --> D["4. Onboard workers & run payroll"]
```
Each business you create gets its own `tenantKey`. Every subsequent call for that business — KYB, funding, workers, payroll — carries that key in the `X-MultiTenantKey` header.
## Prerequisites
Contact your account manager to enable Platform API access.
Generate a Platform token from your [Platform Dashboard](https://platform.cadanapay.com) under Settings > Developers.
Platform API access requires special enablement. Standard org tokens cannot create businesses or use the `X-MultiTenantKey` header.
***
## Create a Business
Create a business on your platform. The response includes the `tenantKey` you'll need for all subsequent calls to this business.
**Response:**
```json theme={null}
{
"businessId": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"tenantKey": "cad95193904"
}
```
Store the `tenantKey` in your database mapped to the business. You'll need it for every API call that targets this business.
Creating a business sends a welcome email to `adminEmail`. Pass `suppressWelcomeEmail: true` if you onboard the business yourself and don't want Cadana to email your customer.
***
### Add Legal Entities (Optional)
For global payroll, you can create legal entities representing subsidiaries or branches in different jurisdictions:
See [Entity Management](/workforce/entity-management) for full details.
***
## Use the Tenant Key
For all business-specific operations, include the `X-MultiTenantKey` header with the tenant key.
If you omit the header, the request does **not** fail — it silently runs against your platform's own main business. Always set `X-MultiTenantKey` when you intend to operate on a sub-business.
Unsure which business a request will hit? Call [`GET /v1/me`](/api-reference/workforce/businesses/get-current-identity) with the same headers — the response includes the business the request resolved to.
```bash theme={null}
curl -X GET 'https://api.cadanapay.com/v1/users' \
-H 'Authorization: Bearer PLATFORM_TOKEN' \
-H 'X-MultiTenantKey: cad95193904'
```
All business-scoped endpoints (persons, users, payrolls, contracts, invoices, KYB) accept the `X-MultiTenantKey` header. Platform-level endpoints (`/v1/platform/*`) like creating and listing businesses do not take it. See the [API Reference](/api-reference/) for all available endpoints.
***
## List Your Businesses
Retrieve all businesses on your platform. No tenant key needed.
**Response:**
```json theme={null}
{
"data": [
{
"id": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"name": "Acme Corporation",
"shortName": "Acme",
"status": "ACTIVE",
"tenantKey": "cad95193904",
"country": "US",
"requiresKYB": true,
"admin": { "name": "Jane Founder", "email": "jane@example.com" },
"address": { "line1": "123 Main Street", "line2": "", "city": "San Francisco", "postalCode": "94105", "state": "CA", "countryCode": "US" },
"createdTimestamp": 1765359752,
"idempotencyKey": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
},
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Beta Inc",
"shortName": "Beta",
"status": "pending_verification",
"tenantKey": "cad12345678",
"country": "NG",
"requiresKYB": true,
"admin": { "name": "Bola Adeyemi", "email": "bola@example.com" },
"address": { "line1": "12 Marina Road", "line2": "", "city": "Lagos", "postalCode": "101241", "state": "LA", "countryCode": "NG" },
"createdTimestamp": 1765359801,
"idempotencyKey": "b6ae5da9-6342-4a58-bd50-8564d68d3f7e"
}
],
"node": {
"previous": null,
"next": "eyJwbGF0Zm9ybVByZWZpeCI6..."
}
}
```
A business starts in `pending_verification` and moves to `ACTIVE` once KYB is approved. Use the `node.next` cursor with the `next` query parameter to page through results.
***
## Complete KYB
A new business starts in `pending_verification` and can't process payments until Know Your Business (KYB) verification is approved. There are two ways to submit it:
* **Hosted form** — [generate a KYB link](/platform/kyb-requirements#hosted-kyb-form) and send it to the business owner. They complete a branded multi-step form, and progress is saved between visits.
* **API submission** — collect the data in your own UI and [submit it directly](/platform/kyb-requirements#api-submission). You can submit everything at once or incrementally.
See [KYB Requirements](/platform/kyb-requirements) for required fields, document uploads, and the nuances of updating a submission (in short: PATCH updates existing fields and principals, re-POST to add principals).
### Track Verification
Poll [`GET /v1/businesses/{businessId}/kyb`](/platform/kyb-requirements#check-kyb-status) — `requirements.currentlyDue` lists anything still outstanding — or subscribe to the [`business.kyb.reviewed` and `business.kyb.completed`](/platform/kyb-requirements#webhook-events) webhooks. When KYB reaches `complete`, the business status flips to `ACTIVE`.
***
## Fund the Business
Once the business is `ACTIVE`, retrieve the bank details to send funds to, and check balances:
See [Fund Your Account](/fund-your-account) for all funding methods (bank transfer, ACH direct debit, crypto) and the sandbox deposit endpoint for instant test funds.
***
## Webhooks
Platform integrations receive webhooks for all businesses. The payload includes a `tenantKey` field to identify which business the event relates to.
```json theme={null}
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"eventType": "business.kyb.completed",
"version": "1.0",
"timestamp": 1765359752,
"data": {
"businessId": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"businessName": "Acme Corporation",
"status": "complete",
"tenantKey": "cad95193904"
}
}
```
See [Events](/reference/events) for all event types and [Webhooks](/reference/webhooks) to configure your endpoint.
***
## Sandbox Testing
In sandbox mode, businesses are created instantly and KYB can be auto-approved using test values (e.g., tax ID `000-CAD-AUTO-APPROVE`). All API calls work the same as production.
Use `https://dev-api.cadanapay.com` as the base URL. See [Sandbox & Testing](/reference/sandbox#kyb-testing) for all test values and simulated scenarios.
***
## Troubleshooting
| Error | Cause | Solution |
| :- | :- | :- |
| Request affects the wrong business (no error) | Missing `X-MultiTenantKey` header — the request ran against your platform's main business | Add the header with the correct tenant key |
| `401 Unauthorized` | Invalid or expired token | Verify your Platform token is valid |
| `403 Forbidden` | Tenant key doesn't belong to your platform | Verify the tenant key matches a business you created |
| `400 unauthorized access` | The `businessId` in the path doesn't belong to the business in `X-MultiTenantKey` | Use the `businessId` and `tenantKey` of the same business |
| `400 Platform access required` | Account not enabled for platform access | Contact your account manager |
***
## Next Steps
Business verification requirements
Fund business accounts before running payroll
Create Person and User records for each business
Run payroll for a business
Reconcile transactions across businesses
Full API documentation
# Overview
Source: https://docs.cadanapay.com/platform/overview
Build multi-tenant integrations with Cadana's Platform API
The Platform API lets you manage multiple businesses from a single integration. You get one API token that can create businesses, onboard workers, run payroll, process payments, and [move funds between your own business accounts](/api-reference/workforce/platform/create-transfer) across all of them — each isolated by a unique tenant key.
This is purpose-built for HR platforms, payroll providers, talent marketplaces, staffing platforms, and international HR providers.
***
## Architecture
```mermaid theme={null}
flowchart TD
APP["Your Platform"] -- "Platform Token + X-MultiTenantKey" --> CADANA
subgraph CADANA["Cadana"]
BIZ_A["Business A"]
BIZ_B["Business B"]
BIZ_C["Business C"]
end
```
Each business has its own workforce, payroll, and payments — fully isolated by tenant key. Businesses can also manage multiple [legal entities](/workforce/entity-management) for global payroll operations across different jurisdictions.
Every API call that targets a specific business includes the `X-MultiTenantKey` header to identify which business you're operating on.
***
## Key Concepts
| Concept | Description |
| :- | :- |
| **Platform Token** | An API token with access across all businesses on your platform. Generated from the Dashboard after platform access is enabled. |
| **Tenant Key** | A unique identifier for each business (e.g., `cad95193904`). Returned when you create a business. |
| **X-MultiTenantKey** | The HTTP header used to specify which business a request applies to. Required for all business-specific operations. |
***
## Platform vs Org Tokens
| Aspect | Org Token | Platform Token |
| :- | :- | :- |
| **Scope** | Single organization | Multiple businesses |
| **Business creation** | Cannot create businesses | Can create businesses via API |
| **X-MultiTenantKey** | Not required | Required for business-specific calls |
| **Use case** | Single-business integration | Multi-tenant platform |
***
## Business Lifecycle
```mermaid theme={null}
flowchart LR
A["Create Business"] --> B["Submit KYB"]
B --> C["Review"]
C -->|Approved| D["ACTIVE"]
C -->|Needs info| E["Fix & Resubmit"]
E --> C
```
| Status | Description | Can transact? |
| :- | :- | :- |
| `pending_verification` | Created, KYB not yet approved | No |
| `ACTIVE` | Fully verified | Yes |
| `CLOSED` | Business closed | No |
A business must complete [KYB verification](/platform/kyb-requirements) before it can process payments — the KYB submission itself moves through its own set of [statuses](/platform/kyb-requirements#kyb-statuses) while the business stays `pending_verification`.
***
## Getting Started
Step-by-step implementation guide
Business verification requirements
User identity verification
Reconcile transactions across businesses
# Transaction Reconciliation
Source: https://docs.cadanapay.com/platform/transaction-reconciliation
Reconcile payroll, payouts, and wallet transactions across all businesses on your platform
The **Platform Disbursements** endpoint gives platform partners a unified view of all transactions across every business under your platform — payroll payments, payouts, and other financial operations — in a single query. Use it to build reconciliation reports, track revenue share, drill into payroll fee breakdowns via invoices, and reconcile your books.
```mermaid theme={null}
flowchart LR
A[Fetch disbursements] --> B[Group by tenantKey]
B --> C[Per-business ledger]
C --> D[Payroll: group by referenceId]
C --> E[Wallet: group by userId]
D --> F[Drill into invoice for fee breakdown]
```
***
## Prerequisites
Get your API key from the [Cadana Dashboard](https://app.cadanapay.com). See [Authentication](/authentication) for details.
You must be set up as a platform partner managing multiple businesses. See [Platform Overview](/platform/overview).
At least one business under your platform must have completed payroll runs or wallet transactions.
***
## Platform Disbursements
Fetch all transactions across every business on your platform. Results are filtered by date range and paginated automatically.
### Query Parameters
| Parameter | Type | Required | Description |
| :- | :- | :- | :- |
| `businessId` | string (uuid) | No | Scope results to a single business account (see [Scope to a Single Business](#scope-to-a-single-business)). |
| `currency` | string | No | Filter by the business-facing currency — matches either the destination `amount` or the `sourceAmount` (e.g. `USD`). |
| `startDate` | string | No | Start of date range (ISO 8601, e.g. `2026-01-01`). Defaults to 7 days ago. |
| `endDate` | string | No | End of date range (ISO 8601, e.g. `2026-02-01`). Defaults to now. |
| `type` | string | No | Comma-separated transaction types to include (see [Transaction Types](#transaction-types)). |
| `status` | string | No | Filter by status: `SUCCESS`, `FAILED`, `INITIATED`, `PROCESSING`, `ROUTED`. |
| `includeRevenueShare` | boolean | No | Set to `true` to include the revenue-share fields in each item (see [Revenue Share](#revenue-share)). Omitted by default. |
### Response Structure
```json theme={null}
{
"data": [ ... ],
"cursor": {
"previous": null,
"next": "eyJsYXN0S2V5Ijp7InBrIjoiLi4uIn19"
}
}
```
### Transaction Object Fields
| Field | Type | Description |
| :- | :- | :- |
| `id` | string | Unique transaction identifier |
| `tenantKey` | string | Identifies which business this transaction belongs to. Use this to group transactions per business. |
| `status` | string | Transaction status: `SUCCESS`, `FAILED`, `INITIATED`, `PROCESSING`, `ROUTED` |
| `referenceId` | string | External reference. Payroll transactions from the same run share the same `referenceId`. |
| `description` | string | Human-readable description (e.g. "January 2026 Payment Dundies") |
| `type` | string | Transaction type (see [Transaction Types](#transaction-types)) |
| `fxRate` | number | FX rate applied. Present only for cross-currency transactions. |
| `paymentMethod` | string | Delivery method: `bank`, `momo`, `wallet`, `ach`, `card`, `proxy` |
| `paymentDetails` | object | Full payment method details (bank account, mobile money, wallet, ACH, card info) |
| `personId` | string | Employee or contractor ID. **Present only on payroll transactions.** |
| `userId` | string | Wallet/user account ID. Present on wallet-based transactions. For payroll, only present when payment is delivered to a wallet. |
| `userName` | string | Recipient name |
| `amount` | object | Amount the recipient receives, in the **destination currency** |
| `sourceAmount` | object | Cost in the **source currency**, before fees |
| `feeAmount` | object | Fees charged, in the source currency |
| `totalAmount` | object | Total debited from sender: `sourceAmount` + `feeAmount` |
| `fxRevenueShare` | object | Revenue earned from FX spread (see [Revenue Share](#revenue-share)) |
| `feeRevenueShare` | object | Revenue earned from fees (see [Revenue Share](#revenue-share)) |
| `totalRevenueShare` | object | Combined total revenue share |
| `createdTimestamp` | string | When the transaction was created |
| `lastUpdatedTimestamp` | string | When the transaction was last updated |
All amount fields follow this structure:
```json theme={null}
{
"value": "100.00",
"currency": "USD"
}
```
***
## Transaction Types
| Type | Description |
| :- | :- |
| `PAYROLL` | Payment from a business to an employee or contractor as part of a payroll run |
| `PAYOUT` | Outbound transfer from a user's wallet to an external bank, mobile money, card, or other destination |
| `TRANSFER` | Internal movement of funds between your platform's own business accounts — the main parent and its children (see [Internal Transfers](#internal-transfers)) |
| `PAYROLL_FEE` | Processing fee for a payroll run (see [Payroll Fee & Invoice Details](#payroll-fee--invoice-details)) |
| `CARD_MAINTENANCE_FEE` | Recurring maintenance fee for a virtual card |
| `CARD_CREATION_FEE` | One-time fee for creating a virtual card |
| `STOCK_BUY` | Stock purchase transaction |
| `STOCK_SELL` | Stock sale transaction |
| `INTEREST` | Interest earned or charged |
***
## Internal Transfers
Move money between your own business accounts — your main business and any of its child businesses — with the [Create Transfer](/api-reference/workforce/platform/create-transfer) endpoint. Transfers are internal, same-currency only, and settle immediately with no fee. Name the `sourceTenantKey` (optional — omit to send from your main business) and `destinationTenantKey` in the request body; allowed movements are main → child, child → child, and child → main.
Create Transfer returns only the transfer `id` — there's no `GET` for it. Find a transfer here by filtering to `type=TRANSFER` (or scope to one account with `businessId`), then match the returned `id` or your `reference`. It appears under the **source** business as an outgoing transaction.
***
## Identifying Businesses with tenantKey
Every transaction includes a `tenantKey` that identifies which business it belongs to. Group by `tenantKey` to produce per-business reconciliation reports.
```json theme={null}
[
{ "id": "...", "tenantKey": "tbl28153208", "type": "PAYROLL" },
{ "id": "...", "tenantKey": "tbl89195039", "type": "PAYROLL" },
{ "id": "...", "tenantKey": "cad35916961", "type": "PAYOUT" }
]
```
***
## Scope to a Single Business
Pass `businessId=` to narrow the list to one business's account. This returns every disbursement where that business's account is the **sender or recipient** — both money it **sent** (payroll, vendor payments, payouts) and money it **received** (deposits, refunds/reversals). It is bidirectional.
This is distinct from `tenantKey`: `tenantKey` returns everything under a tenant, while `businessId` returns only the rows touching that one business's account — a narrower set.
The response shape, fields, and ordering (newest-first by `createdTimestamp`) are identical to the unfiltered call. A few things to keep in mind when reading the items:
* **Direction comes from `type`**, not a dedicated field. For example, `DEPOSIT` is inbound; `PAYROLL_TOTAL` and `VENDOR` are outbound. Don't assume a fixed direction for every type.
* **`userId` / `userName` are the counterparty** (the other side of the transaction), not the scoped business itself — so an item is not necessarily "a payment the business sent to `userId`".
* By default the per-employee `PAYROLL` legs and `PAYROLL_FEE` rows are collapsed into the single `PAYROLL_TOTAL` line; pass an explicit `type` to get the raw rows. Business-relevant types include `PAYROLL_TOTAL`, `PAYROLL`, `PAYROLL_FEE`, `PAYROLL_REVERSAL`, `DEPOSIT`, `DEPOSIT_REVERSAL`, `VENDOR`, `REVENUE_SHARE_PAYOUT`, `REIMBURSEMENT`, `SUBSCRIPTION_PAYMENT`, and `STATUTORY_DEPOSIT`.
### Paging through a business's disbursements
Pagination is the same as the unfiltered endpoint: read `cursor.next` from each response and send it back to fetch the next page, repeating until it comes back `null` (default `limit` 50, max 100). The cursor is opaque — don't parse it.
```
GET /v1/platform/disbursements?businessId=…¤cy=USD&limit=50
→ cursor.next = "eyJsYXN0S2V5Ijp…"
GET /v1/platform/disbursements?businessId=…¤cy=USD&limit=50&next=eyJsYXN0S2V5Ijp…
→ cursor.next = null (done)
```
***
## Payroll Transactions
Payroll transactions (`type: "PAYROLL"`) represent individual payments to employees or contractors. Key identifiers:
* **`personId`** — the employee or contractor being paid. Always present on payroll transactions and is the primary identifier for reconciliation.
* **`userId`** — only present when payment is delivered to a **wallet**. Not present for external payments (bank, mobile money, etc.).
* **`referenceId`** — shared across all payments in the same payroll run, so you can group them.
* **`description`** — contains the name of the payroll run (e.g. "January 2026 Payment Dundies").
**Example — cross-currency payroll payment to a bank account:**
```json theme={null}
{
"id": "a2f47c91-3b8e-4d12-9c6a-7e5d1f8b2a40",
"tenantKey": "tbl89195039",
"status": "SUCCESS",
"referenceId": "9c4829a8-0177-4dad-a0d8-e4f8fc42276a",
"description": "January 2026 Payment Dundies",
"type": "PAYROLL",
"fxRate": 5.812,
"paymentMethod": "bank",
"personId": "0a938505-ae3f-44a2-9793-196a4a11e6f2",
"userName": "Maria Santos",
"amount": { "value": "5812.00", "currency": "BRL" },
"sourceAmount": { "value": "1000.00", "currency": "USD" },
"totalAmount": { "value": "1000.00", "currency": "USD" },
"fxRevenueShare": { "amount": { "value": "10.00", "currency": "USD" }, "rate": 1 },
"totalRevenueShare": { "value": "10.00", "currency": "USD" }
}
```
`userId` is absent here because the payment was delivered to an external bank account, not a wallet.
**Example — same-currency payroll payment via wallet:**
```json theme={null}
{
"id": "c6250a16-eb63-41de-a952-8122d305ebbf",
"tenantKey": "tbl12101631",
"status": "SUCCESS",
"referenceId": "7b82d74e-4ad5-4fbf-af9c-a0f11b224c7a",
"description": "January 2026 Payment PS",
"type": "PAYROLL",
"paymentMethod": "wallet",
"userId": "060e3aeb-4a9d-4a90-aff5-17f8d69635ce",
"personId": "8ab2ba37-3c37-485d-9af9-122d11c96bf9",
"userName": "Reed Maygone",
"amount": { "value": "1000.00", "currency": "USD" },
"sourceAmount": { "value": "1000.00", "currency": "USD" },
"totalAmount": { "value": "1000.00", "currency": "USD" }
}
```
Here `userId` is present because the payment went to the employee's wallet. Same-currency payroll has no `fxRate` or FX revenue share, and may not have fee fields since fees are captured in the payroll fee transaction instead.
***
## Wallet Transactions
Transactions of type `PAYOUT`, `CARD_MAINTENANCE_FEE`, `CARD_CREATION_FEE`, `STOCK_BUY`, `STOCK_SELL`, and `INTEREST` are wallet-level operations. They are associated with a user via the `userId` field.
These transactions may include revenue share fields depending on your platform agreement.
**Example — cross-currency payout to a bank account in Brazil:**
```json theme={null}
{
"id": "c5bcdbba-52a2-44a4-8dda-2b03d47a1af1",
"tenantKey": "tbl28153208",
"status": "SUCCESS",
"type": "PAYOUT",
"fxRate": 5.7896,
"paymentMethod": "bank",
"userId": "8cb1d569-5dfd-4b33-b25e-4fab0ee14251",
"userName": "Paula Torres",
"amount": { "value": "578.96", "currency": "BRL" },
"sourceAmount": { "value": "100.00", "currency": "USD" },
"feeAmount": { "value": "13.50", "currency": "USD" },
"totalAmount": { "value": "113.50", "currency": "USD" },
"fxRevenueShare": { "amount": { "value": "1.00", "currency": "USD" }, "rate": 1 },
"feeRevenueShare": { "amount": { "value": "10.00", "currency": "USD" } },
"totalRevenueShare": { "value": "11.00", "currency": "USD" }
}
```
***
## Understanding Amounts
Each transaction has four amount fields that tell you the full financial picture:
| Field | Description |
| :- | :- |
| `amount` | What the **recipient receives**, in the destination currency |
| `sourceAmount` | Cost in the **sender's currency**, before fees |
| `feeAmount` | Fees charged, in the sender's currency |
| `totalAmount` | Total debited from sender: `sourceAmount` + `feeAmount` |
### Cross-Currency Example
When a user sends USD and the recipient receives BRL:
```
sourceAmount: 100.00 USD (cost before fees)
feeAmount: 13.50 USD (fees)
totalAmount: 113.50 USD (total debited = 100.00 + 13.50)
fxRate: 5.7896 (USD → BRL conversion rate)
amount: 578.96 BRL (what the recipient gets: 100.00 × 5.7896)
```
### Same-Currency Example
When both sides are in the same currency, no FX conversion applies:
```
sourceAmount: 1000.00 USD
feeAmount: 0.00 USD
totalAmount: 1000.00 USD
amount: 1000.00 USD (same as source, no conversion)
```
***
## Revenue Share
If your platform has a revenue share agreement with Cadana, eligible transactions include revenue share fields showing your earnings. These fields are **only present when applicable** — they are omitted when no revenue share applies.
| Field | Description |
| :- | :- |
| `fxRevenueShare` | Revenue earned from the FX spread. Contains `amount` (value + currency) and `rate` (share percentage). |
| `feeRevenueShare` | Revenue earned from transaction fees. Contains `amount` (value + currency). |
| `totalRevenueShare` | Combined total of FX and fee revenue share. Contains `value` and `currency`. |
**Example — transaction with both FX and fee revenue share:**
```json theme={null}
{
"fxRevenueShare": {
"amount": { "value": "0.41", "currency": "USD" },
"rate": 1
},
"feeRevenueShare": {
"amount": { "value": "10.07", "currency": "USD" }
},
"totalRevenueShare": { "value": "10.48", "currency": "USD" }
}
```
In this case, the platform earned \$0.41 from the FX spread and \$10.07 from fees, totaling \$10.48 in revenue share.
Sum `totalRevenueShare` across all transactions to determine your platform's total earnings for a period. Break it down further with `fxRevenueShare` and `feeRevenueShare` to understand the source of revenue.
***
## Payroll Fee & Invoice Details
When a business runs payroll, a separate `PAYROLL_FEE` transaction appears representing the processing fee for the entire run. To see the **detailed breakdown** of how the fee was calculated (per-employee line items), you need the invoice ID.
### Getting the Invoice ID
After a payroll completes, the [payroll response](/api-reference/workforce/payrolls/get) includes an `invoiceId` field. Fetch the payroll by its ID (you can find this via the `referenceId` on any `PAYROLL` transaction from the disbursements response):
The response includes the `invoiceId` once the payroll is in `completed` status. Then use that ID to fetch the full invoice:
The invoice provides a full breakdown of charges associated with a payroll run, including per-employee costs, FX markups, and fees.
To download the invoice as a PDF:
**Response:**
```json theme={null}
{
"fileUrl": "https://...",
"expiresAt": 1710590400
}
```
The `fileUrl` is short-lived. Fetch a new URL each time you need to download the PDF.
***
## Pagination
Results are paginated. The `cursor` object tells you whether more pages are available:
```json theme={null}
{
"cursor": {
"previous": null,
"next": "eyJsYXN0S2V5Ijp7InBrIjoiLi4uIn19"
}
}
```
* **`cursor.next`** — pass as the `cursor` query parameter to fetch the next page
* **`cursor.previous`** — pass to go back to the previous page
* **`null`** — no more pages in that direction
**Fetching the next page:**
***
## Reconciliation Walkthrough
### Step 1: Fetch all successful transactions for the period
Paginate through all results using the `cursor.next` value until it returns `null`.
### Step 2: Group by business
Group transactions by `tenantKey` to build per-business ledgers:
| tenantKey | Transaction Count | Total Debited |
| :- | :- | :- |
| `tbl28153208` | 15 | \$627.47 USD |
| `tbl89195039` | 2 | \$385.00 USD |
| `tbl12101631` | 2 | \$3,000.00 USD |
| `cad35916961` | 6 | \$103.55 USD |
### Step 3: For payroll, identify employees by personId
Filter for `type: "PAYROLL"` and group by `personId` to see how much each employee was paid:
| personId | Employee Name | Total Paid (destination) | tenantKey |
| :- | :- | :- | :- |
| `0a938505-...` | Elizabeth Wilson | 151.39 GHS | `tbl89195039` |
| `00b39f7a-...` | Dwight Schrute | 5,677.18 GHS | `tbl89195039` |
| `8ab2ba37-...` | Reed Maygone | 1,000.00 USD | `tbl12101631` |
| `ca49c55b-...` | Mahatra Ghandi | 2,000.00 USD | `tbl12101631` |
### Step 4: Calculate revenue share
Sum `totalRevenueShare` across all transactions for your platform's total earnings. Break down further with `fxRevenueShare` and `feeRevenueShare`.
### Step 5: Get payroll fee details
For any `PAYROLL_FEE` transactions, use the [Get Invoice](/api-reference/workforce/invoices/get-invoice) endpoint to retrieve the detailed breakdown, including per-employee line items.
***
## Account Statements
Download a formatted **account statement** for a business over a date range with the [Get Statement](/api-reference/workforce/treasury/get-statement) endpoint. The statement bundles the opening and closing balance, period totals, and every transaction line into a single document — ready to hand to accounting or reconcile against your books.
Pick the output with the `format` query parameter:
| `format` | Returns |
| :- | :- |
| *(omitted)* | JSON statement object |
| `csv` | CSV file download |
| `bai2` | BAI2 file download |
***
## Next Steps
Run payroll to generate the transactions you'll reconcile
Understand payroll statuses and when invoices become available
Ensure your business accounts are funded before running payroll
Get real-time notifications for transaction events
# API Overview
Source: https://docs.cadanapay.com/reference/api-overview
Cadana API reference introduction
The Cadana API is organized around REST. It accepts and returns JSON, uses standard HTTP response codes, and follows the [OpenAPI 3.0](https://www.openapis.org/) specification.
## Base URLs
| Environment | URL |
| :- | :- |
| Production | `https://api.cadanapay.com` |
| Sandbox | `https://dev-api.cadanapay.com` |
All endpoints use the `/v1/` prefix.
***
## Money Amounts
Two representations are used, depending on the API family:
| Representation | Example (\$1,000.00) | Used by |
| :- | :- | :- |
| Integer, minor units (cents) | `{ "amount": 100000, "currency": "USD" }` | Workforce API — persons, payrolls, invoices, business balances, wallet balances |
| Decimal string, major units | `{ "value": "1000.00", "currency": "USD" }` | Wallets API — transactions, fees, transfer limits |
Wallet transactions use decimal strings because user wallets can hold assets — including crypto like BTC — whose precision doesn't fit a fixed minor-unit integer. Check the schema of each endpoint before parsing: the same wallet reports its balance in minor units and its transactions in major units.
***
## APIs
Manage employees, contractors, and payroll
Send payments to anyone, anywhere
End-user wallet operations
Gross-to-net tax calculations for 150+ countries
***
## Next Steps
* [Authentication](/reference/authentication) — Set up API keys and tokens
* [Sandbox & Testing](/reference/sandbox) — Test in the sandbox environment
* [Webhooks](/reference/webhooks) — Receive real-time event notifications
* [Pagination](/reference/pagination) — Navigate paginated responses
# Events
Source: https://docs.cadanapay.com/reference/events
All webhook event types and their payloads
All events are delivered in the [standard webhook envelope](/reference/webhooks#webhook-structure). The payloads below show the `data` field contents for each event type.
***
## Quick Reference
| Event | Description |
| :- | :- |
| `person.created` | A person record was created |
| `person.updated` | A person's name, email, or phone number was updated |
| `person.jobinfo.updated` | A person's job information or compensation was updated |
| `person.offboarding.scheduled` | Offboarding was scheduled for a person |
| `person.offboarding.cancelled` | A pending offboarding was cancelled |
| `person.offboarding.completed` | A person transitioned to Former status |
| `person.reinstated` | A former person was reinstated to Active status |
| `user.created` | A user account was created |
| `user.updated` | A user's profile was updated |
| `user.profile.updated` | A user completed a profile action (e.g., password set) |
| `user.kyc.updated` | A user's KYC status changed |
| `user.kyc.expiry` | A user's identity document is expiring or has expired |
| `payroll.created` | A payroll was created |
| `payroll.status.updated` | A payroll's status changed |
| `transaction.initiated` | A transaction was created for processing |
| `transaction.processing` | A transaction passed compliance checks and is being processed |
| `transaction.rfi.updated` | Additional information was requested or provided for a transaction under review |
| `transaction.succeeded` | A transaction completed successfully |
| `transaction.failed` | A transaction failed or was returned |
| `instant-pay.succeeded` | An instant pay request was completed |
| `contract.created` | A contract was created |
| `contract.signed` | A contract signature was recorded |
| `contract.status.updated` | A contract's status changed |
| `virtual-account.requested` | A virtual account was requested |
| `virtual-account.submitted` | A virtual account request was sent to the bank |
| `virtual-account.created` | A virtual account is active with account details |
| `business.kyb.reviewed` | A KYB submission was reviewed |
| `business.kyb.rejected` | A KYB submission was rejected (terminal) |
| `business.kyb.completed` | A business completed KYB verification |
| `business.basicinfo.updated` | A business's basic information was updated |
| `external-account.added` | An external bank account was linked |
| `external-account.removed` | An external bank account was removed |
| `external-account.restricted` | Direct debits were restricted on linked external accounts |
| `external-account.restriction_lifted` | A direct debit restriction on linked external accounts was lifted |
| `invoice.contractor.submitted` | A contractor submitted an invoice for review |
| `invoice.contractor.approved` | A contractor invoice was approved |
| `invoice.contractor.rejected` | A contractor invoice was rejected |
| `invoice.contractor.paid` | A payment was recorded against a contractor invoice |
| `reimbursement.submitted` | A reimbursement was submitted for review |
| `reimbursement.approved` | A reimbursement was approved |
| `reimbursement.rejected` | A reimbursement was rejected |
| `reimbursement.paid` | A reimbursement was paid out |
| `filing.created` | A statutory filing was created |
| `filing.blocked` | A filing is blocked by missing requirements |
| `filing.pending` | A blocked filing was unblocked and is pending again |
| `filing.in_review` | A filing is validated and ready for approval |
| `filing.approved` | A filing was approved for submission |
| `filing.submitted` | A filing was submitted to the government authority |
| `filing.accepted` | The government authority accepted the filing |
| `filing.rejected` | The government authority rejected the filing |
| `filing.overdue` | A filing is past its due date |
| `remittance.created` | A government payment was initiated |
| `remittance.processing` | A government payment is being processed |
| `remittance.completed` | A government payment was completed |
| `remittance.failed` | A government payment failed |
| `requirement.created` | A compliance requirement was surfaced |
| `requirement.satisfied` | A compliance requirement was resolved |
***
## Persons
### person.created
```json JSON theme={null}
{
"id": "e13b9e14-c062-42ea-8563-8fc9223b29b5",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"personType": "CONTRACTOR",
"contractorType": "individual",
"phoneNumber": {
"countryCode": "1",
"number": "4155551234"
},
"tenantKey": "cad95193904"
}
```
### person.updated
```json JSON theme={null}
{
"id": "e13b9e14-c062-42ea-8563-8fc9223b29b5",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"phoneNumber": {
"countryCode": "1",
"number": "4155551234"
},
"tenantKey": "cad95193904"
}
```
### person.jobinfo.updated
Fires whenever a person's job information is saved via [`PUT /v1/persons/{personId}/jobInfo`](/api-reference/workforce/persons/update-job-information). The payload carries the person's job information as stored, with `compensation` in the same `compInfo` shape as [`GET /v1/persons/{personId}`](/api-reference/workforce/persons/get).
```json JSON theme={null}
{
"id": "e13b9e14-c062-42ea-8563-8fc9223b29b5",
"employeeNumber": "EMP-0042",
"title": "Senior Software Engineer",
"titleId": "9f3c1b2a-7e44-4d81-9a0f-2c5b8e6d1a33",
"departmentId": "4a7e2d90-1c63-4f52-8b7a-6d0e9f3c2b18",
"department": "Engineering",
"locationId": "",
"location": "Lagos",
"managerId": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"startDate": "2026-02-01",
"description": "Owns the payments integration surface",
"customFields": [
{
"id": "b2c1d0e9-8f7a-4b6c-9d5e-3a2f1b0c9d8e",
"name": "Cost Centre",
"value": "ENG-042"
}
],
"employmentModel": "eor",
"compensation": {
"frequency": "monthly",
"type": "salaried",
"salary": {
"currency": "USD",
"amount": 500000
},
"allowances": [
{
"name": "Housing",
"isTaxable": true,
"amount": {
"currency": "USD",
"amount": 25050
}
}
],
"employmentModel": "eor"
},
"tenantKey": "cad95193904"
}
```
### Offboarding
Offboarding events track a person's lifecycle from active employment through exit and potential reinstatement. See [Offboard Workers](/workforce/offboarding-workers) for the full guide.
### person.offboarding.scheduled
```json JSON theme={null}
{
"id": "e13b9e14-c062-42ea-8563-8fc9223b29b5",
"exitDate": "2025-06-30",
"reason": "Voluntary resignation",
"tenantKey": "cad95193904"
}
```
### person.offboarding.cancelled
```json JSON theme={null}
{
"id": "e13b9e14-c062-42ea-8563-8fc9223b29b5",
"tenantKey": "cad95193904"
}
```
### person.offboarding.completed
```json JSON theme={null}
{
"id": "e13b9e14-c062-42ea-8563-8fc9223b29b5",
"exitDate": "2025-06-30",
"reason": "Voluntary resignation",
"tenantKey": "cad95193904"
}
```
### person.reinstated
```json JSON theme={null}
{
"id": "e13b9e14-c062-42ea-8563-8fc9223b29b5",
"tenantKey": "cad95193904"
}
```
To get full offboarding details (compensation, payroll inclusion), query [`GET /v1/persons/{personId}`](/api-reference/workforce/persons/get) after receiving the event.
***
## Users
### user.created
```json JSON theme={null}
{
"id": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"personId": "e13b9e14-c062-42ea-8563-8fc9223b29b5",
"phoneNumber": {
"countryCode": "1",
"number": "4155551234"
},
"tenantKey": "cad95193904"
}
```
### user.updated
The payload includes a `type` field indicating what was updated.
| Type | Description |
| :- | :- |
| `basic_info` | Name, email, phone number, or tag was updated. `isNameUpdated` indicates if the name changed. |
```json JSON theme={null}
{
"id": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"type": "basic_info",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"phoneNumber": {
"countryCode": "1",
"number": "4155551234"
},
"tag": "",
"isNameUpdated": false,
"tenantKey": "cad95193904"
}
```
### user.profile.updated
```json JSON theme={null}
{
"id": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"type": "password_set",
"tenantKey": "cad95193904"
}
```
### user.kyc.updated
Fired on every KYC status change. The `type` field indicates which verification component changed.
```json JSON theme={null}
{
"userId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"type": "identity",
"status": "approved",
"tenantKey": "cad95193904"
}
```
| Status | Description |
| :- | :- |
| `processing` | KYC verification is in progress |
| `pending` | The provider returned an inconclusive result and a manual review is underway |
| `approved` | Verification successful |
| `rejected` | Verification failed — resubmission needed |
| Type | Description |
| :- | :- |
| `identity` | Identity document verification |
| `address` | Address verification |
### user.kyc.expiry
Fired when a user's identity document is approaching expiry or has expired.
```json JSON theme={null}
{
"userId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"stage": "in-grace-period",
"documentType": "Drivers license",
"expiryDate": "2026-09-18",
"tenantKey": "cad95193904",
"timestamp": 1789729241
}
```
| Field | Description |
| :- | :- |
| `userId` | The user whose document is expiring |
| `stage` | Where the document is in its expiry window — see below |
| `documentType` | Display name of the document as stored on the KYC record: `Passport`, `Drivers license`, `National ID Card`, `Voter's Card`, or `PAN Card` |
| `expiryDate` | The document's expiration date, `YYYY-MM-DD` |
| `timestamp` | When the event was emitted, unix seconds. Carried inside `data` as well as on the envelope |
| Stage | Description |
| :- | :- |
| `about-to-expire` | Document expires within 2 months |
| `in-grace-period` | Expired but within the 2-month grace period — the account stays unrestricted |
| `expired` | Past grace period — verification reverts to rejected and the user must re-verify |
The event fires **once per stage**, when the document first enters it — not repeatedly while it
stays there. Expect three events per document: one when it enters `about-to-expire`, one on the
expiry date, and one when the grace period runs out. To remind users as the date approaches, drive
your own schedule off the `expiryDate` in the payload rather than waiting for another event.
**What to do with it.** From the first `about-to-expire` event onwards, collect a replacement identity document from the user and submit it with [`POST /v1/users/{userId}/kyc/renewal`](/api-reference/workforce/users/renew-kyc) — you do not have to wait for the document to expire or be rejected. Renewal stays available at every stage, `expired` included. See [Renew an Expiring Document](/platform/kyc-verification#renew-an-expiring-document) for the request shape and rules.
Once the document reaches `expired`, the user's KYC status is already `rejected`, so the ordinary retry route applies as well: [`PATCH /v1/users/{userId}/kyc`](/api-reference/workforce/users/resubmit-kyc). Either way, the outcome arrives on [`user.kyc.updated`](#user-kyc-updated).
***
## Payroll
The `payroll.status.updated` event fires for the values below. The webhook uses its own status vocabulary — the third column shows the corresponding `status` on [`GET /v1/payrolls/{id}`](/api-reference/workforce/payrolls/get). See [Payroll Lifecycle](/workforce/payroll-lifecycle) for the full lifecycle.
| `status` value | Description | Status on GET |
| :- | :- | :- |
| `saved` | Entries added, ready for approval | `Pending Submission` |
| `approved` | Payroll approved, funds not yet collected | `Awaiting Funds` |
| `scheduled` | Funds collected, queued for the payroll date | `Scheduled` |
| `processing` | Disbursements being sent to workers | `Processing` |
| `completed` | All disbursements confirmed | `Completed` |
`Awaiting Funds` is the status a payroll holds between the `approved` and `scheduled` events — receiving `approved` with no `scheduled` following shortly means the payroll is waiting on funds; it proceeds automatically once the business balance covers it. No webhook fires for `Rejected` or `Deleted`; poll [`GET /v1/payrolls/{id}`](/api-reference/workforce/payrolls/get) to detect those.
### payroll.created
```json JSON theme={null}
{
"id": "a3b4c5d6-e7f8-9012-abcd-ef3456789012",
"workerType": "CONTRACTOR",
"type": "ONE_OFF",
"tenantKey": "cad95193904"
}
```
### payroll.status.updated
```json JSON theme={null}
{
"id": "a3b4c5d6-e7f8-9012-abcd-ef3456789012",
"status": "approved",
"tenantKey": "cad95193904"
}
```
***
## Transactions
Every `transaction.*` event carries a `type` field identifying the kind of transaction:
| `type` | Description |
| :- | :- |
| `PAYROLL` | A worker payment from a payroll run. |
| `DEPOSIT` | Funds received into a business virtual account. |
| `PAYOUT` | A direct user-initiated payout or a business payout. |
| `PAYROLL_FEE` | A processing fee charged when a payroll runs, debited from the business wallet. |
And a `recipientType` field identifying what kind of entity `recipientId` refers to:
| `recipientType` | When you'll see it |
| :- | :- |
| `BUSINESS` | Business payouts, business-account deposits, and payroll fee charges. |
| `USER` | Worker wallet payments and direct user payouts. |
| `EMPLOYEE` | Worker direct bank payments where the worker is classified as an employee. |
| `CONTRACTOR` | Worker direct bank payments where the worker is classified as a contractor. |
| Event | Description |
| :- | :- |
| `transaction.initiated` | A transaction was created for processing. |
| `transaction.processing` | The transaction passed compliance checks and is being processed. |
| `transaction.rfi.updated` | Additional information was requested (`status: RFI_REQUESTED`) or provided (`status: RFI_PROVIDED`) for a transaction under review. |
| `transaction.succeeded` | A transaction succeeded. |
| `transaction.failed` | A transaction failed or was returned. Check `message` for details. If `isReturned: true`, the transaction was previously successful but returned by the recipient bank. |
### transaction.initiated
```json JSON theme={null}
{
"id": "b4c5d6e7-f8a9-0123-bcde-f45678901234",
"amount": {
"currency": "USD",
"amount": 100000
},
"type": "PAYOUT",
"reference": "9af0f05e-1efa-407b-be23-8595f89a1b2a",
"recipientId": "9bd99534-8c7f-4b2a-b63d-083e33ca205e",
"recipientType": "BUSINESS",
"tenantKey": "cad95193904"
}
```
### transaction.processing
Fires once per transaction, when it passes compliance checks and is being processed. Most transactions pass checks automatically within seconds of `transaction.initiated`; transactions held for manual review can stay in the initiated state for longer before this event fires.
```json JSON theme={null}
{
"id": "b4c5d6e7-f8a9-0123-bcde-f45678901234",
"amount": {
"currency": "USD",
"amount": 100000
},
"type": "PAYOUT",
"reference": "9af0f05e-1efa-407b-be23-8595f89a1b2a",
"recipientId": "9bd99534-8c7f-4b2a-b63d-083e33ca205e",
"recipientType": "BUSINESS",
"tenantKey": "cad95193904"
}
```
Instantly-settling transactions (for example `PAYROLL_FEE` wallet debits) can move straight from `transaction.initiated` to `transaction.succeeded` without a `transaction.processing` event, so treat this event as optional in the lifecycle. It is delivered at most once per transaction.
### transaction.rfi.updated
Fires when additional information is needed for a transaction under review, and again when that information has been provided. While a request for information is open, the transaction stays in the initiated state; once review completes, the transaction continues its normal lifecycle (`transaction.processing` on approval, `transaction.failed` otherwise).
`status` is `RFI_REQUESTED` when information is being requested — `actionRequired` describes what is needed — and `RFI_PROVIDED` once it has been submitted (the `actionRequired` field is omitted).
```json JSON theme={null}
{
"id": "b4c5d6e7-f8a9-0123-bcde-f45678901234",
"amount": {
"currency": "USD",
"amount": 100000
},
"type": "PAYOUT",
"status": "RFI_REQUESTED",
"actionRequired": "Please provide an invoice for this payment",
"reference": "9af0f05e-1efa-407b-be23-8595f89a1b2a",
"recipientId": "9bd99534-8c7f-4b2a-b63d-083e33ca205e",
"recipientType": "BUSINESS",
"tenantKey": "cad95193904"
}
```
Use this event to surface "action needed" states in your UI — a transaction with an open request for information will otherwise look like a long-running initiated transaction.
### transaction.succeeded
```json JSON theme={null}
{
"id": "b4c5d6e7-f8a9-0123-bcde-f45678901234",
"amount": {
"currency": "USD",
"amount": 100000
},
"type": "PAYROLL",
"reference": "9af0f05e-1efa-407b-be23-8595f89a1b2a",
"recipientId": "9bd99534-8c7f-4b2a-b63d-083e33ca205e",
"recipientType": "USER",
"tenantKey": "cad95193904"
}
```
```json JSON theme={null}
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"amount": {
"currency": "USD",
"amount": 500000
},
"type": "DEPOSIT",
"reference": "dep-ref-001",
"recipientId": "acct-9bd99534-8c7f-4b2a",
"recipientType": "BUSINESS",
"description": "Invoice 28278FC-155",
"paymentInfo": {
"preferredMethod": "bank",
"momo": {
"accountName": "",
"provider": "",
"providerCode": "",
"phoneNumber": { "countryCode": "", "number": "" }
},
"bank": {
"accountName": "ACME TRADING LTD",
"accountNumber": "****2061",
"bankCode": "",
"bankName": "",
"branchName": "",
"sortCode": "108800"
}
},
"tenantKey": "cad95193904"
}
```
```json JSON theme={null}
{
"id": "c7d8e9f0-1234-5678-90ab-cdef12345678",
"amount": {
"currency": "USD",
"amount": 2500
},
"type": "PAYROLL_FEE",
"reference": "9af0f05e-1efa-407b-be23-8595f89a1b2a",
"recipientId": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"recipientType": "BUSINESS",
"tenantKey": "cad95193904"
}
```
`description` is sent on any `DEPOSIT` that has one. For a deposit funded by bank transfer it is the reference the sender entered at their bank; otherwise it is the description recorded when the deposit was created. It is the same value `/v1/disbursements` returns for the transaction.
`paymentInfo` describes the sending account and is sent only for deposits funded by a bank transfer where the sender's name was supplied. It carries `bank.sortCode` with a masked `bank.accountNumber` for GBP, and `bank.bankCode` (the BIC) with a masked `bank.iban` for EUR. USD deposits carry the name in `ach`, `wire` or `swift` according to the banking network, or in `bank` when no network is supplied.
Empty sub-objects are always present, so treat an empty string as "not supplied" rather than expecting the field to be absent. `preferredMethod` names the block that carries the sender.
### transaction.failed
```json JSON theme={null}
{
"id": "b4c5d6e7-f8a9-0123-bcde-f45678901234",
"message": "Account inactive",
"amount": {
"currency": "USD",
"amount": 100000
},
"type": "PAYOUT",
"reference": "9af0f05e-1efa-407b-be23-8595f89a1b2a",
"recipientId": "9bd99534-8c7f-4b2a-b63d-083e33ca205e",
"recipientType": "BUSINESS",
"tenantKey": "cad95193904"
}
```
**Returned transactions** are delivered as `transaction.failed` with `isReturned: true`:
```json JSON theme={null}
{
"id": "b4c5d6e7-f8a9-0123-bcde-f45678901234",
"message": "Account closed",
"isReturned": true,
"amount": {
"currency": "USD",
"amount": 100000
},
"type": "PAYOUT",
"reference": "9af0f05e-1efa-407b-be23-8595f89a1b2a",
"recipientId": "9bd99534-8c7f-4b2a-b63d-083e33ca205e",
"recipientType": "BUSINESS",
"tenantKey": "cad95193904"
}
```
The `type` field tells you the kind of transaction. `reference` is a stable identifier from the source system: `payrollId` for `PAYROLL`, the deposit reference for `DEPOSIT`, the business payout reference for `PAYOUT`. `recipientId` is the ID of whoever is receiving funds — use `recipientType` to interpret it (a `userId` when `recipientType: "USER"`, a `businessId` when `BUSINESS`, a `personId` when `EMPLOYEE` or `CONTRACTOR`).
***
## Instant Pay
### instant-pay.succeeded
```json JSON theme={null}
{
"id": "c5d6e7f8-a901-2345-cdef-567890123456",
"personId": "e13b9e14-c062-42ea-8563-8fc9223b29b5",
"amount": {
"currency": "USD",
"amount": 100000
},
"tenantKey": "cad95193904"
}
```
***
## Contracts
### contract.created
```json JSON theme={null}
{
"id": "d6e7f8a9-0123-4567-def0-678901234567",
"personId": "ee278b2d-cbd8-4104-8af4-f02a41566894",
"templateId": "4f8220e5-f6b5-43cb-963e-e99219ed1736",
"tenantKey": "cad95193904"
}
```
### contract.signed
```json JSON theme={null}
{
"id": "d6e7f8a9-0123-4567-def0-678901234567",
"signatureId": "2dee5427-c648-4696-89cc-95e4d942cd24",
"name": "John Doe",
"email": "john@example.com",
"isComplete": false,
"tenantKey": "cad95193904"
}
```
### contract.status.updated
```json JSON theme={null}
{
"id": "d6e7f8a9-0123-4567-def0-678901234567",
"status": "revoked",
"tenantKey": "cad95193904"
}
```
***
## Invoices
These events cover the contractor self-submitted invoice lifecycle: a contractor raises an invoice billed to your business, you review it, and it is paid. `invoice.contractor.submitted` is your cue to review — call [`POST /v1/invoices/{invoiceId}/review`](/api-reference/workforce/invoices/review-invoice) to approve or reject it. Only invoices with `source` of `CONTRACTOR_SUBMITTED` fire these events. `reason` is present only on `invoice.contractor.rejected`. `contractId` is present when the invoice names one of the contractor's contracts — see [Manage Contracts](/workforce/manage-contracts).
```json JSON theme={null}
{
"id": "f8a9b0c1-2345-6789-bcde-f01234567890",
"invoiceNumber": "1764332528",
"personId": "c5d6e7f8-9012-3456-cdef-789012345678",
"userId": "a3b4c5d6-e7f8-9012-abcd-ef3456789012",
"businessId": "C123456234",
"contractId": "d6e7f8a9-0123-4567-def0-678901234567",
"amount": {
"currency": "USD",
"amount": 100000
},
"datePayable": "2025-12-15",
"date": "2025-11-28",
"tenantKey": "cad95193904"
}
```
```json JSON theme={null}
{
"id": "f8a9b0c1-2345-6789-bcde-f01234567890",
"invoiceNumber": "1764332528",
"personId": "c5d6e7f8-9012-3456-cdef-789012345678",
"userId": "a3b4c5d6-e7f8-9012-abcd-ef3456789012",
"businessId": "C123456234",
"contractId": "d6e7f8a9-0123-4567-def0-678901234567",
"handledBy": "b4c5d6e7-f8a9-0123-bcde-f45678901234",
"tenantKey": "cad95193904"
}
```
```json JSON theme={null}
{
"id": "f8a9b0c1-2345-6789-bcde-f01234567890",
"invoiceNumber": "1764332528",
"personId": "c5d6e7f8-9012-3456-cdef-789012345678",
"userId": "a3b4c5d6-e7f8-9012-abcd-ef3456789012",
"businessId": "C123456234",
"contractId": "d6e7f8a9-0123-4567-def0-678901234567",
"handledBy": "b4c5d6e7-f8a9-0123-bcde-f45678901234",
"reason": "Amount does not match the agreed scope",
"tenantKey": "cad95193904"
}
```
```json JSON theme={null}
{
"id": "f8a9b0c1-2345-6789-bcde-f01234567890",
"invoiceNumber": "1764332528",
"personId": "c5d6e7f8-9012-3456-cdef-789012345678",
"userId": "a3b4c5d6-e7f8-9012-abcd-ef3456789012",
"businessId": "C123456234",
"contractId": "d6e7f8a9-0123-4567-def0-678901234567",
"amount": {
"currency": "USD",
"amount": 100000
},
"paymentDate": "2025-12-10",
"reference": "PAYROLL-9af0f05e-1efa-407b-be23-8595f89a1b2a",
"tenantKey": "cad95193904"
}
```
***
## Reimbursements
These events cover the reimbursement lifecycle: a worker submits a reimbursement — or you create one via [`POST /v1/reimbursements`](/api-reference/workforce/reimbursements/create-reimbursement) — you review it, and it is paid out. `reimbursement.submitted` is your cue to review — call [`POST /v1/reimbursements/{reimbursementId}/review`](/api-reference/workforce/reimbursements/review-approve-or-reject-reimbursement) to approve or reject it. **Approving disburses the reimbursement** from the `sourceCurrency` wallet, and `reimbursement.paid` follows once it settles. `reason` is present only on `reimbursement.rejected`; `amount` is the reimbursed (destination) amount.
```json JSON theme={null}
{
"id": "d1e2f3a4-5678-9012-cdef-345678901234",
"title": "Client dinner",
"personId": "c5d6e7f8-9012-3456-cdef-789012345678",
"userId": "a3b4c5d6-e7f8-9012-abcd-ef3456789012",
"businessId": "9b8a7c6d-5e4f-3210-9876-543210fedcba",
"amount": {
"currency": "USD",
"amount": 100000
},
"tenantKey": "cad95193904"
}
```
```json JSON theme={null}
{
"id": "d1e2f3a4-5678-9012-cdef-345678901234",
"personId": "c5d6e7f8-9012-3456-cdef-789012345678",
"userId": "a3b4c5d6-e7f8-9012-abcd-ef3456789012",
"businessId": "9b8a7c6d-5e4f-3210-9876-543210fedcba",
"handledBy": "b4c5d6e7-f8a9-0123-bcde-f45678901234",
"tenantKey": "cad95193904"
}
```
```json JSON theme={null}
{
"id": "d1e2f3a4-5678-9012-cdef-345678901234",
"personId": "c5d6e7f8-9012-3456-cdef-789012345678",
"userId": "a3b4c5d6-e7f8-9012-abcd-ef3456789012",
"businessId": "9b8a7c6d-5e4f-3210-9876-543210fedcba",
"handledBy": "b4c5d6e7-f8a9-0123-bcde-f45678901234",
"reason": "Receipts missing",
"tenantKey": "cad95193904"
}
```
```json JSON theme={null}
{
"id": "d1e2f3a4-5678-9012-cdef-345678901234",
"personId": "c5d6e7f8-9012-3456-cdef-789012345678",
"userId": "a3b4c5d6-e7f8-9012-abcd-ef3456789012",
"businessId": "9b8a7c6d-5e4f-3210-9876-543210fedcba",
"amount": {
"currency": "USD",
"amount": 100000
},
"disbursementId": "ca51f42c-0adc-4a77-8dd1-a03dd4fb955f",
"tenantKey": "cad95193904"
}
```
***
## Virtual Accounts
A virtual account can belong to either a user or a business. Exactly one of `userId` or `businessId` will be present.
### virtual-account.requested
```json JSON theme={null}
{
"id": "ca51f42c-0adc-4a77-8dd1-a03dd4fb955f",
"userId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"currency": "USD",
"status": "requested",
"description": "in review",
"tenantKey": "cad95193904"
}
```
### virtual-account.submitted
```json JSON theme={null}
{
"id": "ca51f42c-0adc-4a77-8dd1-a03dd4fb955f",
"userId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"currency": "USD",
"status": "submitted",
"description": "sent to bank for processing",
"tenantKey": "cad95193904"
}
```
### virtual-account.created
```json JSON theme={null}
{
"id": "ca51f42c-0adc-4a77-8dd1-a03dd4fb955f",
"userId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"currency": "USD",
"status": "active",
"accountInformation": {
"accountNumber": "000000000000",
"accountName": "JOHN DOE",
"bankName": "Example Bank",
"bankCode": "000000000",
"countryCode": "US",
"accountType": "Checking",
"routingNumber": "000000000",
"bankAddress": "1800 Main St., San Francisco, CA 94105"
},
"tenantKey": "cad95193904"
}
```
***
## Business
### business.kyb.reviewed
Sent when a KYB submission has been reviewed. May indicate approval or that additional information is needed.
```json JSON theme={null}
{
"id": "7c74cc95-d7c6-4537-b388-6d57ed020b34",
"tenantKey": "cad95193904",
"status": "needs-additional-info",
"timestamp": 1765359752,
"businessId": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"businessName": "Acme Corporation",
"requirements": {
"currentlyDue": [
"principals.0.firstName",
"principals.0.identifier.identityFileId",
"entityName"
],
"errors": [
{
"field": "entityName",
"reason": "Only english letters are allowed"
},
{
"field": "principals.0.firstName",
"reason": "Name doesn't match ID document"
}
]
}
}
```
See [KYB Requirements](/platform/kyb-requirements#webhook-events) for handling `needs-additional-info` responses.
### business.kyb.rejected
Sent when a KYB submission has been permanently rejected. This is a terminal state — the business cannot resubmit.
```json JSON theme={null}
{
"id": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"businessName": "Acme Corporation",
"reason": "Business does not meet compliance requirements",
"status": "rejected",
"tenantKey": "cad95193904",
"timestamp": 1765359752
}
```
### business.kyb.completed
```json JSON theme={null}
{
"id": "7c74cc95-d7c6-4537-b388-6d57ed020b34",
"businessId": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"businessName": "Acme Corporation",
"status": "complete",
"timestamp": 1765359752,
"tenantKey": "cad95193904"
}
```
### business.basicinfo.updated
```json JSON theme={null}
{
"id": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"name": "Acme Corporation",
"registrationId": "REG123456",
"tenantKey": "cad95193904"
}
```
***
## External Accounts
### external-account.added
Ready to use for deposits straight away.
```json theme={null}
{
"businessId": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"tenantKey": "cad95193904",
"accounts": [
{
"id": "ca51f42c-0adc-4a77-8dd1-a03dd4fb955f",
"accountNumber": "0000",
"bankName": "First National Bank - Checking",
"status": "active"
}
]
}
```
Submitted, but not usable until the account holder verifies a test deposit.
```json theme={null}
{
"businessId": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"tenantKey": "cad95193904",
"accounts": [
{
"id": "ca51f42c-0adc-4a77-8dd1-a03dd4fb955f",
"accountNumber": "0000",
"bankName": "First National Bank - Checking",
"status": "needs_confirmation",
"microdepositInfo": {
"type": "descriptor_code",
"verificationUrl": "https://verify.example.com/microdeposit/1A2B3C",
"arrivalDate": 1715583600
}
}
]
}
```
`status` is `active` for instantly verified accounts, or `needs_confirmation` for accounts still awaiting [micro-deposit verification](/fund-your-account#complete-micro-deposit-verification). A verified account linked while direct debits are restricted arrives as `restricted` (see [`external-account.restricted`](#external-account-restricted)). This event fires as soon as the account is submitted, so you can react to a newly linked account without polling.
`microdepositInfo` is present only while an account is awaiting verification. `type` is `descriptor_code` (a code on the statement) or `amounts` (two small deposits to read back), and `arrivalDate` is a Unix timestamp for when the deposit is expected — enough to render your interim copy without a follow-up API call. To collect the code or amounts, send the account holder to the link from [Generate External Account Link](/api-reference/workforce/treasury/generate-external-account-link) with `mode` set to `verification`.
Poll [Get External Accounts](/api-reference/workforce/treasury/get-external-accounts) if you need to reliably detect the `needs_confirmation` → `active` transition: the follow-up event carries a different `id` for the same account, so the two cannot be correlated by `id` alone.
### external-account.removed
```json JSON theme={null}
{
"businessId": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"tenantKey": "cad95193904",
"accounts": [
{
"id": "ca51f42c-0adc-4a77-8dd1-a03dd4fb955f",
"accountNumber": "0000",
"bankName": "First National Bank - Checking",
"status": "removed"
}
]
}
```
### external-account.restricted
Direct debits were restricted on the business's linked accounts, usually after the business's bank rejected a debit as unauthorized. `accounts` is the business's full list of linked accounts, the same as [Get External Accounts](/api-reference/workforce/treasury/get-external-accounts) returns. Affected accounts show `status: restricted`, and [deposits](/fund-your-account#create-a-deposit) from them fail until the restriction is lifted. Accounts still awaiting verification keep their own status.
```json JSON theme={null}
{
"businessId": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"tenantKey": "cad95193904",
"accounts": [
{
"id": "ca51f42c-0adc-4a77-8dd1-a03dd4fb955f",
"accountNumber": "0000",
"bankName": "First National Bank - Checking",
"status": "restricted"
}
]
}
```
### external-account.restriction\_lifted
The restriction was lifted. The same accounts return to `status: active` and can be used for deposits again.
```json JSON theme={null}
{
"businessId": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf",
"tenantKey": "cad95193904",
"accounts": [
{
"id": "ca51f42c-0adc-4a77-8dd1-a03dd4fb955f",
"accountNumber": "0000",
"bankName": "First National Bank - Checking",
"status": "active"
}
]
}
```
***
## Statutory Compliance
Filing, remittance, and requirement events share a common structure. The `resourceType` and `resourceId` fields identify the affected resource.
### filing.created
```json JSON theme={null}
{
"resourceType": "filing",
"resourceId": "fil_01HQ3K4N7XYZABC",
"businessId": "biz_01ABC",
"actor": { "type": "system", "id": null },
"data": {
"filingType": "ISR Withholding",
"countryCode": "MX",
"periodLabel": "February 2026",
"status": "pending",
"workerCount": 15
}
}
```
### filing.blocked
```json JSON theme={null}
{
"resourceType": "filing",
"resourceId": "fil_01HQ3K4N7XYZABC",
"businessId": "biz_01ABC",
"actor": { "type": "system", "id": null },
"data": {
"filingType": "ISR Withholding",
"countryCode": "MX",
"periodLabel": "February 2026",
"status": "blocked",
"blockedReasons": [
{
"code": "missing_worker_data",
"message": "3 workers are missing Tax Identification Numbers"
}
]
}
}
```
### filing.approved
```json JSON theme={null}
{
"resourceType": "filing",
"resourceId": "fil_01HQ3K4N7XYZABC",
"businessId": "biz_01ABC",
"actor": { "type": "user", "id": "usr_01PQR" },
"data": {
"filingType": "ISR Withholding",
"countryCode": "MX",
"periodLabel": "February 2026",
"status": "approved"
}
}
```
### filing.accepted
```json JSON theme={null}
{
"resourceType": "filing",
"resourceId": "fil_01HQ3K4N7XYZABC",
"businessId": "biz_01ABC",
"actor": { "type": "authority", "id": null },
"data": {
"filingType": "ISR Withholding",
"countryCode": "MX",
"periodLabel": "February 2026",
"status": "accepted",
"filedAt": "2026-03-04T09:15:00Z"
}
}
```
### remittance.completed
```json JSON theme={null}
{
"resourceType": "remittance",
"resourceId": "rem_01STU",
"businessId": "biz_01ABC",
"actor": { "type": "system", "id": null },
"data": {
"countryCode": "MX",
"authority": "SAT",
"status": "completed",
"amount": 125000.00,
"currency": "MXN",
"paymentReference": "SAT-2026-02-RFC-XAXX010101000-ISR"
}
}
```
### requirement.satisfied
```json JSON theme={null}
{
"resourceType": "requirement",
"resourceId": "req_01XYZ",
"businessId": "biz_01ABC",
"actor": { "type": "system", "id": null },
"data": {
"name": "SAT Employer RFC",
"category": "business_tax_parameter",
"countryCode": "MX",
"status": "satisfied",
"unblockedFilings": ["fil_01HQ3K4N7XYZABC"]
}
}
```
# Pagination
Source: https://docs.cadanapay.com/reference/pagination
How to paginate through list endpoints
Collection endpoints return paginated results using offset pagination. Pass `page` and `limit` as query parameters.
```bash Bash theme={null}
curl -X GET 'https://api.cadanapay.com/v1/persons?page=1&limit=20' \
-H 'Authorization: Bearer YOUR_API_KEY'
```
***
## Request Parameters
| Parameter | Type | Default | Description |
| :- | :- | :- | :- |
| `page` | integer | `1` | Page number to retrieve (starts at 1) |
| `limit` | integer | `20` | Items per page (1–100) |
***
## Response Format
All list endpoints return a `data` array and a `meta` object:
```json JSON theme={null}
{
"data": [
{ "id": "7dd569f9-bd54-4fbb-a5c2-f0aaadc68adf", "..." : "..." },
{ "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "..." : "..." }
],
"meta": {
"limit": 20,
"currentPage": 1,
"itemCount": 45,
"pages": 3,
"skipped": 0
}
}
```
## Meta Fields
| Field | Type | Description |
| :- | :- | :- |
| `limit` | number | Items per page (from request, default 20) |
| `currentPage` | number | Current page number |
| `itemCount` | number | Total items across all pages |
| `pages` | number | Total number of pages |
| `skipped` | number | Number of records skipped to reach the current page |
# Sandbox & Testing
Source: https://docs.cadanapay.com/reference/sandbox
Testing your integration in the sandbox environment
Cadana's sandbox environment lets you test your integration without processing real financial transactions. The sandbox APIs mirror production, so transitioning to live is as simple as updating the host and API key.
**Sandbox host:** `https://dev-api.cadanapay.com`
Use an API key generated for the sandbox environment. Production API keys will not work with the sandbox host.
The sandbox sends **real emails** — user invites, contract signature requests, and other notifications are delivered to the addresses you provide, and user accounts are real login accounts. Use inboxes you control (e.g., plus-addressing on your own domain), and pass `suppressWelcomeEmail` / `suppressNotification` where available.
***
## Funding Your Wallet
Use the sandbox deposit endpoint to add test funds to your wallet:
***
## Testing Payrolls
To run a payroll through its full lifecycle without waiting, save it with a `payrollDate` in the past — it completes immediately on approval. This is the recommended way to test [payslip downloads](/workforce/pay-workers-via-payroll#downloading-payslips) end-to-end, since payslips exist only for `Completed` payrolls. Payrolls with a future `payrollDate` disburse on that date.
***
## Simulated Payment Failures
To test your error handling, use specific `accountName` values when creating a beneficiary to trigger simulated payment failures:
| Account Name | Simulated Failure |
| :- | :- |
| `FAILURE_SIM_001` | Invalid account |
| `FAILURE_SIM_002` | Bank unavailable |
| `FAILURE_SIM_003` | Rejected by compliance |
| `FAILURE_SIM_004` | Returned by bank |
Set the `accountName` in the beneficiary's bank payment details:
When the payout is processed, it will fail with the corresponding error message — in this case, "Rejected by compliance".
***
## Virtual Account Auto-Approval
When creating a virtual account in sandbox, use `99999900` as the `customIdentification.number` to automatically approve the account. The `type` (SSN or BVN) does not matter.
***
## KYC Testing
Use these test values when submitting KYC in sandbox to simulate different verification outcomes.
### Identity Document Number
| Value | Result |
| :- | :- |
| `auto-approve` | Identity verification automatically approved |
| `auto-reject` | Identity verification automatically rejected |
### Address Line 2
| Value | Result |
| :- | :- |
| `auto-approve` | Address verification automatically approved |
| `auto-reject` | Address verification automatically rejected |
Identity and address sentinels can be combined in a single request to control each verification independently. Sentinel values are case-insensitive.
These sentinels work with both initial submission (`POST`) and resubmission (`PATCH`).
The examples below include document file IDs to show the full request shape. When a sentinel is used, `frontFileId`, `selfieFileId`, and `addressProofFileId` can be omitted — no file upload is needed. If you do pass them, they must reference files you actually [uploaded](/reference/working-with-files); made-up IDs are rejected with a `404`.
Without a sentinel, nothing approves the submission for you. The user reaches `pending` and stays there, and `PATCH` will not help — resubmission only works after a rejection. To finish the KYC, either [review it yourself](#reviewing-kyc-directly) or [delete the user](#resetting-sandbox-users) and start again. A submission without a sentinel also needs real document file IDs, just like production.
**Initial submission:**
**Resubmission (after rejection):**
***
## Reviewing KYC Directly
There are two ways to get a user to `approved` in sandbox. Which one you want depends on what you are testing.
**Sentinels are the shortcut.** Put `auto-approve` in the ID number field and Cadana decides the result on the spot. It never opens your uploaded files — it doesn't even need them. Use this when you just want an approved user so you can move on to testing something else, like payments.
**Review KYC is the full path.** You send exactly what your production code would send: real names, real document details, real file IDs. Cadana stores the documents and passes them on for verification, same as production. But sandbox has no real reviewer, so no result ever comes back and the user sits at `pending`. This endpoint is how you decide the outcome yourself:
| Field | Values | What it means |
| :- | :- | :- |
| `action` | `approve` or `reject` | The result you want |
| `type` | `identity` or `address` | Which of the two checks you are deciding |
Returns `204`. Each call decides one check, so a user who needs both identity and address approved takes two calls. It works no matter what state the user is in, and it keeps the details and documents already on file. A `user.kyc.updated` webhook fires, exactly as it would for a real verification result.
### Journey 1: I just need an approved user
You are testing payouts and you need a worker who can receive money. You don't care how they got verified.
Call `POST /v1/users/{userId}/kyc` with `idDetails.number` set to `auto-approve`. You can leave out `frontFileId` and `selfieFileId` — no files are needed.
`GET /v1/users/{userId}/kyc` returns `identity: "approved"` straight away. The user can now be paid.
### Journey 2: I need to prove my document upload works
You are building the upload part of your integration and you want to confirm your files really reach Cadana and get attached to the right user.
Upload the front of the ID and the selfie using the [file upload flow](/reference/working-with-files). Keep the `fileId` each upload returns.
Call `POST /v1/users/{userId}/kyc` with a real ID number and the `fileId` values from the previous step. Do **not** use a sentinel anywhere in this request — a sentinel would skip the document handling you are trying to test.
`GET /v1/users/{userId}/kyc` shows `identity` moving to `processing` and then settling at `pending`. That is the expected end state in sandbox, and it means your documents were accepted and passed on for verification.
Call `POST /v1/sandbox/users/{userId}/kyc/review` with `{"action": "approve", "type": "identity"}`. Use `"action": "reject"` instead if you want to test how your app handles a rejection.
`GET /v1/users/{userId}/kyc` now returns `identity: "approved"`, and your webhook endpoint receives `user.kyc.updated`.
### Journey 3: My test user is stuck
You submitted without a sentinel, the user is sitting at `pending`, and `PATCH` refuses with `identity can only be resubmitted when status is Rejected`. That error is correct — resubmission is only for fixing a rejection, so it cannot rescue a `pending` user.
You have two ways out:
* **Keep the user.** Call Review KYC to approve or reject them. Everything they submitted stays as it is.
* **Start over.** [Delete the sandbox user](#resetting-sandbox-users) and re-invite the same person.
This endpoint exists only in sandbox. In production, KYC results come from the verification provider and this path is not available.
***
## Resetting Sandbox Users
Delete a sandbox user to start over — for example, after a KYC submission that can't be resubmitted. The linked Person record is preserved, so you can re-invite the same person with [`POST /v1/users/invite`](/api-reference/workforce/users/invite).
Returns `204` on success. This endpoint only exists in the sandbox environment.
***
## KYB Testing
Use these test values when submitting KYB information in sandbox to simulate different outcomes.
### Tax Identification Number
| Tax ID | Result |
| :- | :- |
| `000-CAD-AUTO-APPROVE` | KYB automatically approved |
| `000-CAD-AUTO-REJECT` | KYB automatically rejected (terminal) |
| `000-CAD-AUTO-FLAG` | Status set to `needs-additional-info` — flags tax ID as incorrect and requests all business documents |
### Principal SSN
| SSN | Result |
| :- | :- |
| `000-11-2222` | Status set to `needs-additional-info` — requires identity (front and back) and address files for the principal |
| `000-11-3333` | Status set to `needs-additional-info` — principal receives email to complete KYC via provider |
You can't combine `000-CAD-AUTO-APPROVE` with a sentinel SSN in the same submission — the request returns `400 cannot combine auto-approve tax ID with auto-feedback principal presets`.
***
All sandbox test values are ignored silently in production.
# Webhooks
Source: https://docs.cadanapay.com/reference/webhooks
Receive real-time notifications when events occur on the Cadana platform
Webhooks are automated HTTP POST requests sent by Cadana when events occur on the platform. Use them to get real-time updates for transactions, payroll, KYC, and more.
***
## Webhook Structure
Every webhook follows this envelope structure:
```json JSON theme={null}
{
"eventType": "transaction.succeeded",
"id": "e13b9e14-c062-42ea-8563-8fc9223b29b5",
"version": "1.0",
"timestamp": 1681006175,
"data": {
...
}
}
```
| Field | Description |
| :- | :- |
| `id` | Unique webhook ID. Use this to deduplicate — retried webhooks are sent with the same `id`. |
| `eventType` | The event type (e.g., `transaction.succeeded`). See [Events](/reference/events) for the full list. |
| `version` | Webhook version |
| `timestamp` | Unix timestamp when the webhook was sent |
| `data` | Event-specific payload. See [Events](/reference/events) for payload structures. |
***
## Getting Started
### Configure Your Endpoint
1. Navigate to **Settings** → **Developers** → **Webhooks** in the [Dashboard](https://app.cadanapay.com)
2. Click **Add Webhook**
3. Enter the URL where you want to receive events
Platform integrators configure webhooks in the [Platform Dashboard](https://platform.cadanapay.com) instead — the same **Settings** → **Developers** → **Webhooks** path. A platform endpoint receives events for all businesses on the platform; use the `tenantKey` in each payload to tell them apart.
### Handle Events
After registering an endpoint, Cadana sends an HTTP POST request to your URL every time a subscribed event occurs. Parse the `eventType` field to determine how to handle each event.
### Respond Promptly
Your endpoint must return a `2xx` status code to acknowledge receipt. Any response outside the `2xx` range — including `3xx` redirects — is treated as a failure and will trigger retries.
Respond with `200 OK` immediately, then process the payload asynchronously via a message queue. This prevents timeouts.
***
## Verifying Webhooks
Every webhook includes signature headers you should verify to confirm it was sent by Cadana.
### Signature Headers
| Header | Description |
| :- | :- |
| `svix-id` | Unique message ID from the delivery provider |
| `svix-timestamp` | Unix timestamp of the webhook |
| `svix-signature` | HMAC-SHA256 signature of the payload |
### Verification
Use the [Svix libraries](https://docs.svix.com/receiving/verifying-payloads/how) to verify signatures automatically:
```javascript JavaScript theme={null}
import { Webhook } from "svix";
const wh = new Webhook("your-signing-secret");
const payload = wh.verify(body, headers);
```
```python Python theme={null}
from svix.webhooks import Webhook
wh = Webhook("your-signing-secret")
payload = wh.verify(body, headers)
```
```go Go theme={null}
wh, _ := svix.NewWebhook("your-signing-secret")
payload, _ := wh.Verify(body, headers)
```
Your signing secret is available in **Settings** → **Developers** → **Webhooks** in the Dashboard.
For manual verification, see the [Svix manual verification docs](https://docs.svix.com/receiving/verifying-payloads/how-manual).
***
## Retry Policy
If your endpoint fails to respond with a `2xx` status code, Cadana retries delivery with exponential backoff:
| Attempt | Delay |
| :- | :- |
| 1st retry | 5 seconds |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 5 hours |
| 6th retry | 10 hours |
| 7th retry | 24 hours |
After all retries are exhausted (\~3 days), the webhook is marked as failed.
***
## Best Practices
1. **Respond immediately** — Return `200 OK` first, process asynchronously. Don't do heavy work before responding.
2. **Deduplicate** — Use the `id` field to detect and skip duplicate deliveries. Retried webhooks keep the same `id`.
3. **Fetch the latest state** — Webhooks can arrive out of order. After receiving an event, fetch the resource via API (e.g., `GET /v1/payouts/{id}`) to get the latest state.
4. **Implement reconciliation** — Webhook delivery is not guaranteed. Periodically poll the API to catch any missed events.
5. **Verify signatures** — Always verify the `svix-signature` header before processing a webhook to prevent spoofing.
# Working with Files
Source: https://docs.cadanapay.com/reference/working-with-files
Upload and download files for KYC documents, contracts, address proofs, and more
Cadana uses files for identity verification (KYC), business onboarding (KYB), contracts, reimbursement proofs, and address verification. All files follow a **two-step upload flow**: first reserve a file ID and pre-signed URL, then upload the file bytes directly to cloud storage.
***
## Upload a File
### Step 1: Reserve a file ID
Call the upload URL endpoint with the file's `purpose` and `contentType`. You'll receive a `fileId` to reference in later API calls and a `putUrl` to upload the actual file.
**Response:**
```json JSON theme={null}
{
"fileId": "517075a2-17db-469f-9481-eb8347cb920c",
"putUrl": "https://cadana-kyc.s3.amazonaws.com/tmp/file_9c52fa2e?X-Amz-Algorithm=...",
"expiresIn": 900
}
```
| Field | Description |
| :- | :- |
| `fileId` | Unique identifier to reference this file in other API calls |
| `putUrl` | Pre-signed URL for uploading the file bytes (single-use) |
| `expiresIn` | Seconds until the `putUrl` expires (900 = 15 minutes) |
### Step 2: Upload the file
Use the `putUrl` from the response to upload the file bytes with an HTTP `PUT` request. Set the `Content-Type` header to match the `contentType` you specified in Step 1.
```bash Bash theme={null}
curl -X PUT '{putUrl}' \
-H 'Content-Type: image/jpeg' \
--data-binary @/path/to/document.jpg
```
The `putUrl` expires after **15 minutes** and can only be used once. If it expires, request a new upload URL.
Once the upload completes, the `fileId` is ready to use in API calls like [KYC submission](/platform/kyc-verification), [contract upload](/workforce/manage-contracts), or beneficiary creation.
***
## File Purposes
Each file must be tagged with a `purpose` that describes how it will be used. The following values are supported:
| Purpose | Description |
| :- | :- |
| `kyc-id-front` | Front of a government-issued identity document |
| `kyc-id-back` | Back of a government-issued identity document |
| `kyc-selfie` | Selfie photo for identity verification |
| `kyc-id-selfie` | Photo of user holding their identity document |
| `kyc-address-proof` | Proof of address (utility bill, bank statement, etc.) |
| `kyb-incorporation-document` | Certificate of incorporation for business verification |
| `kyb-tax-certificate` | Tax registration certificate for business verification |
| `kyb-address-proof` | Proof of business address |
| `business-onboarding-document` | General business onboarding document |
| `reimbursements-proof` | Receipt or proof for expense reimbursement |
| `contract` | Employment or service contract PDF |
| `statutory-document` | Supporting document for a statutory filing field (e.g. an identity card or signed offer letter) |
***
## Download a File
Retrieve file details and a pre-signed download URL by file ID.
**Response:**
```json JSON theme={null}
{
"fileId": "1d3f870c-bc91-4636-a0d4-8e54bccf7d64",
"purpose": "kyc-id-front",
"getUrl": "https://cadana-kyc.s3.amazonaws.com/...",
"expiresIn": 899,
"createdAt": 1748478276,
"tenantKey": "cad95193904"
}
```
The `getUrl` is a pre-signed, single-use download link. Request a new one each time you need to download the file.
***
## Where Files Are Used
| Use Case | API Fields | Guide |
| :- | :- | :- |
| KYC identity verification | `idDetails.frontFileId`, `idDetails.backFileId`, `idDetails.selfieFileId` | [KYC Verification](/platform/kyc-verification) |
| KYC address proof | `addressProofFileId` | [KYC Verification](/platform/kyc-verification#address-proof-document) |
| Contract upload | `fileId` on contract creation | [Manage Contracts](/workforce/manage-contracts) |
| Business onboarding (KYB) | Incorporation documents, tax certificates | [KYB Requirements](/platform/kyb-requirements) |
| Beneficiary KYC | `fileId` on beneficiary creation | [Making a Payment](/payments/making-a-payment) |
| Statutory filing documents | `document`-typed fields in the statutory fields endpoints | [Statutory: Getting Started](/statutory/getting-started) |
***
## Image Requirements
* **Supported formats:** JPEG, PNG
* Images must be clear and readable
* All text and photos on the document must be visible
* File size should be reasonable for the document type (typically under 10 MB)
# Statutory Filing
Source: https://docs.cadanapay.com/statutory/filing
File statutory returns with government authorities across jurisdictions
**Beta** — this functionality is fully supported today: statutory obligations are calculated, withheld, filed, and remitted for you as part of [Cadana's payroll process](/workforce/pay-workers-via-payroll). What's in beta is the standalone API surface on this page, which puts filings and remittances under your direct, self-serve control. Contact Cadana to enable it for your business.
A filing is the unit of statutory obligation — one return for one authority, one period. Every filing is described by a `breakdown` of contribution lines that sum to the filing total — that breakdown is what gives the filing its meaning, whether Cadana prepared it or you did.
Filings come from two sources:
* **`source: cadana`** — Cadana submits the return to the authority on your behalf. Each breakdown line must reference a `personId`. You can either link a Cadana payroll run (Cadana populates the breakdown from the tax engine) or provide the breakdown yourself if you computed the amounts elsewhere. Filings run the full `pending → in_review → approved → submitted → accepted` lifecycle.
* **`source: external`** — You filed the return with the authority yourself and are sending Cadana the resulting data so a remittance can be created. Breakdown lines may use `personId`, `externalEmployeeId`, or neither (for business-level taxes). The filing is created directly in `accepted` and skips the submission lifecycle. See the [Remittance walkthrough](/statutory/remittance#remittance-only-walkthrough-external-filing) for the end-to-end flow.
This page covers the `source: cadana` flow — Cadana-prepared filings — including review, approval, and submission.
***
## How It Works
After payroll closes, batch-create all required filings for the period. Cadana determines the applicable filing types based on country, business registrations, and employee profiles.
Your finance team reviews the amounts and line items, then approves each filing for submission.
Cadana submits the filing to the relevant government authority (SAT, IMSS, INFONAVIT, etc.).
The authority confirms receipt. Download the acknowledgement document for your records.
***
## Step 1: Create Filings
### From a Cadana Payroll Run
Create all required filings for a period in a single call by referencing a completed payroll run:
**Response:**
```json theme={null}
{
"data": [
{
"id": "fil_01JKL",
"source": "cadana",
"filingTypeId": "ft_mx_isr_monthly",
"filingTypeName": "ISR Withholding",
"authorityName": "SAT",
"status": "pending",
"dueAt": "2026-03-17",
"amounts": { "currency": "MXN", "total": 82345.00 }
},
{
"id": "fil_02MNO",
"source": "cadana",
"filingTypeId": "ft_mx_imss_bimonthly",
"filingTypeName": "IMSS Contributions",
"authorityName": "IMSS",
"status": "pending",
"dueAt": "2026-03-17",
"amounts": { "currency": "MXN", "total": 45123.00 }
}
]
}
```
### Without a Cadana Payroll Run
If you calculate taxes outside Cadana but want Cadana to file with the authority, create the filing one at a time with `source: cadana` and provide the `breakdown` directly. Each breakdown line must reference a `personId` so Cadana has the worker context it needs to file. The filing still runs the full review and submission lifecycle.
Already filed with the authority yourself and just need Cadana to remit? Use `source: external` instead — the filing is created directly in `accepted`, breakdown lines can identify workers by `externalEmployeeId`, and you provide the `filingReference` from the authority. See the [Remittance-only walkthrough](/statutory/remittance#remittance-only-walkthrough-external-filing).
### Inspect Filing Detail
Each filing returns its `breakdown` (the contribution lines you supplied or that Cadana derived from payroll) plus a derived `amounts` rollup:
**Response:**
```json theme={null}
{
"id": "fil_01JKL",
"businessId": "biz_01ABC",
"source": "cadana",
"filingTypeId": "ft_mx_isr_monthly",
"filingTypeName": "ISR Withholding",
"authorityId": "auth_mx_sat",
"authorityName": "SAT",
"countryCode": "MX",
"period": { "start": "2026-02-01", "end": "2026-02-28", "label": "February 2026" },
"status": "pending",
"dueAt": "2026-03-17",
"workerCount": 15,
"amounts": {
"currency": "MXN",
"total": 82345.00
},
"breakdown": [
{ "personId": "per_01ABC", "employeeName": "María García López", "amount": 5489.67 },
{ "personId": "per_02DEF", "employeeName": "Juan Pérez Rivera", "amount": 6121.40 },
{ "personId": "per_03GHI", "employeeName": "Ana Hernández", "amount": 4998.20 }
],
"payrollRunIds": ["pr_01MNO"],
"createdAt": 1709280000
}
```
`amounts.total` is computed from `breakdown` — you never pass `amounts` on create. The breakdown is the source of truth; the total is just its sum.
***
## Step 2: Review and Approve
Before Cadana submits a filing, it must be approved. This gives your finance team a chance to review the amounts.
The endpoint returns `204 No Content` on success. Fetch the filing to see the updated status:
**Response:**
```json theme={null}
{
"id": "fil_01JKL",
"filingTypeId": "ft_mx_isr_monthly",
"filingTypeName": "ISR Withholding",
"authorityName": "SAT",
"status": "approved",
"dueAt": "2026-03-17",
"amounts": { "currency": "MXN", "total": 82345.00 },
"statusHistory": [
{ "from": "in_review", "to": "approved", "timestamp": "2026-03-05T14:30:00Z" }
]
}
```
A filing must be in `in_review` status before it can be approved. Once approved, the filing is queued for submission. Review amounts carefully before approving — reversals require contacting the tax authority directly.
***
## Step 3: Submission and Acceptance
After approval, Cadana submits the filing to the authority. The status transitions through `submitted` → `accepted`. Poll the filing or listen for the `filing.accepted` webhook event:
```json theme={null}
{
"id": "fil_01JKL",
"filingTypeId": "ft_mx_isr_monthly",
"filingTypeName": "ISR Withholding",
"authorityId": "auth_mx_sat",
"authorityName": "SAT",
"status": "accepted",
"dueAt": "2026-03-17",
"filingReference": "0123456ABCD7890EF",
"amounts": { "currency": "MXN", "total": 82345.00 },
"statusHistory": [
{ "from": "pending", "to": "in_review", "timestamp": 1709654400 },
{ "from": "in_review", "to": "approved", "timestamp": 1709656200 },
{ "from": "approved", "to": "submitted", "timestamp": 1709658000 },
{ "from": "submitted", "to": "accepted", "timestamp": 1709658150 }
]
}
```
Once the authority confirms receipt, Cadana populates the `filingReference` field (e.g., the Línea de Captura returned by SAT). This is the reference your subsequent remittance will use to attribute the payment.
***
## Filing Lifecycle
The lifecycle below applies to `source: cadana` filings. `source: external` filings are created directly in `accepted` and do not transition through the other states.
```
pending → in_review → approved → submitted → accepted
| | |
v v v
blocked blocked rejected
| | |
v v v
pending pending pending (retry)
```
| Status | Description |
| :- | :- |
| `pending` | Created with amounts, ready for review |
| `in_review` | Validated and ready for approval |
| `approved` | Queued for submission to government authority |
| `submitted` | Sent to the authority |
| `accepted` | Authority acknowledged receipt — terminal state for both `cadana` and `external` |
| `blocked` | Missing data prevents submission — returns to `pending` when data is updated |
| `rejected` | Authority rejected the filing — returns to `pending` for correction |
***
## Next Steps
Pay government authorities after filings are accepted
See filing and remittance together in an end-to-end walkthrough
Discover jurisdictions, required fields, and requirements
Full endpoint documentation
# Getting Started
Source: https://docs.cadanapay.com/statutory/getting-started
Discover supported jurisdictions, required fields, and outstanding compliance requirements
## Prerequisites
Get your API key from the [Cadana Dashboard](https://app.cadanapay.com). See [Authentication](/authentication) for details.
At least one business must be created with workers [onboarded](/workforce/onboarding-workers) in a supported jurisdiction.
***
## Core Concepts
The Statutory Compliance API handles the post-payroll compliance lifecycle — filing statutory returns with government authorities and remitting payments. Depending on which [mode](/statutory/overview#choose-what-you-need) you're using, you'll engage different parts of the workflow below:
* **Full loop & Filing-only** — You go through the whole discover → check → submit → file workflow because Cadana needs complete worker data to prepare the return on your behalf.
* **Remittance-only** — You file with the authority yourself, so you can skip most of the discovery flow. The minimum you need is a [business](/workforce/manage-contracts) and the filing totals + Línea de Captura (or equivalent reference) to submit per period. Jump straight to the [Remittance-only walkthrough](/statutory/remittance#remittance-only-walkthrough-external-filing).
For the filing-data path, the statutory API acts as the **single front door**: you submit all required data through the statutory endpoints, and the API handles the rest.
```mermaid theme={null}
flowchart LR
A[Discover required fields] --> B[Check what's missing]
B --> C[Submit missing data]
C --> D[Cadana files & remits]
```
The developer workflow is:
1. **Discover** — Call the required-fields endpoint to see what's needed for each jurisdiction
2. **Check** — Call the requirements endpoint to see what's still missing for a specific person or business
3. **Submit** — Send the missing data through the statutory PUT endpoints
4. **File & Remit** — Cadana handles filing with authorities and government payments
***
## Step 1: Discover Jurisdictions
List every jurisdiction the Statutory API supports, along with the filing types available in each.
**Response:**
```json theme={null}
{
"data": [
{
"countryCode": "MX",
"countryName": "Mexico",
"currency": "MXN",
"filingTypesCount": 4,
"payrollFrequency": "MONTHLY",
"status": "live"
},
{
"countryCode": "BR",
"countryName": "Brazil",
"currency": "BRL",
"filingTypesCount": 4,
"payrollFrequency": "MONTHLY",
"status": "live"
}
]
}
```
***
## Step 2: Understand a Jurisdiction
Once you know which jurisdictions are available, drill into a specific country to see its filing types, deadlines, and cadence.
**Response:**
```json theme={null}
{
"data": [
{
"id": "ft_mx_isr_monthly",
"name": "ISR Withholding",
"description": "Monthly income tax withholding return",
"governmentFormName": "Declaración Provisional ISR",
"authority": { "id": "auth_mx_sat", "name": "Servicio de Administración Tributaria", "abbreviation": "SAT" },
"frequency": "MONTHLY",
"dueDay": 17,
"includesRemittance": true,
"legislativeReference": "Ley del Impuesto Sobre la Renta (LISR), Artículo 96"
},
{
"id": "ft_mx_imss_bimonthly",
"name": "IMSS Contributions",
"description": "Social security contributions",
"authority": { "id": "auth_mx_imss", "name": "Instituto Mexicano del Seguro Social", "abbreviation": "IMSS" },
"frequency": "BIMONTHLY",
"dueDay": 17,
"includesRemittance": true,
"legislativeReference": "Ley del Seguro Social (LSS), Artículo 39"
},
{
"id": "ft_mx_infonavit_bimonthly",
"name": "INFONAVIT Contributions",
"description": "Housing fund contributions",
"authority": { "id": "auth_mx_infonavit", "name": "Instituto del Fondo Nacional de la Vivienda para los Trabajadores", "abbreviation": "INFONAVIT" },
"frequency": "BIMONTHLY",
"dueDay": 17,
"includesRemittance": true,
"legislativeReference": "Ley del INFONAVIT, Artículo 29"
},
{
"id": "ft_mx_isn_monthly",
"name": "ISN Payroll Tax",
"description": "State-level payroll tax",
"authority": { "id": "auth_mx_state", "name": "State Tax Authority", "abbreviation": "State" },
"frequency": "MONTHLY",
"dueDay": 17,
"includesRemittance": true,
"legislativeReference": "Ley de Hacienda del Estado (varies by state)"
}
]
}
```
### What Fields Are Required?
Before filing, you need to know which fields are required for tax calculation and statutory filing. The required-fields endpoint returns all fields needed in a given jurisdiction — each field includes an `entity` (who the field belongs to) and a `scope` (what it's used for).
Use this response to build onboarding forms for each jurisdiction. Each field includes an `entity` value of `"person"` or `"business"`, so you can render separate forms for worker data and employer data.
**Response:**
```json theme={null}
{
"countryCode": "MX",
"workerType": "employee",
"version": 1,
"fields": [
{
"key": "firstName",
"name": "First Name",
"description": "Worker's legal first name, required on all statutory returns",
"type": "string",
"required": true,
"entity": "person",
"scope": ["filing"]
},
{
"key": "lastName",
"name": "Last Name",
"description": "Worker's legal last name, required on all statutory returns",
"type": "string",
"required": true,
"entity": "person",
"scope": ["filing"]
},
{
"key": "jobTitle",
"name": "Job Title",
"description": "Worker's job title, required for statutory classification",
"type": "string",
"required": true,
"entity": "person",
"scope": ["filing"]
},
{
"key": "startDate",
"name": "Employment Start Date",
"description": "Date the worker started employment, required for social security and filing",
"type": "string",
"required": true,
"entity": "person",
"scope": ["filing"]
},
{
"key": "rfc",
"name": "Registro Federal de Contribuyentes (RFC)",
"description": "13-character personal tax ID issued by SAT",
"type": "string",
"pattern": "^[A-Z&Ñ]{4}\\d{6}[A-Z0-9]{3}$",
"required": true,
"entity": "person",
"scope": ["filing"]
},
{
"key": "nss",
"name": "Número de Seguridad Social (NSS)",
"description": "11-digit lifetime IMSS affiliation number",
"type": "string",
"pattern": "^\\d{11}$",
"required": true,
"entity": "person",
"scope": ["filing"]
},
{
"key": "addressState",
"name": "State / Province / Region",
"description": "State or province of residence, used for regional tax and social security filings",
"type": "string",
"required": false,
"entity": "person",
"scope": ["filing", "calculation"],
"enum": ["AGU", "BCN", "BCS", "CAM", "CHP", "…"]
},
{
"key": "businessRfc",
"name": "Business RFC (RFC Persona Moral)",
"description": "12-character company tax ID issued by SAT",
"type": "string",
"pattern": "^[A-ZÑ&]{3}\\d{6}[A-Z0-9]{3}$",
"required": true,
"entity": "business",
"scope": ["filing"]
}
]
}
```
Each field has two key attributes:
* **`entity`** — Who the field belongs to: `person` or `business`. This tells you which PUT endpoint to use.
* **`scope`** — What it's used for: `filing` fields go to the statutory PUT endpoint. `calculation` fields go to [`PUT /v1/persons/{id}/taxProfile`](/api-reference/workforce/persons/update-tax-profile).
### How to Submit the Fields
The statutory service is the **single front door** for all filing data. You only need two endpoints:
| Scope | Endpoint |
| :- | :- |
| `filing` | [`PUT /v1/statutory/persons/{personId}/fields/{countryCode}`](/api-reference/statutory/statutory-fields/submit-person-filing-fields) or [`PUT /v1/statutory/businesses/{businessId}/fields/{countryCode}`](/api-reference/statutory/statutory-fields/submit-business-filing-fields) (based on `entity`) |
| `calculation` | [`PUT /v1/persons/{personId}/taxProfile`](/api-reference/workforce/persons/update-tax-profile) |
The workflow is:
1. **Discover** what's needed → `GET /v1/statutory/jurisdictions/{cc}/required-fields`
2. **Check** what's missing → `GET /v1/statutory/requirements?personId=...&countryCode=...`
3. **Submit** what's missing → each requirement's `resolutionGuide` tells you which endpoint to call
The statutory PUT endpoint accepts **all** filing-scope fields — including fields like `firstName` or `jobTitle` that may already exist on the person record. Data already on the person record auto-populates and won't appear as an outstanding requirement, so in practice you're only submitting the fields that are actually missing.
Both return `204 No Content` on success.
#### Document Fields
Some jurisdictions require supporting documents (e.g. India requires Aadhaar and PAN card images) alongside data fields. These appear in the required-fields response with `"type": "document"`, and their value is a **file ID** rather than a raw string:
1. **Upload the document** using the [two-step file upload flow](/reference/working-with-files) with purpose `statutory-document`:
2. **PUT the file bytes** to the returned `putUrl`.
3. **Submit the `fileId`** as the field value:
The service verifies the file exists, was uploaded with purpose `statutory-document`, and belongs to your account — otherwise the request is rejected with `400`.
When you read the data back, document fields are returned as an object with a fresh presigned download link (valid for \~15 minutes — request again when you need a new one):
```json theme={null}
{
"aadhaarCardFront": {
"fileId": "f1e2d3c4-b5a6-4789-9abc-def012345678",
"url": "https://...presigned download URL...",
"fileUploaded": true
}
}
```
#### Read Back Filing Data
The GET endpoint returns a merged view — fields you submitted via PUT combined with data already on the person or business record:
**Response:**
```json theme={null}
{
"entityType": "person",
"entityId": "per_xyz789",
"countryCode": "MX",
"fields": {
"firstName": "María",
"lastName": "García",
"rfc": "GAPM900101ABC",
"nss": "12345678901",
"jobTitle": "Software Engineer",
"startDate": "2026-03-01",
"addressState": "JAL"
}
}
```
Fields like `firstName`, `jobTitle`, and `startDate` were already on the person record from onboarding — they appear automatically in the merged response without needing to be submitted again.
***
## Step 3: Check Requirements
After onboarding, use the requirements endpoint to see what's still missing. This is the most reliable way to check whether a person or business is ready for statutory filing — you don't need to manually cross-reference the required-fields list.
Each requirement's `affectedField` is the same required-field key returned by the required-fields endpoint and accepted by the statutory PUT endpoint, so you can join the required-fields and requirements responses directly on this shared key — no manual mapping needed. The human-readable label is the top-level `name`, and the affected worker or business is identified by the top-level `resourceType` and `resourceId`.
**How requirements are scoped:**
* To check a worker's missing data, query by `personId` and `countryCode` (both required). The response covers that one worker.
* A `businessId` query returns employer/business-level requirements — registrations, business tax IDs, and similar.
### Business-Level Requirements
If a person is linked to a sub-entity via `jobInfo.entityId`, the entity's registration details (taxId, registrationNumber, address) are used for statutory requirements instead of the parent business's. See [Entity Management](/workforce/entity-management).
**Response:**
```json theme={null}
{
"data": [
{
"category": "business_registration",
"status": "outstanding",
"name": "IMSS Employer Registration Number (Registro Patronal — NRP)",
"description": "IMSS employer registration number is required for social security filings",
"affectedField": "imssEmployerRegistration",
"resourceType": "business",
"resourceId": "bus_abc123",
"resolutionGuide": "Submit via PUT /v1/statutory/businesses/{id}/fields/{countryCode}"
}
]
}
```
Each requirement includes a `resolutionGuide` that points to the statutory service's field submission endpoint — [`PUT /v1/statutory/persons/{id}/fields/{cc}`](/api-reference/statutory/statutory-fields/submit-person-filing-fields) for person fields or [`PUT /v1/statutory/businesses/{id}/fields/{cc}`](/api-reference/statutory/statutory-fields/submit-business-filing-fields) for business fields. Once you submit the data, the requirement is resolved automatically.
Filings that depend on outstanding requirements will be in `blocked` status until the requirements are resolved. You do not need to manually retry — the Statutory API detects when missing data is submitted and unblocks filings automatically.
### Per-Worker Requirements
You can also check requirements for a specific worker to see what's missing before their next payroll run.
**Response:**
```json theme={null}
{
"data": [
{
"category": "worker_data",
"status": "outstanding",
"name": "Job Title",
"description": "Worker's job title is required for statutory classification",
"affectedField": "jobTitle",
"resourceType": "person",
"resourceId": "per_xyz789",
"resolutionGuide": "Submit via PUT /v1/statutory/persons/{id}/fields/{countryCode}"
},
{
"category": "worker_data",
"status": "outstanding",
"name": "Número de Seguridad Social (NSS)",
"description": "Worker NSS is required for IMSS and INFONAVIT filings",
"affectedField": "nss",
"resourceType": "person",
"resourceId": "per_xyz789",
"resolutionGuide": "Submit via PUT /v1/statutory/persons/{id}/fields/{countryCode}"
}
]
}
```
Filter by `status=outstanding` to find what still needs attention. Once you submit the missing data via the statutory PUT endpoint, requirements resolve automatically.
***
## Step 4: Check the Compliance Calendar
Once requirements are met, list upcoming and active filings for a business. This gives you a view of the compliance calendar across all jurisdictions.
**Response:**
```json theme={null}
{
"data": [
{
"id": "fil_001",
"businessId": "bus_abc123",
"countryCode": "MX",
"source": "cadana",
"filingTypeId": "ft_mx_isr_monthly",
"filingTypeName": "ISR Withholding",
"authorityName": "SAT",
"status": "pending",
"period": { "start": "2024-06-01", "end": "2024-06-30", "label": "June 2024" },
"dueAt": "2024-07-17",
"workerCount": 12,
"createdAt": "2024-07-01T00:00:00Z"
},
{
"id": "fil_002",
"businessId": "bus_abc123",
"countryCode": "MX",
"source": "cadana",
"filingTypeId": "ft_mx_imss_bimonthly",
"filingTypeName": "IMSS Contributions",
"authorityName": "IMSS",
"status": "blocked",
"period": { "start": "2024-04-01", "end": "2024-06-30", "label": "Q2 2024" },
"dueAt": "2024-07-17",
"workerCount": 12,
"createdAt": "2024-07-01T00:00:00Z"
}
],
"summary": {
"total": 4,
"pending": 2,
"inReview": 0,
"approved": 0,
"submitted": 0,
"accepted": 0,
"blocked": 1,
"cancelled": 0,
"rejected": 0
}
}
```
The `summary` object provides a quick overview of your compliance posture. Use `blocked` to surface filings that need attention.
***
## Next Steps
Walk through the complete calculate, file, remit, prove cycle
Full endpoint documentation
# Overview
Source: https://docs.cadanapay.com/statutory/overview
Automate statutory filings and government remittances across jurisdictions
The Statutory Compliance API automates the post-payroll compliance lifecycle — filing statutory returns with government authorities and remitting payments through Cadana's local payment rails. It builds on the existing [Tax Calculation](/tax/overview), [Person](/workforce/onboarding-workers), and [Payroll](/workforce/pay-workers-via-payroll) APIs.
**Availability** — jurisdiction discovery, required fields, compliance requirements, and statutory data submission are generally available. Statutory obligations themselves are fully handled today — calculated, withheld, filed, and remitted — as part of [Cadana's payroll process](/workforce/pay-workers-via-payroll). The standalone filing, remittance, compliance-summary, and events APIs, which give you direct self-serve control over that lifecycle, are in **beta** — contact Cadana to enable them for your business.
***
## Key Capabilities
Create and submit statutory returns to government authorities across jurisdictions.
Pay what's owed through local payment rails with automatic FX conversion.
Real-time visibility into filing status, deadlines, and requirements across all countries.
Every action recorded with legislative citations and immutable event history.
***
## Choose What You Need
Filing and remittance are two co-equal products. A remittance always points at an `accepted` filing — what varies between modes is who prepared the filing data and whether Cadana also moves the money.
Cadana prepares and submits statutory returns to the authority. You handle the payment yourself outside Cadana.
You file with the authority yourself, then submit the resulting totals to Cadana so Cadana can pay the authority through local rails.
The full loop. Cadana prepares the filing, submits to the authority, and pays.
The data model is the same across all three — a `Filing` resource (with `source: cadana` or `source: external`) plus, where applicable, a `Remittance` resource that references it. The difference between modes is which endpoints you call:
* **Filing only** — Create filings with `source: cadana` and approve them for submission. Skip the `/remittances` endpoints entirely. See the [Filing guide](/statutory/filing).
* **Remittance only** — Create filings with `source: external` (you provide totals + `filingReference` + per-employee breakdown), then call [`POST /v1/statutory/remittances`](/api-reference/statutory/remittances/create-a-remittance). See the [Remittance-only walkthrough](/statutory/remittance#remittance-only-walkthrough-external-filing).
* **Both** — Create filings with `source: cadana`, approve them, then remit. See the [full loop guide](/statutory/the-full-loop).
***
## The Compliance Loop
The [Tax Calculation API](/tax/overview) computes gross-to-net deductions with legislative citations.
Create filings from payroll runs — Cadana determines applicable filing types per jurisdiction.
Initiate government payments through local payment rails with automatic currency conversion.
Download filed documents, payment receipts, and audit the full event trail.
***
## Submitting Compliance Data
The statutory API is the **single front door** for all filing data. You discover what's needed, check what's missing, and submit it through the statutory endpoints. See the [Getting Started guide](/statutory/getting-started) for the full workflow.
| What | Endpoint |
| :- | :- |
| Person filing fields | [`PUT /v1/statutory/persons/{id}/fields/{cc}`](/api-reference/statutory/statutory-fields/submit-person-filing-fields) |
| Business filing fields | [`PUT /v1/statutory/businesses/{id}/fields/{cc}`](/api-reference/statutory/statutory-fields/submit-business-filing-fields) |
| Tax calculation inputs | [`PUT /v1/persons/{id}/taxProfile`](/api-reference/workforce/persons/update-tax-profile) |
Data already on the person or business record (name, address, tax ID, etc.) auto-populates — you only need to submit what's actually missing.
When missing data is submitted, the Statutory API detects the change and unblocks filings automatically — no manual retries needed.
***
## Filing Lifecycle
`source: cadana` filings follow the full lifecycle below. `source: external` filings are created directly in `accepted` and skip the submission states — Cadana never submits them to the authority because you already did.
```
pending → in_review → approved → submitted → accepted
| | |
v v v
blocked blocked rejected
| | |
v v v
pending pending pending (retry)
```
* **pending** — Created with amounts, being validated
* **in\_review** — Validated and ready for approval
* **approved** — Queued for submission to government authority
* **submitted** — Sent to the authority
* **accepted** — Authority acknowledged receipt. Terminal state for `cadana` and `external` filings alike.
* **blocked** — Missing data or requirements prevent submission
* **rejected** — Authority rejected the filing — returns to pending for correction
* **cancelled** — Filing was cancelled
***
## Guides
Discover jurisdictions, required fields, and outstanding requirements
Create, review, approve, and submit filings to government authorities
Pay government authorities with automatic FX conversion
Walk through the complete calculate, file, remit, prove cycle
***
## API Reference
Full endpoint documentation
# Statutory Remittance
Source: https://docs.cadanapay.com/statutory/remittance
Pay government authorities through local payment rails with automatic FX conversion
**Beta** — this functionality is fully supported today: statutory obligations are calculated, withheld, filed, and remitted for you as part of [Cadana's payroll process](/workforce/pay-workers-via-payroll). What's in beta is the standalone API surface on this page, which puts filings and remittances under your direct, self-serve control. Contact Cadana to enable it for your business.
Cadana remits payroll taxes and statutory contributions to government authorities on your behalf. Every remittance is tied to an `accepted` filing — whether [Cadana prepared the filing](/statutory/filing) (`source: cadana`) or you filed it yourself and submitted the totals to Cadana (`source: external`). Cadana debits your business balance, converts FX if needed, and routes the payment through local rails.
***
## How It Works
Every remittance points at one or more `accepted` filings. For `source: cadana` filings Cadana drives the filing through `accepted` itself; for `source: external` filings the customer creates them directly in `accepted` after filing with the authority.
Specify which filings to pay and the currency to fund from. Optionally provide `paymentDetails` (same shape as a beneficiary in Embedded Payments) to route the payment to a bank account you specify — otherwise Cadana uses its internal default routing for the authority.
Cadana handles FX conversion (if needed) and delivers the payment to the government account via local rails. The filing's `filingReference` is used as the bank attribution reference (or override it with `paymentReference` on the remittance).
Once the payment is confirmed, download the receipt for your audit trail.
***
## Remittance-Only Walkthrough (External Filing)
Use this flow when you file statutory returns with the authority yourself and just need Cadana to remit the payments. The data you'd normally collect in an internal Excel workbook — totals, government references, per-employee breakdowns, bank routing — maps directly to the API.
### Scenario
Your Mexican business closes February 2026 payroll with 15 employees. You've already filed all four returns through the government portals and received a Línea de Captura for each:
| Filing | Authority | Total (MXN) | Línea de Captura |
| :- | :- | -: | :- |
| ISR Withholding | SAT | 82,345.00 | `0123456ABCD7890EF` |
| IMSS Contributions | IMSS | 45,123.00 | `9876543ZYXW1234AB` |
| INFONAVIT Contributions | INFONAVIT | 18,400.00 | `1122334455667788CD` |
| ISN Payroll Tax (Jalisco) | State (JAL) | 6,250.00 | `JAL-2026-02-PTR123` |
### Step 1: Create an External-Source Filing
For each return, create a filing with `source: external`. Provide the total, the Línea de Captura as `filingReference`, and a per-employee `breakdown` — Cadana stores the breakdown so each remittance can be reconciled back to specific workers without those workers needing person records in Cadana.
**Response:**
```json theme={null}
{ "id": "fil_01JKL" }
```
The filing is created directly in `accepted` — there's no review or submission step on Cadana's side because you've already submitted the return to SAT. Repeat this call once for each of the other three returns (IMSS, INFONAVIT, ISN).
Identify workers in `breakdown` using `externalEmployeeId` (your own ID) when they're not onboarded into Cadana, or `personId` when they are. For business-level taxes that aren't allocated per employee, a breakdown line can omit both — just `amount`.
### Step 2: Create the Remittance
Once all four filings exist, create remittances against them. You can batch filings to the same authority into one remittance, or create them one-by-one — whichever matches the bank transfers you're going to fund.
The example below shows the ISR remittance with a customer-provided CLABE as the destination. The CLABE goes under `paymentDetails.bank.accountNumber` — the same shape used for beneficiaries in the [Embedded Payments API](/payments/overview). The Línea de Captura supplied on the filing is automatically used as the bank reference unless you override it with `paymentReference`.
**Response:**
```json theme={null}
{ "id": "rem_01STU" }
```
Omit `paymentDetails` to use Cadana's internal default routing for the authority. Provide it when you want to specify the routing yourself — useful when you manage your own statutory-payment account mapping. The `paymentDetails` shape is the same `paymentMethods` object used by vendor payments and beneficiaries elsewhere in the API, so the routing primitives (`bank`, `momo`) work the same way across products.
### Step 3: Track and Reconcile
Fetch the remittance to see the FX conversion, the destination Cadana used, and the lifecycle:
**Response:**
```json theme={null}
{
"id": "rem_01STU",
"businessId": "biz_01ABC",
"filingIds": ["fil_01JKL"],
"status": "completed",
"countryCode": "MX",
"amount": 82345.00,
"fundedAmount": 4574.80,
"fundedCurrency": "USD",
"fxRate": 18.001,
"authorityId": "auth_mx_sat",
"authorityName": "SAT",
"paymentMethod": "local_rails",
"paymentDetails": {
"preferredMethod": "bank",
"bank": {
"accountName": "SAT — ISR Federal Treasury",
"accountNumber": "012345678901234567",
"bankName": "BBVA México",
"bankCode": "012",
"currency": "MXN"
}
},
"paymentReference": "0123456ABCD7890EF",
"receiptUrl": "https://api.cadanapay.com/v1/statutory/remittances/rem_01STU/receipt",
"createdAt": 1741590000
}
```
`paymentReference` echoes the value Cadana attached to the transfer — defaulted from the filing's `filingReference` (Línea de Captura) unless you overrode it explicitly on the remittance.
***
## After-Filing Walkthrough (Cadana Filing)
When Cadana prepared and submitted the filing (`source: cadana`), the remittance step is simpler — there's no destination to specify and Cadana already knows the period reference. Once the filing reaches `accepted`, create the remittance with just the filing IDs and the funding currency:
When you fund from a different currency than the filing currency, Cadana automatically converts at the current rate. The `fundedAmount` shows exactly how much will be debited from your wallet, and `fxRate` shows the conversion rate applied. You can also fund in the local currency by setting `fundFromCurrency` to `"MXN"`.
***
## Remittance Statuses
| Status | Description |
| :- | :- |
| `pending` | Payment initiated, awaiting processing |
| `processing` | Payment is being processed through local payment rails |
| `completed` | Payment delivered to the government authority |
| `failed` | Payment failed — check the error details and retry |
The status progresses: `pending` → `processing` → `completed`. Listen for the `remittance.completed` webhook event to be notified, or poll [`GET /v1/statutory/remittances/{id}`](/api-reference/statutory/remittances/get-a-remittance).
***
## Download Receipt
Once the remittance is `completed`, download the receipt for your audit trail:
The receipt is returned as a PDF containing the authority's confirmation of payment, the amount, and the filing reference number.
***
## Next Steps
Create Cadana-prepared or external filings before remitting
See filing and remittance together in an end-to-end walkthrough
Discover jurisdictions, required fields, and requirements
Full endpoint documentation
# The Full Loop
Source: https://docs.cadanapay.com/statutory/the-full-loop
Walk through the complete calculate, file, remit, and prove cycle
**Beta** — this functionality is fully supported today: statutory obligations are calculated, withheld, filed, and remitted for you as part of [Cadana's payroll process](/workforce/pay-workers-via-payroll). What's in beta is the standalone API surface on this page, which puts filings and remittances under your direct, self-serve control. Contact Cadana to enable it for your business.
Every payroll run triggers statutory obligations -- taxes, social security, housing contributions. The Statutory Compliance API turns these into a four-phase cycle: **calculate, file, remit, prove**. This guide walks through all four phases with a real-world scenario.
## Scenario
Your business `biz_01ABC` runs payroll in Mexico for 15 employees. February 2026 payroll closes on February 28, producing four statutory filings:
| Filing | Authority | Due Date |
| :- | :- | :- |
| ISR (Income Tax Withholding) | SAT (Servicio de Administracion Tributaria) | March 17, 2026 |
| IMSS (Social Security) | IMSS (Instituto Mexicano del Seguro Social) | March 17, 2026 |
| INFONAVIT (Housing Fund) | INFONAVIT | March 17, 2026 |
| ISN (Payroll Tax) | State Treasury | March 17, 2026 |
***
## Step 1: Batch Create Filings from Payroll
After the payroll run completes, create all required filings for the period in a single call. Cadana automatically determines which filings are required based on the country, business registrations, and employee profiles.
**Response:**
```json theme={null}
{
"data": [
{ "id": "fil_01JKL" },
{ "id": "fil_02MNO" },
{ "id": "fil_03PQR" },
{ "id": "fil_04STU" }
]
}
```
### Filing Detail: ISR
Each filing returns the `breakdown` (the per-contribution lines that sum to the filing total) plus a derived `amounts` rollup. Retrieve the ISR filing to inspect the detail:
**Response:**
```json theme={null}
{
"id": "fil_01JKL",
"businessId": "biz_01ABC",
"source": "cadana",
"filingTypeId": "ft_mx_isr_monthly",
"filingTypeName": "ISR Withholding",
"authorityId": "auth_mx_sat",
"authorityName": "SAT",
"countryCode": "MX",
"period": {
"start": "2026-02-01",
"end": "2026-02-28",
"label": "February 2026"
},
"status": "pending",
"dueAt": "2026-03-17",
"workerCount": 15,
"amounts": {
"currency": "MXN",
"total": 82345.00
},
"breakdown": [
{ "personId": "per_01ABC", "employeeName": "María García López", "amount": 5489.67 },
{ "personId": "per_02DEF", "employeeName": "Juan Pérez Rivera", "amount": 6121.40 },
{ "personId": "per_03GHI", "employeeName": "Ana Hernández", "amount": 4998.20 }
],
"payrollRunIds": ["pr_01MNO"],
"createdAt": 1709280000,
"updatedAt": 1709280000
}
```
All amounts in the API use regular decimal values (e.g., `82345.00`), not lowest-denomination integers. `amounts.total` is computed server-side from the `breakdown` lines.
***
## Step 2: Review and Approve
Before Cadana submits a filing to the authority, it must be approved. This gives your finance team a chance to review the amounts.
### Check Filing Status
### Approve the Filing
Once reviewed, approve it for submission:
The endpoint returns `204 No Content` on success. Fetch the filing to see the updated status:
**Response:**
```json theme={null}
{
"id": "fil_01JKL",
"filingTypeId": "ft_mx_isr_monthly",
"filingTypeName": "ISR Withholding",
"authorityId": "auth_mx_sat",
"authorityName": "SAT",
"status": "approved",
"dueAt": "2026-03-17",
"amounts": { "currency": "MXN", "total": 82345.00 },
"statusHistory": [
{ "from": "pending", "to": "in_review", "timestamp": 1709654400 },
{ "from": "in_review", "to": "approved", "timestamp": 1709656200 }
]
}
```
Once approved, the filing is queued for submission. Review amounts carefully before approving -- reversals require contacting the tax authority directly.
***
## Step 3: Filing Submitted and Accepted
After approval, Cadana submits the filing to the relevant authority. The filing status transitions through `submitted` and then to `accepted` once the authority confirms receipt.
Poll the filing or listen for the `filing.accepted` webhook event:
```json theme={null}
{
"id": "fil_01JKL",
"filingTypeId": "ft_mx_isr_monthly",
"filingTypeName": "ISR Withholding",
"authorityId": "auth_mx_sat",
"authorityName": "SAT",
"status": "accepted",
"dueAt": "2026-03-17",
"filingReference": "0123456ABCD7890EF",
"amounts": { "currency": "MXN", "total": 82345.00 },
"statusHistory": [
{ "from": "pending", "to": "in_review", "timestamp": 1709654400 },
{ "from": "in_review", "to": "approved", "timestamp": 1709656200 },
{ "from": "approved", "to": "submitted", "timestamp": 1709658000 },
{ "from": "submitted", "to": "accepted", "timestamp": 1709658150 }
]
}
```
***
## Step 4: Create Remittance
Once a filing is accepted, pay the authority. Cadana handles FX conversion and routes the payment to the correct government account.
**Response:**
```json theme={null}
{
"id": "rem_01STU"
}
```
When you fund from a different currency than the filing currency, Cadana automatically converts at the current rate. The `fundedAmount` shows exactly how much will be debited from your wallet, and `fxRate` shows the conversion rate applied. You can also fund in the local currency by setting `fundFromCurrency` to `"MXN"`.
***
## Step 5: Proof of Payment
After the remittance is processed, retrieve the proof of payment for your records.
### Check Remittance Status
**Response:**
```json theme={null}
{
"id": "rem_01STU",
"businessId": "biz_01ABC",
"filingIds": ["fil_01JKL"],
"status": "completed",
"countryCode": "MX",
"amount": 82345.00,
"fundedAmount": 4574.80,
"fundedCurrency": "USD",
"fxRate": 18.001,
"authorityId": "auth_mx_sat",
"authorityName": "SAT",
"paymentMethod": "local_rails",
"paymentReference": "REF-2026-0310-001",
"receiptUrl": "https://api.cadanapay.com/v1/statutory/remittances/rem_01STU/receipt",
"createdAt": "2026-03-10T09:00:00Z"
}
```
### Download Receipt
The receipt is returned as a PDF document containing the authority's confirmation of payment, the amount, and the filing reference number.
***
## Step 6: Verify the Loop is Closed
After processing all four filings, check the overall status for the period:
**Response:**
```json theme={null}
{
"data": [
{
"id": "fil_01JKL",
"businessId": "biz_01ABC",
"countryCode": "MX",
"source": "cadana",
"filingTypeId": "ft_mx_isr_monthly",
"filingTypeName": "ISR Withholding",
"authorityName": "SAT",
"status": "accepted",
"period": { "start": "2026-02-01", "end": "2026-02-28", "label": "February 2026" },
"dueAt": "2026-03-17",
"workerCount": 15,
"createdAt": 1709280000,
"updatedAt": 1709658150
},
{
"id": "fil_02MNO",
"businessId": "biz_01ABC",
"countryCode": "MX",
"source": "cadana",
"filingTypeId": "ft_mx_imss_bimonthly",
"filingTypeName": "IMSS Contributions",
"authorityName": "IMSS",
"status": "accepted",
"period": { "start": "2026-02-01", "end": "2026-02-28", "label": "February 2026" },
"dueAt": "2026-03-17",
"workerCount": 15,
"createdAt": 1709280000,
"updatedAt": 1709658200
},
{
"id": "fil_03PQR",
"businessId": "biz_01ABC",
"countryCode": "MX",
"source": "cadana",
"filingTypeId": "ft_mx_infonavit_bimonthly",
"filingTypeName": "INFONAVIT Contributions",
"authorityName": "INFONAVIT",
"status": "approved",
"period": { "start": "2026-02-01", "end": "2026-02-28", "label": "February 2026" },
"dueAt": "2026-03-17",
"workerCount": 15,
"createdAt": 1709280000,
"updatedAt": 1709656200
},
{
"id": "fil_04STU",
"businessId": "biz_01ABC",
"countryCode": "MX",
"source": "cadana",
"filingTypeId": "ft_mx_isn_monthly",
"filingTypeName": "ISN Payroll Tax",
"authorityName": "State",
"status": "pending",
"period": { "start": "2026-02-01", "end": "2026-02-28", "label": "February 2026" },
"dueAt": "2026-03-17",
"workerCount": 15,
"createdAt": 1709280000,
"updatedAt": 1709280000
}
],
"summary": {
"total": 4,
"pending": 1,
"inReview": 0,
"approved": 1,
"submitted": 0,
"accepted": 2,
"blocked": 0,
"cancelled": 0,
"rejected": 0
}
}
```
***
## Full Timeline
Here is the complete sequence of events for the ISR filing, from payroll close to proof of payment:
| Date | Event | Status |
| :- | :- | :- |
| Mar 1 | Payroll run `pr_01MNO` closes; batch filing created | `pending` |
| Mar 5 | Finance team reviews and approves ISR filing | `approved` |
| Mar 5 | Cadana submits to SAT | `submitted` |
| Mar 5 | SAT confirms receipt | `accepted` |
| Mar 10 | Remittance created; USD converted to MXN | `processing` |
| Mar 10 | Payment delivered to SAT | `completed` |
| Mar 10 | Receipt downloaded for records | -- |
***
## Alternative: External Payroll
If you run payroll outside of Cadana but still want Cadana to file with the authorities, provide each filing's `breakdown` explicitly instead of referencing a payroll run. Cadana will still drive the `pending → approved → submitted → accepted` lifecycle for you:
This is the **unbundled** model — you provide the amounts, Cadana handles filing, remittance, and proof. Use `payrollRunIds` when Cadana runs your payroll (bundled), or `filings` when you calculate externally (unbundled). Both follow the same approve-submit-accept cycle.
If you've already filed with the authority yourself and just need remittance, set `source: "external"` on every entry (a batch must be all `cadana` or all `external` — mixing sources returns `400`) and provide a `filingReference` per entry — those filings are created directly in `accepted`. See the [Remittance walkthrough](/statutory/remittance#remittance-only-walkthrough-external-filing) for the full flow.
***
## Next Steps
Discover jurisdictions, required fields, and outstanding requirements
Full endpoint documentation for filings, remittances, and requirements
# Overview
Source: https://docs.cadanapay.com/tax/estimate
Instant gross-to-net and net-to-gross salary estimates across 150+ countries
The Employment Cost Calculator gives you instant salary estimates without creating a person record — one API call with a country code and salary amount. Use it for compensation planning, cost modeling, and hiring decisions.
***
## Gross to Net
Estimate take-home pay from a gross salary:
**Response:**
```json theme={null}
{
"grossAmount": { "amount": 4000000, "currency": "MXN" },
"netAmount": { "amount": 3213701, "currency": "MXN" },
"deductions": [
{ "name": "Sickness and Maternity Insurance", "isStatutory": true, "amount": { "amount": 38232, "currency": "MXN" } },
{ "name": "Disability and Life Insurance", "isStatutory": true, "amount": { "amount": 25887, "currency": "MXN" } },
{ "name": "Severance at Advanced Age and Old Age", "isStatutory": true, "amount": { "amount": 46597, "currency": "MXN" } },
{ "name": "Income Tax (ISR)", "isStatutory": true, "amount": { "amount": 675582, "currency": "MXN" } }
],
"employerContributions": [
{ "name": "Work Risk Insurance", "isStatutory": true, "amount": { "amount": 22514, "currency": "MXN" } },
{ "name": "Sickness and Maternity Insurance", "isStatutory": true, "amount": { "amount": 178226, "currency": "MXN" } },
{ "name": "Disability and Life Insurance", "isStatutory": true, "amount": { "amount": 72485, "currency": "MXN" } },
{ "name": "Retirement", "isStatutory": true, "amount": { "amount": 82839, "currency": "MXN" } },
{ "name": "Severance at Advanced Age and Old Age", "isStatutory": true, "amount": { "amount": 311186, "currency": "MXN" } },
{ "name": "Nurseries and Social Benefits", "isStatutory": true, "amount": { "amount": 41420, "currency": "MXN" } },
{ "name": "National Housing Fund", "isStatutory": true, "amount": { "amount": 207099, "currency": "MXN" } },
{ "name": "State Payroll Tax", "isStatutory": true, "amount": { "amount": 120000, "currency": "MXN" } }
]
}
```
***
## Net to Gross
Calculate the gross salary needed to achieve a target take-home amount:
***
## Response Fields
| Field | Description |
| :- | :- |
| `grossAmount` | Total salary before deductions |
| `netAmount` | Take-home pay after all deductions |
| `allowances` | Imputed-income line items (e.g. benefits-in-kind) surfaced by the country engine. Omitted when none apply. |
| `deductions` | Employee deductions — income tax, social security, etc. Each entry's `isStatutory` flag indicates whether the deduction is mandated by law. |
| `employerContributions` | Employer-side costs not deducted from the employee's pay |
| `outputCurrency`, `fxRate` | Present only when the request passed `outputCurrency` — echoes the requested currency and the rate applied. |
Amounts are in the smallest currency unit (e.g., centavos for MXN). An amount of `4000000` in MXN means \$40,000.00 MXN.
***
## When to Use Estimate vs. Calculate
| | Estimate | Calculate |
| :- | :- | :- |
| **Endpoint** | `POST /v1/tax/estimate` | `POST /v1/tax/calculate` |
| **Person record** | Not needed | Required |
| **Best for** | Compensation planning, cost modeling, hiring decisions | Payroll processing, year-to-date tracking, compliance |
| **Year-to-date** | No | Yes |
| **Country-specific fields** | No | Yes (marital status, dependents, etc.) |
For payroll-grade calculations with year-to-date tracking and country-specific tax profiles, use the [Tax Engine](/tax/getting-started) with a person record.
***
## Embeddable Widget
Need a visual calculator for your website? The [Gross-to-Net Salary Calculator widget](/tools/calculator/overview) wraps this same API in an embeddable, themeable UI component that works in any website or CMS.
Add the calculator to your site with a single script tag
Customize countries, periods, labels, and theming
***
## Next Steps
Full tax calculations with person records and year-to-date tracking
Full endpoint documentation
# Integration Guide
Source: https://docs.cadanapay.com/tax/getting-started
Integrate the Global Tax Engine API
## Prerequisites
Get your API key from the [Cadana Dashboard](https://app.cadanapay.com). See [Authentication](/authentication) for details.
Your business must be created and verified on the platform.
***
## Check Location Support
Verify which countries and regions are supported. The response includes valid state/region codes needed when creating a person.
**Response:**
```json theme={null}
{
"data": [
{
"name": "Australia",
"code": "AU",
"regions": [
{ "name": "New South Wales", "code": "NSW" },
{ "name": "Victoria", "code": "VIC" },
{ "name": "Queensland", "code": "QLD" }
]
}
]
}
```
***
## Get Required Tax Fields
Some countries require additional fields (state, employer risk class, year-to-date figures, etc.) for accurate calculations. Retrieve the required fields before creating a person.
**Response:**
```json theme={null}
{
"data": [
{
"name": "riskCategory",
"description": "IMSS Riesgos de Trabajo class (5 statutory classes per LSS Art. 73)",
"type": "string",
"required": false,
"enum": ["I", "II", "III", "IV", "V"],
"provider": "employer",
"default": "I"
},
{
"name": "addressState",
"description": "State (entidad federativa) where the employee works",
"type": "string",
"required": false,
"enum": ["AGU", "BCN", "BCS", "CAM", "CHP", "…"],
"provider": "employee",
"default": "OTHER"
},
{
"name": "yearsOfService",
"description": "Years of seniority — drives SBC factor de integración (LFT Art. 76+80+87)",
"type": "number",
"required": false,
"provider": "employer",
"default": 1
},
{
"name": "hasInfonavitCredit",
"description": "Toggle for INFONAVIT loan deduction line",
"type": "boolean",
"required": false,
"provider": "employer",
"default": false
},
{
"name": "ytdGrossIncome",
"description": "Pay this worker has already received in the current tax year, before they joined your payroll. Leave empty if this is their first payment of the tax year. If you enter an amount, also set the tax year it applies to.",
"type": "number",
"required": false,
"provider": "employer",
"default": 0
}
],
"schemaVersion": "2020-12",
"version": "v2.4.0",
"effectiveFrom": "2026-02-01",
"pinnable": true,
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"riskCategory": {
"type": "string",
"enum": ["I", "II", "III", "IV", "V"],
"default": "I"
},
"addressState": {
"type": "string",
"enum": ["AGU", "BCN", "BCS", "CAM", "CHP", "…"],
"default": "OTHER"
},
"yearsOfService": {
"type": "number",
"default": 1
},
"hasInfonavitCredit": {
"type": "boolean",
"default": false
},
"ytdGrossIncome": {
"type": "number",
"default": 0
}
},
"allOf": [
{
"if": {
"properties": {
"hasInfonavitCredit": {
"const": true
}
},
"required": ["hasInfonavitCredit"]
},
"then": {
"required": ["infonavitDeductionValue"]
}
}
]
}
}
```
### Field properties
Each field is self-describing, so you can render an onboarding form dynamically instead of hard-coding one per country.
| Property | Description |
| :- | :- |
| `name` | Field key. Submit the collected value under this key in `additionalAttributes` on [`POST /v1/tax/calculate`](/api-reference/tax/tax-calculator/calculate-taxes) or in the person's `taxProfile`. |
| `description` | Human-readable explanation — use it as the form label or helper text. |
| `type` | Value type: `string`, `number`, or `boolean`. Maps to the input control (text, number, checkbox). |
| `required` | Whether the field must be provided. Required fields never rely on a default. |
| `enum` | Present when the field accepts a fixed set of values — render as a dropdown of these options. |
| `default` | Present when the field has a default. If you omit the field, the engine applies this value automatically — so you can pre-fill the control with it, and optional fields that have a `default` need no user input at all. |
The `default` is the same value the engine seeds during calculation, so what you see here is exactly what's applied when the field is omitted. Pre-populate your form with each field's `default` to reduce the inputs a worker has to fill in.
### Rendering forms from the schema
Alongside `data`, the response carries a full [JSON Schema (draft 2020-12)](https://json-schema.org/draft/2020-12/schema) under `schema`, and the `schemaVersion` it targets. `data` is enough for a flat form; `schema` describes the same fields **and the relationships between them**, so you can render conditional fields and validate a completed profile with any standard validator (e.g. [Ajv](https://ajv.js.org/) for JavaScript or [jsonschema](https://pypi.org/project/jsonschema/) for Python) — no per-country logic.
| Keyword | What it tells you |
| :- | :- |
| `properties` | Available fields with their types and constraints |
| `enum` | Allowed values for a fixed-option field — render as a dropdown |
| `const` | A fixed value the field must take (used inside conditional rules) |
| `minimum` | Lower bound for a numeric field |
| `default` | Value the engine applies when the field is omitted — use it to pre-fill |
| `required` | Fields that must be present |
| `additionalProperties` | Always `false` — only listed fields are accepted |
| `allOf` / `if`-`then` | Conditional rules — e.g. if one field has a given value, another is constrained |
Some cross-field relationships have no standard keyword, so they are expressed as `x-cadana-*` extensions. A standard validator ignores them; handle them yourself where you need the rule enforced:
| Keyword | Meaning |
| :- | :- |
| `x-cadana-lteField` | This numeric field must be **≤** the named field (e.g. `childrenUnder3 ≤ numberOfChildren`) |
| `x-cadana-groupSumLteField` | The **sum** of every field sharing a group must be ≤ the named field |
| `x-cadana-group` | The group label a field belongs to (used with `x-cadana-groupSumLteField`) |
| `x-cadana-ui` | Rendering hints keyed by field — e.g. `visibleWhen` tells your form when to show a conditional field |
| `x-cadana-provider` | Internal source that surfaces the field. May be omitted |
For example, a country schema can gate one field on another and cap a subset against a total:
```json theme={null}
{
"properties": {
"numberOfChildren": { "type": "number", "minimum": 0, "default": 0 },
"childrenUnder3": { "type": "number", "minimum": 0, "default": 0, "x-cadana-lteField": "numberOfChildren" },
"disabilityDegree": { "type": "string", "enum": ["none", "33_to_64", "65_plus"], "default": "none" },
"hasReducedMobility": { "type": "boolean", "default": false }
},
"allOf": [
{
"if": { "not": { "required": ["disabilityDegree"], "properties": { "disabilityDegree": { "const": "33_to_64" } } } },
"then": { "properties": { "hasReducedMobility": { "const": false } } }
}
],
"x-cadana-ui": {
"hasReducedMobility": { "visibleWhen": { "field": "disabilityDegree", "equals": "33_to_64" } }
}
}
```
Read `x-cadana-ui.visibleWhen` to decide when to show `hasReducedMobility`, and let the schema be the source of truth for validation before you submit.
***
## Create a Person
Create a person with their address, salary, and compensation details. The returned `id` is used for all subsequent tax calculations.
**Response:**
```json theme={null}
{
"id": "96bfcbf3-c201-405d-848d-f55bec77588c"
}
```
Populate any country-specific fields from the previous step on the person record. Use [`PUT /v1/persons/{id}/personalInfo`](/api-reference/workforce/persons/update-personal-information) to add `taxId` and `socialSecurityId`, or [`PUT /v1/persons/{id}/taxProfile`](/api-reference/workforce/persons/update-tax-profile) for the `calculation`-scope fields returned by the fields endpoint above.
### Manage Persons
| Operation | Endpoint |
| :- | :- |
| Get | [`GET /v1/persons/{id}`](/api-reference/workforce/persons/get) |
| Update basic info | [`PUT /v1/persons/{id}/basicInfo`](/api-reference/workforce/persons/update-basic-information) |
| Update personal info | [`PUT /v1/persons/{id}/personalInfo`](/api-reference/workforce/persons/update-personal-information) |
| Update job info | [`PUT /v1/persons/{id}/jobInfo`](/api-reference/workforce/persons/update-job-information) |
| Update tax profile | [`PUT /v1/persons/{id}/taxProfile`](/api-reference/workforce/persons/update-tax-profile) |
***
## Calculate Taxes
Calculate taxes for a person. Pass the `personId` and the salary for the period.
**Response:**
```json theme={null}
{
"personId": "96bfcbf3-c201-405d-848d-f55bec77588c",
"grossAmount": { "amount": 4000000, "currency": "MXN" },
"netAmount": { "amount": 3213701, "currency": "MXN" },
"deductions": [
{
"name": "Sickness and Maternity Insurance",
"isStatutory": true,
"amount": { "amount": 38232, "currency": "MXN" }
},
{
"name": "Disability and Life Insurance",
"isStatutory": true,
"amount": { "amount": 25887, "currency": "MXN" }
},
{
"name": "Severance at Advanced Age and Old Age",
"isStatutory": true,
"amount": { "amount": 46597, "currency": "MXN" }
},
{
"name": "Income Tax (ISR)",
"isStatutory": true,
"amount": { "amount": 675582, "currency": "MXN" }
}
],
"employerContributions": [
{
"name": "Work Risk Insurance",
"isStatutory": true,
"amount": { "amount": 22514, "currency": "MXN" }
},
{
"name": "Sickness and Maternity Insurance",
"isStatutory": true,
"amount": { "amount": 178226, "currency": "MXN" }
},
{
"name": "Disability and Life Insurance",
"isStatutory": true,
"amount": { "amount": 72485, "currency": "MXN" }
},
{
"name": "Retirement",
"isStatutory": true,
"amount": { "amount": 82839, "currency": "MXN" }
},
{
"name": "Severance at Advanced Age and Old Age",
"isStatutory": true,
"amount": { "amount": 311186, "currency": "MXN" }
},
{
"name": "Nurseries and Social Benefits",
"isStatutory": true,
"amount": { "amount": 41420, "currency": "MXN" }
},
{
"name": "National Housing Fund",
"isStatutory": true,
"amount": { "amount": 207099, "currency": "MXN" }
},
{
"name": "State Payroll Tax",
"isStatutory": true,
"amount": { "amount": 160000, "currency": "MXN" }
}
]
}
```
### Response fields
| Field | Description |
| :- | :- |
| `grossAmount` | Input salary for the period |
| `netAmount` | Take-home pay after all deductions |
| `allowances` | Imputed-income line items (e.g. benefits-in-kind) surfaced by the country engine. Omitted when none apply. |
| `deductions` | Employee deductions (social security, income tax, etc.). Each entry's `isStatutory` flag indicates whether the deduction is mandated by law. |
| `employerContributions` | Employer-side costs not deducted from the employee's pay. |
| `summary` | Step-by-step calculation breakdown showing formulas, brackets, and rates for each line item |
The `additionalAttributes` field accepts country-specific values like year-to-date salary or pension contributions. These are calculation-time inputs — different from the tax profile fields set when [creating a person](#create-a-person). Use the [tax fields endpoint](#get-required-tax-fields) to discover what's available for each country. Numeric fields are expressed in major currency units — e.g. India's `hraReceived`, `rentPaid`, and `declared80c` are whole rupees — unlike `amount` objects, which use the lowest denomination.
***
## Next Steps
Quick estimates without a person record — great for compensation planning
Full API documentation
Product overview and capabilities
After calculating taxes, file returns and remit to government authorities
Pay your workers using the calculated tax amounts
# Overview
Source: https://docs.cadanapay.com/tax/overview
Simplify global payroll tax calculations with a single API
The Global Tax Engine calculates payroll taxes across multiple countries through a single API. It handles country-specific regulations, tax brackets, deductions, and employer contributions — so you don't have to build and maintain tax logic for each market.
***
## Key Capabilities
Tax calculations across countries and regions, with support for state-level rules.
Calculate gross-to-net or net-to-gross for compensation planning.
Step-by-step calculation details showing exact formulas, brackets, and rates applied.
Tax tables and regulations are kept up to date automatically.
***
## How It Works
Verify which countries and regions are available.
Create a person with their address, salary, and country-specific tax fields.
Submit salary details and receive a full breakdown of deductions and contributions.
***
## Example Response
A tax calculation for a Brazilian employee earning R\$10,000/month:
```json theme={null}
{
"personId": "81933bed-97ba-4477-81a7-b8c5ab507b6f",
"grossAmount": { "amount": 1000000, "currency": "BRL" },
"netAmount": { "amount": 761244, "currency": "BRL" },
"deductions": [
{
"name": "INSS (Instituto Nacional do Seguro Social)",
"amount": { "amount": 82839, "currency": "BRL" }
},
{
"name": "IRRF (Imposto de Renda Retido na Fonte)",
"amount": { "amount": 155917, "currency": "BRL" }
}
],
"employerContributions": [
{
"name": "FGTS (Fundo de Garantia do Tempo de Serviço)",
"amount": { "amount": 80000, "currency": "BRL" }
}
]
}
```
The response includes employee deductions, employer contributions, and an optional `summary` field with step-by-step calculation details for each line item.
Amounts are in the **lowest denomination** of the currency. For BRL, `1000000` = R\$10,000.00.
***
## Getting Started
Step-by-step guide to integrating the Tax Engine
Full API documentation
File statutory returns and remit taxes to government authorities
Quick gross-to-net salary estimates without creating a person
# Pinning a Ruleset Version
Source: https://docs.cadanapay.com/tax/version-pinning
Reproduce a tax calculation against the exact rules that produced it
Tax rules change. A calculation you ran last quarter will not necessarily reproduce today, because the country's ruleset has moved on. Pinning lets you name the exact ruleset a calculation should use, so a figure you showed a customer, filed, or stored can be recomputed and audited later.
By default every calculation uses the current ruleset. That is the right choice for new work — pin only when you need a result to stay stable.
***
## Find a version to pin
Ask which rulesets a country has:
**Response:**
```json theme={null}
{
"data": [
{ "country": "mx", "version": "v2.1.0", "effectiveFrom": "2026-01-01", "isLatest": true, "pinnable": true },
{ "country": "mx", "version": "v2.0.3", "updatedAt": "2026-08-10T06:42:54Z", "pinnable": false }
]
}
```
`isLatest` marks the ruleset a request uses when you do not pin. `pinnable` tells you whether a version can be requested by name.
Only pin a version with `pinnable: true`. Rulesets published before versioning was introduced cannot be reproduced — the rules they depended on were never captured — so requesting one returns a 400 rather than a result computed from the wrong rules.
***
## Pin a calculation
Pass `version` alongside your usual request body:
The same field works on [Estimate Gross or Net Salary](/api-reference/tax/tax-calculator/estimate-gross-or-net-salary) and [Validate Tax Fields](/api-reference/tax/tax-calculator/validate-tax-fields), and as a `version` query parameter on [List Required Additional Tax Fields by Country](/api-reference/tax/tax-calculator/list-required-additional-tax-fields-by-country).
A pinned response echoes the version it used:
```json theme={null}
{
"grossAmount": { "amount": 1000000, "currency": "MXN" },
"netAmount": { "amount": 955743, "currency": "MXN" },
"rulesetVersion": "v2.1.0"
}
```
`rulesetVersion` appears only when you pin. Its absence means the current ruleset was used.
***
## Pin the fields too
The inputs a country requires change between versions, so a stored payload can stop matching the ruleset it was collected for. Pass the same `version` to the fields endpoint to see the inputs as they were:
The response carries `version`, `effectiveFrom` and `pinnable` alongside the field list, so you can record which contract a stored payload was built against.
***
## When a pin is rejected
Pinning never falls back to current rules — a request that cannot be honoured fails instead of quietly returning a different answer. Each case returns a 400:
| Response | Meaning |
| :- | :- |
| `ruleset version "v9.9.9" is not available for MX` | No such version for this country. Check [List Tax Ruleset Versions](/api-reference/tax/tax-calculator/list-tax-ruleset-versions). |
| `ruleset version "v1.0.0" cannot be pinned for CA: it was published before rulesets were versioned` | The version exists but predates versioning, so it is reported as `pinnable: false`. |
| `ruleset version pinning is not supported for GH` | This country's calculation does not read versioned rulesets, so a pin could not be applied. |
| `version "latest" is not a valid ruleset version` | Malformed. Use a semver such as `v2.1.0` or `2.1.0`. |
Read `pinnable` from the versions response before pinning and you will avoid the first three entirely.
***
## Staying current
Pinning holds a calculation still; it does not keep you up to date. To see what changed since the version you are on, and whether the required fields moved:
`actionRequired` tells you whether the tax field requirements changed, which is the case that needs code on your side rather than just a version bump.
***
## Next Steps
Run your first tax calculation
Full endpoint reference
# Configuration Options
Source: https://docs.cadanapay.com/tools/calculator/configuration
Configuration reference for the salary calculator widget
All options are optional except for the API key.
## Configuration Reference
| Option | Type | Default | Description |
| :- | :- | :- | :- |
| `key` | `string` | **required** | Your Cadana **public** API key |
| `selector` | `string` | – | CSS selector of mount element |
| `mount` | `HTMLElement` | – | Pass the element directly (overrides `selector`) |
| `cssVars` | `Record` | – | Override any design token (see [Theming](/tools/calculator/theming)) |
| `defaultCountry` | `string` | – | Pre-select country (ISO-3166 alpha-2 or name) |
| `defaultPeriod` | `string` | `'monthly'` | `'monthly'` or `'annually'` |
| `labels.form` | partial object | – | Override form labels (Country / Gross Salary / Calculate) |
| `labels.result` | partial object | – | Override section headings in result view |
***
## Passing Config
**Script tag** — use a `data-config` attribute with a JSON string:
```html HTML theme={null}
```
**JS init** — pass an object directly:
```javascript JavaScript theme={null}
window.CadanaCalculator.init({
key: 'YOUR_PUBLIC_API_KEY',
selector: '#calculator',
defaultCountry: 'US',
defaultPeriod: 'monthly',
cssVars: {
'--cdn-primary': 'hsl(15 90% 55%)'
}
});
```
The `data-config` value must be valid JSON.
***
## Labels
Customize the text displayed throughout the widget to match your brand voice or localize for different regions.
### Form Labels (`labels.form`)
| Option | Default | Description |
| :- | :- | :- |
| `country` | `"I want to hire in"` | Country selection label |
| `grossSalary` | `"Gross Salary"` | Salary input field label |
| `calculate` | `"Calculate"` | Calculate button text |
### Result Labels (`labels.result`)
| Option | Default | Description |
| :- | :- | :- |
| `grossSalary` | `"Gross Salary"` | Gross salary section header |
| `netSalary` | `"Net Salary"` | Net salary section header |
| `deductions` | `"Deductions"` | Deductions section header |
| `employerContributions` | `"Employer Contributions"` | Employer contributions header |
| `totalEmployerCost` | `"Total Employer Cost"` | Total cost section header |
**Example:**
```javascript JavaScript theme={null}
init({
key: 'YOUR_PUBLIC_API_KEY',
labels: {
form: {
country: 'Select your country',
calculate: 'Estimate now'
},
result: {
totalEmployerCost: 'All-in Cost',
deductions: 'Taxes & Fees'
}
}
});
```
Only specify the labels you want to override. Unspecified labels use their default English values.
# Custom Events
Source: https://docs.cadanapay.com/tools/calculator/events
Listen to calculator widget events for analytics and integration
The widget dispatches custom events that bubble from the host element, allowing you to integrate with analytics, tracking systems, or your existing JavaScript.
## Event Reference
All events are dispatched on the widget's host element and bubble up to `document`. Use standard `addEventListener()` to listen for them.
| Event | Payload | When fired |
| :- | :- | :- |
| `cdn-calculator:ready` | `{ countries: Country[] }` | Widget initialized and country data loaded |
| `cdn-calculator:calculate` | `{ country, currency, gross, period }` | User clicked calculate (before API request) |
| `cdn-calculator:result` | `Result & { period }` | Calculation completed successfully |
| `cdn-calculator:view` | `{ view: 'form' \| 'result' }` | User switched between form and result views |
| `cdn-calculator:error` | `ApiError` | API request failed or validation error |
***
## Event Details
### cdn-calculator:ready
Fired when the widget is fully loaded and ready for user interaction.
```javascript JavaScript theme={null}
mount.addEventListener('cdn-calculator:ready', e => {
console.log('Widget ready with', e.detail.countries.length, 'countries');
});
```
### cdn-calculator:calculate
Fired when user submits the form, before making the API request.
```javascript JavaScript theme={null}
mount.addEventListener('cdn-calculator:calculate', e => {
console.log('Calculating for', e.detail.country.name, 'at', e.detail.gross);
analytics.track('salary_calculation_started', e.detail);
});
```
### cdn-calculator:result
Fired when calculation completes successfully.
```javascript JavaScript theme={null}
mount.addEventListener('cdn-calculator:result', e => {
console.log('Total employer cost:', e.detail.totalEmployerCost);
});
```
Amounts on this event are always **per month**, whichever pay period the user selected — the tax API calculates one pay period at a time. Read `detail.period` to see which period was requested, and multiply by 12 yourself to show an annual figure.
### cdn-calculator:view
Fired when user navigates between form and result views.
```javascript JavaScript theme={null}
mount.addEventListener('cdn-calculator:view', e => {
console.log('User switched to', e.detail.view, 'view');
});
```
### cdn-calculator:error
Fired when an error occurs (invalid API key, network issues, etc.).
```javascript JavaScript theme={null}
mount.addEventListener('cdn-calculator:error', e => {
console.error('Widget error:', e.detail.message);
});
```
***
## Integration Example
**Google Analytics 4:**
```javascript JavaScript theme={null}
mount.addEventListener('cdn-calculator:result', e => {
gtag('event', 'salary_calculated', {
country: e.detail.country.code,
gross_amount: e.detail.grossAmount.amount,
employer_cost: e.detail.totalEmployerCost.amount
});
});
```
***
## Event Payload Structures
### cdn-calculator:ready
```javascript JavaScript theme={null}
{
countries: [
{
code: "CO",
name: "Colombia",
regions: [
{ code: "AMA", name: "Amazonas" },
{ code: "ANT", name: "Antioquia" }
]
}
]
}
```
### cdn-calculator:calculate
```javascript JavaScript theme={null}
{
country: {
code: "CO",
name: "Colombia"
},
currency: {
code: "COP",
name: "Colombian Peso"
},
gross: 2000000,
period: "monthly"
}
```
### cdn-calculator:result
```javascript JavaScript theme={null}
{
grossAmount: {
amount: 200000000, // in cents
currency: "COP"
},
netAmount: {
amount: 142824527,
currency: "COP"
},
deductions: [
{
name: "Pension Fund",
isStatutory: true,
amount: {
amount: 8000000,
currency: "COP"
}
}
],
employerContributions: [
{
name: "Pension Fund",
isStatutory: true,
amount: {
amount: 24000000,
currency: "COP"
}
}
],
totalEmployerCost: {
amount: 43044000,
currency: "COP"
},
country: {
code: "CO",
name: "Colombia"
},
period: "monthly" // "monthly" | "annually" — the period the user asked for
}
```
### cdn-calculator:view
```javascript JavaScript theme={null}
{
view: "form" | "result"
}
```
### cdn-calculator:error
```javascript JavaScript theme={null}
{
message: "Unable to load calculator. Please ensure your key is valid.",
status: 401,
code: "UNAUTHORIZED"
}
```
All monetary amounts are returned in cents to avoid floating-point precision issues. Divide by 100 to get the actual currency amount.
# Full Integration Options
Source: https://docs.cadanapay.com/tools/calculator/full-integration
Advanced integration methods for the salary calculator widget
The calculator provides multiple integration methods to fit your technical needs.
| Audience | Recommended approach | Best for |
| :- | :- | :- |
| No-code / CMS user | [Copy-paste snippet](#copy-paste-snippet) | WordPress, Wix, Webflow, Squarespace |
| Front-end engineer | [Manual JS initialization](#manual-js-initialization) or [ESM Module](#esm-module) | Custom websites, SPAs, advanced theming |
| React / Vue / Svelte dev | Same as front-end engineer (framework-agnostic) | Modern web apps |
Not sure which to choose? Start with the [copy-paste snippet](#copy-paste-snippet) — it works everywhere and requires zero coding knowledge.
***
## Copy-Paste Snippet
```html HTML theme={null}
```
* Drop it anywhere inside `` — the widget auto-mounts in place.
* Responsive: stretches to the width of its parent container.
* All CSS lives in Shadow DOM — no style leakage.
***
## Manual JS Initialization
For more control over when and where the widget loads. This approach lets you:
* Load the widget inside modals or dynamic content
* Pass runtime configuration (country from URL params, custom labels, etc.)
* Integrate with your existing JavaScript build process
```html HTML theme={null}
```
Set `data-autoboot="false"` to prevent the widget from starting before you call `init()` manually.
***
## ESM Module
For modern development workflows with bundlers and build systems:
* Dynamic imports for code splitting
* No npm dependencies or build step required
* TypeScript-friendly with proper module resolution
```javascript JavaScript theme={null}
const { init } = await import("https://widgets.cadanapay.com/calculators/salary.mjs")
init({
key: 'YOUR_PUBLIC_API_KEY',
selector: '#calculator',
defaultCountry: 'CO',
defaultPeriod: 'monthly',
cssVars: {
'--cdn-primary': 'hsl(204 100% 50%)'
}
});
```
The ESM build is identical to the CDN version — same API, same events, same theming options.
# Getting Started
Source: https://docs.cadanapay.com/tools/calculator/getting-started
Add the gross-to-net salary calculator to your website
## Quick Start
Copy and paste this snippet into your web page:
```html HTML theme={null}
```
The widget automatically loads and replaces the script tag. Users can immediately start calculating salaries for 150+ countries. All styling is isolated with Shadow DOM — no conflicts with your existing CSS.
**Before you start:**
* Get your Public API key from your [Dashboard](https://app.cadanapay.com)
* Add your domain to the API key's whitelist
For security, Cadana enforces domain whitelisting to ensure your embedded calculator only renders on trusted websites you own and control. The widget displays an error if loaded from an unauthorized domain.
Use your **public** Cadana API key (prefix `cdpk_`). Never embed a secret or server-side key in client-side code.
***
## Widget URLs
| Environment | URL |
| :- | :- |
| Production | `https://widgets.cadanapay.com/calculators/salary.js` |
| Staging | `https://dev-widgets.cadanapay.com/calculators/salary.js` |
ESM versions are also available at the same paths with `.mjs` extension.
Use the staging URL for testing and development. The production URL should only be used for live websites.
**Try it first:** [Interactive demo](https://dev-widgets.cadanapay.com/demo/salary-calculator.html) — experiment with themes, config, and settings before implementing. You can copy the generated code directly into your website.
For more control over integration, see [Full Integration Options](/tools/calculator/full-integration).
# Overview
Source: https://docs.cadanapay.com/tools/calculator/overview
Gross-to-net salary calculator widget overview
A lightweight, embeddable JavaScript widget that lets your users estimate **gross-to-net salary, payroll taxes, and employer cost** for 150+ countries.
The calculator works everywhere — WordPress, Wix, Webflow, React, Vue, plain HTML, Framer — and uses Shadow DOM for complete style isolation.
## Key Features
20+ CSS variables with Shadow DOM isolation. Match your brand without style conflicts.
Mobile-first, WCAG compliant with keyboard navigation and screen reader support.
Only 6.8 KB gzipped with intelligent caching. Semantic HTML for SEO.
Drop into any website or CMS with HTML support.
Hook into user interactions for analytics and tracking.
Public API keys only with domain whitelisting.
# Theming
Source: https://docs.cadanapay.com/tools/calculator/theming
Customize the salary calculator widget's appearance with CSS variables
The widget uses Shadow DOM for complete style isolation — your existing CSS never conflicts with the calculator. All theming is done through CSS custom properties applied to the widget's shadow root.
## CSS Variables Reference
| Variable | Default | Description |
| :- | :- | :- |
| `--cdn-opacity` | `1` | Global opacity multiplier |
| `--cdn-radius` | `0.625rem` | Border radius for all elements |
| `--cdn-background` | `hsl(0 0% 100% / var(--cdn-opacity))` | Widget background color |
| `--cdn-foreground` | `hsl(0 0% 3.9% / var(--cdn-opacity))` | Primary text color |
| `--cdn-primary` | `hsl(0 0% 9.05% / var(--cdn-opacity))` | Primary button/emphasis color |
| `--cdn-primary-foreground` | `hsl(0 0% 98% / var(--cdn-opacity))` | Text color on primary background |
| `--cdn-border` | `hsl(0 0% 89.8% / var(--cdn-opacity))` | Border color |
| `--cdn-border-size` | `1px` | Border width |
| `--cdn-ring` | `hsl(0 0% 3.9% / var(--cdn-opacity))` | Focus ring color |
| `--cdn-muted` | `hsl(0 0% 96.1% / var(--cdn-opacity))` | Muted background color |
| `--cdn-muted-foreground` | `hsl(0 0% 45.1% / var(--cdn-opacity))` | Muted text color |
| `--cdn-destructive` | `hsl(0 84.2% 60.2% / var(--cdn-opacity))` | Error/destructive color |
| `--cdn-destructive-foreground` | `hsl(0 0% 98% / var(--cdn-opacity))` | Text color on destructive background |
| `--cdn-font-family` | `inherit` | Font family (inherits from parent) |
| `--cdn-font-size-base` | `1rem` | Base font size |
| `--cdn-font-size-form-label` | `var(--cdn-font-size-base)` | Form label font size |
| `--cdn-font-size-form-input` | `var(--cdn-font-size-base)` | Form input font size |
| `--cdn-font-size-calc-button` | `var(--cdn-font-size-base)` | Calculate button font size |
| `--cdn-font-size-result-title` | `var(--cdn-font-size-base)` | Result title font size |
| `--cdn-font-size-result-label` | `var(--cdn-font-size-base)` | Result label font size |
| `--cdn-font-size-result-total` | `var(--cdn-font-size-base)` | Result total font size |
| `--cdn-font-size-result-value` | `var(--cdn-font-size-base)` | Result value font size |
| `--cdn-transition-function` | `cubic-bezier(.4, 0, .2, 1)` | Animation timing function |
***
## Usage
```javascript JavaScript theme={null}
init({
cssVars: {
'--cdn-primary': 'hsl(15 90% 55%)',
'--cdn-radius': '0.75rem',
'--cdn-font-family': 'Inter, sans-serif',
'--cdn-font-size-form-label': '1.125rem'
}
});
```
Color tokens accept any valid CSS color format:
* Hex: `#ff0000`, `#f00`
* RGB/RGBA: `rgb(255, 0, 0)`, `rgba(255, 0, 0, 0.5)`
* HSL/HSLA: `hsl(0, 100%, 50%)`, `hsla(0, 100%, 50%, 0.5)`
* Named colors: `red`, `blue`, `transparent`
* CSS4 colors: `oklch()`, `lab()`
# Updating the Widget
Source: https://docs.cadanapay.com/tools/calculator/updating
How calculator updates reach your site, and what to do when they don't
We ship fixes and improvements to the calculator continuously. The widget URL is stable — there is no version number in it — so `salary.js` always serves the current build.
Whether your site picks up a new build automatically depends on **how you embedded it**.
***
## Two integration modes
| How you embedded | Updates reach you |
| :- | :- |
| Script tag pointing at `widgets.cadanapay.com` | Automatically, on the next page load |
| Bundled into your build (Framer, Webflow, npm import) | Only when you rebuild and redeploy |
### Script tag — updates are automatic
If your page loads the widget at runtime:
```html HTML theme={null}
```
the browser fetches the current build every time the page loads. You get updates without doing anything. See [Forcing an update](#forcing-an-update) if a cached copy is being served instead.
### Bundled — you must redeploy
Some site builders and bundlers copy the widget into your own build output instead of fetching it at runtime. Framer does this, as do npm-based bundlers when you import the module. Your build then serves its own snapshot from your domain, usually under a content-hashed filename such as `salary.a1b2c3.mjs`.
That snapshot never changes on its own. **Rebuild and redeploy to pick up a new version** — and remember to do it for every environment, including staging and demo sites.
***
## Which mode am I in?
Open your browser's developer tools, go to the **Network** tab, reload the page, and find the calculator script.
| Request URL | Mode |
| :- | :- |
| `widgets.cadanapay.com/calculators/salary.js` | Script tag — updates automatically |
| Your own domain, hashed filename (`salary.a1b2c3.mjs`) | Bundled — redeploy to update |
***
## Which build am I running?
Check the `last-modified` response header on the calculator script in the same Network tab. It tells you when the build you are running was published.
If it predates a fix you are expecting, you are on an older copy — either cached in your browser, or baked into your last deploy.
***
## Forcing an update
A hard reload (`Cmd`/`Ctrl` + `Shift` + `R`) makes your browser re-fetch the script. Enough to confirm what the current build does, but it only affects the machine you are sitting at.
A hard reload cannot clear your visitors' caches. To push an update to everyone at once, change the URL by adding any parameter you control:
```html HTML theme={null}
```
Browsers treat this as a new URL and fetch it fresh. Bump the value whenever you want to guarantee everyone is on the latest build. The widget reads its key from the query string, so extra parameters are safe to add.
***
## Testing an update before it goes live
Point a staging page at the staging widget to try a build against your own integration first:
```html HTML theme={null}
```
Staging keys and domain whitelists are separate from production — see [Getting Started](/tools/calculator/getting-started) for both widget URLs.
If you bundle the widget, pin your staging and production deploys to the same build before comparing them. Otherwise you may be looking at two different versions without realising it.
***
## What stays stable
Updates are meant to be drop-in. The embed snippet, the [configuration options](/tools/calculator/configuration), the [CSS variables](/tools/calculator/theming), and the [events](/tools/calculator/events) are all part of the widget's public surface, and we avoid breaking them.
# Global Wallets
Source: https://docs.cadanapay.com/wallets/overview
Multi-currency wallets built for global teams
Cadana Global Wallets are stored-value accounts that give your workforce the ability to store, convert, and spend earnings — instantly, securely, and without friction.
Wallets are provisioned automatically when you [create a User](/wallets/worker-wallets) for a worker. Once the worker completes [KYC](/platform/kyc-verification) and any required [tax forms](/wallets/tax-forms), their wallet is fully active.
***
## Key Features
Workers hold balances in multiple currencies with transparent, affordable conversion built in.
Withdraw to 100+ countries via bank transfer, mobile money, e-wallets, cards, and more.
Issue virtual USD cards linked to wallet balances for online purchases and digital spending.
Hold funds in USD for currency stability in volatile markets.
Invest directly in US stocks from the wallet.
Provision unique ACH routing and account numbers for direct funding via bank transfer.
***
## How Workers Get Paid
Wallets support two funding paths:
| Method | How it works |
| :- | :- |
| **Payroll** | Platform runs payroll through Cadana — funds are disbursed to worker wallets automatically. See [Pay Workers via Payroll](/workforce/pay-workers-via-payroll). |
| **Virtual Accounts** | Each wallet gets a unique ACH account number. External parties send funds directly via ACH credit. See [Virtual Accounts](/wallets/virtual-accounts). |
***
## How Workers Cash Out
Workers send funds from their wallet to saved beneficiaries — bank accounts, mobile money, e-wallets, or cards in 100+ countries. See [Payout](/wallets/payout).
***
## Feature Configuration
Wallet features can be enabled or disabled per tenant or per user. Contact your account manager to configure.
| Feature | Flag | Scope | Description |
| :- | :- | :- | :- |
| Virtual USD Card | `virtual_card` | Tenant / User | Virtual debit card for online spending |
| Savings Vaults | `savings` | Tenant / User | Interest-bearing USD sub-wallet |
| Stock Investing | `stocks` | Tenant / User | Direct US stock investment from wallet |
| Tax Forms | `tax_forms` | Tenant | W-9, W-8BEN, W-8BEN-E, and 1099 forms |
***
## Getting Started
Create Users, complete KYC, and provision wallets
Fund wallets via dedicated ACH accounts
Send funds to beneficiaries worldwide
Full API documentation
# Payout
Source: https://docs.cadanapay.com/wallets/payout
Send funds from wallets to beneficiaries worldwide
Workers can send funds from their wallet to saved beneficiaries — bank accounts, mobile money, e-wallets, cards, and more across 100+ countries.
***
## Payment Methods & Requirements
Before adding a beneficiary, check which payment methods are available for the destination country and what fields are required.
### List available payment methods
**Response:**
```json theme={null}
[
{
"country": "BR",
"countryName": "Brazil",
"paymentMethods": [
{ "type": "bank", "currency": "BRL", "status": "active" },
{ "type": "proxy", "currency": "BRL", "status": "active" },
{ "type": "swift", "currency": "BRL", "status": "active" }
]
}
]
```
The response is an array — one entry per country matching the query.
### Get field requirements for a corridor
Each country + currency + payment method combination has specific field requirements. Use the payment requirements endpoint to get the JSON schema for the `paymentDetails` object.
This returns a JSON Schema describing which fields are required, valid values for enums (e.g. bank codes), and validation rules.
### Get payout providers
For countries with multiple bank or mobile money providers, retrieve the list of supported providers.
### Supported payment methods
| Method | Description | Use case |
| :- | :- | :- |
| `bank` | Local bank transfer | Most countries |
| `momo` | Mobile money | Africa (M-Pesa, MTN, etc.) |
| `ach` | ACH transfer | United States |
| `swift` | SWIFT/wire transfer | International |
| `wallet` | Cadana wallet | User-to-user transfers |
| `crypto` | Crypto wallet | BTC, USDC, ETH, etc. |
| `proxy` | Proxy payment | UPI (India), etc. |
***
## Beneficiaries
A beneficiary is a saved payment destination. Each beneficiary has a payment method and is scoped to a specific user.
### Add a beneficiary
**Response:**
```json theme={null}
{
"id": "7a7f80a6-1665-4f64-9ef3-d5f90f8f309b"
}
```
### Manage beneficiaries
| Operation | Endpoint |
| :- | :- |
| List | [`GET /v1/users/{userId}/beneficiaries`](/api-reference/wallets/beneficiaries/list-beneficiaries) |
| Get | [`GET /v1/users/{userId}/beneficiaries/{beneficiaryId}`](/api-reference/wallets/beneficiaries/get-beneficiary) |
| Delete | [`DELETE /v1/users/{userId}/beneficiaries/{beneficiaryId}`](/api-reference/wallets/beneficiaries/delete-beneficiary) |
***
## Check Fees, Timelines & Limits
Before creating a transaction, look up the fees, delivery timeline, and transfer limits that apply to the user's corridor.
### Fetch the payout catalog
Pass `sourceCurrency` to quote every corridor's fee in that currency. It defaults to `USD` when omitted.
To quote the catalog in a different currency:
### Get a concrete estimate
Supply `paymentMethod` and `destinationCurrency` to receive an `estimate` block alongside the catalog — a per-transaction fee, delivery ETA, and transfer limits for the requested corridor. Add `destinationCountry` when several countries share a currency (e.g. USD sent to Ecuador vs. USD sent to El Salvador) so the estimate resolves to the right country, and `sourceAmount` to resolve percentage-based fees into a concrete amount.
**Response:**
```json theme={null}
{
"data": {
"sourceCurrency": "USD",
"payouts": [
{
"paymentMethod": "bank",
"destinationCurrency": "AUD",
"destinationCountry": "AU",
"fee": { "type": "fixed", "value": 1, "min": 0, "max": 0 },
"feeCurrency": "USD",
"timeline": { "minDays": 1, "maxDays": 2, "businessDaysOnly": true }
}
],
"estimate": {
"paymentMethod": "bank",
"destinationCurrency": "AUD",
"destinationCountry": "AU",
"fee": { "value": "1.00", "currency": "USD" },
"timeline": {
"minDays": 1,
"maxDays": 2,
"businessDaysOnly": true,
"estimatedArrival": "Apr 24, 2026 (excluding holidays)"
},
"limits": { "min": "2", "max": "20000", "currency": "AUD" }
}
}
}
```
`data.payouts` is always the full, unfiltered catalog — query parameters only shape the `estimate` block. `fee.type` is either `fixed` (denominated in `feeCurrency`) or `percentage` (a rate, e.g. `1.5` = 1.5%).
***
## Get an FX Quote
Before creating a transaction, get an FX quote to lock in the conversion rate. Quotes are temporary — use the returned `id` as the `quoteId` when creating the transaction.
**Response:**
```json theme={null}
{
"id": "57c74c5d-38a0-41e7-a19e-161f16dc4898",
"from": "USD",
"to": "PHP",
"rate": "56.1234",
"expirationTimestamp": "2025-05-29T21:42:52Z"
}
```
Quotes expire — check `expirationTimestamp` and request a new quote if the previous one has expired.
A quote is required for **every** payout, including same-currency ones. For a same-currency payout (e.g., a USD wallet to a US bank account), request a quote with `from` and `to` set to the same currency — the rate is `1`.
***
## Create a Transaction
Send funds from the user's wallet to a beneficiary. You can specify either a destination amount or a source amount — Cadana handles FX conversion using the quote rate.
### Send a specific destination amount
Send exactly 500 PHP to the beneficiary. Cadana deducts the equivalent USD from the wallet.
### Send a specific source amount
Send 10 USD worth of the destination currency to the beneficiary.
**Response:**
```json theme={null}
{
"id": "d0137ede-7df1-4a54-8206-b3ab7b03876f"
}
```
### Transaction fields
| Field | Required | Description |
| :- | :- | :- |
| `type` | Yes | `payout` |
| `beneficiaryId` | Yes | The beneficiary to send to |
| `quoteId` | Yes | FX quote ID from [`POST /v1/fx-quotes`](/api-reference/payments/fx/create-fx-quote) |
| `amount` + `sourceCurrency` | One of | Destination amount — send exactly this amount in the destination currency |
| `sourceAmount` | One of | Source amount — deduct exactly this amount from the wallet |
| `reference` | Yes | Your unique reference for idempotency. Must be a UUID. |
***
## Track the Transaction
**Response:**
```json theme={null}
{
"id": "d0137ede-7df1-4a54-8206-b3ab7b03876f",
"type": "payout",
"beneficiaryId": "c871b333-e129-409c-aabd-7cfb55a967cc",
"quoteId": "57c74c5d-38a0-41e7-a19e-161f16dc4898",
"amount": { "value": "500.00", "currency": "PHP" },
"sourceAmount": { "value": "9.00", "currency": "USD" },
"feeAmount": { "value": "0.10", "currency": "USD" },
"fxRate": "56.1234",
"reference": "3f70be8e-426f-4e89-b883-9c97a1c334d5",
"status": "success",
"createdTimestamp": 1748478276,
"lastUpdatedTimestamp": 1748478276
}
```
### Transaction statuses
| Status | Description |
| :- | :- |
| `initiated` | Transaction created, processing has started |
| `processing` | Funds are being sent to the beneficiary |
| `success` | Funds delivered |
| `failed` | Transaction failed |
`feeAmount` is omitted when no fee applies to the corridor.
### List all transactions
Filter by `reference` or `type` using query parameters.
***
## Webhook Events
Subscribe to transaction events for real-time status updates:
| Event | Description |
| :- | :- |
| `transaction.initiated` | Transaction created and processing has started |
| `transaction.processing` | Transaction passed compliance checks and is being processed |
| `transaction.rfi.updated` | Additional information was requested or provided for a transaction under review |
| `transaction.succeeded` | Funds delivered to the beneficiary |
| `transaction.failed` | Transaction failed |
See [Events](/reference/events) for webhook payloads and [Webhooks](/reference/webhooks) to configure your endpoint.
***
## Next Steps
Wallet product overview
Fund wallets via dedicated ACH accounts
Full API documentation
# Tax Forms
Source: https://docs.cadanapay.com/wallets/tax-forms
Retrieve and download tax forms for contractors
Contractors hired by U.S. businesses are required to complete and sign a tax form as part of their wallet onboarding. The applicable form depends on the contractor's tax residency:
| Form | When it applies |
| :- | :- |
| **W-9** | U.S. contractors |
| **W-8BEN** | Non-U.S. individual contractors |
| **W-8BEN-E** | Non-U.S. entity contractors |
Contractors complete and sign their tax form through the Cadana app during wallet onboarding. Once signed, you can retrieve and download the completed forms via the API.
If a contractor already has a signed tax form, you can also submit it directly via the API instead of routing them through the app — see [Submit a Tax Form](#submit-a-tax-form).
**1099-NEC** forms are generated automatically at tax year-end for U.S. contractors with a completed W-9 who earned at least \$600. They appear alongside other forms in the list endpoint once filed.
***
## Submit a Tax Form
Submit a completed **W-9**, **W-8BEN** or **W-8BEN-E** on a contractor's behalf. Upload the signed PDF first using the [file upload flow](/reference/working-with-files), then pass the returned `fileId` along with the raw field data.
**Response:**
```json theme={null}
{
"id": "ca51f42c-0adc-4a77-8dd1-a03dd4fb955f"
}
```
| Field | Description |
| :- | :- |
| `fileId` | ID of the uploaded signed form PDF, from the [file upload flow](/reference/working-with-files) |
| `type` | Form type — `W9`, `W8BEN` or `W8BENE` |
| `w9` / `w8ben` / `w8bene` | The raw field data for the form, matching `type` |
| `id` | Identifier of the created tax form |
You must upload a valid, filled and signed tax form (W-9, W-8BEN or W-8BEN-E) as a PDF. **1099-NEC** forms cannot be uploaded; they are generated automatically at year-end.
***
## List Tax Forms
Retrieve all tax forms for a user.
**Response:**
```json theme={null}
{
"data": [
{
"name": "W8BEN",
"formId": "30c6c36b5ae0985c1d715bbf25b878a1349cc99a",
"createdAt": 1690339130,
"isComplete": true,
"isDocumentDownloadable": true
}
]
}
```
| Field | Description |
| :- | :- |
| `name` | Form type — `W9`, `W8BEN`, `W8BENE`, or `1099` |
| `formId` | Unique identifier — use this to download the form |
| `createdAt` | Unix timestamp when the form was created |
| `isComplete` | Whether the form has been fully filled out and signed |
| `isDocumentDownloadable` | Whether a PDF download is available |
***
## Download a Tax Form
Download the PDF for a specific form.
**Response:**
The response body is the tax form PDF itself, returned as a base64-encoded
document with content type `application/pdf`. Decode the body and write it to a
`.pdf` file — there is no JSON wrapper and no separate download link.
Only forms with `isDocumentDownloadable: true` can be downloaded. Check this field before requesting the download.
# Virtual Accounts
Source: https://docs.cadanapay.com/wallets/virtual-accounts
Fund worker wallets via dedicated ACH accounts
Virtual accounts let you provision unique ACH routing and account numbers for each worker's wallet. External parties (employers, clients, platforms) can fund wallets directly via ACH credit — no payroll required.
```mermaid theme={null}
flowchart TD
subgraph PLATFORM
direction TB
P[Platform onboards workers]
subgraph USER_WALLETS
direction LR
W1[User Wallet #1
USD virtual acct]
W2[User Wallet #2
USD virtual acct]
Wn[User Wallet …]
end
end
Bank[(External Bank)]
Bank -- "ACH credit" --> W1
Bank -- "ACH credit" --> W2
Bank -- "ACH credit" --> Wn
```
***
## Prerequisites
The worker must have a User account with a provisioned wallet. See [Set Up Worker Wallets](/wallets/worker-wallets).
Both `identity` and `address` KYC must be approved before you can provision a virtual account. See [KYC Verification](/platform/kyc-verification).
Work with your account manager to enable virtual accounts for your platform.
***
## Create a Virtual Account
Once KYC is approved, provision a virtual account for the worker's wallet.
The account is created in a `Requested` state. Cadana submits it to the bank for processing, and it moves to `Approved` once provisioning is complete.
***
## Retrieve Account Details
Fetch the virtual account details including the routing and account numbers.
**Response:**
```json theme={null}
{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef0123456789",
"currency": "USD",
"status": "Approved",
"accountInformation": {
"accountNumber": "9876543210",
"routingNumber": "000000000",
"bankName": "Example Bank",
"accountName": "John Doe",
"bankCode": "000000000",
"countryCode": "US",
"accountType": "checking"
},
"createdTimestamp": 1690339130,
"lastUpdatedTimestamp": 1690339230
}
]
}
```
| Field | Description |
| :- | :- |
| `accountInformation.accountNumber` | Unique account number for ACH credits |
| `accountInformation.routingNumber` | Bank routing number |
| `accountInformation.bankName` | Name of the custodial bank |
| `status` | `Requested`, `Approved`, `Rejected`, or `Deactivated` |
***
## Fund the Wallet
Once the virtual account is `Approved`, external parties can send ACH credits to the account number. Funds appear in the worker's wallet once the transfer settles (typically 1–3 business days for ACH).
### Sandbox testing
In sandbox mode, simulate a deposit to test the funding flow without a real ACH transfer.
Returns `204` on success. The deposit is reflected in the worker's wallet balance immediately.
***
## Webhook Events
Subscribe to virtual account events for real-time status updates:
| Event | Description |
| :- | :- |
| `virtual-account.requested` | Virtual account is in review |
| `virtual-account.submitted` | Sent to bank for processing |
| `virtual-account.created` | Account is active with full account information |
See [Events](/reference/events) for webhook payloads and [Webhooks](/reference/webhooks) to configure your endpoint.
***
## Supported Countries
**Restricted**
Virtual account services are available for these jurisdictions with additional monitoring and controls.
| Country | ISO Code | US ACH/FedWire |
| :- | :- | :- |
| Aland Islands | ALA | Yes |
| Angola | AGO | Yes |
| Antarctica | ATA | Yes |
| Barbados | BRB | Yes |
| Benin | BEN | Yes |
| Bolivia | BOL | Yes |
| Bouvet Island | BVT | Yes |
| British Indian Ocean Territory | IOT | Yes |
| Bulgaria | BGR | Yes |
| Burkina Faso | BFA | Yes |
| Cabo Verde | CPV | Yes |
| Cambodia | KHM | Yes |
| Cameroon | CMR | Yes |
| Central African Republic | CAF | Yes |
| Chad | TCD | Yes |
| Christmas Island | CXR | Yes |
| Cocos (Keeling) Islands | CCK | Yes |
| Comoros | COM | Yes |
| Congo | COG | Yes |
| Cook Islands | COK | Yes |
| Cote d'Ivoire | CIV | Yes |
| Djibouti | DJI | Yes |
| Equatorial Guinea | GNQ | Yes |
| Eritrea | ERI | Yes |
| Eswatini | SWZ | Yes |
| Ethiopia | ETH | Yes |
| Falkland Islands | FLK | Yes |
| Faroe Islands | FRO | Yes |
| French Southern Territories | ATF | Yes |
| Gabon | GAB | Yes |
| Gibraltar | GIB | Yes |
| Guinea | GIN | Yes |
| Heard Island and McDonald Islands | HMD | Yes |
| Indonesia | IDN | Yes |
| Jamaica | JAM | Yes |
| Kiribati | KIR | Yes |
| Kuwait | KWT | Yes |
| Kyrgyzstan | KGZ | Yes |
| Lao PDR | LAO | Yes |
| Lesotho | LSO | Yes |
| Liberia | LBR | Yes |
| Madagascar | MDG | Yes |
| Maldives | MDV | Yes |
| Mali | MLI | Yes |
| Mauritania | MRT | Yes |
| Monaco | MCO | Yes |
| Montserrat | MSR | Yes |
| Namibia | NAM | Yes |
| Nigeria | NGA | Yes |
| Norfolk Island | NFK | Yes |
| Palau | PLW | Yes |
| Panama | PAN | Yes |
| Papua New Guinea | PNG | Yes |
| Philippines | PHL | Yes |
| Pitcairn | PCN | Yes |
| Saint Helena, Ascension and Tristan da Cunha | SHN | Yes |
| Saint Kitts and Nevis | KNA | Yes |
| Sao Tome and Principe | STP | Yes |
| Senegal | SEN | Yes |
| Sierra Leone | SLE | Yes |
| Solomon Islands | SLB | Yes |
| South Africa | ZAF | Yes |
| South Georgia and the South Sandwich Islands | SGS | Yes |
| Sri Lanka | LKA | Yes |
| Suriname | SUR | Yes |
| Svalbard and Jan Mayen | SJM | Yes |
| Tajikistan | TJK | Yes |
| Tanzania | TZA | Yes |
| Thailand | THA | Yes |
| Togo | TGO | Yes |
| Tokelau | TKL | Yes |
| Tonga | TON | Yes |
| Trinidad & Tobago | TTO | Yes |
| Turkey | TUR | Yes |
| Turkmenistan | TKM | Yes |
| Tuvalu | TUV | Yes |
| Uganda | UGA | Yes |
| United Arab Emirates | ARE | Yes |
| United States Minor Outlying Islands | UMI | Yes |
| Vanuatu | VUT | Yes |
| Vietnam | VNM | Yes |
| Virgin Islands (British) | VGB | Yes |
| Western Sahara | ESH | Yes |
**Standard**
Virtual account services are available for these jurisdictions with regular monitoring and controls.
| Country | ISO Code | US ACH/FedWire |
| :- | :- | :- |
| Albania | ALB | Yes |
| American Samoa | ASM | Yes |
| Andorra | AND | Yes |
| Anguilla | AIA | Yes |
| Antigua and Barbuda | ATG | Yes |
| Argentina | ARG | Yes |
| Armenia | ARM | Yes |
| Aruba | ABW | Yes |
| Australia | AUS | Yes |
| Austria | AUT | Yes |
| Azerbaijan | AZE | Yes |
| Bahamas | BHS | Yes |
| Bahrain | BHR | Yes |
| Belgium | BEL | Yes |
| Belize | BLZ | Yes |
| Bermuda | BMU | Yes |
| Bonaire, Sint Eustatius and Saba | BES | Yes |
| Bosnia-Herzegovina | BIH | Yes |
| Botswana | BWA | Yes |
| Brazil | BRA | Yes |
| Brunei Darussalam | BRN | Yes |
| Canada | CAN | Yes |
| Cayman Islands | CYM | Yes |
| Chile | CHL | Yes |
| Colombia | COL | Yes |
| Costa Rica | CRI | Yes |
| Croatia | HRV | Yes |
| Curacao | CUW | Yes |
| Cyprus | CYP | Yes |
| Czechia | CZE | Yes |
| Denmark | DNK | Yes |
| Dominica | DMA | Yes |
| Dominican Republic | DOM | Yes |
| Ecuador | ECU | Yes |
| Egypt | EGY | Yes |
| El Salvador | SLV | Yes |
| Estonia | EST | Yes |
| Fiji | FJI | Yes |
| Finland | FIN | Yes |
| France | FRA | Yes |
| French Guiana | GUF | Yes |
| French Polynesia | PYF | Yes |
| Gambia | GMB | Yes |
| Georgia | GEO | Yes |
| Germany | DEU | Yes |
| Ghana | GHA | Yes |
| Greece | GRC | Yes |
| Greenland | GRL | Yes |
| Grenada | GRD | Yes |
| Guadeloupe | GLP | Yes |
| Guam | GUM | Yes |
| Guatemala | GTM | Yes |
| Guernsey | GGY | Yes |
| Guyana | GUY | Yes |
| Holy See | VAT | Yes |
| Honduras | HND | Yes |
| Hong Kong | HKG | Yes |
| Hungary | HUN | Yes |
| Iceland | ISL | Yes |
| India | IND | Yes |
| Ireland | IRL | Yes |
| Isle of Man | IMN | Yes |
| Israel | ISR | Yes |
| Italy | ITA | Yes |
| Japan | JPN | Yes |
| Jersey | JEY | Yes |
| Jordan | JOR | Yes |
| Kazakhstan | KAZ | Yes |
| Korea (Republic of) | KOR | Yes |
| Latvia | LVA | Yes |
| Liechtenstein | LIE | Yes |
| Lithuania | LTU | Yes |
| Luxembourg | LUX | Yes |
| Macau | MAC | Yes |
| Malawi | MWI | Yes |
| Malaysia | MYS | Yes |
| Malta | MLT | Yes |
| Marshall Islands | MHL | Yes |
| Martinique | MTQ | Yes |
| Mauritius | MUS | Yes |
| Mayotte | MYT | Yes |
| Mexico | MEX | Yes |
| Micronesia | FSM | Yes |
| Moldova | MDA | Yes |
| Mongolia | MNG | Yes |
| Montenegro | MNE | Yes |
| Nauru | NRU | Yes |
| Netherlands | NLD | Yes |
| New Caledonia | NCL | Yes |
| New Zealand | NZL | Yes |
| Niue | NIU | Yes |
| Northern Mariana Islands | MNP | Yes |
| Norway | NOR | Yes |
| Oman | OMN | Yes |
| Paraguay | PRY | Yes |
| Peru | PER | Yes |
| Poland | POL | Yes |
| Portugal | PRT | Yes |
| Puerto Rico | PRI | Yes |
| Reunion | REU | Yes |
| Romania | ROU | Yes |
| Rwanda | RWA | Yes |
| Saint Barthelemy | BLM | Yes |
| Saint Lucia | LCA | Yes |
| Saint Martin (French part) | MAF | Yes |
| Saint Pierre and Miquelon | SPM | Yes |
| Saint Vincent and the Grenadines | VCT | Yes |
| Samoa | WSM | Yes |
| San Marino | SMR | Yes |
| Saudi Arabia | SAU | Yes |
| Serbia | SRB | Yes |
| Seychelles | SYC | Yes |
| Singapore | SGP | Yes |
| Sint Maarten | SXM | Yes |
| Slovakia | SVK | Yes |
| Slovenia | SVN | Yes |
| Spain | ESP | Yes |
| Sweden | SWE | Yes |
| Switzerland | CHE | Yes |
| Taiwan | TWN | Yes |
| Timor-Leste | TLS | Yes |
| Tunisia | TUN | Yes |
| Turks and Caicos Islands | TCA | Yes |
| United Kingdom | GBR | Yes |
| United States of America | USA | Yes |
| Uruguay | URY | Yes |
| Uzbekistan | UZB | Yes |
| Virgin Islands (U.S.) | VIR | Yes |
| Wallis and Futuna | WLF | Yes |
| Zambia | ZMB | Yes |
***
## Next Steps
Create Users and complete KYC
Send funds from wallets to beneficiaries
Virtual accounts API documentation
# Set Up Worker Wallets
Source: https://docs.cadanapay.com/wallets/worker-wallets
Create User accounts, complete KYC, and provision Cadana wallets for workers
A **User** is a Cadana account for an existing [Person](/workforce/onboarding-workers). Creating a User provisions a Global Wallet that the worker can use to receive payments, access self-service features, and manage their funds.
***
## Prerequisites
Get your API key from the [Cadana Dashboard](https://app.cadanapay.com). See [Authentication](/authentication) for details.
The worker must already be onboarded as a Person. See [Onboard Workers](/workforce/onboarding-workers).
***
## Step 1: Create a User
Invite the worker to Cadana by creating a User linked to their Person record. This provisions a wallet in their compensation currency.
**Response:**
```json theme={null}
{
"userId": "3a1b2c3d-4e5f-6789-abcd-ef0123456789"
}
```
By default, the worker receives a welcome email with instructions to set up their password and log into the Cadana app.
### Get the onboarding link
Pass `returnLink: true` to receive the worker's unique onboarding URL in the response. This is useful when you want to send it through a custom channel.
**Response:**
```json theme={null}
{
"userId": "3a1b2c3d-4e5f-6789-abcd-ef0123456789",
"inviteLink": "https://app.cadanapay.com/login?auth=..."
}
```
The `inviteLink` is also returned when `suppressWelcomeEmail` is `true`, even without setting `returnLink`.
**Using custom authentication?** Pass `"suppressWelcomeEmail": true` to skip the welcome email and password setup. You'll handle login via your own SSO flow instead. See [Custom Authentication](/white-label/custom-authentication).
***
## Step 2: KYC Verification
Before the wallet can be fully functional, the worker must complete identity verification (KYC). There are two ways to do this:
### Option A: Worker self-service
The worker completes KYC themselves through the Cadana app or your [white-label UI](/white-label/overview) after logging in.
### Option B: Submit KYC via API
You can submit KYC on behalf of the worker directly through the API. This is useful when you've already collected identity documents during your own onboarding flow.
Returns `204` on success — the submission is sent for verification.
Upload identity documents first using the [file upload flow](/reference/working-with-files), then pass the returned `fileId` values in `idDetails`. `frontFileId` and `selfieFileId` are required; add `backFileId` for two-sided documents. See [KYC Verification](/platform/kyc-verification#identity-document) for the full field reference.
### Check KYC status
Poll the KYC endpoint or listen for the `user.kyc.updated` [webhook event](/reference/events) to track verification progress:
**Response:**
```json theme={null}
{
"idDetails": {
"country": "NG",
"type": "Passport"
},
"identity": "approved",
"address": "approved",
"firstName": "John",
"lastName": "Doe"
}
```
**Sandbox behavior:** Submit with the sentinel values `idDetails.number: "auto-approve"` and `address.line2: "auto-approve"` to have verification approved automatically. Submissions without a sentinel are not auto-approved. See [KYC Testing](/reference/sandbox#kyc-testing).
For details on what's required, see [KYC Verification](/platform/kyc-verification).
### Tax Forms (U.S. Businesses)
Contractors hired by U.S. businesses are also required to complete and sign a tax form (W-9, W-8BEN, or W-8BEN-E) as part of wallet onboarding. The contractor completes this through the Cadana app alongside KYC — no API integration needed.
You can retrieve and download completed forms via the API. See [Tax Forms](/wallets/tax-forms).
***
## Wallet Payment Method
When you create a User for a Person, the platform automatically sets wallet as the preferred payment method. No additional API call is needed — payroll disbursements will go to the worker's Cadana wallet by default.
You can verify this by checking the Person's payment info:
If you need to switch the worker to a different payment method (bank transfer, mobile money, etc.), use [`PUT /v1/persons/{personId}/paymentInfo`](/api-reference/workforce/persons/update-payment-information). See [Onboard Workers](/workforce/onboarding-workers#direct-payment-methods-alternative) for details.
***
## Check Wallet Balance
Retrieve a worker's wallet balances. A worker can hold balances in multiple currencies.
**Response:**
```json theme={null}
{
"data": [
{
"id": "517075a2-17db-469f-9481-eb8347cb920c",
"currency": "USD",
"balance": 703448,
"available": 703448,
"processing": 0
}
],
"node": {
"previous": null,
"next": null
}
}
```
| Field | Description |
| :- | :- |
| `currency` | Wallet currency |
| `balance` | Total balance in lowest denomination (cents) |
| `available` | Amount available for withdrawal |
| `processing` | Amount currently being processed (e.g. pending withdrawals) |
***
## Managing Users
| Operation | Endpoint | Description |
| :- | :- | :- |
| List users | [`GET /v1/users`](/api-reference/workforce/users/list) | Filter by `personId` or `email` |
| Get user | [`GET /v1/users/{userId}`](/api-reference/workforce/users/get) | Retrieve user details and status |
See the [Workforce API Reference](/api-reference/workforce) for full request and response schemas.
***
## Next Steps
Pay workers into their wallets
How identity verification works
W-9 and W-8 forms for U.S. business contractors
Integrate your own SSO for worker login
Full wallet endpoint documentation
# All Notification Kinds
Source: https://docs.cadanapay.com/white-label/all-notification-kinds
Complete reference of email notifications sent by the platform
Every notification below is automatically branded with your [theme](/white-label/theme) (logo, colors, company name). Expand any notification to preview the email template.
***
## Worker Notifications
Notifications sent to employees and contractors on your platform.
### Onboarding
| Notification | Trigger | Description |
| - | - | - |
| **Welcome Email** | Worker invited to the platform | Welcome message with account setup instructions |
| **Account Reinstated** | Previously offboarded user reactivated | Confirmation that account is active again |
| **Offboarding Notice** | Exit date scheduled | Last working day, final pay date, and next steps |
> **Subject:** Welcome to **Acme Corp**
>
> Hi **Sarah** 👋
>
> Welcome to your **Acme Corp** account. **Example Inc** has onboarded you to the platform. You can use **Acme Corp** to get access to your income, save in alternate currencies, invest and use our virtual cards for international transactions with zero limits.
>
> **Finish setting up your account below**
>
> Username: **[sarah.johnson@example.com](mailto:sarah.johnson@example.com)**
>
> \[Set Password]
> Hi **Sarah Johnson** 👋
>
> We're pleased to inform you that your account with **Example Inc** has been reinstated.
>
> Please reach out to your admin if you have any questions.
>
> Regards,
> **Acme Corp**
> **Subject:** Offboarding Notice
>
> Your offboarding has been scheduled for **February 28, 2026** by your employer. You will be paid through **Regular Payroll**.
>
> You can access your paystubs from your dashboard at any time.
***
### Identity Verification
| Notification | Trigger | Description |
| - | - | - |
| **KYC Reminder** | KYC Reminder triggered on the platform | Steps to complete verification with a link to the app |
| **KYC Status Update** | Verification approved or needs attention | Result of verification and next steps if needed |
| **KYC Re-verification** | Documents need to be resubmitted | What documents to provide and why |
| **Document Expiring** | ID approaching expiration | Document type, expiry date, and renewal link |
| **Document Expiry Extended** | Expiry deadline extended | New deadline confirmation |
> **Subject:** A quick step to finish setting up your account
>
> Hi **Sarah Johnson** 👋
>
> This is a quick reminder to complete your KYC verification. To help keep your account secure and fully available, please take a moment to complete your KYC verification.
>
> **What to do:**
>
> 1. Sign in to your account.
> 2. Upload a valid government issued ID
>
> \[Complete Verification]
> Hi **Sarah** 👋
>
> Your **Identity Verification** has been approved! You can continue setting up your account by completing your address verification.
>
> \[Continue Onboarding]
> Hi **Sarah** 👋
>
> Your **Identity Verification** has been rejected for reason: **Document expired**. Please try again and contact support if any help is required.
>
> \[Continue Onboarding]
> Hi **Sarah Johnson** 👋
>
> We need you to re-submit your identity documents. Your previous submission could not be verified.
>
> Please log in to your account and upload a valid government-issued ID.
>
> \[Upload Documents]
> Hi **Sarah Johnson** 👋
>
> Your **Passport** is expiring on **March 15, 2026**. Please update your documents to avoid any interruption to your account.
>
> \[Update Documents]
> Hi **Sarah Johnson** 👋
>
> Good news! The deadline to update your expiring document has been extended to **April 30, 2026**.
>
> Please ensure you update your documents before this new deadline.
***
### Card Activity
| Notification | Trigger | Description |
| - | - | - |
| **Card Created** | Virtual card activated | Confirmation that card is active with instructions to view in app |
| **Transaction OTP** | 3D Secure verification needed | One-time verification code and card last 4 digits |
| **Transaction Approved** | Card purchase successful | Amount, merchant name, and transaction details |
| **Transaction Declined** | Card purchase failed | Amount, decline reason, and how to resolve |
| **Transaction Reversed** | Charge reversed | Amount and reversal details |
| **Refund Incoming** | Merchant issued refund | Refund amount and original transaction reference |
| **Card Blocked** | Card frozen | Confirmation and how to unblock |
| **Card Unblocked** | Card reactivated | Confirmation that card is active |
| **Maintenance Fee Notice** | Upcoming card fee (if applicable) | Fee amount and charge date |
*Transaction OTP is time-sensitive and delivered immediately.*
> Hi **Sarah Johnson** 👋
>
> We are pleased to inform you that your virtual card is now active and ready for use. You may now begin using it for all your online purchases. Apart from being convenient, it also provides an additional layer of security when shopping online.
>
> To get started, please log in to your account and navigate to the "Virtual Card" section. If you have any questions or concerns, please do not hesitate to contact our support team.
>
> We're looking forward to making your online shopping experience smoother and safer!
> Hi **Sarah Johnson** 👋
>
> An OTP (One-Time Password) has been generated for a recent transaction made with your card ending in \*\***4832**. Please find the OTP below.
>
> **847291**
>
> This code expires in 5 minutes. If you did not initiate this transaction, please contact support immediately.
> Hi **Sarah Johnson** 👋
>
> A transaction of **USD 149.99** has been made using your virtual card.
>
> | | |
> | - | - |
> | Amount: | **USD 149.99** |
> | Description: | **Online purchase** |
> | Category: | **Shopping** |
> | Merchant Name: | **Amazon** |
>
> If you did not authorize this transaction or suspect any fraudulent activity, please contact our customer support immediately for assistance.
> Hi **Sarah Johnson** 👋
>
> Your most recent transaction of **USD 149.99** with your virtual card was declined.
>
> **Reason**
> **Insufficient funds**
>
> Please ensure your card details are correct and you have sufficient funds to complete the transaction. If the issue persists, please contact our customer support for assistance.
> Hi **Sarah Johnson** 👋
>
> A transaction of **USD 149.99** on your virtual card has been reversed.
>
> | | |
> | - | - |
> | Amount: | **USD 149.99** |
> | Description: | **Original purchase** |
>
> The funds have been credited back to your card. If you have any questions, please contact support.
> Hi **Sarah Johnson** 👋
>
> We've received notification of a refund from **Amazon** for a transaction of **USD 149.99** on your virtual card. The refund is currently being processed and will be credited to your card when the transaction is completed.
>
> If you did not authorize this transaction or suspect any fraudulent activity, please contact our customer support immediately for assistance.
> Hi **Sarah Johnson** 👋
>
> You have successfully frozen your virtual card. You can unfreeze your card at any time by logging in to your account. Please note that you will not be able to use your virtual card for any transactions while it is frozen.
>
> If you did not authorize this or suspect any fraudulent activity, please contact our customer support immediately for assistance.
> Hi **Sarah Johnson** 👋
>
> Your virtual card has been blocked due to the following reason: **Suspicious activity detected**
>
> If you have any questions or concerns, please reach out to our support team.
> Hi **Sarah Johnson** 👋
>
> Your virtual card has been successfully unblocked and is now active and ready for use.
>
> Please ensure responsible card usage to avoid any potential disruptions in the future. If you have any questions or concerns, please reach out to our support team.
> Hi **Sarah Johnson** 👋
>
> This is a reminder that a maintenance fee of **USD 2.99** will be charged to your virtual card on **February 1, 2026**.
>
> Ensure you have sufficient balance to avoid any service interruption.
***
### Money Movement
| Notification | Trigger | Description |
| - | - | - |
| **Deposit Received** | Funds added to account | Amount and currency received |
| **Payout Initiated** | Withdrawal started | Recipient, amount, method, and estimated arrival |
| **Payout Completed** | Funds sent successfully | Amount and reference number |
| **Payout Failed** | Withdrawal unsuccessful | Amount, failure reason, and how to resolve |
| **Payout Delayed** | Transfer taking longer than expected | Reason for delay and new estimated arrival |
> Hi **Sarah Johnson** 👋
>
> You've received **USD 2,500.00** in your **Acme Corp** Account. Open the app to view more details.
> Hi **Sarah Johnson** 👋
>
> You've initiated a transfer from your account, and your money is on its way.
>
> | | |
> | - | - |
> | Recipient: | **John Doe** |
> | Amount: | **USD 2,500.00** |
> | Method: | **Bank Transfer** |
> | Estimated Arrival: | **January 16, 2026** |
> **Subject:** Money Received
>
> Hi **Sarah Johnson** 👋
>
> You have received **USD 2,500.00** from **Example Inc** for **Monthly Salary**.
>
> The payment confirmation letter is attached to this email and available for download. Share this letter with your bank as proof of source of funds for the payout.
> Hi **Sarah Johnson** 👋
>
> We regret to inform you that your recent transfer has failed and could not be completed. Please contact support if you need assistance.
>
> | | |
> | - | - |
> | Amount: | **USD 2,500.00** |
> | Recipient: | **John Doe** |
> | Reason: | **Invalid account details** |
> Hi **Sarah Johnson** 👋
>
> Your recent transaction of **USD 2,500.00** initiated on **January 14, 2026** is currently pending. We're working with our payment partner to resolve the delays and will update you as soon as we have more information. We sincerely regret the inconvenience and appreciate your patience.
***
### Payroll & Salary
| Notification | Trigger | Description |
| - | - | - |
| **Payment Preview** | Contractor payment review requested | Time worked, rate, overtime, and how to dispute |
| **Salary Received** | Pay deposited | Amount, employer name, and pay period |
| **Payslip Available** | New payslip generated | Pay period and download link with password hint |
| **1099 Filed** | Tax form submitted (US contractors) | Tax year and link to download form |
*Payment Preview is manually triggered and only available for contractor payments.*
*Payslip PDFs are password-protected. Password: last 6 digits of bank account or DOB (MMYYYY).*
> Hi **Sarah** 👋
>
> Below are the details for your upcoming payroll from **Example Inc**:
>
> Time Worked: **160 hours**
> Compensation Rate: **USD 5,000.00**
> Overtime Worked: **8 hours**
> Overtime Rate: **USD 375.00**
>
> Please review and if you find a discrepancy, please raise a dispute with your Support Specialist.
> **Subject:** Money Received
>
> Hi **Sarah Johnson** 👋
>
> You have received **USD 2,500.00** from **Example Inc** for **Monthly Salary**.
>
> The payment confirmation letter is attached to this email and available for download. Share this letter with your bank as proof of source of funds for the payout.
>
> For security purposes, the attached document is password protected. To view it, simply enter the last 6 digits of your account number as your password.
> **Subject:** Payslip Generated
>
> Hi **Sarah Johnson** 👋
>
> **Example Inc** has just processed your pay for the period of **January 1-15, 2026** and a payslip has been generated for you.
>
> Your payslip is attached to this email and is available for download. For security purposes, the attached document is password protected. To view it, simply enter the last 6 digits of your account number as your password.
>
> \[View Payslip]
> Hi **Sarah Johnson** 👋
>
> Your 1099 tax form for the **2025** tax year has been filed and is now available for download.
>
> \[Download 1099]
***
### Security
| Notification | Trigger | Description |
| - | - | - |
| **New Login Detected** | Login from new device | Device, location, time, and "wasn't me" action |
| **Unusual Login Location** | Login from unexpected place | Location details and link to secure account |
| **Password Changed** | Password updated | Timestamp and security notice |
| **Password Reset OTP** | Reset requested | 6-digit code with 15-minute expiry |
| **Magic Link** | Passwordless login requested | One-click login link with 15-minute expiry |
| **2FA Reset** | Two-factor authentication reset | Confirmation and security reminder |
| **Recovery Code Used** | Backup code accessed | Which code was used and security notice |
| **Compliance Hold** | Account restriction applied | Reason, status, and required actions |
*All security alerts include "If this wasn't you" instructions.*
> **Subject:** New login detected on your account
>
> Hi **Sarah Johnson** 👋
>
> We noticed an unusual login to your account from a new device or location. We want to verify if it was you.
>
> * Device Name: **iPhone 15 Pro**
> * Date and Time: **January 14, 2026 at 10:23 AM**
> * Location: **San Francisco, CA**
> * IP: **192.168.1.1**
>
> \*Location is approximate based on the login's IP address.
>
> If this was you, no action is required. But if it wasn't you, please change your password immediately and reach out to support if you need further assistance.
> **Subject:** Unusual login location detected
>
> Hi **Sarah Johnson** 👋
>
> We detected a login to your account from an unusual location: **Lagos, Nigeria**.
>
> If this was you, no action is required. If not, please secure your account immediately by changing your password.
>
> \[Secure My Account]
> **Subject:** Your Password has been Updated
>
> Hi **Sarah Johnson** 👋
>
> This is to confirm that your password has been successfully changed.
>
> If you did not initiate this change, please contact our support team immediately at **[support@example.com](mailto:support@example.com)**.
> Hi **Sarah Johnson** 👋
>
> We've received a request to reset the password for your account. To proceed, please use the following verification code:
>
> **847291**
>
> This code expires in 15 minutes. If you did not request a password reset, please ignore this email.
> Hi **Sarah Johnson** 👋
>
> Here's your secure link to **sign in to your account**.
>
> This link expires in **15 minutes**, but you can get a new one by going through the activation steps again.
>
> \[Open your secure link]
> Hi **Sarah Johnson** 👋
>
> Your two-factor authentication has been reset. You will need to set up 2FA again the next time you log in.
>
> If you did not request this reset, please contact support immediately.
> Hi **Sarah Johnson** 👋
>
> A recovery code was used to access your account. Recovery code **#3** has been used and is no longer valid.
>
> If this wasn't you, please secure your account immediately.
> Hi **Sarah Johnson** 👋
>
> Your account has been placed on hold due to: **Additional verification required**.
>
> Please contact support to resolve this issue and restore full access to your account.
***
### Investments & Savings
| Notification | Trigger | Description |
| - | - | - |
| **Stock Order Executed** | Buy/sell order complete | Order type, symbol, shares, price, and total |
| **Dividend Received** | Dividend payment credited | Stock name, dividend amount, and payment date |
| **Auto-Save Processed** | Scheduled savings transfer complete | Amount saved and new balance |
| **Interest Earned** | Interest credited to savings | Interest amount, rate, and updated balance |
> **Subject:** Your order has been executed
>
> Hi **Sarah Johnson** 👋
>
> Your order to buy **$1,855.00** of **AAPL** was successful! It was processed on **January 14, 2026**. You purchased **10** shares at an average price of **$185.50** per share.
>
> Your trade confirmation will be available in your order history.
> **Subject:** Your order has been executed
>
> Hi **Sarah Johnson** 👋
>
> Your order to sell **$1,855.00** of **AAPL** was successful! It was processed on **January 14, 2026**. You received **$1,855.00** for **10** shares at an average price of **\$185.50** per share. Funds are now available in your wallet.
>
> Your trade confirmation will be available in your order history.
> Hi **Sarah Johnson** 👋
>
> You've received a dividend payment of **\$12.50** from **Apple Inc (AAPL)** on **January 15, 2026**.
>
> The funds have been added to your wallet.
> Hi **Sarah Johnson** 👋
>
> Your scheduled savings transfer of **\$100.00** has been processed successfully.
>
> Your new savings balance is **\$1,250.00**.
> Hi **Sarah Johnson** 👋
>
> You've earned **\$12.50** in interest on your savings this month at a rate of **4.5%** APY.
>
> Your updated savings balance is **\$5,262.50**.
***
### Contracts
| Notification | Trigger | Description |
| - | - | - |
| **Signature Required** | Contract needs signing | Contract name and link to sign |
| **Contract Signed** | All signatures complete | Confirmation and download link |
| **Signature Voided** | Signature request cancelled | Contract name and cancellation reason |
> Hi **Sarah** 👋
>
> **Example Inc** has requested your signature on a document: **Employment Agreement**.
>
> \[Review & Sign]
> **Subject:** Contract Completed
>
> Hi **Sarah Johnson** 👋
>
> Your contract has been completed. All parties have signed the contract (**Employment Agreement**).
> Hi **Sarah Johnson** 👋
>
> The signature request for **Employment Agreement** has been voided by **Example Inc**.
>
> If you have questions, please contact your administrator.
***
### Other
| Notification | Trigger | Description |
| - | - | - |
| **Wage Access Approved** | Early wage request approved | Amount available for withdrawal |
| **Time Hours Reviewed** | Timesheet approved/rejected | Hours submitted, review period, and status |
> Hi **Sarah Johnson** 👋
>
> Your instant pay request has been approved! You will receive **USD 500.00** through **Bank Transfer** in your **checking** account.
> Hi **Sarah Johnson** 👋
>
> Your timesheet for **January 1-15, 2026** has been **approved** by **John Smith**.
>
> Total hours: **80 hours**
***
## Business/Admin Notifications
Notifications sent to business owners and administrators.
### Onboarding
| Notification | Trigger | Description |
| - | - | - |
| **Admin Onboarding** | Admin user added | Welcome message with account setup instructions |
| **Business Welcome** | Business starts onboarding | Required documents and step-by-step guide |
| **KYB Reminder** | Business verification incomplete | List of required documents |
| **KYB Status Update** | Business verification processed | Result and any follow-up actions |
| **KYB Information Reminder** | Requested information still outstanding 3 days after a review | Reminder that verification is waiting on the business, with a link to the hosted form |
| **UBO Verification Request** | Beneficial owner needs to verify | Owner name, ownership %, and verification link |
> **Subject:** Welcome to **Acme Corp**
>
> Hi **Sarah** 👋
>
> You can use **Acme Corp** to effortlessly manage your global workforce, hire contractors and employees in over 100 countries, and streamline compliance and payroll processes — all in one place. We're here to simplify your international operations, letting you focus on what matters most in your business.
>
> **Finish setting up your account below**
>
> Username: **[sarah.johnson@example.com](mailto:sarah.johnson@example.com)**
>
> \[Set Password]
> Hi **Sarah Johnson** 👋
>
> Welcome to Cadana! We're your biggest fans and we're excited to help **Example Inc** get started!
>
> To begin your onboarding, please click the unique link below. This will guide you through a secure, step-by-step process to set up your Cadana account and submit all required business information.
>
> \[Start Your Onboarding]
>
> The process takes about 6 minutes to complete and you can pause and resume your progress at any time using this link.
> Hi **Sarah Johnson** 👋
>
> This is a reminder to complete your business verification for **Example Inc**.
>
> Please submit the following documents:
>
> * Certificate of Incorporation
> * Proof of Business Address
> * Director ID Documents
>
> \[Complete Verification]
> Hi **Sarah Johnson** 👋
>
> Great news! **Example Inc** has been verified and your account is now fully active.
>
> You can now access all features including payroll, payments, and more.
> Hi **Sarah Johnson** 👋
>
> Your business verification for **Example Inc** requires additional attention. Reason: **Document quality insufficient**.
>
> Please re-submit the required documents.
>
> \[Upload Documents]
> **Subject:** Action needed: information requested for your KYB verification
>
> Hi **Sarah Johnson** 👋
>
> This is a reminder that the KYB (Know Your Business) verification for **Example Inc** is still waiting on the additional information we requested. The details of what is needed are listed in your application.
>
> Once the requested updates are in, we can continue your verification without further delay.
>
> \[Complete KYB Verification]
> Hi **Sarah Johnson** 👋
>
> As part of **Example Inc**'s verification, we need to verify all beneficial owners with 25% or more ownership.
>
> **John Smith** (owning **35%**) needs to complete identity verification.
>
> \[Start Verification]
***
### Payroll Operations
| Notification | Trigger | Description |
| - | - | - |
| **Payroll Review** | Payroll submitted for review (if setup requires multiple reviews) | Number of payments, total amount, and processing date |
| **Direct Debit Initiated** | Payroll funding started | Debit amount and expected debit date |
| **Invoice Generated** | New invoice created | Invoice number, amount, and due date |
> **Subject:** Payroll Review Requested
>
> Hi **Sarah Johnson** 👋
>
> **John Smith** has submitted a payroll that needs to be reviewed.
>
> The **Standard** payroll is for **45** people and amount **USD \$125,000.00**. Please review by the payroll date **January 15, 2026**, to prevent any delays in processing.
>
> \[Review Payroll]
> Hi **Sarah Johnson** 👋
>
> A direct debit of **USD 125,000.00** has been initiated to fund your upcoming payroll.
>
> Expected debit date: **January 13, 2026**
>
> Please ensure sufficient funds are available in your linked bank account.
> Hi **Sarah Johnson** 👋
>
> A new invoice has been generated for **Example Inc**.
>
> | | |
> | - | - |
> | Invoice Number: | **INV-2026-001234** |
> | Amount: | **USD 5,000.00** |
> | Due Date: | **January 28, 2026** |
>
> \[View Invoice]
***
### Approvals
| Notification | Trigger | Description |
| - | - | - |
| **Wage Access Request** | Employee requests early wage access | Employee name, amount, and approve/reject actions |
| **Reimbursement Request** | Employee submits expense | Amount, reason, and receipt details |
| **Contractor Invoice Submitted** | Contractor submits an invoice | Contractor name, invoice number, amount, and due date |
> Hi **Sarah Johnson** 👋
>
> **John Smith** has submitted an instant pay that needs to be approved or rejected.
>
> \[Review Request]
> Hi **Sarah Johnson** 👋
>
> **John Smith** has submitted a reimbursement request for **USD 250.00**.
>
> Reason: **Client dinner**
>
> \[Review Request]
> Hi **Sarah Johnson** 👋
>
> An invoice from **John Smith** at **Acme Inc** requires your review.
>
> Invoice Number: **1764332528**
> Amount: **USD 1,000.00**
> Due Date: **15 Dec 2025**
>
> \[Review invoice]
***
### Account
| Notification | Trigger | Description |
| - | - | - |
| **Low Balance Reminder** | Account below threshold | Current balance and recommended top-up amount |
| **Virtual Account Updated** | Account details changed | New account information |
| **Document Uploaded** | New document in vault | Document name and view link |
| **Micro-Deposit Verification Required** | Bank account linked manually with micro-deposits | Steps to verify the account using the test-deposit code or amounts |
> **Subject:** Reminder to run your payroll
>
> Hello **Sarah**,
>
> It's that time again to pay your employees and contractors!
>
> Your current balance is: **USD 50,000.00**. Your total payroll amount is **USD 125,000.00**.
>
> Please transfer at least **USD 75,000.00** by **January 12, 2026** to ensure your people get paid on time.
> Hi **Sarah Johnson** 👋
>
> Your virtual account details have been updated.
>
> | | |
> | - | - |
> | Account Name: | **Example Inc** |
> | Account Number: | **1234567890** |
> | Routing Number: | **000000000** |
>
> Please update any scheduled transfers with the new details.
> Hi **Sarah Johnson** 👋
>
> A new document has been uploaded to your vault: **Q4 2025 Financial Report**.
>
> \[View Document]
> Hi **Sarah Johnson** 👋
>
> To verify your bank account ending in **4242** (First National Bank), please follow the steps below:
>
> 1. Check your **First National Bank** account for a small deposit arriving on or near **February 12, 2026**.
> 2. Look for a 6-character code in the transaction description starting with "SM".
> 3. Return to the app and enter the code to complete verification.
>
> If you did not initiate this request, please contact our support team immediately.
*When the bank sends two test deposits instead of a coded one, the steps ask for the exact amounts of both deposits.*
***
## Customization
All notifications automatically include your platform branding:
| Element | Description |
| - | - |
| **Logo** | Displayed in email header |
| **Primary color** | Buttons and accents |
| **Company name** | Used throughout email copy |
| **Support email** | Shown in footer |
Configure these in your [Theme](/white-label/theme) settings.
# Custom Authentication
Source: https://docs.cadanapay.com/white-label/custom-authentication
Integrate your authentication system with Cadana for seamless Single Sign-On (SSO)
Custom authentication lets you connect your own identity provider to Cadana so users can access your white-label app without creating separate Cadana credentials. Users log into your system, and you exchange their JWT for a Cadana session — a seamless Single Sign-On (SSO) experience.
This is the standard approach for [White-Label UI](/white-label/overview) integrations where Cadana hosts a fully branded web app on your subdomain (e.g., `payroll.yourcompany.com`).
If your users should sign in with a corporate identity provider such as Okta, Microsoft Entra ID or Google Workspace rather than from your own product, see [SAML Single Sign-On](/white-label/saml-sso). The two can be enabled side by side.
***
## Prerequisites
Your identity provider must issue standard JWTs and expose a **JWKS (JSON Web Key Set)** endpoint for signature verification. Cadana supports popular providers including **Auth0**, **AWS Cognito**, and **Stytch**. Custom engines are also supported — contact your account manager to confirm compatibility.
Get your API key from the [Cadana Dashboard](https://app.cadanapay.com). See [Authentication](/authentication) for details.
You need a white-label setup with a custom domain (or use the default Cadana domain).
Any custom authentication engine must be **ISO and SOC 2 compliant** to ensure security and compatibility with Cadana's platform.
***
## How It Works
1. **User logs in** to your application
2. **You issue a JWT** with the user's identity
3. **You exchange the JWT** with Cadana for a one-time redirect token
4. **You redirect the user** to the branded white-label app with the token
5. **Cadana validates the token** and establishes an active session
```mermaid theme={null}
sequenceDiagram
participant U as User
participant P as Your App
participant C as Cadana API
participant W as White-Label App
U->>P: 1. Log in
P->>P: 2. Issue JWT
P->>C: 3. Exchange JWT (POST /v1/auth/login/jwt)
C-->>P: 4. Redirect token
P->>W: 5. Redirect to White-Label App
W->>C: 6. Establish session
W-->>U: 7. Access granted
```
***
## Step 1: Configure SSO Settings
Configure the following settings for your platform:
| Setting | Description | Example |
| :- | :- | :- |
| **Auth Issuer (ISS)** | Your identity provider's issuer URL | `https://auth.yourcompany.com` |
| **JWKS Endpoint** | URL where Cadana fetches your public keys | `https://auth.yourcompany.com/.well-known/jwks.json` |
| **Login URL** | Where Cadana redirects when a session expires | `https://yourcompany.com/login` |
| **Logout URL** | Where Cadana redirects when a user logs out | `https://yourcompany.com/logout` |
SSO settings are currently configured with the help of your Cadana account manager. Self-service configuration in the Dashboard is coming soon.
Cadana uses the ISS and JWKS endpoint to validate and decode your JWTs. Your auth engine must be able to mint standard JWTs and expose verification metadata at the JWKS endpoint.
***
## Step 2: Create the User with SSO
When onboarding a user who will access the white-label app via SSO, first create their Person and User records. See [Onboard Workers](/workforce/onboarding-workers) for the full Person creation flow.
Once the Person exists, create the User with [`POST /v1/users/invite`](/api-reference/workforce/users/invite). Set `suppressWelcomeEmail` to `true` so they don't receive the default Cadana sign-up email — they'll log in through your system instead.
***
## Step 3: Attach Your Auth ID to the User
For every user, attach your internal auth identifier as the `sub` (subject) on their Cadana User record. This is the critical step that ties your identity system to Cadana's.
Use [`PUT /v1/users/{userId}/sub`](/api-reference/workforce/users/attach-a-custom-sub) with the `tokenSub` field set to your internal user ID — the same value that appears in the `sub` claim of your JWTs.
Returns `204` on success.
The `tokenSub` value must exactly match the `sub` claim in the JWTs you issue for this user. If they don't match, the token exchange in the next step will fail.
***
## Step 4: Exchange JWT for Redirect Token
When a user needs to access the white-label app, exchange their JWT for a Cadana redirect token using [`POST /v1/auth/login/jwt`](/api-reference/workforce/custom-auth/jwt).
**Response:**
```json theme={null}
{
"redirectToken": "eybzym7hwk..."
}
```
### JWT Requirements
Your JWT must include these claims:
| Claim | Required | Description |
| :- | :- | :- |
| `iss` | Yes | Issuer — must match the Auth Issuer you configured in Step 1 |
| `sub` | Yes | Subject — the user's unique ID, must match the `tokenSub` you attached in Step 3 |
| `exp` | Yes | Expiration timestamp |
| `iat` | Yes | Issued-at timestamp |
The JWT header must include a `kid` (Key ID) field. The `kid` must exactly match a key in your JWKS endpoint so Cadana can verify the signature.
***
## Step 5: Redirect to the White-Label App
Use the redirect token to send the user to the white-label app. The token is one-time use — once consumed, it establishes an active session.
**Default Cadana domain:**
```
https://app.cadanapay.com/login?redirectToken={{redirectToken}}
```
**Custom domain:**
```
https://payroll.yourcompany.com/login?redirectToken={{redirectToken}}
```
Once the user lands on this URL, Cadana exchanges the redirect token for an active session. The user gets full access to the branded white-label app without any additional login.
### Land on a specific page
By default the user lands on the home page of their app. To open a specific page instead, append a `redirect` query parameter with the path of that page to either URL above:
```
/login?redirectToken={{redirectToken}}&redirect=/time-tracking
```
The path is applied inside the app the user is signed in to: the worker app for employees and contractors (for example `/time-tracking`), and the admin dashboard for admins (for example `/people`).
* `redirect` must be a path starting with `/`. URL-encode it if it carries its own query string.
* For workers, a full URL or a path that points outside the worker app is ignored, and the worker lands on the home page.
* A path that doesn't exist in the user's app shows that app's "page not found" screen, so send worker paths to workers and admin paths to admins.
***
## Session Management
Cadana manages session lifecycle automatically. When a session event occurs, Cadana redirects the user to the URLs you configured in Step 1:
| Event | Redirect destination |
| :- | :- |
| Session expires | Your **Login URL** — user re-authenticates and you repeat the JWT exchange |
| User logs out | Your **Logout URL** — handle cleanup in your app |
***
## Full Integration Example
Here's the complete flow for onboarding a user and enabling SSO access:
```mermaid theme={null}
sequenceDiagram
participant P as Your Platform
participant C as Cadana API
participant U as User
participant W as White-Label App
Note over P,W: One-time setup per user
P->>C: 1. Create Person + User
Note right of P: suppressWelcomeEmail: true
C-->>P: personId, userId
P->>C: 2. Attach sub (PUT /v1/users/{id}/sub)
Note right of P: tokenSub: your internal user ID
C-->>P: 204 OK
Note over P,W: Every login session
U->>P: 3. Log in to your app
P->>P: 4. Issue JWT (iss, sub, exp, iat, kid)
P->>C: 5. Exchange JWT (POST /v1/auth/login/jwt)
C-->>P: redirectToken
P->>W: 6. Redirect user to White-Label App
Note right of P: payroll.yourcompany.com/login?redirectToken=...
W-->>U: 7. Active session established
```
***
## Next Steps
Set up a custom domain for the white-label app
Create Person and User records
Customize the white-label app for your brand
# Custom Domain
Source: https://docs.cadanapay.com/white-label/custom-domain
Serve the white-label app from your own domain
You can serve the white-label app from a Cadana-provided subdomain or your own custom domain.
***
## Option 1: Cadana-Provided Subdomain
By default, Cadana provides all white-label clients with a unique subdomain (e.g. `yourcompany.cadanapay.com`). No configuration needed — you can use it directly or redirect from your own domain.
***
## Option 2: Custom Domain
For a fully branded experience, provide your own domain (e.g. `payroll.yourcompany.com`). Cadana will generate ACM (AWS Certificate Manager) certificates that you verify via DNS.
### Setup steps
Share your custom domain with your account manager.
Cadana generates ACM certificates and sends you CNAME records to add.
Add the CNAME records to your domain's DNS configuration.
Cadana verifies the certificates and completes the setup.
### Example DNS records
| Domain | Record Name | Type | Value |
| :- | :- | :- | :- |
| `app.example.com` | `_53dc40d...app.example.com.` | CNAME | `_ec562bv...acm-validations.aws.` |
| `app.example.com` | `app.example.com` | CNAME | `{{uniquePlatformID}}.cloudfront.net.` |
DNS propagation can take up to 48 hours. Do not direct traffic to the custom domain until Cadana confirms the certificates are verified.
# Customizing Notifications
Source: https://docs.cadanapay.com/white-label/customizing-notifications
Customize email notifications for the white-label app
Cadana sends email notifications for key user actions (onboarding, payroll, KYC, contracts, and more). All notifications are styled with your [theme](/white-label/theme) and can be sent from a custom email address.
For the full list of notification types, see [All Notification Kinds](/white-label/all-notification-kinds).
***
## Default Behavior
* Emails are sent from `support@cadanapay.com`
* All notifications use your platform theme (colors, logo)
* Webhooks are also emitted for each action, so you can send your own custom notifications if needed
***
## Custom Sender Address
You can send notifications from your own email address (e.g. `support@yourcompany.com`) instead of the default Cadana address.
Share your desired sender email address and display name with your account manager.
Cadana will provide DKIM verification records. Add them to your domain's DNS configuration.
Once DNS records propagate, notifications will be sent from your custom address.
### Example DKIM records
| Type | Name | Value |
| :- | :- | :- |
| CNAME | `abc123...._domainkey.example.com` | `abc123....dkim.amazonses.com` |
| CNAME | `def456...._domainkey.example.com` | `def456....dkim.amazonses.com` |
| CNAME | `ghi789...._domainkey.example.com` | `ghi789....dkim.amazonses.com` |
DNS propagation can take up to 48 hours. Notifications will continue to send from `support@cadanapay.com` until verification is complete.
***
## BCC Emails
You can add BCC (blind carbon copy) email addresses to receive a copy of every notification sent to your users. This is useful for compliance, record-keeping, or support visibility.
Share the BCC email addresses with your account manager to configure.
# Overview
Source: https://docs.cadanapay.com/white-label/overview
Embed Cadana's pre-built payroll and wallet UI under your brand
The White-Label product lets you deploy Cadana's payroll and wallet interfaces on your own domain, with your brand's look and feel. Your end-users interact with a fully functional application — you control the theming, authentication, domain, and notifications.
***
## Capabilities
Match Cadana's UI to your brand colors, typography, and logo. Applied to both the admin and worker portals.
Serve the application from your own domain (e.g., `payroll.yourcompany.com`) for a seamless end-user experience.
Sign users in from your own product with a JWT exchange (Auth0, Cognito, Stytch, or any OIDC-compliant engine).
Let your staff sign in with your corporate identity provider (Okta, Microsoft Entra, Google Workspace).
Customize email notifications with your brand name, logo, and colors. Control which notification types are sent.
***
## How It Works
Contact your account manager to enable white-label for your business or platform. You'll get access to the configuration APIs and dashboard settings.
Define your brand colors, logo, and typography via the [Theme](/white-label/theme) settings. The theme applies to both admin and worker-facing UIs.
Point your subdomain to Cadana and configure the DNS records. See [Custom Domain](/white-label/custom-domain) for step-by-step instructions.
Configure your OIDC issuer and JWKS endpoints, map user subjects, and exchange tokens for seamless SSO. See [Custom Authentication](/white-label/custom-authentication). Or connect your corporate SAML identity provider so staff sign in with the credentials they already have. See [SAML Single Sign-On](/white-label/saml-sso).
Choose which email notifications to send and apply your branding. See [Customizing Notifications](/white-label/customizing-notifications).
***
## Guides
Sign users in from your own product with a JWT exchange
Connect Okta, Entra or Google Workspace for your staff
Customize colors, logo, and typography
Serve the app from your own domain
Brand and configure email notifications
# SAML Single Sign-On
Source: https://docs.cadanapay.com/white-label/saml-sso
Let your staff sign in to the white-label app with your corporate identity provider (Okta, Microsoft Entra, Google Workspace)
SAML SSO connects your corporate identity provider to your Cadana platform. Your users click **Sign in with SSO** on the white-label app, authenticate with the provider they already use every day, and land in Cadana signed in. No Cadana password, no token exchange on your side.
This is the right choice when the people using Cadana are your own employees or admins and you already run Okta, Microsoft Entra ID, Google Workspace, OneLogin or any other SAML 2.0 identity provider. If you want to sign users in from your own product with a JWT you mint yourself, use [Custom Authentication](/white-label/custom-authentication) instead. Both can be enabled on the same platform.
SAML SSO is available to platforms on a dedicated Cadana identity pool, which every platform created since the pool-per-platform rollout has. Contact your account manager if you are unsure.
***
## How It Works
1. **User opens** your white-label app and chooses **Sign in with SSO**
2. **Cadana sends them** to your identity provider
3. **They authenticate** there, including any MFA your provider enforces
4. **Your provider returns** a SAML assertion with the user's email
5. **Cadana matches the email** to an existing Cadana user on your platform and starts their session
```mermaid theme={null}
sequenceDiagram
participant U as User
participant W as White-Label App
participant C as Cadana
participant I as Your Identity Provider
U->>W: 1. Sign in with SSO
W->>C: 2. Start SSO login
C-->>U: 3. Redirect to your identity provider
U->>I: 4. Authenticate (+ your MFA)
I-->>C: 5. SAML assertion (email)
C->>C: 6. Match email to a Cadana user
C-->>W: 7. Redirect back, session established
W-->>U: 8. Access granted
```
Everything after step 7 is a normal Cadana session: refresh, logout and session expiry behave exactly as they do for password logins.
***
## Before You Start
Ask your account manager for the two values below. They are fixed for your platform and available before anything is set up on either side, so this is the first thing to do.
## Step 1: Create the Cadana Application in Your Identity Provider
Create a new SAML 2.0 application in your identity provider and give it the two values you received:
| Value | What it is | Example |
| :- | :- | :- |
| **ACS URL** (Reply URL, Single sign-on URL) | Where your provider posts the SAML assertion | `https://cadana-yourplatform.auth.eu-west-1.amazoncognito.com/saml2/idpresponse` |
| **Entity ID** (Audience URI, Identifier) | Cadana's service-provider identifier for your platform | `urn:amazon:cognito:sp:eu-west-1_AbCdEfGhI` |
Then make sure the assertion carries the user's **email address** as an attribute. Cadana matches users by email, so this attribute must contain the same address the user has on their Cadana account.
Applications → Create App Integration → SAML 2.0. Set the Single sign-on URL to the ACS URL and the Audience URI to the Entity ID. Under Attribute Statements add `email` → `user.email`.
Enterprise applications → New application → Create your own application → Integrate any other application. Under Single sign-on → SAML set the Identifier to the Entity ID and the Reply URL to the ACS URL. The default claim `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` is used as the email.
Apps → Web and mobile apps → Add custom SAML app. Set the ACS URL and Entity ID. Under Attribute mapping add Primary email → `email`.
Assign the application to the users or groups who should be able to sign in to Cadana.
***
## Step 2: Send Cadana Your Provider Details
Send your account manager:
| Setting | Description | Example |
| :- | :- | :- |
| **Metadata URL** | Your provider's SAML metadata document for the Cadana application | `https://yourcompany.okta.com/app/abc123/sso/saml/metadata` |
| **Email attribute** | The attribute name in the assertion that carries the email, if it is not the provider default | `email` |
If your provider does not publish a metadata URL that Cadana can fetch, send the metadata XML file instead.
SAML settings are configured by your Cadana account manager. Self-service configuration in the Dashboard is coming soon.
Once configured, Cadana tells you SSO is live and the **Sign in with SSO** option appears on your white-label app's login page.
***
## Step 3: Make Sure Each User Exists in Cadana
SAML SSO signs in users who already have a Cadana account on your platform. It never creates accounts. Create your users as you do today, through the Dashboard or with [`POST /v1/users/invite`](/api-reference/workforce/users/invite). If users will only ever sign in through SSO, set `suppressWelcomeEmail` to `true` so they are not asked to set a Cadana password.
The email on the Cadana user must match the email your identity provider asserts, character for character apart from letter case.
Linking is automatic. The first time a user signs in through SSO, Cadana matches the asserted email to their account and links the two. Users who existed before SSO was enabled and users created afterwards are treated the same way.
A user who authenticates at your identity provider but has no Cadana account, or whose Cadana email differs from the asserted one, is refused with the message *There is no Cadana account linked to that identity*. No account is created for them.
***
## Step 4: Sign In
Users open your white-label app and choose **Sign in with SSO**.
To take a user who is already signed in to your own app straight into Cadana, link to the white-label login page with `sso=1`. The app starts the SSO flow at once; because the user already has a session with your identity provider there is no prompt, and they land signed in. Add `redirect` to choose the page:
```
https://payroll.yourcompany.com/login?sso=1&redirect=/employeeView/time-tracking
```
The `redirect` must be a path inside the app. External URLs are ignored. Without `sso=1` the login page is shown with the SSO button.
Logins always start from the white-label app. If you want a tile for Cadana in your identity provider's app launcher, point it at your white-label login page (for example `https://payroll.yourcompany.com/login`): the user clicks the SSO button there and, because they are already signed in with you, lands in Cadana without seeing another prompt. An assertion sent to Cadana without that first step is rejected.
***
## What to Expect
| Topic | Behaviour |
| :- | :- |
| **Password login** | Still available to users who have a Cadana password. SSO is an additional way in, not a replacement. |
| **Multi-factor authentication** | Enforced by your identity provider. Cadana does not ask for a second factor on SSO logins. |
| **Session length** | Same as every Cadana session. When it expires the user signs in again through SSO. |
| **Removing access** | Unassign the user from the application in your identity provider, or offboard them in Cadana. Either stops new SSO logins. |
| **Changing a user's email** | Change it in Cadana and in your provider. The next SSO login links the new email automatically. |
| **Changing identity provider** | Contact your account manager. Users are linked to the new provider at their next sign-in. |
***
## Troubleshooting
| What the user sees | Cause | Fix |
| :- | :- | :- |
| *There is no Cadana account linked to that identity* | No Cadana user has the asserted email on your platform | Create or invite the user, or correct the email on either side |
| *Your identity provider could not sign you in* | Your provider rejected the login, or the Cadana application is not assigned to the user | Check the user's assignment and your provider's sign-in logs |
| *That sign-in link has expired* | More than 10 minutes passed between starting the login and returning | Start again from the login page |
| *This login was started in another browser* | The user opened the post-login link in a different browser or device than the one they started in | Start again in the browser they will use |
***
## Next Steps
Sign users in from your own product with a JWT exchange
Serve the white-label app from your own domain
Create Person and User records
Customize the white-label app for your brand
# Theme
Source: https://docs.cadanapay.com/white-label/theme
Customize the visual appearance of the white-label app
Customize the appearance of the white-label app to match your brand. Theme settings apply to both the admin and worker-facing portals.
To set up your theme, provide your brand guide to your account manager and the Cadana team will configure it for you.
***
## Colors
| Setting | Description |
| :- | :- |
| **Foreground** | Default text color for body text |
| **Primary** | Your brand's main color — applied to buttons, checkboxes, toggles, loaders, and other interactive elements |
| **Secondary** | Alternate brand color used for secondary components |
***
## Typography
You can use any [Google Font](https://fonts.google.com) or a custom font. Custom fonts must be either free or properly licensed.
| Setting | Description |
| :- | :- |
| **Body Font** | Applied to paragraphs, labels, links, inputs, and buttons |
| **Title Font** | Applied to headings |
***
## Logo
Include your logo in your brand guide. Use a logo with a transparent or white background for best results. The logo should be hosted on a reliable platform (your website or a CDN).
***
## Preview
Use the [Theme Configuration Utility](https://dev-app.cadanapay.com/theme-configuration) to preview your theme before applying it.
# Admin Management
Source: https://docs.cadanapay.com/workforce/admin-management
Add and remove business administrators via the API
Admins are **Users** who have access to the [Cadana Dashboard](https://app.cadanapay.com) and can manage business settings, payroll, and worker records. They are distinct from **Persons**, which represent worker HR records.
Use these endpoints to programmatically grant or revoke Dashboard access for team members — for example, adding a finance manager or removing someone who has left the company.
***
## Prerequisites
Get your API key from the [Cadana Dashboard](https://app.cadanapay.com). See [Authentication](/authentication) for details.
You need an active business. **Platform integrators** should include the `X-MultiTenantKey` header with the business's tenant key — see [Multi-Tenant Setup](/platform/multi-tenant-setup).
***
## Add an Admin
Add a new admin to your business with [`POST /v1/users/admins`](/api-reference/workforce/users/add-admin). Provide the admin's name and email address.
**Response:**
```json theme={null}
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```
By default, the new admin receives a welcome email with a sign-up link to set their password and access the Dashboard.
To skip the welcome email (for example, if you handle onboarding separately), set `suppressWelcomeEmail` to `true` in the request body.
### Assign a Custom Role
If your business has custom roles configured, pass the `roleId` to restrict what the admin can access. See [Custom Roles](#custom-roles) below to list available roles.
***
## Remove an Admin
Remove an admin from your business with [`DELETE /v1/users/admins/{userId}`](/api-reference/workforce/users/remove-admin). Pass the admin's user ID (returned when you added them).
Returns `204` on success, `404` if the user is not found.
This action is immediate and cannot be undone. The admin loses Dashboard access as soon as the request completes. To restore access, add them again with a new `POST /v1/users/admins` request.
***
## Custom Roles
Custom roles let you control what each admin can do in the Dashboard. Use [`GET /v1/permissions/roles`](/api-reference/workforce/platform/custom-roles) to list the roles available for your business.
**Response:**
```json theme={null}
{
"data": [
{
"id": "f7e6d5c4-b3a2-1098-fedc-ba9876543210",
"name": "Finance Manager",
"description": "This is the role for a finance manager",
"operations": [
{
"resource": "payroll",
"description": "Operations related to payroll management",
"permissions": ["read", "write"]
}
]
}
]
}
```
Custom roles are created in the [Dashboard](https://app.cadanapay.com) under **Settings > Permissions > Add new role**. Once created, they appear in this endpoint and can be assigned to admins via `roleId`.
Pass a role's `id` as the `roleId` field when [adding an admin](#add-an-admin) to assign them that role. If no `roleId` is provided, the admin gets full access.
***
## Next Steps
Set up API keys and authentication
Create Person records for your workforce
Configure platform access with X-MultiTenantKey
# Departments & Custom Fields
Source: https://docs.cadanapay.com/workforce/departments-custom-fields
Organize workers by department and track custom metadata
Departments and custom fields let you define organizational structure and track arbitrary metadata for your workforce. Both are **defined at the business level**, then **assigned to individual workers** via their job information.
* **Departments** — organizational units like "Engineering" or "Sales"
* **Custom fields** — arbitrary metadata like "T-shirt Size" or "Employee ID"
Once created on a business, departments and custom fields become available to assign when updating a person's `jobInfo`.
***
## Prerequisites
Get your API key from the [Cadana Dashboard](https://app.cadanapay.com). See [Authentication](/authentication) for details.
You need a `businessId` for these endpoints. **Platform integrators** receive this from the [`POST /v1/platform/businesses`](/api-reference/workforce/platform/create) response when creating a business. **Direct API users** can find it in the [Dashboard](https://app.cadanapay.com) under **Settings > Business**.
***
## Departments
### Create Departments
Create one or more departments in a single request. Pass an array of department names in the `data` field.
**Response:**
```json theme={null}
{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Engineering"
},
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Sales"
},
{
"id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"name": "Operations"
}
]
}
```
### Update a Department
Rename an existing department by its ID.
Returns `204` on success.
### View Departments
Departments are returned as an array on the business object. Use [`GET /v1/businesses/{businessId}`](/api-reference/workforce/businesses/get-business) to retrieve them.
The response includes a `departments` array:
```json theme={null}
{
"id": "3e8b9a1c-5d4f-4a2e-b7c6-9f0e8d7c6b5a",
"name": "ACME LLC",
"departments": [
{ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Legal" },
{ "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "name": "Sales" },
{ "id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "name": "Operations" }
],
"customFields": []
}
```
***
## Custom Fields
### Create Custom Fields
Create one or more custom fields in a single request. Pass an array of field names in the `data` field.
**Response:**
```json theme={null}
{
"data": [
{
"id": "d4e5f6a7-b8c9-0123-def0-234567890123",
"name": "T-shirt Size",
"value": ""
},
{
"id": "e5f6a7b8-c9d0-1234-ef01-345678901234",
"name": "Desk Number",
"value": ""
}
]
}
```
Custom fields are created with an empty `value`. Values are set per-person when you assign the field via `jobInfo`.
### Update a Custom Field
Rename an existing custom field by its ID.
Returns `204` on success.
### View Custom Fields
Like departments, custom fields are returned on the business object via [`GET /v1/businesses/{businessId}`](/api-reference/workforce/businesses/get-business):
```json theme={null}
{
"customFields": [
{ "id": "d4e5f6a7-b8c9-0123-def0-234567890123", "name": "Employee ID", "value": "" },
{ "id": "e5f6a7b8-c9d0-1234-ef01-345678901234", "name": "Desk Number", "value": "" }
]
}
```
***
## Assign to Workers
Once departments and custom fields exist on the business, assign them to individual workers via [`PUT /v1/persons/{personId}/jobInfo`](/api-reference/workforce/persons/update-job-information).
* `department` — pass the department **name** (string), not the ID
* `customFields` — pass an array of objects with `name` and `value`
Returns `204` on success.
For full details on creating persons and setting up job information, see [Onboard Workers](/workforce/onboarding-workers).
***
## Next Steps
Create Person records and assign departments during onboarding
Run payroll for workers across departments
Platform overview and business creation
# Employment Models
Source: https://docs.cadanapay.com/workforce/employment-models
Direct employment vs Employer of Record, and what each model collects at payroll
Cadana supports two employment models. Set `compInfo.employmentModel: "eor"` when [creating a person](/api-reference/workforce/persons/create) — or when updating compensation via [`PUT /v1/persons/{personId}/jobInfo`](/api-reference/workforce/persons/update-job-information) — to employ the worker through Cadana's Employer of Record. Omit the field for direct employment.
***
## What the Payroll Debit Collects
The employment model determines what the payroll `debit` — the amount collected from your business account, returned by [`GET /v1/payrolls/{payrollId}`](/api-reference/workforce/payrolls/get) — includes:
| | Direct employment | Employer of Record |
| :- | :- | :- |
| `compInfo.employmentModel` | omitted | `"eor"` |
| `debit` collects | Net pay + fees | Gross pay + employer contributions |
| Income tax & statutory deductions | Calculated and shown on the payslip, **not** collected | Collected |
| Invoice line categories | `NET_PAYROLL` + fee items | `GROSS_PAYROLL` + `EMPLOYER_CONTRIBUTION` |
For an identical employee earning ₹400,000/month gross, funded in USD at the same FX rate, the direct debit is ≈ $3,330 while the EOR debit is ≈ $4,275.
After the payroll completes, [`GET /v1/invoices/{invoiceId}`](/api-reference/workforce/invoices/get-invoice) breaks the debit into the line categories above.
***
## Creating an EOR Employee
Payroll runs identically for both models — see [Pay Workers via Payroll](/workforce/pay-workers-via-payroll).
***
## Next Steps
The full EOR lifecycle — quote, hire, agreement, onboarding, payroll, termination
Create Person records and configure compensation
Status flow, webhooks, and multi-currency handling
# Entity Management
Source: https://docs.cadanapay.com/workforce/entity-management
Manage legal sub-entities for global payroll across jurisdictions
Every organisation on Cadana starts with a **Business** — the top-level legal entity you created during onboarding (or via the Platform API). An **Entity** is a child of that business: a subsidiary, branch, or regional office that operates in a different jurisdiction under the same tenant.
```mermaid theme={null}
flowchart TD
BIZ["Business (Parent)
Acme Inc · US · USD"] --> E1["Entity
Acme UK Ltd · GB · GBP"]
BIZ --> E2["Entity
Acme GmbH · DE · EUR"]
BIZ --> E3["Entity
Acme Nigeria Ltd · NG · NGN"]
```
Each entity has its own legal name, country, currency, registration number, tax ID, and address. When a person is linked to an entity, that entity's details — not the parent business's — are used for statutory filings and government remittances.
If your company operates in a single jurisdiction, you don't need entities — the parent business is sufficient. Entities are designed for **global payroll** where you need distinct employer identities per country.
***
## Prerequisites
Get your API key from the [Cadana Dashboard](https://app.cadanapay.com). See [Authentication](/authentication) for details.
You need an active business with a tenant key. **Platform integrators** receive this from the [`POST /v1/platform/businesses`](/api-reference/workforce/platform/create) response. **Direct API users** can find it in the [Dashboard](https://app.cadanapay.com) under **Settings > Business**.
***
## Create an Entity
Create a legal entity under the current business tenant.
**Response:**
```json theme={null}
{
"id": "37ad8c1e-0187-4e10-8c54-395e3385b4d2"
}
```
### Required Fields
| Field | Description |
| :- | :- |
| `legalName` | Legal name of the entity |
| `country` | ISO 3166-1 alpha-2 country code (e.g., `GH`, `NG`, `GB`) |
| `currency` | ISO 4217 currency code (e.g., `GHS`, `NGN`, `GBP`) |
### Optional Fields
| Field | Description |
| :- | :- |
| `entityType` | Legal structure: `corporation`, `c-corporation`, `s-corporation`, `partnership`, `sole proprietorship`, `limited liability company`, `non profit`, `other` |
| `address` | Entity's registered address |
| `phoneNumber` | Entity's phone number |
| `registrationNumber` | Business registration number in the jurisdiction |
| `taxId` | Tax identification number |
| `incorporationDate` | Date of incorporation (`YYYY-MM-DD`) |
Entities start as `ACTIVE`. KYB verification per entity is planned for a future phase.
***
## List Entities
Retrieve all entities under the current tenant.
**Response:**
```json theme={null}
[
{
"id": "37ad8c1e-0187-4e10-8c54-395e3385b4d2",
"legalName": "Acme UK Ltd",
"country": "GB",
"currency": "GBP",
"entityType": "corporation",
"status": "ACTIVE",
"createdTimestamp": 1609459200,
"lastUpdatedTimestamp": 1609459200
}
]
```
***
## Get a Single Entity
**Response:**
```json theme={null}
{
"id": "37ad8c1e-0187-4e10-8c54-395e3385b4d2",
"tenantKey": "cad617152",
"legalName": "Acme UK Ltd",
"country": "GB",
"currency": "GBP",
"address": {
"city": "London",
"countryCode": "GB",
"line1": "10 Downing Street",
"line2": "",
"postalCode": "SW1A 2AA",
"state": "England"
},
"phoneNumber": {
"countryCode": "44",
"number": "2071234567"
},
"registrationNumber": "12345678",
"taxId": "GB123456789",
"incorporationDate": "2020-01-15",
"entityType": "corporation",
"status": "ACTIVE",
"createdTimestamp": 1609459200,
"lastUpdatedTimestamp": 1609459200
}
```
***
## Update an Entity
Update an existing entity. This is a partial update — only include the fields you want to change.
Returns `204` on success.
***
## Link Persons to Entities
Assign a person to a specific entity by setting `entityId` in their job information via [`PUT /v1/persons/{personId}/jobInfo`](/api-reference/workforce/persons/update-job-information).
Returns `204` on success.
If `entityId` is not set, the person is employed under the parent business. This is the default behavior and is fully backwards compatible — existing persons without an `entityId` continue to work as before.
***
## Statutory Compliance Impact
When a person is linked to an entity via `entityId`, statutory filings use the **entity's** employer details instead of the parent business's. The entity fields used for compliance are:
| Field | Purpose |
| :- | :- |
| `taxId` | Employer tax identification on statutory returns |
| `registrationNumber` | Employer registration with government authorities |
| `country` | Determines applicable jurisdiction and filing types |
| `address` | Employer address on statutory returns |
Make sure entities have complete registration details (`taxId`, `registrationNumber`, `address`) before running statutory filings for persons linked to them. Missing data will cause filings to be `blocked` until resolved.
For full details on statutory filings and requirements, see the [Statutory Compliance](/statutory/overview) guide.
***
## Next Steps
Create Person records and assign them to entities during onboarding
Automate filings and remittances for entity jurisdictions
Platform integration and business creation
Organize workers by department and track custom metadata
# Hire Through EOR End-to-End
Source: https://docs.cadanapay.com/workforce/hire-through-eor
Employ workers in countries where you have no entity — Cadana is the legal employer, you direct the work, and every step runs through the API
With the Employer of Record (EOR) model, Cadana is the worker's legal employer. Cadana holds the employment agreement, runs compliant payroll, withholds and remits taxes and statutory contributions, and handles compliant termination. You direct the day-to-day work, and you integrate the whole lifecycle through the same APIs used for direct employment — EOR is a mode, not a separate product surface.
This guide walks the full EOR lifecycle and links into the detailed guide for each step. If you employ workers under your own entity and only need payroll, see [Pay an Employee End-to-End](/workforce/pay-an-employee) — the two flows share most steps.
## How Responsibilities Split
| | You (the client) | Cadana |
| :- | :- | :- |
| Work direction | Day-to-day management, role, performance | — |
| Worker data | Provide profile, statutory fields, payment details | Validates against jurisdiction rules |
| Employment agreement | Review; countersign where applicable | Issues the compliant local contract |
| Payroll | Approve payroll runs | Calculates, withholds, remits, files |
| Funding | Fund the payroll debit (gross + employer contributions) | Collects and disburses |
| Termination | Request offboarding with exit date and reason | Executes compliantly (notice, severance, final pay) |
## Step 1 — Estimate the Cost of Hire
Before making an offer, estimate the full employer cost — gross↔net plus employer-side contributions — with the [Employment Cost Calculator](/tax/estimate). Pass the total monthly pay (basic plus allowances); no person record is needed:
The response's `employerContributions` array is what you will fund on top of gross pay under EOR — see [Employment Models](/workforce/employment-models) for exactly what the payroll debit collects.
## Step 2 — Confirm EOR Availability and Local Employment Rules
List jurisdictions filtered to EOR support — each country reports its `eorStatus` alongside the `workerTypes` and `employmentModels` it supports:
Then fetch the jurisdiction's details. For EOR countries the response carries an `eor` block with the local employment rules you need to shape a compliant offer — probation and fixed-term limits, working hours, notice periods, statutory leave minimums, 13th-salary obligations, payout constraints, and termination rules. Numeric fields carry a `value` (`null` where no statutory rule exists) plus `notes` explaining the underlying statute:
```json theme={null}
{
"countryCode": "MX",
"eorStatus": "live",
"eor": {
"payroll": {
"payrollFrequency": { "value": "semi-monthly", "notes": "Quincenal payroll is required for salaried employees — LFT Art. 88 caps the pay period at 15 days…" },
"thirteenthSalaryRequired": { "value": true, "notes": "Aguinaldo: minimum 15 days' salary, pro-rated for partial years, payable by December 20 (LFT Art. 87)…" }
},
"payoutMethod": {
"walletPayoutAllowed": { "value": true, "notes": "LFT Art. 101 permits salary payment by bank deposit, card, transfer or other electronic means with the worker's prior consent…" }
},
"employmentTerms": {
"probationMaxMonths": { "value": 1, "notes": "30 days for standard indefinite contracts (LFT Art. 39-A), extendable to ~6 months for managerial or specialized roles…" },
"workingHoursPerWeek": { "value": 48, "notes": "8h/day over a 6-day day-shift week; the enacted 40-hour reform phases in from 2027…" }
},
"leave": { "annualLeaveMinDays": { "value": 12, "notes": "Working days after 1 year of service (Vacaciones Dignas reform); rises 2 days/year to 20 at year 5…" } },
"termination": {
"terminationRegime": { "value": "without_cause_with_mandatory_cost", "notes": "No labor-authority pre-approval is needed and paying statutory indemnification lawfully ends the relationship…" },
"severanceSummary": "Without cause: 3 months' integrated salary plus 20 days' salary per year of service plus seniority premium of 12 days per year…"
}
}
}
```
`termination.terminationRegime` classifies the dismissal regime: `at_will` (the employer may dismiss without cause and without mandated notice or severance cost), `without_cause_with_mandatory_cost` (dismissal without cause is lawful, but statutory notice and/or severance applies — e.g. CA, MX, AE), or `cause_required` (a statutorily valid ground is required — most of Europe and LATAM).
Finally, discover every data field the jurisdiction requires you to collect, exactly as in [Pay an Employee, step 2](/workforce/pay-an-employee#step-2--discover-what-data-the-jurisdiction-needs).
## Step 3 — Create the EOR Employee
Create the person with `compInfo.employmentModel: "eor"` and their compensation — basic in `salary`, HRA and other allowances in `allowances` — then complete their identity details, tax profile, statutory filing fields, and payment details. This is identical to [Pay an Employee, steps 3–7](/workforce/pay-an-employee#step-3--create-the-employee) — the employment model changes what the payroll debit collects, not how you submit data.
Use the [requirements endpoint](/api-reference/statutory/requirements/list-outstanding-requirements) to loop until nothing is outstanding — under EOR, complete statutory data is a precondition for issuing the employment agreement.
Before setting the worker's payment details, check the jurisdiction's `eor.payoutMethod.walletPayoutAllowed` from step 2 — some countries require salary to be credited to a bank account in the worker's name, so a wallet can't be their payout destination there.
## Step 4 — The Employment Agreement
Once the worker's data is complete, the local employment agreement is issued from a country-specific template. Both signing modes are supported — see [Manage Contracts](/workforce/manage-contracts) for the full flows:
* **Electronic signing** — create the contract from a template, then hand each signer a [signing URL](/api-reference/workforce/contracts/signing-url). The business representative signs first, then the worker.
* **Offline signing** — a contract executed outside the platform (for example, wet-signed locally) is [uploaded as a signed PDF](/api-reference/workforce/contracts/upload-contract) and attached to the person's record.
Track progress with the [`contract.created`, `contract.signed`, and `contract.status.updated`](/reference/events) webhook events. Contract issuance can be asynchronous — treat the webhooks, not the create call, as your source of truth for signature state.
EOR employment agreement templates are configured per country with your account manager. The agreement is between the worker and Cadana as the employing entity — your business is referenced as the client the work is performed for.
## Step 5 — Worker Account and Verification
Every worker gets a Cadana account. Invite them once their person record exists — the invite email (or a [returnable onboarding link](/wallets/worker-wallets#get-the-onboarding-link), if you deliver it yourself) takes them into the app, where they complete identity and address verification at first sign-in:
If you're embedding the experience in your own product instead, submit verification data directly via the [KYC API](/platform/kyc-verification) and track state with the `user.kyc.updated` webhook. Both paths are covered in [Worker Wallets](/wallets/worker-wallets).
## Step 6 — Fund the Payroll
EOR payrolls debit **gross pay plus employer contributions** from your business balance (direct employment debits net plus fees — see [Employment Models](/workforce/employment-models)). Fund your account ahead of the payroll date and read your balance back at any time:
See [Fund Your Account](/fund-your-account) for deposit methods (bank transfer, direct debit from a linked external account, and sandbox test deposits).
## Step 7 — Run Payroll
Payroll runs identically to direct employment: create, save entries, review the computed `debit`, approve. Follow [Pay an Employee, steps 8–9](/workforce/pay-an-employee#step-8--run-payroll). After completion, the payroll's invoice itemizes the debit into `GROSS_PAYROLL` and `EMPLOYER_CONTRIBUTION` line categories with per-line FX rates.
## Changing Salary or Role
Compensation and job changes go through the job info endpoint — send the whole `compInfo`, including allowances, since it replaces the stored compensation. Under EOR this also triggers the corresponding employment-agreement update on the Cadana side:
Changes surface on the [`person.jobinfo.updated`](/reference/events#personjobinfoupdated) webhook. Salary changes apply from the next payroll run that you save.
## Termination and Offboarding
Request offboarding with an exit date and reason — Cadana handles the compliant execution (statutory notice, severance, and final pay under local law):
Offboarding is scheduled, not immediate — the worker transitions to `Former` on the exit date, and you can [cancel](/api-reference/workforce/persons/cancel-offboarding) before then or [reinstate](/api-reference/workforce/persons/reinstate) after. The full lifecycle, request fields, and the four `person.offboarding.*` / `person.reinstated` webhook events are covered in [Offboard Workers](/workforce/offboarding-workers).
Under EOR, local law governs notice periods and severance — an exit date that violates statutory notice can't be executed as requested. Check the jurisdiction's `eor.termination` block (dismissal regime, notice scale, severance formula, and process requirements) from [step 2](#step-2--confirm-eor-availability-and-local-employment-rules) when planning termination timing.
## Webhooks to Subscribe To
| Event | Fires when |
| :- | :- |
| `person.created` / `person.updated` | Worker record created / contact details changed |
| `person.jobinfo.updated` | Job information or compensation changed |
| `contract.created` / `contract.signed` / `contract.status.updated` | Agreement issued / signature collected / status changed |
| `user.kyc.updated` | Worker identity verification state changed |
| `payroll.status.updated` | Payroll moved through its lifecycle |
| `person.offboarding.scheduled` / `.cancelled` / `.completed`, `person.reinstated` | Termination lifecycle |
See [Webhooks](/reference/webhooks) for setup and [Events](/reference/events) for payloads.
## Next Steps
The shared data-collection and payroll steps in full detail
What the payroll debit collects under EOR vs direct
Template-based e-signing and signed-PDF upload
Scheduling, cancelling, reinstating, and webhooks
# Instant Pay
Source: https://docs.cadanapay.com/workforce/instant-pay
Give workers early access to earned wages before their next payroll date
Instant Pay (also known as Earned Wage Access) lets workers withdraw a portion of their earned wages before the next scheduled payroll date. Workers request instant pay through the Cadana app — the developer API lets you configure per-person access limits and track transactions.
```mermaid theme={null}
sequenceDiagram
participant B as Business
participant P as Platform
participant C as Cadana
participant W as Worker
B->>C: Enable Instant Pay (via account manager)
P->>C: Configure per-person access (PUT /ipSetting)
W->>C: Request instant pay (Cadana app)
C->>W: Disburse earned wages
C->>P: Webhook: instant-pay.succeeded
```
***
## Prerequisites
Get your API key from the [Cadana Dashboard](https://app.cadanapay.com). See [Authentication](/authentication) for details.
Workers must be onboarded as Persons — you'll use their `personId` to configure settings and view transactions. See [Onboard Workers](/workforce/onboarding-workers).
Contact your Cadana account manager to enable Instant Pay. This is a business-level feature that must be activated before workers can use it.
***
## Configure Per-Person Access
By default, workers inherit the business-level `accessPercentage`. Use the per-person setting to override this for individual workers — for example, to grant a higher limit to a long-tenured employee or restrict access for a new hire.
### Update a Person's Setting
Set the percentage of earned wages a person can access via instant pay. The value must be between 0 and 100.
Returns `204` on success.
### Get a Person's Setting
**Response:**
```json theme={null}
{
"accessPercentage": 50
}
```
***
## Business-Level Settings
When Instant Pay is enabled, the business object includes an `instantPaySettings` field with the defaults configured by your Cadana account manager. These are visible via [`GET /v1/businesses/{businessId}`](/api-reference/workforce/businesses/get-business) but can only be changed by Cadana.
| Field | Type | Description |
| :- | :- | :- |
| `accessPercentage` | integer | Default percentage of earned wages workers can access (0–100) |
| `numDaysBeforePayroll` | integer | Number of days before the payroll date when instant pay becomes unavailable |
| `requiresApproval` | boolean | Whether instant pay requests require business approval before disbursement |
| `contractorAccess` | boolean | Whether contractors (in addition to employees) can use instant pay |
**Example on the business object:**
```json theme={null}
{
"instantPaySettings": {
"accessPercentage": 30,
"numDaysBeforePayroll": 4,
"requiresApproval": false,
"contractorAccess": true
}
}
```
Per-person settings (via `PUT /v1/persons/{personId}/ipSetting`) override the business-level `accessPercentage` for that individual worker.
***
## Track Transactions
Retrieve instant pay transactions for a specific person. Each transaction represents a single instant pay withdrawal.
**Response:**
```json theme={null}
{
"data": [
{
"id": "d52e4a13-7b6f-4c8d-a9e1-2f3b5c7d8e9a",
"amount": {
"currency": "USD",
"amount": 50000
},
"status": "completed",
"createdTimestamp": 1693782273
},
{
"id": "f74a6c35-9d8e-4b0f-c1a2-4e5f7a8b9c0d",
"amount": {
"currency": "USD",
"amount": 25000
},
"status": "processing",
"createdTimestamp": 1693868673
}
]
}
```
### Filter by Unpaid
Use the `isUnpaid` query parameter to only return transactions that haven't been reconciled against a payroll yet.
### Transaction Statuses
| Status | Meaning |
| :- | :- |
| `completed` | Funds have been disbursed to the worker |
| `processing` | Withdrawal is in progress |
| `failed` | Withdrawal failed — the worker can retry |
Amounts are in the **lowest denomination** of the currency. For USD, `50000` = \$500.00.
***
## Webhook Event
### instant-pay.succeeded
Fires when an instant pay withdrawal is successfully disbursed to a worker. Subscribe via the [Dashboard](https://app.cadanapay.com) or see [Webhooks](/reference/webhooks) for setup.
```json JSON theme={null}
{
"id": "c5d6e7f8-a901-2345-cdef-567890123456",
"personId": "e13b9e14-c062-42ea-8563-8fc9223b29b5",
"amount": {
"currency": "USD",
"amount": 50000
},
"tenantKey": "cad95193904"
}
```
See [Events](/reference/events#instant-pay) for all event types and payload details.
***
## Next Steps
Create Person records before configuring instant pay
Run payroll to reconcile instant pay advances
Set up real-time notifications for instant pay events
Full list of webhook event types and payloads
# Manage Contracts
Source: https://docs.cadanapay.com/workforce/manage-contracts
Create contracts from templates or upload signed PDFs, track signatures, and manage the contract lifecycle
Cadana supports two contract flows: **template-based** contracts that are sent for electronic signature, and **upload-based** contracts where you upload an already-signed PDF. This guide covers both.
***
## Prerequisites
Contracts must be enabled for your account. If you don't see the Contracts section in the Dashboard, contact your account manager to enable it.
Get your API key from the [Cadana Dashboard](https://app.cadanapay.com). See [Authentication](/authentication).
If using template-based contracts, create your templates in the Dashboard under **People > Contracts > Browse Templates** before using the API. Not needed for uploaded contracts.
Workers must be onboarded as Persons — you'll assign contracts using their `personId`. See [Onboard Workers](/workforce/onboarding-workers).
***
## Option A: Template-Based Contracts
Use this flow when you want Cadana to handle electronic signatures. You select a contract template, Cadana generates the document, and both parties sign digitally.
### List Available Templates
Retrieve the templates available for your business. Each template is scoped to a `personType` (employee or contractor) and `country`.
**Response:**
```json theme={null}
{
"data": [
{
"id": "c47f3b21-9a8e-4d5c-b6f2-1e0a9d8c7b6a",
"name": "Standard Global Contractor Agreement",
"personType": "contractor",
"type": "regular",
"country": "US",
"templateVariables": [
{
"name": "firstName",
"label": "First Name",
"ref": "person.firstName",
"type": "string",
"description": "The contractor's first name",
"isRequired": false
}
]
}
]
}
```
Template variables (e.g., `person.firstName`) are auto-populated from the Person record when the contract is created.
### Create a Contract
Create a contract from a template and assign it to a person. The `sender` is the business representative who will sign first.
**Response:**
```json theme={null}
{
"id": "d52e4a13-7b6f-4c8d-a9e1-2f3b5c7d8e9a",
"name": "Jane Doe - Standard Global Contractor Agreement",
"personId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"templateId": "c47f3b21-9a8e-4d5c-b6f2-1e0a9d8c7b6a",
"status": "awaiting business signature",
"signatures": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "HR Department",
"emailAddress": "hr@example.com",
"order": 0,
"status": "unsigned",
"signedAt": 0,
"lastViewedAt": 0
},
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Jane Doe",
"emailAddress": "amara@example.com",
"order": 1,
"status": "unsigned",
"signedAt": 0,
"lastViewedAt": 0
}
],
"subject": "The contract is ready for signature",
"title": "Your Company has sent you a contract for signature"
}
```
To suppress the default email notification to the signer — for example, if you handle notifications through your own system — pass `suppressNotification: true` in the request body.
Contract creation is not idempotent — retrying the same request creates a second, identical contract, and there is no API endpoint to revoke a contract. Check for an existing contract with [`GET /v1/contracts?personId=...`](/api-reference/workforce/contracts/list) before retrying.
### Get the Signing URL
Retrieve a URL that the next signer can use to view and sign the contract. The URL is valid for **336 hours (14 days)**.
**Response:**
```json theme={null}
{
"url": "https:///sign-contract?token=abc123&redirectUrl=https%3A%2F%2Fyourapp.com%2Fcontracts",
"signer": "business"
}
```
| Field | Description |
| :- | :- |
| `url` | The signing URL for viewing and signing the contract |
| `signer` | Who needs to sign next: `business` or `person` |
The domain in the URL is either `app.cadanapay.com` or your white-label domain, depending on your platform configuration.
The optional `redirectUrl` query parameter specifies where to send the signer after they complete signing.
If you suppress email notifications (`suppressNotification: true`), you're responsible for delivering the signing URL to each signer. Use the signing URL endpoint to generate links on demand.
### Track Signature Status
List contracts to check their signature status. Optionally filter by `personId`.
**Response:**
```json theme={null}
{
"data": [
{
"id": "d52e4a13-7b6f-4c8d-a9e1-2f3b5c7d8e9a",
"name": "Jane Doe - Standard Global Contractor Agreement",
"personId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"templateId": "c47f3b21-9a8e-4d5c-b6f2-1e0a9d8c7b6a",
"status": "completed",
"signatures": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "HR Department",
"emailAddress": "hr@example.com",
"order": 0,
"status": "signed",
"signedAt": 1689759900,
"lastViewedAt": 1689759850
},
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Jane Doe",
"emailAddress": "amara@example.com",
"order": 1,
"status": "signed",
"signedAt": 1689760200,
"lastViewedAt": 1689760100
}
],
"createdAt": 1689759850
}
]
}
```
Each signature includes `signedAt` (epoch timestamp, `0` if unsigned) and `lastViewedAt` so you can track engagement.
### Contract Status Flow
```mermaid theme={null}
flowchart LR
A[awaiting business signature] --> B[awaiting person signature]
B --> C[completed]
A -.-> D[revoked]
B -.-> D
```
| Status | Meaning |
| :- | :- |
| `awaiting business signature` | Business representative needs to sign first |
| `awaiting person signature` | Business signed, waiting for the worker |
| `completed` | Both parties have signed |
| `revoked` | Contract was cancelled before completion |
Contracts can only be revoked before both parties have signed — and not via the API; there is no revocation endpoint. Once `completed`, the contract is final.
***
## Retrieve a Contract
Fetch the full details of a single contract by its ID.
To include a presigned download URL for the contract PDF, pass `includeDownloadUrl=true`:
**Response:**
```json theme={null}
{
"id": "d52e4a13-7b6f-4c8d-a9e1-2f3b5c7d8e9a",
"name": "Jane Doe - Standard Global Contractor Agreement",
"personId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"templateId": "c47f3b21-9a8e-4d5c-b6f2-1e0a9d8c7b6a",
"status": "completed",
"signatures": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "HR Department",
"emailAddress": "hr@example.com",
"order": 0,
"status": "signed",
"signedAt": 1689759900,
"lastViewedAt": 1689759850
},
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Jane Doe",
"emailAddress": "amara@example.com",
"order": 1,
"status": "signed",
"signedAt": 1689760200,
"lastViewedAt": 1689760100
}
],
"createdAt": 1689759850,
"downloadUrl": "https://storage.example.com/contracts/d52e4a13.pdf?token=abc123&expires=900",
"downloadUrlExpiresAt": 1689760750
}
```
The `downloadUrl` and `downloadUrlExpiresAt` fields are only present when `includeDownloadUrl=true`. The download URL expires after **15 minutes**.
***
## Option B: Upload a Signed Contract
Use this flow when you already have a signed contract PDF — for example, a contract signed offline or through a third-party e-signature tool.
### Step 1: Upload the File
Upload the PDF using the two-step file upload flow. See [Working with Files](/reference/working-with-files) for the full process.
**Response:**
```json theme={null}
{
"fileId": "e63f5b24-8c7d-4a9e-b0f1-3d4e6f8a9b0c",
"putUrl": "https://s3.amazonaws.com/...",
"expiresIn": 900
}
```
Then upload the file to the `putUrl` within the expiry window (15 minutes):
```bash Bash theme={null}
curl -X PUT '' \
-H 'Content-Type: application/pdf' \
--data-binary '@/path/to/signed-contract.pdf'
```
### Step 2: Create the Contract Record
Attach the uploaded file to the person's record using the `fileId` from step 1.
**Response:**
```json theme={null}
{
"id": "f74a6c35-9d8e-4b0f-c1a2-4e5f7a8b9c0d"
}
```
Uploaded contracts are created with status `completed` since they're already signed. They appear alongside template-based contracts when you list contracts for a person, and can be retrieved with [`GET /v1/contracts/{contractId}`](/api-reference/workforce/contracts/retrieve).
***
## Webhook Events
Cadana emits contract webhook events so you can track the signing lifecycle in real time. Subscribe via the [Dashboard](https://app.cadanapay.com) or see [Webhooks](/reference/webhooks) for setup.
### contract.created
Fired when a new contract is created (template-based or uploaded).
```json JSON theme={null}
{
"id": "d52e4a13-7b6f-4c8d-a9e1-2f3b5c7d8e9a",
"personId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"templateId": "c47f3b21-9a8e-4d5c-b6f2-1e0a9d8c7b6a",
"tenantKey": "cad95193904"
}
```
### contract.signed
Fired each time a signature is collected. The `isComplete` field tells you whether all signatures are now in.
```json JSON theme={null}
{
"id": "d52e4a13-7b6f-4c8d-a9e1-2f3b5c7d8e9a",
"signatureId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "HR Department",
"email": "hr@example.com",
"isComplete": false,
"tenantKey": "cad95193904"
}
```
### contract.status.updated
Fired on status transitions (e.g., revoked).
```json JSON theme={null}
{
"id": "d52e4a13-7b6f-4c8d-a9e1-2f3b5c7d8e9a",
"status": "revoked",
"tenantKey": "cad95193904"
}
```
See [Events](/reference/events) for all event types and payload details.
***
## Troubleshooting
| Issue | Cause | Solution |
| :- | :- | :- |
| `401 Unauthorized` | Invalid or missing API key | Check your API key in the [Dashboard](https://app.cadanapay.com) |
| `400` on create | Missing required fields | Ensure `templateId`, `personId`, and `sender` (with `name` and `email`) are provided |
| No templates returned | Templates not configured for your business | Create templates in the Dashboard under **People > Contracts > Browse Templates** |
| Signing URL expired | URL older than 336 hours | Generate a new signing URL with `GET /v1/contracts/{id}/signingUrl` |
***
## Next Steps
Create Person records before issuing contracts
Pay workers after contracts are in place
Upload and download files (contracts, KYC documents, etc.)
# Offboard Workers
Source: https://docs.cadanapay.com/workforce/offboarding-workers
Schedule offboarding, cancel pending offboarding, and reinstate former workers
Offboarding moves a worker from **Active** to **Former** status on a scheduled exit date. Use it when an employee resigns, a contract ends, or you need to remove someone from your active workforce. Cadana handles the status transition automatically on the exit date — you just schedule it.
***
## Schedule Offboarding
To schedule a worker for offboarding, provide an exit date and reason. You can optionally adjust their compensation for the exit period.
### Request Fields
| Field | Type | Required | Description |
| :- | :- | :- | :- |
| `exitDate` | `string` (date) | Yes | The date the person will exit the organization (`YYYY-MM-DD`) |
| `reason` | `string` | Yes | The reason for offboarding |
| `compensation` | `object` | No | Adjusted compensation for the exit period (uses the same `compInfo` schema as onboarding). Omit to keep current compensation unchanged. If provided, proration for partial periods is your responsibility. |
| `includeInRegularPayroll` | `boolean` | No | Whether to include the person in the regular payroll run for their exit period |
If you set `includeInRegularPayroll` to `true`, the exit date must fall after the business's payroll cutoff day for the current period. Otherwise, the person may not be included in the expected payroll run. The payroll cutoff day is configured in the Dashboard under **Settings → Company Profile → Payroll & Tax Info**.
***
## Cancel Offboarding
Cancel a pending offboarding if the decision is reversed or it was scheduled in error. This returns the person to **Active** status.
The person must be in **Offboarding** status. Cancelling offboarding for a person who is already **Former** or **Active** returns a `400` error.
***
## Reinstate a Worker
To bring a former worker back to active status — for example, a rehire or a reversal — use the reinstate endpoint.
The person must be in **Former** status. Reinstating a person who is still **Active** or **Offboarding** returns a `400` error.
***
## Offboarding Lifecycle
The diagram below shows how a person's status transitions through the offboarding process.
```mermaid theme={null}
stateDiagram-v2
Active --> Offboarding: Schedule offboarding
Offboarding --> Active: Cancel offboarding
Offboarding --> Former: Auto-completes day after exit date
Former --> Active: Reinstate
```
Offboarding completes automatically on the day after the exit date. For example, if the exit date is June 30, the person's status changes to **Former** on July 1.
***
## Webhook Events
Cadana delivers webhook events for each offboarding lifecycle transition. Subscribe to these via the [Webhooks](/reference/webhooks) configuration.
| Event | Fired when | Payload |
| :- | :- | :- |
| `person.offboarding.scheduled` | Offboarding is scheduled | `exitDate`, `reason` |
| `person.offboarding.cancelled` | Pending offboarding is cancelled | Person ID only |
| `person.offboarding.completed` | Person transitions to Former | `exitDate`, `reason` |
| `person.reinstated` | Former person is reinstated to Active | Person ID only |
To get full offboarding details (compensation, payroll inclusion), query [`GET /v1/persons/{personId}`](/api-reference/workforce/persons/get) after receiving the event.
See [Events](/reference/events#offboarding) for full payload examples.
## Notifications
When offboarding is scheduled, Cadana sends an **offboarding scheduled** email notification to the person, informing them of their exit date.
***
## Offboarding Details in GET Response
While a person is in **Offboarding** status, the [`GET /v1/persons/{personId}`](/api-reference/workforce/persons/get) response includes an `offboardingDetails` object with the scheduled offboarding information.
| Field | Type | Description |
| :- | :- | :- |
| `exitDate` | `string` (date) | The scheduled exit date |
| `reason` | `string` | The reason for offboarding |
| `compensation` | `object` | Adjusted compensation for the exit period, if provided |
| `includeInRegularPayroll` | `boolean` | Whether the person is included in the regular payroll run |
The `offboardingDetails` field is only present while the person is in **Offboarding** status. Once they transition to **Former** or are cancelled back to **Active**, this field is no longer included in the response.
***
## Next Steps
Understand payroll status transitions and webhook events
Run payroll for your active workers
# Onboard Workers
Source: https://docs.cadanapay.com/workforce/onboarding-workers
Create employee and contractor records, configure compensation, and set up payment methods
Onboarding a worker means creating a **Person** — the HR record that stores their personal details, job information, and compensation. Once a Person exists, you can run payroll, issue contracts, and configure how they get paid.
***
## Prerequisites
* A Cadana account with API access
* Your API key from the [Dashboard](https://app.cadanapay.com)
***
## Step 1: Create a Person
A Person represents an employee or contractor in your organization.
**Response:**
```json theme={null}
{
"id": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e"
}
```
Save the `id` — you'll need it to configure payment methods, create a user, and run payroll.
### Required Fields
| Field | Description |
| :- | :- |
| `type` | `EMPLOYEE` or `CONTRACTOR` |
| `firstName`, `lastName` | Contact person's legal name |
| `email` | Must be unique per tenant for active persons |
| `address.countryCode` | ISO country code — determines available payment methods and tax rules |
| `compInfo.salary` | Amount in minor units (cents). `500000` USD = \$5,000.00 |
| `compInfo.currency` | Compensation currency |
| `compInfo.frequency` | `hourly`, `daily`, `weekly`, `bi-weekly`, or `monthly` |
| `compInfo.allowances` | Recurring allowances paid on top of `salary` (e.g. housing, transport). Each has a `name`, optional `isTaxable` (defaults to `true`), and an `amount` in the salary currency. |
| `compInfo.type` | `net` (contractor) or `salaried` (employee with tax calculation) |
Set `compInfo.employmentModel: "eor"` to employ the worker through Cadana's Employer of Record — this changes what payroll collects. See [Employment Models](/workforce/employment-models).
For workers with variable pay, you may set `compInfo.salary` to `0` during onboarding. You'll provide the actual amount when [saving payroll entries](/workforce/pay-workers-via-payroll#step-2-save-entries) during a payroll run.
### Contractor-Specific Fields
| Field | Description |
| :- | :- |
| `contractorType` | `INDIVIDUAL` or `BUSINESS` — required when `type` is `CONTRACTOR` |
| `businessName` | Company name — required when contractorType is `BUSINESS` |
### Optional Fields
| Field | Description |
| :- | :- |
| `jobInfo.title` | Job title |
| `jobInfo.department` | Department name (must match a department configured on the business) |
| `jobInfo.startDate` | Employment start date (`YYYY-MM-DD`) |
| `jobInfo.employeeNumber` | Your internal employee/contractor ID |
| `jobInfo.entityId` | UUID of the legal entity this person belongs to. See [Entity Management](/workforce/entity-management). |
| `jobInfo.customFields` | Custom fields configured on the business |
| `phoneNumber` | Must be globally unique across all tenants |
| `metadata` | Key-value pairs for your own tracking (max 50 entries) |
***
## Step 2: Configure Payment Method
Payment methods are configured separately after creating the Person. By default, workers are paid via **Cadana Wallet** — the recommended option for most integrations.
### Cadana Wallet (Default)
Workers are paid into a [Cadana Global Wallet](https://cadanapay.com/product/global-wallets), where they can manage their funds, withdraw to a local bank, and access financial features like Virtual Cards.
To set up a wallet, the worker needs a **User account**. See [Set Up Worker Wallets](/wallets/worker-wallets) for the full flow (create User, complete KYC, wallet is provisioned automatically).
**Wallet is set automatically.** When you [create a User](/wallets/worker-wallets) for a Person, the platform automatically sets wallet as the preferred payment method. No separate `PUT /paymentInfo` call is needed.
### Direct Payment Methods (Alternative)
Direct payment methods (bank transfer, mobile money, etc.) require enablement for your business. Contact your account manager to configure direct-to-bank payouts.
If enabled, you can pay workers directly to their bank account or mobile money without creating a User account.
### Payment Methods
| Method | `preferredMethod` | Use Case | Requires User |
| :- | :- | :- | :- |
| Cadana Wallet | `wallet` | Default — workers manage funds via Cadana | Yes |
| Bank transfer | `bank` | Local bank account | No |
| Mobile money | `momo` | Mobile money (GH, KE, UG, etc.) | No |
| ACH | `ach` | US bank accounts | No |
| SWIFT | `swift` | International wire transfers | No |
| Proxy | `proxy` | Proxy-based payments (e.g., Pix) | No |
Available payment methods vary by country and currency. The API will return a validation error if the method isn't supported for the worker's country. See [Payment Methods](/api-reference/payments/resources/get-payment-methods) for coverage.
### Check Current Payment Info
***
## Step 3: Issue a Contract (Optional)
You can issue a contract to the worker immediately after onboarding. See [Manage Contracts](/workforce/manage-contracts) for the full guide.
***
## Managing Person Records
After onboarding, you can update worker details as needed:
| Operation | Endpoint | What it updates |
| :- | :- | :- |
| Get person | [`GET /v1/persons/{personId}`](/api-reference/workforce/persons/get) | Retrieve full person record |
| List persons | [`GET /v1/persons`](/api-reference/workforce/persons/list) | List all persons (filterable by email) |
| Update basic info | [`PUT /v1/persons/{personId}/basicInfo`](/api-reference/workforce/persons/update-basic-information) | Name, email, phone, metadata, contractor type and business name |
| Update personal info | [`PUT /v1/persons/{personId}/personalInfo`](/api-reference/workforce/persons/update-personal-information) | Date of birth, national ID, tax ID, social security ID, gender, nationality, address |
| Update job info | [`PUT /v1/persons/{personId}/jobInfo`](/api-reference/workforce/persons/update-job-information) | Title, department, compensation, start date |
| Update payment info | [`PUT /v1/persons/{personId}/paymentInfo`](/api-reference/workforce/persons/update-payment-information) | Preferred payment method and details |
| Update tax profile | [`PUT /v1/persons/{personId}/taxProfile`](/api-reference/workforce/persons/update-tax-profile) | Marital status, number of dependents, and other country-specific tax fields |
See the [Workforce API Reference](/api-reference/workforce) for full request and response schemas.
Contract type is owned by your system. Workers cannot switch themselves between individual and business in the
Cadana app — send `contractorType` on `basicInfo` to change it. `BUSINESS` requires `businessName`, `INDIVIDUAL`
requires `firstName` and `lastName`, and the field is rejected for employees.
Person profile writes (e.g., tax profile, payment info) are applied asynchronously — a read immediately after a write may return the previous state, and approving a payroll immediately after saving it can briefly fail. Retry after a short delay.
***
## Change Employment Type
To switch a person between Employee and Contractor without offboarding and re-onboarding, use the **Update Person Type** endpoint. The person must be in **Active** status.
A successful request returns **`204 No Content`**.
### Request Fields
| Field | Type | Required | Description |
| :- | :- | :- | :- |
| `newType` | `string` | Yes | Target employment type: `EMPLOYEE` or `CONTRACTOR` |
| `compInfo` | `object` | No | Compensation for the new type. If omitted, incompatible compensation is auto-converted. |
| `compInfo.type` | `string` | Yes (if compInfo provided) | Compensation type: `net`, `salaried`, `gross`, or `fixed` |
| `compInfo.salary` | `object` | Yes (if compInfo provided) | Amount in minor units with currency (e.g., `{ "amount": 600000, "currency": "GHS" }`) |
| `compInfo.frequency` | `string` | No | Pay frequency: `monthly`, `daily`, `weekly`, `biweekly`, or `hourly`. Defaults to `monthly`. |
### Auto-Conversion Rules
When `compInfo` is omitted, the API automatically converts incompatible compensation types:
| Current comp type | Target person type | Result |
| :- | :- | :- |
| `salaried` | Contractor | Auto-converted to `net` — same amount and frequency |
| `fixed` | Employee | Auto-converted to `net` — same amount, frequency defaults to `monthly` if not set |
| `net` or `gross` | Either | No conversion needed — left unchanged |
Auto-conversion only changes the compensation **type**. All other compensation fields (allowances, deductions, tax reliefs, employer contributions, pension settings) are preserved. The tax engine interprets these fields based on the person's type at payroll calculation time.
### Compensation Type Compatibility
When providing `compInfo`, the compensation type must be compatible with the target person type:
| Comp type | Employee | Contractor |
| :- | :- | :- |
| `salaried` | Yes | No |
| `gross` | Yes | Yes |
| `net` | Yes | Yes |
| `fixed` | No | Yes |
Pass `compInfo` when you want to set a specific compensation structure for the new type — for example, switching a contractor to a salaried employee with tax calculation, or changing the pay amount alongside the type switch.
***
## Next Steps
Create a User account and provision a Cadana wallet
Pay your onboarded workers
Issue employment or contractor agreements
Add and manage business administrators
# Overview
Source: https://docs.cadanapay.com/workforce/overview
Manage employees, contractors, and payroll globally
The Workforce Management API handles the full lifecycle of managing a global team — from onboarding workers to running payroll and staying compliant.
***
## Capabilities
Create and manage employee and contractor records across countries.
Run payroll for employees and contractors with multi-currency support and automatic FX handling.
Built-in KYB for businesses and KYC for workers with automated verification against government databases.
Issue employment contracts from templates or upload signed PDFs.
Manage contractor invoices and approve them for payment.
Enable workers to access earned wages before the next pay cycle.
***
## How It Works
A typical integration follows this flow:
Complete [KYB verification](/platform/kyb-requirements) before the business can process payments.
Create a **Person** (HR record) for each employee or contractor, then create a **User** (Cadana account) so they can receive payments.
Workers complete identity verification before they can receive funds. In sandbox, KYC is auto-approved. [KYC Verification](/platform/kyc-verification).
Create a payroll, add compensation entries, and approve. Cadana handles FX, disbursement, and status tracking automatically. [Payroll Lifecycle](/workforce/payroll-lifecycle).
***
## Guides
Run payroll for employees and contractors across currencies
Business verification process and required documents
User identity verification flow and statuses
Full endpoint documentation
# Pay a Contractor End-to-End
Source: https://docs.cadanapay.com/workforce/pay-a-contractor
The complete journey — onboard a contractor, put an agreement in place, provision their wallet, run payroll, and cash out
This guide walks the full lifecycle of paying an independent contractor through Cadana, start to finish. Each step links to a deeper guide for full field references — this page is the map. (Hiring an employee instead? Employees add statutory compliance and a tax profile — see [Pay an Employee End-to-End](/workforce/pay-an-employee).)
```mermaid theme={null}
flowchart LR
A[1. Onboard
Person] --> B[2. Agreement
optional]
A --> C[3. Wallet
User + KYC]
C --> D[4. Pay
Payroll]
D --> E[5. Cash out
UI or API]
```
***
## Prerequisites
* An API key from the [Dashboard](https://app.cadanapay.com) — see [Authentication](/authentication)
* A funded business account — see [Fund Your Account](/fund-your-account), or use the [sandbox deposit endpoint](/api-reference/workforce/sandbox/deposit) for test funds
Building against the sandbox first? Read [Sandbox & Testing](/reference/sandbox) — it covers test funding, KYC sentinel values, and resetting users.
***
## Step 1: Onboard the Contractor
Create a [Person](/workforce/onboarding-workers) with `type: "CONTRACTOR"`. Contractors are either individuals or registered businesses — set `contractorType` accordingly (`businessName` is required for `BUSINESS`).
The response is the `personId` you'll use in every later step:
```json theme={null}
{
"id": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e"
}
```
Two things to get right here:
* **Amounts are in minor units** — `500000` USD is \$5,000.00.
* **`compInfo` is what payroll pays.** For contractors, use type `net` (or `fixed`). If pay varies per cycle (hourly work), set `salary.amount` to `0` and supply the real amount on each payroll entry in [Step 4](#step-4-run-payroll). For deliverable-based work, use [milestone-based pay](#milestone-based-pay) instead.
See [Onboard Workers](/workforce/onboarding-workers) for all optional fields (departments, custom fields, metadata).
***
## Step 2: Put an Agreement in Place (Optional)
Payroll does not require a contract — but most businesses want one on file. Cadana supports two flows; both attach the agreement to the contractor's `personId`. See [Manage Contracts](/workforce/manage-contracts) for the full lifecycle.
List your [contract templates](/api-reference/workforce/contracts/list-templates), then create a contract for the contractor. Template variables (name, salary, dates) are filled from the Person record at creation time.
Both parties then sign electronically — fetch each signer's link with [`GET /v1/contracts/{contractId}/signingUrl`](/api-reference/workforce/contracts/signing-url) and track progress via the contract `status` (`awaiting business signature` → `awaiting person signature` → `completed`).
Already signed elsewhere? [Upload the PDF](/reference/working-with-files) with purpose `contract`, then attach it:
Uploaded contracts are recorded with status `completed` immediately.
**The agreement documents the terms; it doesn't drive payment.** Payroll amounts come from the Person's `compInfo` (or per-cycle entries), and payroll runs regardless of contract status. Template variables snapshot the Person's data when the contract is created — updating `compInfo` later does not update an existing contract.
***
## Step 3: Give Them a Wallet
Contractors are paid into a [Cadana Global Wallet](/wallets/overview) by default. Provisioning takes two calls — full details in [Set Up Worker Wallets](/wallets/worker-wallets).
**1. Create a User** for the Person. This provisions a wallet in their compensation currency and automatically sets `wallet` as their preferred payment method:
By default the contractor receives a welcome email to set their password. Pass `suppressWelcomeEmail: true` if you handle login via [your own SSO](/white-label/custom-authentication).
**2. Submit KYC.** The wallet can't receive funds until identity verification is approved. Upload the ID documents ([file upload flow](/reference/working-with-files)), then submit — `issuedBy`, `frontFileId`, and `selfieFileId` are required:
Track approval by [polling KYC status](/api-reference/workforce/users/get-kyc) or subscribing to the `user.kyc.updated` [webhook](/reference/events). Alternatively, the contractor can complete KYC themselves in the Cadana app after logging in. See [KYC Verification](/platform/kyc-verification) for statuses, rejections, and resubmission.
**In sandbox, use the sentinel values** — `idDetails.number: "auto-approve"` and `address.line2: "auto-approve"`. Non-sentinel submissions are never resolved; see [KYC Testing](/reference/sandbox#kyc-testing).
Contractors hired by U.S. businesses also complete a tax form (W-9, W-8BEN, or W-8BEN-E) in the Cadana app alongside KYC — no API work needed on your side. See [Tax Forms](/wallets/tax-forms).
Confirm the wallet is ready:
**Skipping the wallet?** If direct payouts are enabled for your business, you can pay a contractor's bank, ACH, or mobile-money account instead — no User account needed. Set it with [`PUT /v1/persons/{personId}/paymentInfo`](/api-reference/workforce/persons/update-payment-information); see [Onboard Workers](/workforce/onboarding-workers#direct-payment-methods-alternative). Direct methods require enablement by your account manager.
***
## Step 4: Run Payroll
Pay the contractor with a one-off contractor payroll — full details in [Pay Workers via Payroll](/workforce/pay-workers-via-payroll).
**1. Create** the payroll shell:
**2. Save** the entries. The `salary` here is what gets paid this cycle (minor units):
Saving is asynchronous — poll [`GET /v1/payrolls/{payrollId}`](/api-reference/workforce/payrolls/get) until the status is `Pending Submission`, then review the calculated `debit`: the total that will leave your business account, i.e. net pay plus fees. The fee breakdown is on the linked invoice ([`GET /v1/invoices/{invoiceId}`](/api-reference/workforce/invoices/get-invoice)).
**3. Approve** to trigger fund collection and disbursement:
**4. Confirm the money landed.** When the payroll reaches `Completed`, each entry carries `transactionIds` — resolve them with [`GET /v1/users/{userId}/transactions/{transactionId}`](/api-reference/wallets/transactions/get-transaction) or check the [wallet balance](/api-reference/wallets/balances/get-user-wallets-balances). Wallet credits can trail the `Completed` status by up to a minute.
Distribute earning statements with [payslip links](/workforce/pay-workers-via-payroll#downloading-payslips), and reconcile fees against the payroll's [invoice](/api-reference/workforce/invoices/get-invoice).
### Milestone-based pay
For deliverable-based work, let milestones drive payment instead of per-cycle payroll entries. Create the contractor with `compInfo.type: "milestone"` and `salary.amount: 0`, then add each deliverable as a milestone. `contractId` is optional — set it to the [contract](/workforce/manage-contracts) the work falls under so the resulting invoice is tied to it (the contract must belong to the same contractor and be signed by both parties).
Planned milestones can be edited with [`PUT /v1/persons/{personId}/milestones/{milestoneId}`](/api-reference/workforce/milestones/update-milestone). When the work is delivered, mark the milestone complete — this generates a contractor invoice and returns its id:
```json theme={null}
{
"id": "f8a9b0c1-2345-6789-bcde-f01234567890"
}
```
The invoice carries the milestone's `contractId`, and the milestone's `invoiceId` links back to it — see [`GET /v1/persons/{personId}/milestones`](/api-reference/workforce/milestones/list-milestones). Review the invoice with [`POST /v1/invoices/{invoiceId}/review`](/api-reference/workforce/invoices/review-invoice); as it is approved, rejected, and paid, the milestone status follows (`Submitted` → `Approved` → `Paid`), and the `invoice.contractor.*` [webhooks](/reference/events#invoices) fire along the way. Contractors can also raise invoices themselves from the Cadana app; on any contractor-submitted invoice, `contractId` tells you which contract it bills against.
***
## Step 5: The Contractor Gets Their Money
Funds sit in the contractor's wallet until they're withdrawn. Two paths:
### Self-serve (Cadana or white-label app)
The contractor logs in — via the welcome-email credentials or [your SSO](/white-label/custom-authentication) — and cashes out to a bank account, mobile money, or card in 100+ countries from the app. Nothing to build; brand it with [White-Label UI](/white-label/overview).
### On their behalf (API)
Your platform can run the payout for them — full details in [Payout](/wallets/payout):
1. Check the corridor: [payment methods](/api-reference/payments/resources/get-payment-methods) for the destination country, [field requirements](/api-reference/payments/resources/payment-corridor-requirements) for the details schema, and [fees, timelines, and limits](/api-reference/wallets/transactions/get-user-fees).
2. [Create a beneficiary](/api-reference/wallets/beneficiaries/create-beneficiary) — the saved payment destination.
3. [Get an FX quote](/api-reference/payments/fx/create-fx-quote). A quote is required even when source and destination currencies are the same (the rate is `1`).
4. Create the transaction. `reference` is your idempotency key and must be a UUID:
5. [Track the transaction](/api-reference/wallets/transactions/get-transaction) to `success`, or subscribe to `transaction.succeeded` / `transaction.failed` [webhooks](/reference/events).
Wallet transaction amounts are decimal strings in **major** units (`"500.00"` = \$500.00) — unlike the minor-unit integers used by payroll. See [Money Amounts](/reference/api-overview#money-amounts).
***
## Recap
| Step | Endpoint | You have |
| :- | :- | :- |
| Onboard | [`POST /v1/persons`](/api-reference/workforce/persons/create) | `personId` |
| Agreement | [`POST /v1/contracts`](/api-reference/workforce/contracts/create) or [`POST /v1/contracts-upload`](/api-reference/workforce/contracts/upload-contract) | signed agreement on file |
| Wallet | [`POST /v1/users/invite`](/api-reference/workforce/users/invite) + [`POST /v1/users/{userId}/kyc`](/api-reference/workforce/users/submit-kyc) | `userId`, KYC-approved wallet |
| Pay | [`POST /v1/payrolls`](/api-reference/workforce/payrolls/create) → save → approve | funds in the wallet |
| Pay (milestones) | [`POST /v1/persons/{personId}/milestones`](/api-reference/workforce/milestones/add-milestone) → complete → review invoice | contractor invoice linked to the contract |
| Cash out | self-serve app, or [`POST /v1/users/{userId}/transactions`](/api-reference/wallets/transactions/create-transaction) | funds at the contractor's bank |
***
## Next Steps
Full Person field reference
All payroll statuses and webhooks
Users, KYC, and wallet details
Beneficiaries, quotes, and corridors
# Pay an Employee End-to-End
Source: https://docs.cadanapay.com/workforce/pay-an-employee
From checking jurisdiction support to a completed payroll with payslips — the full employee lifecycle in one walkthrough
This walkthrough takes one employee from "does Cadana support their country?" to a completed payroll with a downloadable payslip. (Paying an independent contractor instead? That flow skips statutory compliance and tax profiles — see [Pay a Contractor End-to-End](/workforce/pay-a-contractor).) The worked example uses India — one of the [supported jurisdictions](/api-reference/statutory/jurisdictions/list-supported-jurisdictions) — because it exercises every part of the flow: statutory documents, a tax profile, and the [EOR employment model](/workforce/employment-models). Every step is country-parameterized: the discovery endpoints in steps 1–2 tell you exactly what to collect for any other jurisdiction.
Profile writes are applied asynchronously — a read immediately after a write can return the previous state. When scripting this flow, poll or retry briefly between a write and its verification. See the notes in each step.
## Step 1 — Confirm the jurisdiction is supported
List jurisdictions and confirm the worker's country is `live`. The response includes each country's filing types, payroll frequency, and currency.
## Step 2 — Discover what data the jurisdiction needs
The [required fields endpoint](/api-reference/statutory/jurisdictions/get-required-fields-for-tax-calculation-and-statutory-filing) returns every field the jurisdiction requires, tagged by `entity` (`person` or `business`) and `scope`:
* `scope: ["calculation"]` fields feed the tax engine — submit them to the person's tax profile (step 4)
* `scope: ["filing"]` fields go on statutory returns — submit them via the statutory fields endpoints (step 5)
* Fields with both scopes are submitted to both
## Step 3 — Create the employee
Create the person with their address (which determines the tax jurisdiction), compensation, and employment model. An Indian salary is structured as basic plus allowances: `compInfo.salary` is the basic (₹1,00,000 here — the EPF wage base), and `compInfo.allowances` carries HRA and the special allowance (₹50,000 each) for a ₹2,00,000 gross. Set `compInfo.employmentModel: "eor"` to employ through Cadana's Employer of Record — this determines what the payroll debit collects; see [Employment Models](/workforce/employment-models).
Then add identity details — date of birth, gender, and the identity documents the jurisdiction requires (for India: PAN as `taxId`, Aadhaar as `nationalId`, UAN as `socialSecurityId`):
## Step 4 — Set the tax profile
Submit the `calculation`-scope fields from step 2 to the person's tax profile. These drive payroll tax computation — regime choice, employer size, year-to-date figures, and any declarations.
The tax profile PUT replaces the whole profile — send every field you want kept, not just the one you're changing.
## Step 5 — Submit statutory filing data
Submit the `filing`-scope person fields. Fields are validated against the jurisdiction's definitions — bad formats and unknown keys are rejected with per-field errors.
Document-type fields (like India's Aadhaar and PAN card images) take a `fileId`: reserve one with [create upload URL](/api-reference/workforce/files/create-upload-url) (purpose `statutory-document`), PUT the file bytes to the returned URL, then submit the `fileId` as the field value. See [document fields](/statutory/getting-started#document-fields).
Business-side fields (for India: TAN, employer PAN, EPFO establishment ID, ESIC employer code, LIN) go to the [business fields endpoint](/api-reference/statutory/statutory-fields/submit-business-filing-fields) once per jurisdiction.
## Step 6 — Verify nothing is missing
The requirements endpoint returns per-field `satisfied`/`outstanding` status. Loop until nothing is outstanding — each entry names the exact field and endpoint to fix it.
## Step 7 — Add payment details
Fetch the corridor's requirements schema — it specifies every field, format, and conditional rule for the worker's country, currency, and payment method — then build the payment info from it.
A person must have payment details before they can be added to a payroll.
## Step 8 — Run payroll
Create a payroll, save it with the worker's entry, then approve. Entries carry the basic salary plus any allowances, bonuses, or voluntary deductions; the engine computes taxes and statutory contributions on save. The entry mirrors the person's compensation from step 3 — basic in `salary`, HRA and the special allowance in `allowances` — and the full gross is taxed. The HRA exemption itself is driven by the tax profile (`hraReceived` and `rentPaid`, old regime only), not by the allowance name.
Fetch the saved payroll to review the computed gross, deductions, employer contributions, net, and the `debit` that will be collected from your balance (what the debit covers depends on the [employment model](/workforce/employment-models)):
Then approve it:
A payroll dated in the future is `Scheduled` and disburses on the payroll date. In sandbox, use a past `payrollDate` to complete the payroll immediately on approval — see [testing payrolls](/reference/sandbox#testing-payrolls).
## Step 9 — Payslips and the invoice
Once the payroll is `Completed`, download payslips and reconcile the invoice:
The payroll's `invoiceId` links to an [itemized invoice](/api-reference/workforce/invoices/get-invoice) breaking the debit into line categories with per-line FX rates.
## What's next
Employing through Cadana's EOR: agreements, worker verification, and termination
What the debit collects under direct employment vs EOR
Status flow, FX handling, and webhooks
Filing data, documents, and the requirements loop in depth
Standalone tax calculations with full formula transparency
# Pay Workers via Payroll
Source: https://docs.cadanapay.com/workforce/pay-workers-via-payroll
Create, save, and approve payrolls to pay workers through Cadana
This guide details the steps needed for a business to pay its workers via a scheduled payroll. Cadana handles fund collection, scheduling, and disbursement automatically.
```mermaid theme={null}
flowchart TD
subgraph PLATFORM
direction TB
B[Platform onboards workers]
BA[(Platform Account)]
subgraph WORKERS
direction LR
W1[Worker #1]
W2[Worker #2]
Wn[Worker …]
end
end
Bank[(External Bank)]
Bank -- "ACH credit / Wire" --> BA
BA -- "Payroll payout" --> W1
BA -- "Payroll payout" --> W2
BA -- "Payroll payout" --> Wn
```
***
## Prerequisites
Get your API key from the [Cadana Dashboard](https://app.cadanapay.com). See [Authentication](/authentication) for details.
Your business must have completed KYB verification. See [KYB Requirements](/platform/kyb-requirements).
Your business account must have sufficient funds to cover the payroll. See [Fund Your Account](/fund-your-account).
Workers must be onboarded as Persons with payment methods configured — you'll use their `personId` to set up pay entries. See [Onboard Workers](/workforce/onboarding-workers).
***
## Step 1: Create a Payroll
Start by creating an empty payroll shell. Specify the worker type and whether this is a one-off or regular payroll.
**Response:**
```json theme={null}
{
"payrollId": "a1b2c3d4-e5f6-7890-abcd-ef0123456789"
}
```
Save the `payrollId` — you'll use it in the next steps.
### Payroll Types
| Field | Values | Description |
| :- | :- | :- |
| `workerType` | `EMPLOYEE`, `CONTRACTOR` | Type of workers being paid |
| `type` | `ONE_OFF`, `REGULAR` | One-off payment or recurring payroll |
***
## Step 2: Save Entries
Add the pay date, pay period, and compensation entries for each worker. Each entry requires a `personId` and `salary` amount.
If you set compensation info (`compInfo`) when [onboarding the worker](/workforce/onboarding-workers), you can retrieve it from the Person profile with [`GET /v1/persons/{personId}`](/api-reference/workforce/persons/get) and use those values for the salary entry.
Returns `204` on success. Saving is asynchronous — the payroll status moves to `Pending Submission` a few seconds later (poll with backoff before approving; see [Payroll Lifecycle](/workforce/payroll-lifecycle#status-reference)). Fetch the payroll with [`GET /v1/payrolls/{id}`](/api-reference/workforce/payrolls/get) to see the calculated `debit` — the total amount that will be debited from the business account. What the debit collects depends on each worker's [employment model](/workforce/employment-models).
Pass `?includeFxRates=true` to receive the captured FX rates synchronously in a `200 OK` body — `{ "fxRates": { "USD-PHP": 58.75, ... } }`, keyed `{fundingCurrency}-{salaryCurrency}` — instead of polling `GET /v1/payrolls/{id}`. Omitting the flag preserves the default `204 No Content` behavior.
Amounts are in the **lowest denomination** of the currency. For USD, `100000` = \$1,000.00. For GHS, `500000` = GHS 5,000.00.
You can re-save a payroll to update entries before approving. Each save replaces the previous entries.
For workers with variable pay (e.g., hourly contractors), set `compInfo.salary` to `0` during onboarding and provide the actual amount when saving payroll entries.
### Per-Cycle Compensation Fields
Beyond `personId` and `salary`, each entry accepts per-cycle compensation overrides. Fields are optional — send what's relevant to the cycle.
| Field | Type | Description |
| :- | :- | :- |
| `bonus` | Money | Bonus paid this period. |
| `commission` | Money | Commission paid this period. |
| `allowances` | Allowance\[] | Non-statutory allowance line items (e.g., housing, transport stipends). |
| `deductions` | Deduction\[] | Voluntary deduction line items (e.g., pension top-ups, garnishments, equipment repayments). |
| `timeWorked` | number | Units of work this period (hours, days, or `1` for salaried-monthly). The unit is implied by the person's `compInfo.frequency` — never sent on the entry. Defaults to `1` when omitted. |
| `overtime` | Money | Overtime rate (when paired with `overtimeWorked`) or a flat overtime amount (when sent alone). |
| `overtimeWorked` | number | Overtime units worked this period. Multiplies against `overtime` when both are sent. Must be non-negative. |
**Example:**
```json theme={null}
{
"personId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"salary": { "amount": 500000, "currency": "USD" },
"bonus": { "amount": 50000, "currency": "USD" },
"commission": { "amount": 25000, "currency": "USD" },
"allowances": [
{ "name": "Wellness Stipend", "isTaxable": true, "amount": { "amount": 20000, "currency": "USD" } },
{ "name": "Internet Stipend", "isTaxable": false, "amount": { "amount": 10000, "currency": "USD" } }
],
"deductions": [
{ "name": "Voluntary Pension Top-up", "isStatutory": false, "amount": { "amount": 15000, "currency": "USD" } }
]
}
```
Reads as `$5,000 monthly base + $500 bonus + $250 commission + $200 wellness stipend + $100 internet stipend − $150 voluntary pension`. Statutory items (income tax, social security, etc.) are computed by the engine and returned on `GET`.
Frequency is always sourced from the person's stored `compInfo.frequency`. Sending it on the entry is unsupported — adjust the person's frequency via [`PUT /v1/persons/{personId}/jobInfo`](/api-reference/workforce/persons/update-job-information) if it needs to change.
**Tax profile** information are person-level, not per-cycle. Set them once via [`PUT /v1/persons/{personId}/taxProfile`](/api-reference/workforce/persons/update-tax-profile)
### Custom Metadata
You can pass a `tags` object on both the create and save requests to store custom metadata against the payroll (e.g., internal reference IDs, department codes). This is a free-form object — any key-value pairs you include will be persisted and returned when you retrieve the payroll.
### Custom Fees (Platforms)
Platforms can pass a `customFees` array on the save request to add platform-specific fee line items to the payroll invoice. Each item requires a `name` and `amount`. These are additive — they supplement standard per-seat subscription fees and are included in the total amount collected by Cadana on your behalf.
```json theme={null}
{
"customFees": [
{ "name": "Platform Fee", "amount": { "amount": 50000, "currency": "USD" } },
{ "name": "Processing Fee", "amount": { "amount": 10000, "currency": "USD" } }
]
}
```
Custom fee items do not participate in revenue share calculations. Validation returns `400` for empty names, non-positive amounts, or missing currency.
### Save Errors
When saving entries fails validation, the API returns `400` with an `invalid_request_body` code. Each error in the `params` object is prefixed with a `[ErrorType]` tag you can use to programmatically filter or map errors.
**Response format:**
```json theme={null}
{
"code": "invalid_request_body",
"message": "One or more input values are invalid. Please re-renter valid values",
"params": {
"entry-0-paymentInfo": "[EmptyWalletDetails] Jane Doe has invalid payment method set: Wallet details cannot be empty for wallet payments",
"entry-1-personId": "[PersonNotFound] Person ID 8ef9a712-cdae-4110-b1ea-9ba95abbee6e is not a valid person in the system"
}
}
```
**Error types:**
| Error Type | Description |
| :- | :- |
| `NoPaymentMethod` | Person has no payment method configured |
| `PersonNotFound` | Person ID does not match any person in the system |
| `EmptyWalletDetails` | Wallet details are missing when payment method is wallet |
| `InvalidPreferredMethod` | Preferred method is not a supported type |
| `RestrictedCountry` | Beneficiary country is restricted for payments |
| `InvalidBankDetails` | Bank payment validation failed (e.g., missing account name, invalid IBAN) |
| `InvalidMomoDetails` | Mobile money validation failed (e.g., missing phone number, provider) |
| `InvalidWalletDetails` | Wallet validation failed (e.g., invalid currency) |
| `InvalidCryptoDetails` | Crypto wallet validation failed (e.g., missing address, unsupported chain) |
| `InvalidAchDetails` | ACH payment validation failed |
| `InvalidSwiftDetails` | SWIFT payment validation failed |
| `InvalidCardDetails` | Card payment validation failed |
| `InvalidProxyDetails` | Proxy payment validation failed |
| `InvalidPaymentDetails` | Catch-all for unrecognized payment method types |
To extract the error type programmatically, split the param value on `"] "` — the text before it is the error type (without the leading `[`).
***
## Step 3: Approve
Once you're satisfied with the entries, approve the payroll. This triggers fund collection and schedules disbursement.
Returns `204` on success. The payroll moves to `Awaiting Funds` (the `payroll.status.updated` webhook reports this as `approved`).
After approval:
* With **sufficient funds**, the payroll moves to `Scheduled` within moments. With **insufficient funds**, it stays in `Awaiting Funds` until you [fund your account](/fund-your-account) — then it proceeds automatically, no re-approval needed.
* If the scheduled date is **in the future**, the payroll will be processed on the scheduled day.
***
## Tracking Payroll Status
Poll the payroll to check its current status and see calculated totals. The response shape depends on the payroll's `workerType` — see [Response Shape by Worker Type](#response-shape-by-worker-type) below.
**Contractor response:**
```json theme={null}
{
"workerType": "Contractor",
"status": "Completed",
"type": "One_off",
"invoiceId": "8dbaa174-b5a0-4396-8c87-77e746b50917",
"payrollDate": "2026-02-28",
"debit": { "amount": 177000, "currency": "USD" },
"gross": { "amount": 175000, "currency": "USD" },
"net": { "amount": 175000, "currency": "USD" },
"payPeriod": {
"fromDate": "2026-02-01",
"toDate": "2026-02-28"
},
"entries": [
{
"entryId": "af431639-a488-4ce4-965f-92d598f067eb",
"transactionIds": ["d3c44687-3cad-4a9a-a3f6-5e415816289d"],
"personId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"salary": { "amount": 100000, "currency": "USD" },
"gross": { "amount": 100000, "currency": "USD" },
"net": { "amount": 100000, "currency": "USD" }
},
{
"entryId": "b2f5c8d1-3e6a-4f90-8c7b-1d2e3f4a5b6c",
"transactionIds": ["e4d55798-4dbe-4b0b-b4a7-6f526927390e"],
"personId": "3c7d9e21-fa4b-4a12-bc89-0d1234567890",
"salary": { "amount": 75000, "currency": "USD" },
"gross": { "amount": 75000, "currency": "USD" },
"net": { "amount": 75000, "currency": "USD" }
}
]
}
```
Enum values are returned in mixed case (`Contractor`, `One_off`, `Completed`) — compare them case-insensitively.
The response includes an `invoiceId` once entries are saved (from `Pending Submission` onward). Use [`GET /v1/invoices/{invoiceId}`](/api-reference/workforce/invoices/get-invoice) to retrieve a detailed breakdown of fees charged for the payroll run.
A payroll can report `Completed` up to a minute before the funds are visible in worker wallets. To confirm a payment landed, follow the entry's `transactionIds` — each ID resolves via [`GET /v1/users/{userId}/transactions/{transactionId}`](/api-reference/wallets/transactions/get-transaction) — or check the worker's wallet balance.
### Response Shape by Worker Type
For `EMPLOYEE` payrolls, the response also includes withholding aggregates at the top level and per-entry line items so you can reconcile gross-to-net for each worker. None of these fields appear on `CONTRACTOR` payrolls.
**Aggregate-level (employee payrolls only):**
| Field | Type | Description |
| :- | :- | :- |
| `tax` | Money | Total tax withheld across all entries |
| `pension` | Money | Total pension contribution across all entries |
| `statutoryDeductions` | Money | Total of all legally-mandated deductions (income tax, social security, etc.) |
| `employerContributions` | Money | Total employer-side contributions (e.g., employer social security) |
**Per-entry (employee payrolls only):**
| Field | Type | Description |
| :- | :- | :- |
| `allowances` | Allowance\[] | Allowance line items (e.g., Transport, Housing). Omitted when empty. |
| `deductions` | Deduction\[] | Deduction line items. `isStatutory` distinguishes legally-mandated items from voluntary ones. Omitted when empty. |
| `employerContributions` | Deduction\[] | Employer-side contribution line items. Omitted when empty. |
The `Allowance` and `Deduction` line-item shapes are the same ones returned by [`POST /v1/tax/calculate`](/api-reference/tax/tax-calculator/calculate-taxes) and [`POST /v1/tax/estimate`](/api-reference/tax/tax-calculator/estimate-gross-or-net-salary).
**Employee response example:**
```json theme={null}
{
"workerType": "Employee",
"type": "One_off",
"status": "Pending Submission",
"payrollDate": "2026-08-31",
"debit": { "amount": 134201, "currency": "USD" },
"gross": { "amount": 162164, "currency": "USD" },
"net": { "amount": 134201, "currency": "USD" },
"tax": { "amount": 21760, "currency": "USD" },
"pension": { "amount": 0, "currency": "USD" },
"statutoryDeductions": { "amount": 27963, "currency": "USD" },
"employerContributions": { "amount": 9089, "currency": "USD" },
"payPeriod": { "fromDate": "2026-08-01", "toDate": "2026-08-31" },
"fxRates": { "USD-PHP": 61.665991 },
"entries": [
{
"entryId": "a8c56b6e-9f8d-4fc5-8295-eab6ca99bc31",
"personId": "cd434844-1b73-4603-b0f2-b8c1784f6ead",
"salary": { "amount": 7500000, "currency": "PHP" },
"bonus": { "amount": 1000000, "currency": "PHP" },
"gross": { "amount": 10000000, "currency": "PHP" },
"net": { "amount": 8275625, "currency": "PHP" },
"allowances": [
{ "name": "Transport", "isTaxable": true, "amount": { "amount": 500000, "currency": "PHP" } },
{ "name": "Housing", "isTaxable": true, "amount": { "amount": 1000000, "currency": "PHP" } }
],
"deductions": [
{ "name": "Social Security", "isStatutory": true, "amount": { "amount": 175000, "currency": "PHP" } },
{ "name": "National Health Insurance (PhilHealth)", "isStatutory": true, "amount": { "amount": 187500, "currency": "PHP" } },
{ "name": "Home Development Mutual Fund (PAG-IBIG)", "isStatutory": true, "amount": { "amount": 20000, "currency": "PHP" } },
{ "name": "PAYE (Income Tax)", "isStatutory": true, "amount": { "amount": 1341875, "currency": "PHP" } }
],
"employerContributions": [
{ "name": "Social Security", "isStatutory": true, "amount": { "amount": 350000, "currency": "PHP" } },
{ "name": "National Health Insurance (PhilHealth)", "isStatutory": true, "amount": { "amount": 187500, "currency": "PHP" } },
{ "name": "Home Development Mutual Fund (PAG-IBIG)", "isStatutory": true, "amount": { "amount": 20000, "currency": "PHP" } },
{ "name": "Employees' Compensation (EC)", "isStatutory": true, "amount": { "amount": 3000, "currency": "PHP" } }
]
}
]
}
```
Entry-level `gross` and `net` are omitted until the entry has been calculated. New aggregate and per-entry fields are additive — clients that don't read them are unaffected.
***
## Downloading Payslips
Distribute earning statements — gross pay, withholdings, deductions, and net pay — to workers by fetching presigned download URLs for each payslip PDF. Payslips exist only for `Completed` payrolls. Once a payroll completes, call [`GET /v1/payrolls/{payrollId}/payslip-links`](/api-reference/workforce/payrolls/get-payslip-links). By default the response returns one URL per person.
```json theme={null}
{
"payrollId": "a1b2c3d4-e5f6-7890-abcd-ef0123456789",
"links": [
{
"personId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"personName": "Jane Doe",
"paystubId": "1b2e3f4a-5c6d-7e8f-9012-345678901234",
"url": "https://example-bucket.s3.amazonaws.com/payslips/8ef9a712.pdf?X-Amz-Signature=...",
"expiresAt": "2026-05-11T17:00:00Z"
}
],
"notReady": [
{ "personId": "3c7d9e21-fa4b-4a12-bc89-0d1234567890", "status": "generating" }
]
}
```
Each entry carries `personId` and `paystubId` so you can match the payslip back to the worker and pay period in your own system before delivery. `personName` is included for display.
To receive a single archive of every ready payslip instead, pass `format=zip`. The response then contains a single `zipUrl` rather than the per-person `links` array — useful when handing a full payroll's payslips to a finance team in one download.
Presigned URLs are accessible without authentication — anyone holding the link can download the payslip until it expires. Deliver them only to the intended recipient (over an authenticated channel), and treat the URL itself like a password. Do not log it, paste it into shared chats, or embed it in pages cached by third parties.
URLs expire shortly after issuance (see `expiresAt`) — download or rehost them promptly rather than storing them in long-lived UI. Payslips for not-yet-processed payrolls, or still rendering, appear under `notReady`; retry the request once the payroll is `Completed` and generation finishes to pick them up.
In sandbox, save the payroll with a past `payrollDate` — it completes immediately on approval, making payslips available right away. See [Sandbox & Testing](/reference/sandbox#testing-payrolls).
## List All Payrolls
**Response:**
```json theme={null}
{
"data": [
{
"payrollId": "a1b2c3d4-e5f6-7890-abcd-ef0123456789",
"status": "Completed",
"numPeople": 2,
"debit": { "amount": 177000, "currency": "USD" },
"gross": { "amount": 175000, "currency": "USD" },
"net": { "amount": 175000, "currency": "USD" },
"tax": { "amount": 0, "currency": "USD" },
"pension": { "amount": 0, "currency": "USD" }
}
]
}
```
List items are summaries — fetch a payroll by ID for its type, dates, and entries.
***
## Status Flow
```
Created → Pending Submission → Awaiting Funds → Scheduled → Processing → Completed
```
See [Payroll Lifecycle](/workforce/payroll-lifecycle#status-reference) for the full status list (including terminal states), the meaning of each value, webhook payloads, and multi-currency handling.
***
## Webhooks
Subscribe to payroll and transaction events to track progress without polling.
**Payroll status changes:**
```json JSON theme={null}
{
"id": "e13b9e14-c062-42ea-8563-8fc9223b29b5",
"status": "completed",
"tenantKey": "cad95193904"
}
```
**Individual transaction events** (`transaction.initiated`, `transaction.succeeded`, `transaction.failed`) fire for each worker's payment.
See [Webhooks](/reference/webhooks) to configure your endpoint and [Events](/reference/events) for all event types.
***
## Troubleshooting
| Issue | Cause | Solution |
| :- | :- | :- |
| `401 Unauthorized` | Invalid or missing API key | Check your API key in the [Dashboard](https://app.cadanapay.com) |
| `400` on save | Missing required fields | Ensure `payrollDate`, `payPeriod`, and at least one entry with `personId` and `salary` are provided |
| Stuck in `awaiting funds` | Insufficient business account balance | Fund your business account, then the payroll will proceed automatically |
| Payroll won't approve | Entries not saved | Save entries (Step 2) before approving |
***
## Next Steps
Full status flow, webhooks, and multi-currency handling
Set up Person records and payment methods
Real-time notifications for payroll and transactions
Full API documentation
# Payroll
Source: https://docs.cadanapay.com/workforce/payroll
Run payroll for employees and contractors across currencies
Cadana's Payroll Engine handles the full payroll cycle — create a run, add compensation entries, approve, and Cadana takes care of disbursement, FX conversion, and status tracking.
***
## Key Capabilities
Pay workers in their local currency with automatic FX conversion at competitive rates.
Create, save, approve, and track payroll runs through completion with webhook notifications.
***
## Guides
Step-by-step guide to creating, saving, and approving a payroll run
Payroll states, multi-currency handling, and webhook events
Direct employment vs Employer of Record, and what each collects at payroll
See how payroll connects with tax calculation, statutory filing, and remittance
# Overview
Source: https://docs.cadanapay.com/workforce/payroll-and-compliance
Calculate, pay, file, and remit — modular building blocks for global workforce operations
Cadana's payroll and compliance APIs are modular building blocks. Use one product or chain them together. Each works standalone — pick what you need.
***
## Products
Instant gross-to-net and net-to-gross salary estimates across 90+ countries. One API call, no person record needed.
Stateful tax calculations with full breakdowns, legislative citations, and year-to-date tracking.
Run payroll end-to-end: create, approve, disburse, and generate payslips across currencies.
File statutory returns with government authorities. Auto-detect filing types, check requirements, track through acceptance.
Pay government authorities through local payment rails. Automatic FX conversion, payment tracking, downloadable receipts.
***
## How They Connect
Model compensation costs with [`POST /v1/tax/estimate`](/api-reference/tax/tax-calculator/estimate-gross-or-net-salary) — no setup needed.
Onboard workers with [`POST /v1/persons`](/api-reference/workforce/persons/create). Check what fields each country needs with [`GET /v1/tax/fields`](/api-reference/tax/tax-calculator/list-required-additional-tax-fields-by-country).
Get full tax breakdowns with [`POST /v1/tax/calculate`](/api-reference/tax/tax-calculator/calculate-taxes) — ISR, IMSS, INFONAVIT, and every country-specific deduction.
Create, save, and approve payroll runs with the [Payroll API](/workforce/pay-workers-via-payroll). Cadana handles disbursement and FX.
Create filings from payroll runs or provide amounts directly. Track through submission and government acceptance. [Filing guide](/statutory/the-full-loop).
Pay what's owed through local payment rails with automatic FX conversion. Download receipts for audit. [Remittance guide](/statutory/the-full-loop#step-4-create-remittance).
Each step is independent. Enter at any point. If you only need cost estimates, stop at step 1. If you run payroll elsewhere but need filing and remittance, start at step 5.
***
## Common Integration Patterns
| Pattern | Products Used | Description |
| :- | :- | :- |
| **Cost modeling** | Employment Cost Calculator | One endpoint, no setup. Estimate salaries across countries for hiring decisions. |
| **Payroll processing** | Global Tax Engine + Payroll Engine | Create persons, calculate taxes, run payroll. Full gross-to-net-to-disbursement. |
| **Full compliance** | All five | Calculate, pay, file, remit, prove. The complete loop. |
| **External payroll + compliance** | Statutory Filing + Remittance | You run payroll in your own system. Cadana files returns and/or pays authorities. Pass explicit amounts instead of payroll run IDs. |
| **Remittance only** | Statutory Remittance | You handle filings yourself. Cadana pays the government authorities through local rails. |
***
## Guides
Supported countries, required fields, and tax calculations
Create, save, and approve payrolls
Supported jurisdictions, required fields, and compliance calendar
Worker management, contracts, invoices, and onboarding
# Payroll Lifecycle
Source: https://docs.cadanapay.com/workforce/payroll-lifecycle
Status transitions, webhook events, multi-currency handling, and edge cases for payrolls
A payroll moves through a series of statuses from creation to completion. This page covers what happens at each stage, the webhook events you'll receive, and how multi-currency payrolls are handled.
***
## Overview
```mermaid theme={null}
sequenceDiagram
participant App as Your App
participant Cadana
participant Webhook
App->>Cadana: 1. POST /v1/payrolls
Cadana->>Webhook: payroll.created
App->>Cadana: 2. POST /v1/payrolls/{id}/save
Cadana->>Webhook: payroll.status.updated (saved)
App->>Cadana: 3. POST /v1/payrolls/{id}/approve
Cadana->>Webhook: payroll.status.updated (approved)
Note over Cadana: Automatic (if balance sufficient)
Cadana->>Webhook: payroll.status.updated (scheduled)
Note over Cadana: Automatic (on disbursement date)
Cadana->>Webhook: payroll.status.updated (processing)
Note over Cadana: Automatic
Cadana->>Webhook: payroll.status.updated (completed)
```
**Only 3 API calls needed** — everything after approval is automatic.
Select the worker type (employee/contractor) and create a payroll.
[`POST /v1/payrolls`](/api-reference/workforce/payrolls/create) → Webhook: `payroll.created`
Provide the pay date, pay period, and compensation for each worker.
[`POST /v1/payrolls/{id}/save`](/api-reference/workforce/payrolls/save) → Webhook: `payroll.status.updated` (saved)
Approve the payroll to trigger fund collection and scheduling.
[`POST /v1/payrolls/{id}/approve`](/api-reference/workforce/payrolls/approve) → Webhook: `payroll.status.updated` (approved)
For the full integration walkthrough, see [Pay Workers via Payroll](/workforce/pay-workers-via-payroll).
***
## What Happens After Approval
Once you call `POST /v1/payrolls/{id}/approve`, the rest is automatic:
### Balance Check
On approval, the payroll enters `Awaiting Funds` — this is the state the `payroll.status.updated` (approved) webhook signals. Cadana then checks whether the business account has sufficient funds to cover the payroll.
* **Sufficient funds** — the payroll moves to `Scheduled` within moments. Webhook: `payroll.status.updated` (scheduled)
* **Insufficient funds** — the payroll stays in `Awaiting Funds` until you [fund the account](/fund-your-account). Cadana re-checks the balance every few minutes; once the account is funded, the payroll proceeds — no need to re-approve.
### Scheduling
If the payroll date is in the future, the payroll stays in `Scheduled` until that date. If the payroll date is today or in the past, disbursement begins immediately.
### Disbursement
On the payroll date, Cadana triggers disbursements for each worker. The payroll moves to `Processing`. Each worker's payment is handled independently — if one worker's payment fails, the others continue. Webhook: `payroll.status.updated` (processing)
Once the payroll reaches `Processing`, each entry on the `GET /v1/payrolls/{payrollId}` response includes a `transactionIds` array linking it to its underlying disbursement transactions. This is a polling-friendly alternative to the per-worker `transaction.*` webhook events for clients that prefer to fetch state on demand.
### Completion
Once all disbursements are confirmed, the payroll moves to `Completed`. Webhook: `payroll.status.updated` (completed)
Payslips exist only for `Completed` payrolls — [`GET /v1/payrolls/{payrollId}/payslip-links`](/api-reference/workforce/payrolls/get-payslip-links) returns entries under `notReady` until then. See [Downloading Payslips](/workforce/pay-workers-via-payroll#downloading-payslips).
After completion, the payroll response includes an `invoiceId`. Fetch the invoice for a detailed breakdown of fees charged for the payroll run.
### Status Reference
These are the values returned in the `status` field on `GET /v1/payrolls` and `GET /v1/payrolls/{id}`.
```mermaid theme={null}
flowchart TD
A[Created] -->|POST /v1/payrolls/id/save| B[Pending Submission]
B -->|POST /v1/payrolls/id/approve| E[Awaiting Funds]
E -->|Balance covers the payroll| D[Scheduled]
D -->|Disbursement triggered| F[Processing]
F -->|All disbursements confirmed| G[Completed]
```
| Status | Meaning |
| :- | :- |
| `Created` | Empty payroll shell, no entries yet — returned right after [`POST /v1/payrolls`](/api-reference/workforce/payrolls/create) |
| `Pending Submission` | Entries added, ready for approval — this is the value the `status` field returns after a successful [`POST /v1/payrolls/{id}/save`](/api-reference/workforce/payrolls/save). (The corresponding `payroll.status.updated` webhook payload uses `saved`.) |
| `Pending Approval` | Submitted for an approver to action. You will only see this when the dashboard is used to submit a payroll; the public API has no submit endpoint. The [`POST /v1/payrolls/{id}/approve`](/api-reference/workforce/payrolls/approve) endpoint accepts payrolls in either `Pending Submission` or `Pending Approval`. |
| `Awaiting Funds` | Approved, funds not yet collected. Every approved payroll passes through this status — with sufficient balance it moves to `Scheduled` within moments; otherwise it stays here until funded. (The corresponding `payroll.status.updated` webhook payload uses `approved`.) |
| `Scheduled` | Funded and queued for the payroll date |
| `Processing` | Disbursements are being sent to workers |
| `Completed` | All disbursements confirmed (terminal) |
| `Rejected` | An approver declined the payroll (terminal) |
| `Deleted` | Caller deleted the payroll via [`DELETE /v1/payrolls/{id}`](/api-reference/workforce/payrolls/delete) before disbursement started (terminal) |
`POST /v1/payrolls/{id}/save` returns `204` immediately, but the new `Pending Submission` status takes a few seconds to be reflected on `GET /v1/payrolls/{id}`. A tight poll loop right after save may still see `Created` briefly — poll with backoff until the status is `Pending Submission` before calling approve, otherwise approve will reject the payroll with `Payroll has an unexpected status Created`. `POST /v1/payrolls/{id}/approve` is strongly consistent — a follow-up GET will reflect the new status immediately.
`Pending Submission` and `Awaiting Funds` are not auto-cancelled — they sit indefinitely until you explicitly approve, delete, or (for `Awaiting Funds`) fund the business balance.
***
## Deleting a Payroll
You can delete a payroll at any point **before it starts processing**.
Returns `204` on success. The payroll moves to `Deleted`.
Once a payroll reaches `Processing` or `Completed`, it cannot be deleted.
***
## Webhook Events
Cadana emits two payroll webhook event types. Subscribe via the [Dashboard](https://app.cadanapay.com) or see [Webhooks](/reference/webhooks) for setup.
### payroll.created
Fired when a new payroll is created.
```json JSON theme={null}
{
"id": "e13b9e14-c062-42ea-8563-8fc9223b29b5",
"workerType": "CONTRACTOR",
"type": "ONE_OFF",
"tenantKey": "cad95193904"
}
```
### payroll.status.updated
Fired when the payroll moves into one of the values below. The webhook uses its own status vocabulary — the third column shows the corresponding `status` on [`GET /v1/payrolls/{id}`](/api-reference/workforce/payrolls/get).
```json JSON theme={null}
{
"id": "e13b9e14-c062-42ea-8563-8fc9223b29b5",
"status": "completed",
"tenantKey": "cad95193904"
}
```
| `status` value | Meaning | Status on GET |
| :- | :- | :- |
| `saved` | Entries saved, ready for approval | `Pending Submission` |
| `approved` | Payroll approved, funds not yet collected | `Awaiting Funds` |
| `scheduled` | Funds collected, queued for the payroll date | `Scheduled` |
| `processing` | Disbursements being sent to workers | `Processing` |
| `completed` | All disbursements confirmed | `Completed` |
`Awaiting Funds` is the status a payroll holds between the `approved` and `scheduled` events. If you receive `approved` and no `scheduled` event follows shortly, the payroll is waiting on funds — compare its `debit` to your business balance and top up; it proceeds automatically once funded. No webhook fires for `Rejected` or `Deleted`; poll [`GET /v1/payrolls/{id}`](/api-reference/workforce/payrolls/get) to detect those.
### Per-Worker Transaction Events
Each worker's payment fires individual transaction events: `transaction.initiated`, `transaction.succeeded`, and `transaction.failed`. These let you track each worker's payout independently of the overall payroll status.
The transaction's `reference` field contains the `payrollId`. `recipientId` is the worker's identifier — use `recipientType` to interpret it: a `userId` when `recipientType: "USER"` (Cadana wallet payments), or a `personId` when `EMPLOYEE` or `CONTRACTOR` (direct bank payments).
```json JSON theme={null}
{
"id": "9af0f05e-1efa-407b-be23-8595f89a1b2a",
"amount": { "currency": "USD", "amount": 100000 },
"type": "PAYROLL",
"reference": "a1b2c3d4-e5f6-7890-abcd-ef0123456789",
"recipientId": "8ef9a712-cdae-4110-b1ea-9ba95abbee6e",
"recipientType": "USER",
"tenantKey": "cad95193904"
}
```
See [Events](/reference/events) for all event types and payload details.
***
## Multi-Currency Payrolls
When a worker's salary currency differs from the business's funding currency (e.g., a US company paying a contractor in BRL), FX conversion is involved.
### How It Works
Cadana captures FX rates and calculates the debit amount in the funding currency. Each entry stores its conversion rate individually. Pass `?includeFxRates=true` on the save call to receive the captured rates in the response (`200 OK` with `{ "fxRates": { "USD-BRL": 5.41, ... } }`) instead of polling the payroll. Keys are `{fundingCurrency}-{salaryCurrency}` and each value is the number of salary-currency units per unit of funding currency.
If there's a delay between save and disbursement (future payroll date, awaiting funds, etc.), rates may be refreshed with current market rates.
After a rate refresh, the total debit is recalculated. If the new amount exceeds the business balance, the payroll moves back to `Awaiting Funds`.
```mermaid theme={null}
flowchart TD
A[Created] -->|POST /v1/payrolls/id/save
FX rates captured| B[Pending Submission]
B -->|POST /v1/payrolls/id/approve| E[Awaiting Funds]
E -->|Balance covers debit| D[Scheduled]
subgraph Disbursement Time
D --> F{FX rates
expired?}
F -->|No| G[Processing]
F -->|Yes| H[FX Rate Refresh]
H --> I{Balance OK?}
I -->|Yes| G
I -->|No| E
end
G -->|All disbursements confirmed| J[Completed]
```
### Recommended Approach
1. **Poll after save** — check the `debit` amount on the payroll to understand the total cost in your funding currency.
2. **Fund with headroom** — if your payroll date is days away, consider funding slightly above the quoted debit to absorb potential rate movement.
3. **Monitor `Awaiting Funds`** — if a rate refresh causes the debit to exceed your balance, the payroll pauses until you top up.
***
## Edge Cases
### Individual Worker Payment Fails
If one worker's disbursement fails (e.g., invalid bank details), the other workers' payments continue. The payroll still moves to `Completed` once all disbursements are resolved. Use `transaction.failed` webhooks to identify which worker's payment needs attention. The payroll itself does not have a `failed` status — that string only appears on per-worker `transaction.*` events.
### Multiple Payrolls Awaiting Funds
When multiple payrolls are in `Awaiting Funds` and the business adds funds, Cadana schedules them in priority order:
1. **Earliest payroll date first**
2. **Creation time** as a tiebreaker
Only payrolls fully coverable by the available balance move to `Scheduled`. The rest remain in `Awaiting Funds` until more funds are added.
### Payroll Stuck in Awaiting Funds
The payroll remains in `Awaiting Funds` indefinitely until the business account is funded. Once sufficient funds are available, the payroll proceeds automatically. See [Fund Your Account](/fund-your-account) for funding options.
### Re-saving Before Approval
You can call save multiple times before approving. Each save **replaces** the previous entries entirely — there is no incremental add/remove of individual entries.
***
## Next Steps
Step-by-step guide to create, save, and approve a payroll
Add funds via bank transfer, ACH, or crypto
Configure your webhook endpoint
All event types and payload schemas
# Get jurisdiction details
Source: https://docs.cadanapay.com/api-reference/statutory/jurisdictions/get-jurisdiction-details
/openapi/statutory-compliance.yaml get /v1/statutory/jurisdictions/{country_code}
Returns full configuration for a jurisdiction: government authorities,
filing types, supported worker types and employment models, EOR
availability, and required fields for statutory filing.
For jurisdictions where Cadana's Employer of Record service is available,
the response includes an `eor` block with local employment rules — payroll
conventions, payout constraints, employment terms, leave minimums, and
termination rules. See the Hire Through EOR guide.
The embedded `requiredFields` array covers filing-scope fields only,
each tagged with an `entity` of `person` or `business`. For the
complete set — including calculation-scope fields — use
`GET /v1/statutory/jurisdictions/{country_code}/required-fields`.
Employer and employee contribution rates are not part of this response
— use the Global Tax API's `POST /v1/tax/calculate`, which returns
statutory deductions and employer contributions per worker.
# Get required fields for tax calculation and statutory filing
Source: https://docs.cadanapay.com/api-reference/statutory/jurisdictions/get-required-fields-for-tax-calculation-and-statutory-filing
/openapi/statutory-compliance.yaml get /v1/statutory/jurisdictions/{country_code}/required-fields
Returns all fields required for tax calculation and/or statutory filing
in this jurisdiction. This is a **discovery endpoint** — it tells you
what data needs to be collected for both persons (workers) and businesses
(employers).
Each field includes:
- **`entity`** — Who the field belongs to: `person` (worker data) or
`business` (employer data)
- **`scope`** — Whether the field is needed for `calculation` (tax engine input),
`filing` (statutory return), or both
- **`condition`** — When present, the field only applies if another field
matches a specific value (e.g. pension PIN only when `is_pensionable` is true)
Use this to:
- Render onboarding forms with the right fields for each country
- Split fields into separate person vs. business forms using each
field's `entity` value in the response (there is no `entity` query
parameter — use the `scope` and `worker_type` parameters to narrow
the results server-side)
- Validate that existing records have complete data
- Understand what `worker_data` requirements will surface if fields are missing
**How to submit fields by scope:**
- `scope: ["filing"]` fields → Submit via
`PUT /v1/statutory/persons/{id}/fields/{countryCode}` (person entity) or
`PUT /v1/statutory/businesses/{id}/fields/{countryCode}` (business entity)
- `scope: ["calculation"]` fields → Submit via
`PUT /v1/persons/{id}/taxProfile`
- `scope: ["filing", "calculation"]` fields → Submit to both endpoints
# List filing types for a jurisdiction
Source: https://docs.cadanapay.com/api-reference/statutory/jurisdictions/list-filing-types-for-a-jurisdiction
/openapi/statutory-compliance.yaml get /v1/statutory/jurisdictions/{country_code}/filing-types
Returns every type of statutory return that must be filed in this jurisdiction.
Each filing type includes the government authority, frequency, and required data.
# List supported jurisdictions
Source: https://docs.cadanapay.com/api-reference/statutory/jurisdictions/list-supported-jurisdictions
/openapi/statutory-compliance.yaml get /v1/statutory/jurisdictions
Returns all countries and regions where Cadana supports statutory filing.
Each jurisdiction includes a summary of filing types, payroll frequency, currency,
and the hiring capabilities Cadana supports there (worker types, employment models,
and EOR availability).
# Calculate Taxes
Source: https://docs.cadanapay.com/api-reference/tax/tax-calculator/calculate-taxes
/openapi/global-tax.yaml post /v1/tax/calculate
Calculate taxes for an existing Cadana person. The person's address determines the jurisdiction, and their stored compensation and tax profile provide calculation inputs (overridden by `salary` when supplied).
Beyond the base salary, the request accepts the same per-entry earnings as a payroll entry — `allowances`, `bonus`, `overtime`, and voluntary `deductions` — and computes them identically to a payroll run.
# Estimate Gross or Net Salary
Source: https://docs.cadanapay.com/api-reference/tax/tax-calculator/estimate-gross-or-net-salary
/openapi/global-tax.yaml post /v1/tax/estimate
Estimate gross or net salary for a given country and type.
Pass an optional `version` alongside the fields below to pin the estimate to a specific tax ruleset version, for example `v2.1.0`; omit it to use the current ruleset. Discover pinnable versions with [List Tax Ruleset Versions](/api-reference/tax/tax-calculator/list-tax-ruleset-versions). A version that is unknown, malformed or not pinnable is rejected with a 400 rather than silently falling back. When a version is pinned the response returns it as `rulesetVersion`.
# Get Tax Ruleset Changelog
Source: https://docs.cadanapay.com/api-reference/tax/tax-calculator/get-tax-ruleset-changelog
/openapi/global-tax.yaml get /v1/tax/changelog
Returns what changed between the version you are on and the current one for a country, including any tax field requirements that changed. Without `fromVersion` it returns the latest release only.
# List Countries and Regions
Source: https://docs.cadanapay.com/api-reference/tax/tax-calculator/list-countries-and-regions
/openapi/global-tax.yaml get /v1/tax/locations
Returns a list of supported countries and their valid states/regions
# List Required Additional Tax Fields by Country
Source: https://docs.cadanapay.com/api-reference/tax/tax-calculator/list-required-additional-tax-fields-by-country
/openapi/global-tax.yaml get /v1/tax/fields
Returns a list of tax-related additionalfields required to run calculations in the specified country
# List Tax Ruleset Versions
Source: https://docs.cadanapay.com/api-reference/tax/tax-calculator/list-tax-ruleset-versions
/openapi/global-tax.yaml get /v1/tax/versions
Lists the tax ruleset versions retained for a country, newest first. Use this to discover a version to pin on a calculation. Only versions with `pinnable: true` can be pinned; earlier versions predate versioned rulesets and are rejected with a 400 if requested.
# Validate Tax Fields
Source: https://docs.cadanapay.com/api-reference/tax/tax-calculator/validate-tax-fields
/openapi/global-tax.yaml post /v1/tax/fields/validate
Validates tax field values against a country's ruleset before you use them in a calculation, returning any errors and cross-field warnings.
# Create Custom Field
Source: https://docs.cadanapay.com/api-reference/workforce/businesses/create-custom-field
/openapi/workforce-management.yaml post /v1/businesses/{businessId}/customFields
Create a custom field
# Create Department
Source: https://docs.cadanapay.com/api-reference/workforce/businesses/create-department
/openapi/workforce-management.yaml post /v1/businesses/{businessId}/departments
Create a department
# Get Business
Source: https://docs.cadanapay.com/api-reference/workforce/businesses/get-business
/openapi/workforce-management.yaml get /v1/businesses/{businessId}
Get a business
# Get Current Identity
Source: https://docs.cadanapay.com/api-reference/workforce/businesses/get-current-identity
/openapi/workforce-management.yaml get /v1/me
Retrieve the calling API key's access level and the business the request resolved to, including its `businessId` and tenant key. With a Platform API token, pass `X-MultiTenantKey` to confirm which sub-business your requests operate on.
# Update Custom Field
Source: https://docs.cadanapay.com/api-reference/workforce/businesses/update-custom-field
/openapi/workforce-management.yaml put /v1/businesses/{businessId}/customFields/{customFieldId}
Update a custom field
# Update Department
Source: https://docs.cadanapay.com/api-reference/workforce/businesses/update-department
/openapi/workforce-management.yaml put /v1/businesses/{businessId}/departments/{departmentId}
Update a department
# Create
Source: https://docs.cadanapay.com/api-reference/workforce/contracts/create
/openapi/workforce-management.yaml post /v1/contracts
Create a contract
# List
Source: https://docs.cadanapay.com/api-reference/workforce/contracts/list
/openapi/workforce-management.yaml get /v1/contracts
Fetch all contracts
# List Templates
Source: https://docs.cadanapay.com/api-reference/workforce/contracts/list-templates
/openapi/workforce-management.yaml get /v1/contract-templates
Fetch all contract tempaltes
# Retrieve
Source: https://docs.cadanapay.com/api-reference/workforce/contracts/retrieve
/openapi/workforce-management.yaml get /v1/contracts/{contractId}
Retrieve a single contract by ID
# Signing URL
Source: https://docs.cadanapay.com/api-reference/workforce/contracts/signing-url
/openapi/workforce-management.yaml get /v1/contracts/{contractId}/signingUrl
Get contract signing URL. The generated links last for 336 hours.
# Upload Contract
Source: https://docs.cadanapay.com/api-reference/workforce/contracts/upload-contract
/openapi/workforce-management.yaml post /v1/contracts-upload
Upload a contract PDF using a fileId from the files API
# JWT
Source: https://docs.cadanapay.com/api-reference/workforce/custom-auth/jwt
/openapi/workforce-management.yaml post /v1/auth/login/jwt
Exchange your internal JWT to a cadana redirect token
# Create
Source: https://docs.cadanapay.com/api-reference/workforce/entities/create
/openapi/workforce-management.yaml post /v1/entities
Create a new legal entity under the current business
# Get
Source: https://docs.cadanapay.com/api-reference/workforce/entities/get
/openapi/workforce-management.yaml get /v1/entities/{entityId}
Fetch a single legal entity by ID
# List
Source: https://docs.cadanapay.com/api-reference/workforce/entities/list
/openapi/workforce-management.yaml get /v1/entities
List all legal entities for the current tenant
# Update
Source: https://docs.cadanapay.com/api-reference/workforce/entities/update
/openapi/workforce-management.yaml patch /v1/entities/{entityId}
Update an existing legal entity
# Create upload URL
Source: https://docs.cadanapay.com/api-reference/workforce/files/create-upload-url
/openapi/workforce-management.yaml post /v1/files/upload-url
Reserve a file ID and receive a short-lived HTTPS **PUT** URL.
After the client uploads the bytes to that URL, it can reference
`fileId` in later API calls (e.g. Beneficiary creation KYC).
# Get file details
Source: https://docs.cadanapay.com/api-reference/workforce/files/get-file-details
/openapi/workforce-management.yaml get /v1/files/{fileId}
Get details and a download URL for a file by its fileId
# Get instant pay setting
Source: https://docs.cadanapay.com/api-reference/workforce/instant-pay/get-instant-pay-setting
/openapi/workforce-management.yaml get /v1/persons/{personId}/ipSetting
Fetch a person's instant pay setting
# Get instant pay transactions
Source: https://docs.cadanapay.com/api-reference/workforce/instant-pay/get-instant-pay-transactions
/openapi/workforce-management.yaml get /v1/persons/{personId}/instantPays
Fetch a person's instant pay transactions
# Update instant pay setting
Source: https://docs.cadanapay.com/api-reference/workforce/instant-pay/update-instant-pay-setting
/openapi/workforce-management.yaml put /v1/persons/{personId}/ipSetting
Update a person's instant pay setting
# Cancel Invoice
Source: https://docs.cadanapay.com/api-reference/workforce/invoices/cancel-invoice
/openapi/workforce-management.yaml post /v1/invoices/{invoiceId}/cancel
Cancel an invoice. The invoice is voided and can no longer be paid or reviewed.
# Get Invoice
Source: https://docs.cadanapay.com/api-reference/workforce/invoices/get-invoice
/openapi/workforce-management.yaml get /v1/invoices/{invoiceId}
Get invoice details
# Get Invoice URL
Source: https://docs.cadanapay.com/api-reference/workforce/invoices/get-invoice-url
/openapi/workforce-management.yaml get /v1/invoices/{invoiceId}/url
Get a short-lived URL for the invoice PDF
# List Invoices
Source: https://docs.cadanapay.com/api-reference/workforce/invoices/list-invoices
/openapi/workforce-management.yaml get /v1/invoices
List invoices for your business. Optionally filter by person, user, source, status, type, or invoice date range. Filters can be combined; all are optional.
# Review Invoice
Source: https://docs.cadanapay.com/api-reference/workforce/invoices/review-invoice
/openapi/workforce-management.yaml post /v1/invoices/{invoiceId}/review
Approve or reject a contractor self-submitted invoice. Only invoices with a `source` of `CONTRACTOR_SUBMITTED` in `pending` status can be reviewed; any other invoice returns 400.
# Generate KYB Link
Source: https://docs.cadanapay.com/api-reference/workforce/kyb/generate-kyb-link
/openapi/workforce-management.yaml post /v1/businesses/{businessId}/kyb-link
Get KYB Link
# Get KYB
Source: https://docs.cadanapay.com/api-reference/workforce/kyb/get-kyb
/openapi/workforce-management.yaml get /v1/businesses/{businessId}/kyb
Returns the business's KYB details. Returns 404 if KYB has not been started for the business.
# Submit KYB
Source: https://docs.cadanapay.com/api-reference/workforce/kyb/submit-kyb
/openapi/workforce-management.yaml post /v1/businesses/{businessId}/kyb
Submit Know Your Business (KYB) information for verification. Use POST to provide new information — the initial submission and any change to the set of principals; to correct information already on file, use PATCH instead.
# Update KYB
Source: https://docs.cadanapay.com/api-reference/workforce/kyb/update-kyb
/openapi/workforce-management.yaml patch /v1/businesses/{businessId}/kyb
Update KYB information already provided. Only the fields you include are changed; new principals cannot be added with PATCH — re-POST the full submission instead. Document arrays are appended to, not replaced.
# Add milestone
Source: https://docs.cadanapay.com/api-reference/workforce/milestones/add-milestone
/openapi/workforce-management.yaml post /v1/persons/{personId}/milestones
Add a milestone (deliverable) to a milestone-type contractor. `documentIds` are file ids from the upload-url flow (POST /v1/files/upload-url with purpose `milestone-document`).
# Complete milestone
Source: https://docs.cadanapay.com/api-reference/workforce/milestones/complete-milestone
/openapi/workforce-management.yaml post /v1/persons/{personId}/milestones/{milestoneId}/complete
Mark a planned or rejected milestone complete. Generates a contractor invoice and returns its `invoiceId`, and moves the milestone to `Submitted`. As that invoice is approved, rejected, or paid, the milestone status updates to match.
# Delete milestone
Source: https://docs.cadanapay.com/api-reference/workforce/milestones/delete-milestone
/openapi/workforce-management.yaml delete /v1/persons/{personId}/milestones/{milestoneId}
Delete a planned milestone.
# List milestones
Source: https://docs.cadanapay.com/api-reference/workforce/milestones/list-milestones
/openapi/workforce-management.yaml get /v1/persons/{personId}/milestones
List a milestone-type contractor's milestones. Set `includeDocuments=true` to receive presigned document download URLs.
# Update milestone
Source: https://docs.cadanapay.com/api-reference/workforce/milestones/update-milestone
/openapi/workforce-management.yaml put /v1/persons/{personId}/milestones/{milestoneId}
Update a planned milestone. `documentIds` are file ids from the upload-url flow and replace the milestone's documents.
# Approve
Source: https://docs.cadanapay.com/api-reference/workforce/payrolls/approve
/openapi/workforce-management.yaml post /v1/payrolls/{payrollId}/approve
Approve a payroll
# Create
Source: https://docs.cadanapay.com/api-reference/workforce/payrolls/create
/openapi/workforce-management.yaml post /v1/payrolls
Create a new payroll
# Delete
Source: https://docs.cadanapay.com/api-reference/workforce/payrolls/delete
/openapi/workforce-management.yaml delete /v1/payrolls/{payrollId}
Delete a payroll. Cannot delete payrolls in Processing or Completed status.
# Get
Source: https://docs.cadanapay.com/api-reference/workforce/payrolls/get
/openapi/workforce-management.yaml get /v1/payrolls/{payrollId}
Fetch a payroll's details
# Get Payslip Links
Source: https://docs.cadanapay.com/api-reference/workforce/payrolls/get-payslip-links
/openapi/workforce-management.yaml get /v1/payrolls/{payrollId}/payslip-links
Retrieve presigned download links for a payroll's payslips. Payslips exist only once the payroll is `Completed`; for payrolls that have not yet processed, entries appear under `notReady` with status `not_found`. Each link expires after a short window, so fetch and use them promptly.
Pass `format=zip` to receive a single presigned URL pointing to a bundled archive of all ready payslips instead of per-person links.
Payslips that are still being generated (or failed to generate) are returned under `notReady` with a `status` of `generating` or `error`. Retry the request after generation completes to receive their links.
In sandbox, a payroll saved with a past `payrollDate` completes immediately on approval — the fastest way to obtain payslips for testing.
# List
Source: https://docs.cadanapay.com/api-reference/workforce/payrolls/list
/openapi/workforce-management.yaml get /v1/payrolls
Fetch all payrolls
# Save
Source: https://docs.cadanapay.com/api-reference/workforce/payrolls/save
/openapi/workforce-management.yaml post /v1/payrolls/{payrollId}/save
Save a payroll
# Cancel offboarding
Source: https://docs.cadanapay.com/api-reference/workforce/persons/cancel-offboarding
/openapi/workforce-management.yaml post /v1/persons/{personId}/cancelOffboarding
Cancel a pending offboarding and return the person to active status
# Get payment information
Source: https://docs.cadanapay.com/api-reference/workforce/persons/get-payment-information
/openapi/workforce-management.yaml get /v1/persons/{personId}/paymentInfo
Fetch a person's payment information
# Reinstate
Source: https://docs.cadanapay.com/api-reference/workforce/persons/reinstate
/openapi/workforce-management.yaml post /v1/persons/{personId}/reinstate
Reinstate a former person back to active status
# Schedule offboarding
Source: https://docs.cadanapay.com/api-reference/workforce/persons/schedule-offboarding
/openapi/workforce-management.yaml post /v1/persons/{personId}/offboard
Schedule a person for offboarding on a given exit date
# Update job information
Source: https://docs.cadanapay.com/api-reference/workforce/persons/update-job-information
/openapi/workforce-management.yaml put /v1/persons/{personId}/jobInfo
Update a person's job information
# Update payment information
Source: https://docs.cadanapay.com/api-reference/workforce/persons/update-payment-information
/openapi/workforce-management.yaml put /v1/persons/{personId}/paymentInfo
Update a person's payment information. Required fields vary by payout corridor — use [GET /v1/payment-requirements](/api-reference/payments/resources/payment-corridor-requirements) as the authoritative list of fields for each currency and method.
# Update person type
Source: https://docs.cadanapay.com/api-reference/workforce/persons/update-person-type
/openapi/workforce-management.yaml put /v1/persons/{personId}/type
Switch a person's employment type between employee and contractor. The person must be in Active status. Optionally provide new compensation info. If omitted, incompatible compensation is auto-converted (salaried becomes net, fixed becomes net).
# Create
Source: https://docs.cadanapay.com/api-reference/workforce/platform/create
/openapi/workforce-management.yaml post /v1/platform/businesses
Create a business
# Create Transfer
Source: https://docs.cadanapay.com/api-reference/workforce/platform/create-transfer
/openapi/workforce-management.yaml post /v1/platform/transfers
Move money between your own business accounts — your main business and
its child businesses — internally and same-currency only. Settles
immediately with no fee.
Set `sourceTenantKey` to the business funds move from (omit it to send
from your main business) and `destinationTenantKey` to the business they
move to. Allowed movements are main → child, child → child, and
child → main.
There is no `GET` for a transfer — track it via the
[platform disbursements endpoint](/api-reference/workforce/platform/list-disbursements)
filtered to `type=TRANSFER`, matching the returned `id` or your `reference`.
# Custom Roles
Source: https://docs.cadanapay.com/api-reference/workforce/platform/custom-roles
/openapi/workforce-management.yaml get /v1/permissions/roles
Get Custom Roles
# Get Disbursement
Source: https://docs.cadanapay.com/api-reference/workforce/platform/get-disbursement
/openapi/workforce-management.yaml get /v1/platform/disbursements/{disbursementId}
Fetch a single disbursement by ID.
# List
Source: https://docs.cadanapay.com/api-reference/workforce/platform/list
/openapi/workforce-management.yaml get /v1/platform/businesses
Fetch all businesses
# List Disbursements
Source: https://docs.cadanapay.com/api-reference/workforce/platform/list-disbursements
/openapi/workforce-management.yaml get /v1/platform/disbursements
Retrieve all disbursements across all businesses under the platform. Use this endpoint for financial reconciliation across your sub-businesses.
# Cancel Reimbursement
Source: https://docs.cadanapay.com/api-reference/workforce/reimbursements/cancel-reimbursement
/openapi/workforce-management.yaml delete /v1/reimbursements/{reimbursementId}
Cancel a reimbursement. Only reimbursements that are not already approved or paid can be cancelled.
# Create Reimbursement
Source: https://docs.cadanapay.com/api-reference/workforce/reimbursements/create-reimbursement
/openapi/workforce-management.yaml post /v1/reimbursements
Create a reimbursement for a worker in your business. The reimbursement is created in a pending status and must be reviewed (via [`POST /v1/reimbursements/{reimbursementId}/review`](#tag/reimbursements/post/v1/reimbursements/{reimbursementId}/review)) before it is paid out. Each entry may optionally reference one or more `fileIds` of previously uploaded files to attach proof (e.g. receipts).
# Get Reimbursement
Source: https://docs.cadanapay.com/api-reference/workforce/reimbursements/get-reimbursement
/openapi/workforce-management.yaml get /v1/reimbursements/{reimbursementId}
Get a reimbursement's details.
# List Reimbursements
Source: https://docs.cadanapay.com/api-reference/workforce/reimbursements/list-reimbursements
/openapi/workforce-management.yaml get /v1/reimbursements
List reimbursements for your business.
# Review (Approve or Reject) Reimbursement
Source: https://docs.cadanapay.com/api-reference/workforce/reimbursements/review-approve-or-reject-reimbursement
/openapi/workforce-management.yaml post /v1/reimbursements/{reimbursementId}/review
Approve or reject a reimbursement. **Approving pays it out** — the reimbursement amount is disbursed immediately from the `sourceCurrency` wallet (subject to a balance check), and a `reimbursement.paid` webhook follows once the disbursement settles. Rejecting requires a `reason`. Only reimbursements in a pending status can be reviewed; any other reimbursement returns 400.
# Delete User
Source: https://docs.cadanapay.com/api-reference/workforce/sandbox/delete-user
/openapi/workforce-management.yaml delete /v1/sandbox/users/{userId}
Delete a user
# Deposit
Source: https://docs.cadanapay.com/api-reference/workforce/sandbox/deposit
/openapi/workforce-management.yaml post /v1/sandbox/business-deposits
Add funds to a business for payroll
# Review KYC
Source: https://docs.cadanapay.com/api-reference/workforce/sandbox/review-kyc
/openapi/workforce-management.yaml post /v1/sandbox/users/{userId}/kyc/review
Approve or reject a user's KYC in sandbox without waiting for a verification provider. Use this to finish a submission made with real details and real document file IDs — the `auto-approve` and `auto-reject` sentinels skip the documents entirely, so they cannot test that path. Each call decides one check, so approving both identity and address takes two calls. Works from any status, including `pending`.
# Create Crypto Deposit Address
Source: https://docs.cadanapay.com/api-reference/workforce/treasury/create-crypto-deposit-address
/openapi/workforce-management.yaml post /v1/businesses/{businessId}/crypto-deposit-addresses
Creates a crypto deposit address for the business on the given chain. Fails if an address already exists for that currency and chain.
# Create Deposit
Source: https://docs.cadanapay.com/api-reference/workforce/treasury/create-deposit
/openapi/workforce-management.yaml post /v1/businesses/{businessId}/deposits
Create a deposit to add funds to a business account. Only `active` external accounts can be debited; a `restricted` or unverified account fails with `400`.
# Create Reserve
Source: https://docs.cadanapay.com/api-reference/workforce/treasury/create-reserve
/openapi/workforce-management.yaml post /v1/businesses/{businessId}/reserves
Lock or release business reserves (e.g. security deposits)
# Delete External Account
Source: https://docs.cadanapay.com/api-reference/workforce/treasury/delete-external-account
/openapi/workforce-management.yaml delete /v1/businesses/{businessId}/external-accounts/{externalAccountId}
Delete an external account linked to a business
# Generate External Account Link
Source: https://docs.cadanapay.com/api-reference/workforce/treasury/generate-external-account-link
/openapi/workforce-management.yaml post /v1/businesses/{businessId}/external-accounts/link
Generate External Account Link
# Generate Withdrawal Link
Source: https://docs.cadanapay.com/api-reference/workforce/treasury/generate-withdrawal-link
/openapi/workforce-management.yaml post /v1/businesses/{businessId}/withdrawal/link
Generate a short-lived JWT-signed URL that lets a business
owner withdraw funds from their wallet to an external bank beneficiary
without signing into the dashboard.
# Get Balance Ledger
Source: https://docs.cadanapay.com/api-reference/workforce/treasury/get-balance-ledger
/openapi/workforce-management.yaml get /v1/businesses/{businessId}/transactions
Retrieve a business's balance ledger — every credit and debit on the business's balance, including funding deposits and outgoing payments, with the direction and balance effect of each entry. Use this endpoint to reconcile balance movements against your own books.
For full disbursement records of each movement — payment method, FX rate, and fee breakdown — use [Platform Disbursements](/api-reference/workforce/platform/list-disbursements) filtered by `businessId` instead.
# Get Balances
Source: https://docs.cadanapay.com/api-reference/workforce/treasury/get-balances
/openapi/workforce-management.yaml get /v1/businesses/{businessId}/balances
Get a business's account balances
# Get External Accounts
Source: https://docs.cadanapay.com/api-reference/workforce/treasury/get-external-accounts
/openapi/workforce-management.yaml get /v1/businesses/{businessId}/external-accounts
Get external accounts linked to a business
# Get Funding Details
Source: https://docs.cadanapay.com/api-reference/workforce/treasury/get-funding-details
/openapi/workforce-management.yaml get /v1/businesses/{businessId}/funding-details
Returns the business's funding details.
# Get Statement
Source: https://docs.cadanapay.com/api-reference/workforce/treasury/get-statement
/openapi/workforce-management.yaml get /v1/businesses/{businessId}/statement
Download a business's account statement for a date range. Returns JSON by default; set `format` to `csv` or `bai2` to download a file instead.
# Add Admin
Source: https://docs.cadanapay.com/api-reference/workforce/users/add-admin
/openapi/workforce-management.yaml post /v1/users/admins
Add an admin to the business account
# Attach a custom sub
Source: https://docs.cadanapay.com/api-reference/workforce/users/attach-a-custom-sub
/openapi/workforce-management.yaml put /v1/users/{userId}/sub
Append your external token subject to cadana user
# Get
Source: https://docs.cadanapay.com/api-reference/workforce/users/get
/openapi/workforce-management.yaml get /v1/users/{userId}
Fetch a user's details
# Get Balances
Source: https://docs.cadanapay.com/api-reference/workforce/users/get-balances
/openapi/workforce-management.yaml get /v1/users/{userId}/balances
Get a user's wallet balances
# Get KYC
Source: https://docs.cadanapay.com/api-reference/workforce/users/get-kyc
/openapi/workforce-management.yaml get /v1/users/{userId}/kyc
Fetch a user's kyc details
# Invite
Source: https://docs.cadanapay.com/api-reference/workforce/users/invite
/openapi/workforce-management.yaml post /v1/users/invite
Create a user for the associated person record
# List
Source: https://docs.cadanapay.com/api-reference/workforce/users/list
/openapi/workforce-management.yaml get /v1/users
Get all users
# Remove Admin
Source: https://docs.cadanapay.com/api-reference/workforce/users/remove-admin
/openapi/workforce-management.yaml delete /v1/users/admins/{userId}
Remove an admin from the business account
# Renew KYC
Source: https://docs.cadanapay.com/api-reference/workforce/users/renew-kyc
/openapi/workforce-management.yaml post /v1/users/{userId}/kyc/renewal
Submit a replacement identity document for a user whose current document is expiring, without waiting for it to be rejected. Only accepted once the document has entered its expiry window — document stage `about-to-expire`, `in-grace-period` or `out-of-grace` (reported as `expired` on the `user.kyc.expiry` webhook). Identity moves to `processing` and the verification result is delivered asynchronously on the `user.kyc.updated` webhook. Identity renewals only; address renewal is not supported.
# Resubmit KYC
Source: https://docs.cadanapay.com/api-reference/workforce/users/resubmit-kyc
/openapi/workforce-management.yaml patch /v1/users/{userId}/kyc
Resubmit KYC identity and/or address information after rejection. Identity can only be resubmitted when status is rejected. Address can only be resubmitted when status is not started or rejected.
# Submit KYC
Source: https://docs.cadanapay.com/api-reference/workforce/users/submit-kyc
/openapi/workforce-management.yaml post /v1/users/{userId}/kyc
Submit Know Your Customer (KYC) information for verification
# Submit Tax Form
Source: https://docs.cadanapay.com/api-reference/workforce/users/submit-tax-form
/openapi/workforce-management.yaml post /v1/users/{userId}/tax-forms
Submit a completed W9, W8BEN or W8BENE tax form. Provide a fileId for the signed document (from the files API) and the raw field data.
# Tax Form Download
Source: https://docs.cadanapay.com/api-reference/workforce/users/tax-form-download
/openapi/workforce-management.yaml get /v1/users/{userId}/tax-forms/{formId}/download
Download a user's tax form as a PDF. The response body is the base64-encoded PDF document (content type application/pdf), not a JSON link.
# Tax Forms
Source: https://docs.cadanapay.com/api-reference/workforce/users/tax-forms
/openapi/workforce-management.yaml get /v1/users/{userId}/tax-forms
Get a user's tax form
# List audit events
Source: https://docs.cadanapay.com/api-reference/statutory/events/list-audit-events
/openapi/statutory-compliance.yaml get /v1/statutory/events
**Beta** — contact Cadana to enable access for your business.
**Not yet implemented.**
Returns the immutable audit log. Every state change on every resource
(filing, remittance, requirement) is recorded as an event with the
actor, timestamp, and before/after state.
# Amend a previously accepted filing
Source: https://docs.cadanapay.com/api-reference/statutory/filings/amend-a-previously-accepted-filing
/openapi/statutory-compliance.yaml post /v1/statutory/filings/{filing_id}/amend
**Beta** — contact Cadana to enable access for your business.
Creates an amendment filing linked to the original. The original filing's
status remains `accepted` and gains an `amendedBy` reference to the new filing. The new
amendment filing starts in `pending` status.
# Approve a filing for submission
Source: https://docs.cadanapay.com/api-reference/statutory/filings/approve-a-filing-for-submission
/openapi/statutory-compliance.yaml post /v1/statutory/filings/{filing_id}/approve
**Beta** — contact Cadana to enable access for your business.
Approves a filing, moving it from `in_review` to `approved`. Once approved,
Cadana will submit the filing to the government authority within the
jurisdiction's processing window.
Requires all requirements to be satisfied and no active blocked reasons.
# Cancel a filing
Source: https://docs.cadanapay.com/api-reference/statutory/filings/cancel-a-filing
/openapi/statutory-compliance.yaml post /v1/statutory/filings/{filing_id}/cancel
**Beta** — contact Cadana to enable access for your business.
Cancels a filing that has not yet been submitted. Cannot cancel filings
that are already submitted, accepted, or rejected.
# Create a filing
Source: https://docs.cadanapay.com/api-reference/statutory/filings/create-a-filing
/openapi/statutory-compliance.yaml post /v1/statutory/filings
**Beta** — contact Cadana to enable access for your business.
Creates a single statutory filing. Every filing is described by a
`breakdown` array of contribution lines that sum to the filing total.
The `source` field selects who's submitting the return to the
authority:
- **`source: cadana`** (default) — Cadana will submit the return. Each
breakdown line must reference a `personId`. Provide `payrollRunIds`
as a shortcut (Cadana populates the breakdown from the tax engine)
or pass the `breakdown` explicitly if you computed amounts outside
Cadana. The filing is created in `pending` and runs the full
lifecycle through `accepted`. If requirements are missing, it
transitions to `blocked` with actionable reasons.
- **`source: external`** — You have already filed with the authority
yourself. Provide the `breakdown` plus the `filingReference` (e.g.,
the Línea de Captura from SAT). Breakdown lines may use `personId`,
`externalEmployeeId`, or neither (for business-level taxes). The
filing is created directly in `accepted` and skips the submission
lifecycle. Used by the remittance-only flow.
# Create all filings for a period
Source: https://docs.cadanapay.com/api-reference/statutory/filings/create-all-filings-for-a-period
/openapi/statutory-compliance.yaml post /v1/statutory/filings/batch
**Beta** — contact Cadana to enable access for your business.
Creates statutory filings for a business and period in a single call.
Supports two modes:
**Cadana payroll:** Provide `payrollRunIds` — Cadana determines which
filing types apply based on active workers and their compensation flags
(IMSS-eligible, INFONAVIT-eligible, etc.) and pulls amounts from the tax engine.
**External payroll:** Provide a `filings` array with explicit amounts and
person IDs per filing type. Use this when the client runs payroll outside
Cadana and just needs Cadana to file and remit.
All filings in one batch must share the same `source` — a batch is either
all `cadana` or all `external`; mixing sources returns `400`.
For example, a Mexican business with 15 employees would get up to 4 filings:
ISR, IMSS, INFONAVIT, and ISN.
# Download filing document
Source: https://docs.cadanapay.com/api-reference/statutory/filings/download-filing-document
/openapi/statutory-compliance.yaml get /v1/statutory/filings/{filing_id}/document
**Beta** — contact Cadana to enable access for your business.
Returns a signed URL to download the filing document (PDF). Only available
after the filing has been submitted.
# Get a filing
Source: https://docs.cadanapay.com/api-reference/statutory/filings/get-a-filing
/openapi/statutory-compliance.yaml get /v1/statutory/filings/{filing_id}
**Beta** — contact Cadana to enable access for your business.
Returns full filing details including amounts, status history,
blocked reasons, and linked payroll runs.
# Get compliance health summary
Source: https://docs.cadanapay.com/api-reference/statutory/filings/get-compliance-health-summary
/openapi/statutory-compliance.yaml get /v1/statutory/compliance/summary
**Beta** — contact Cadana to enable access for your business.
**Not yet implemented.**
Returns a single-call overview of the business's entire statutory
compliance posture across all jurisdictions. Includes filing counts by
status, remittance totals, upcoming deadlines, and outstanding
requirements.
This is the "how are we doing?" endpoint — designed to power compliance
dashboards without needing multiple API calls.
# List filings
Source: https://docs.cadanapay.com/api-reference/statutory/filings/list-filings
/openapi/statutory-compliance.yaml get /v1/statutory/filings
**Beta** — contact Cadana to enable access for your business.
Returns filings across all jurisdictions for a business. Use this as
both a compliance calendar ("what's due?") and a filing history
("what's done?").
The response includes a `summary` object with counts by status —
use this to power compliance health dashboards without additional
API calls.
# Create a remittance
Source: https://docs.cadanapay.com/api-reference/statutory/remittances/create-a-remittance
/openapi/statutory-compliance.yaml post /v1/statutory/remittances
**Beta** — contact Cadana to enable access for your business.
Initiates a government payment. A remittance is always tied to one or
more `accepted` filings — whether Cadana prepared them
(`source: cadana`) or you filed them externally and submitted the
totals (`source: external`). Funds are debited from your business
balance and routed to the authority through Cadana's local rails.
By default Cadana uses its internal mapping for the authority's bank
account. Provide a `paymentDetails` block — the same `paymentMethods`
object used by vendor payments and beneficiaries elsewhere in the API —
to specify the routing yourself (for example, a Mexican CLABE under
`bank.accountNumber`). Use the top-level `paymentReference` field to
set the bank attribution reference (defaults to the linked filing's
`filingReference`).
# Download remittance receipt
Source: https://docs.cadanapay.com/api-reference/statutory/remittances/download-remittance-receipt
/openapi/statutory-compliance.yaml get /v1/statutory/remittances/{remittance_id}/receipt
**Beta** — contact Cadana to enable access for your business.
Returns a signed URL to download proof of government payment.
Available after the remittance reaches `completed` status.
# Get a remittance
Source: https://docs.cadanapay.com/api-reference/statutory/remittances/get-a-remittance
/openapi/statutory-compliance.yaml get /v1/statutory/remittances/{remittance_id}
**Beta** — contact Cadana to enable access for your business.
Returns full remittance details including payment status, FX conversion
information, and proof of payment (when available).
# List remittances
Source: https://docs.cadanapay.com/api-reference/statutory/remittances/list-remittances
/openapi/statutory-compliance.yaml get /v1/statutory/remittances
**Beta** — contact Cadana to enable access for your business.
Returns government payments across jurisdictions for a business. Each
remittance is linked to one or more filings and tracks the actual money
movement to the government authority.
The response includes a `summary` object with counts by status.
# List outstanding requirements
Source: https://docs.cadanapay.com/api-reference/statutory/requirements/list-outstanding-requirements
/openapi/statutory-compliance.yaml get /v1/statutory/requirements
Returns requirements — data inputs, documents, or registrations needed
before filings can proceed.
Filter by `businessId` for a business-wide view (all categories), or
by `personId` to see what a specific worker is missing. At least one
of `businessId` or `personId` must be provided, and `countryCode` is
required whenever `personId` is used.
Requirements are generated automatically based on the business's
jurisdictions and workforce composition. They update dynamically as
workers are added or laws change.
# Get business filing data
Source: https://docs.cadanapay.com/api-reference/statutory/statutory-fields/get-business-filing-data
/openapi/statutory-compliance.yaml get /v1/statutory/businesses/{business_id}/fields/{country_code}
Returns statutory filing data for a business in a jurisdiction. The response
is a merged view: fields explicitly submitted via PUT are combined with
defaults read from the business service.
# Get person filing data
Source: https://docs.cadanapay.com/api-reference/statutory/statutory-fields/get-person-filing-data
/openapi/statutory-compliance.yaml get /v1/statutory/persons/{person_id}/fields/{country_code}
Returns statutory filing data for a person in a jurisdiction. The response
is a merged view: fields explicitly submitted via PUT are combined with
defaults read from the person service (e.g., firstName, lastName).
# Submit business filing fields
Source: https://docs.cadanapay.com/api-reference/statutory/statutory-fields/submit-business-filing-fields
/openapi/statutory-compliance.yaml put /v1/statutory/businesses/{business_id}/fields/{country_code}
Submit statutory filing fields for a business in a jurisdiction. Use this
endpoint for fields returned by the required-fields endpoint with
`entity: "business"` and `scope` containing `"filing"`.
Fields are validated against the jurisdiction's field definitions.
Unknown keys are rejected.
# Submit person filing fields
Source: https://docs.cadanapay.com/api-reference/statutory/statutory-fields/submit-person-filing-fields
/openapi/statutory-compliance.yaml put /v1/statutory/persons/{person_id}/fields/{country_code}
Submit statutory filing fields for a person in a jurisdiction. Use this
endpoint for fields returned by the required-fields endpoint with
`entity: "person"` and `scope` containing `"filing"`.
Fields are validated against the jurisdiction's field definitions.
Unknown keys are rejected.
For `document`-typed fields (e.g. `aadhaarCardFront`), upload the file
first: `POST /v1/files/upload-url` (Workforce Management API) with
`{"purpose": "statutory-document", "contentType": "image/png"}`
returns a `fileId` and a presigned `putUrl`; HTTP PUT the file bytes
to the `putUrl`, then submit the `fileId` as the field value here.
The GET endpoint returns document fields as an object with a presigned
download `url`.
# Postman Collection
Source: https://docs.cadanapay.com/resources/postman
Test Cadana APIs with Postman
Explore and test Cadana APIs using our official Postman workspace.
Access all Cadana API collections with pre-configured requests
***
## Available Collections
| Collection | Description |
| :- | :- |
| **Payments API** | Payouts, beneficiaries, FX quotes, virtual accounts |
| **Wallets API** | User wallets, transfers, user beneficiaries |
| **Workforce API** | Payroll, persons, users, compliance |
| **Tax API** | Tax calculations, statutory info |
***
## Quick Setup
### 1. Fork the Workspace
1. Open the [Cadana Developers workspace](https://www.postman.com/cadana/workspace/cadana-developers)
2. Click **Fork** on any collection you want to use
3. Select your destination workspace
### 2. Set Up Environment
Create an environment with these variables:
| Variable | Value |
| :- | :- |
| `baseUrl` | `https://dev-api.cadanapay.com` (sandbox) or `https://api.cadanapay.com` (production) |
| `bearerToken` | Your API key |
| `tenantKey` | Your business tenant key (for platform integrations) |
### 3. Make Requests
1. Select your environment from the dropdown
2. Choose a request from the collection
3. Click **Send**
***
## Multi-Tenant Requests
For platform integrations, include the tenant header:
```
X-MultiTenantKey: {{tenantKey}}
```
This is pre-configured in the collections for endpoints that require it.
***
## Staying Updated
The Postman collections are synced with our OpenAPI specifications. Fork the collections to receive updates when the API changes.
***
## Troubleshooting
### "401 Unauthorized"
* Verify `bearerToken` is set correctly
* Check you're using the right environment (sandbox vs production)
* For platform tokens, ensure `tenantKey` is set
### "400 Bad Request"
* Check required fields in the request body
* Verify data types match the expected schema
***
## Need Help?
Full endpoint documentation
Talk to our team
# Supported Countries
Source: https://docs.cadanapay.com/resources/supported-countries
Supported countries, currencies, and payment methods for funding your Cadana business account
This guide outlines how underlying businesses can fund their Cadana accounts from supported countries. Cadana's sponsor banks are located in the United States, United Kingdom, and Europe, enabling funding in USD, GBP, and EUR. Local payment methods (ACH, SEPA, Faster Payments, etc.) are only available when funding from a local bank account in the respective currency zone. Cross-border funding is supported via SWIFT.
***
## Payment Methods
| Method | Description |
| :- | :- |
| **Local USD** | ACH, Wire, Direct Debit (only from US bank accounts) |
| **Local GBP** | Faster Payments, CHAPS, BACS (only from UK bank accounts) |
| **Local EUR** | SEPA (only from SEPA zone bank accounts) |
| **Cross-border** | SWIFT (available for all supported currencies from any country) |
***
## Jurisdiction Risk Tiers
| Tier | Description | Countries |
| :- | :- | -: |
| **Standard** | Standard Know-Your-Business (KYB) or Know-Your-Customer (KYC) requirements. | 187 |
| **High-Risk (EDD Required)** | Additional identity verification and Enhanced Due Diligence required. | 34 |
| **Cadana Approval Required** | Special approval needed. Exceptions may be made with Enhanced Due Diligence. | 24 |
| **Prohibited** | Cadana does not onboard businesses or individuals from these jurisdictions. | 6 |
***
## Standard Risk (187 countries)
Standard Know-Your-Business (KYB) or Know-Your-Customer (KYC) requirements.
| Country | USD | GBP | EUR |
| :- | :- | :- | :- |
| American Samoa | ACH, Wire, Direct Debit | SWIFT only | SWIFT only |
| Andorra | SWIFT only | SWIFT only | SEPA, SWIFT |
| Anguilla | SWIFT only | SWIFT only | SWIFT only |
| Antarctica | SWIFT only | SWIFT only | SWIFT only |
| Antigua and Barbuda | SWIFT only | SWIFT only | SWIFT only |
| Argentina | SWIFT only | SWIFT only | SWIFT only |
| Armenia | SWIFT only | SWIFT only | SWIFT only |
| Australia | SWIFT only | SWIFT only | SWIFT only |
| Austria | SWIFT only | SWIFT only | SEPA, SWIFT |
| Azerbaijan | SWIFT only | SWIFT only | SWIFT only |
| Bahamas | SWIFT only | SWIFT only | SWIFT only |
| Bahrain | SWIFT only | SWIFT only | SWIFT only |
| Belgium | SWIFT only | SWIFT only | SEPA, SWIFT |
| Belize | SWIFT only | SWIFT only | SWIFT only |
| Benin | SWIFT only | SWIFT only | SWIFT only |
| Bermuda | SWIFT only | SWIFT only | SWIFT only |
| Bolivia (Plurinational State of) | SWIFT only | SWIFT only | SWIFT only |
| Bonaire, Sint Eustatius and Saba | SWIFT only | SWIFT only | SWIFT only |
| Bouvet Island | SWIFT only | SWIFT only | SWIFT only |
| Brazil | SWIFT only | SWIFT only | SWIFT only |
| British Indian Ocean Territory | SWIFT only | SWIFT only | SWIFT only |
| Brunei Darussalam | SWIFT only | SWIFT only | SWIFT only |
| Bulgaria | SWIFT only | SWIFT only | SEPA, SWIFT |
| Cabo Verde | SWIFT only | SWIFT only | SWIFT only |
| Cambodia | SWIFT only | SWIFT only | SWIFT only |
| Canada | SWIFT only | SWIFT only | SWIFT only |
| Cayman Islands | SWIFT only | SWIFT only | SWIFT only |
| Chad | SWIFT only | SWIFT only | SWIFT only |
| Chile | SWIFT only | SWIFT only | SWIFT only |
| Christmas Island | SWIFT only | SWIFT only | SWIFT only |
| Cocos (Keeling) Islands | SWIFT only | SWIFT only | SWIFT only |
| Colombia | SWIFT only | SWIFT only | SWIFT only |
| Comoros | SWIFT only | SWIFT only | SWIFT only |
| Congo | SWIFT only | SWIFT only | SWIFT only |
| Cook Islands | SWIFT only | SWIFT only | SWIFT only |
| Costa Rica | SWIFT only | SWIFT only | SWIFT only |
| Curaçao | SWIFT only | SWIFT only | SWIFT only |
| Cyprus | SWIFT only | SWIFT only | SEPA, SWIFT |
| Czechia | SWIFT only | SWIFT only | SWIFT only |
| Denmark | SWIFT only | SWIFT only | SEPA, SWIFT |
| Djibouti | SWIFT only | SWIFT only | SWIFT only |
| Dominica | SWIFT only | SWIFT only | SWIFT only |
| Dominican Republic | SWIFT only | SWIFT only | SWIFT only |
| Ecuador | SWIFT only | SWIFT only | SWIFT only |
| Egypt | SWIFT only | ✗ | ✗ |
| El Salvador | SWIFT only | SWIFT only | SWIFT only |
| Equatorial Guinea | SWIFT only | SWIFT only | SWIFT only |
| Estonia | SWIFT only | SWIFT only | SEPA, SWIFT |
| Eswatini | SWIFT only | SWIFT only | SWIFT only |
| Falkland Islands (Malvinas) | SWIFT only | SWIFT only | SWIFT only |
| Faroe Islands | SWIFT only | SWIFT only | SWIFT only |
| Fiji | SWIFT only | SWIFT only | SWIFT only |
| Finland | SWIFT only | ✗ | ✗ |
| France | SWIFT only | SWIFT only | SEPA, SWIFT |
| French Guiana | SWIFT only | SWIFT only | SEPA, SWIFT |
| French Polynesia | SWIFT only | SWIFT only | SWIFT only |
| French Southern Territories | SWIFT only | SWIFT only | SEPA, SWIFT |
| Gabon | SWIFT only | SWIFT only | SWIFT only |
| Gambia | SWIFT only | SWIFT only | SWIFT only |
| Georgia | SWIFT only | SWIFT only | SWIFT only |
| Germany | SWIFT only | SWIFT only | SEPA, SWIFT |
| Ghana | SWIFT only | SWIFT only | SWIFT only |
| Gibraltar | SWIFT only | Faster Payments, CHAPS, BACS | SWIFT only |
| Greece | SWIFT only | SWIFT only | SEPA, SWIFT |
| Greenland | SWIFT only | SWIFT only | SWIFT only |
| Grenada | SWIFT only | SWIFT only | SWIFT only |
| Guadeloupe | SWIFT only | SWIFT only | SEPA, SWIFT |
| Guam | ACH, Wire, Direct Debit | SWIFT only | SWIFT only |
| Guatemala | SWIFT only | SWIFT only | SWIFT only |
| Guernsey | SWIFT only | Faster Payments, CHAPS, BACS | SWIFT only |
| Guinea | SWIFT only | SWIFT only | SWIFT only |
| Guyana | SWIFT only | SWIFT only | SWIFT only |
| Heard Island and McDonald Islands | SWIFT only | SWIFT only | SWIFT only |
| Holy See | SWIFT only | SWIFT only | SWIFT only |
| Honduras | SWIFT only | SWIFT only | SWIFT only |
| Hong Kong | SWIFT only | SWIFT only | SWIFT only |
| Hungary | SWIFT only | ✗ | ✗ |
| Iceland | SWIFT only | SWIFT only | SEPA, SWIFT |
| India | SWIFT only | SWIFT only | SWIFT only |
| Indonesia | SWIFT only | SWIFT only | SWIFT only |
| Ireland | SWIFT only | SWIFT only | SEPA, SWIFT |
| Isle of Man | SWIFT only | Faster Payments, CHAPS, BACS | SWIFT only |
| Israel | SWIFT only | SWIFT only | SWIFT only |
| Italy | SWIFT only | SWIFT only | SEPA, SWIFT |
| Japan | SWIFT only | SWIFT only | SWIFT only |
| Jersey | SWIFT only | Faster Payments, CHAPS, BACS | SWIFT only |
| Jordan | SWIFT only | SWIFT only | SWIFT only |
| Kazakhstan | SWIFT only | SWIFT only | SWIFT only |
| Kiribati | SWIFT only | SWIFT only | SWIFT only |
| Korea, Republic of | SWIFT only | SWIFT only | SWIFT only |
| Kuwait | SWIFT only | ✗ | ✗ |
| Kyrgyzstan | SWIFT only | SWIFT only | SWIFT only |
| Latvia | SWIFT only | ✗ | ✗ |
| Lesotho | SWIFT only | SWIFT only | SWIFT only |
| Liberia | SWIFT only | SWIFT only | SWIFT only |
| Liechtenstein | SWIFT only | SWIFT only | SEPA, SWIFT |
| Lithuania | SWIFT only | SWIFT only | SEPA, SWIFT |
| Luxembourg | SWIFT only | SWIFT only | SEPA, SWIFT |
| Macao | SWIFT only | SWIFT only | SWIFT only |
| Madagascar | SWIFT only | SWIFT only | SWIFT only |
| Malawi | SWIFT only | SWIFT only | SWIFT only |
| Malaysia | SWIFT only | SWIFT only | SWIFT only |
| Maldives | SWIFT only | SWIFT only | SWIFT only |
| Mali | SWIFT only | SWIFT only | SWIFT only |
| Malta | SWIFT only | SWIFT only | SEPA, SWIFT |
| Marshall Islands | SWIFT only | SWIFT only | SWIFT only |
| Martinique | SWIFT only | SWIFT only | SEPA, SWIFT |
| Mauritania | SWIFT only | SWIFT only | SWIFT only |
| Mauritius | SWIFT only | SWIFT only | SWIFT only |
| Mayotte | SWIFT only | SWIFT only | SEPA, SWIFT |
| Mexico | SWIFT only | SWIFT only | SWIFT only |
| Micronesia, Federated States of | SWIFT only | SWIFT only | SWIFT only |
| Moldova, Republic of | SWIFT only | SWIFT only | SWIFT only |
| Mongolia | SWIFT only | SWIFT only | SWIFT only |
| Montenegro | SWIFT only | SWIFT only | SWIFT only |
| Montserrat | SWIFT only | SWIFT only | SWIFT only |
| Nauru | SWIFT only | SWIFT only | SWIFT only |
| Netherlands | SWIFT only | ✗ | ✗ |
| New Caledonia | SWIFT only | SWIFT only | SWIFT only |
| New Zealand | SWIFT only | SWIFT only | SWIFT only |
| Niue | SWIFT only | SWIFT only | SWIFT only |
| Norfolk Island | SWIFT only | SWIFT only | SWIFT only |
| Northern Mariana Islands | ACH, Wire, Direct Debit | SWIFT only | SWIFT only |
| Norway | SWIFT only | SWIFT only | SEPA, SWIFT |
| Oman | SWIFT only | SWIFT only | SWIFT only |
| Palau | SWIFT only | SWIFT only | SWIFT only |
| Panama | SWIFT only | SWIFT only | SWIFT only |
| Papua New Guinea | SWIFT only | SWIFT only | SWIFT only |
| Paraguay | SWIFT only | SWIFT only | SWIFT only |
| Peru | SWIFT only | SWIFT only | SWIFT only |
| Philippines | SWIFT only | SWIFT only | SWIFT only |
| Pitcairn | SWIFT only | SWIFT only | SWIFT only |
| Poland | SWIFT only | SWIFT only | SEPA, SWIFT |
| Portugal | SWIFT only | SWIFT only | SEPA, SWIFT |
| Puerto Rico | ACH, Wire, Direct Debit | SWIFT only | SWIFT only |
| Romania | SWIFT only | SWIFT only | SEPA, SWIFT |
| Rwanda | SWIFT only | SWIFT only | SWIFT only |
| Réunion | SWIFT only | SWIFT only | SEPA, SWIFT |
| Saint Barthélemy | SWIFT only | SWIFT only | SEPA, SWIFT |
| Saint Helena, Ascension and Tristan da Cunha | SWIFT only | SWIFT only | SWIFT only |
| Saint Kitts and Nevis | SWIFT only | SWIFT only | SWIFT only |
| Saint Lucia | SWIFT only | SWIFT only | SWIFT only |
| Saint Martin (French part) | SWIFT only | SWIFT only | SEPA, SWIFT |
| Saint Pierre and Miquelon | SWIFT only | SWIFT only | SEPA, SWIFT |
| Saint Vincent and the Grenadines | SWIFT only | SWIFT only | SWIFT only |
| Samoa | SWIFT only | SWIFT only | SWIFT only |
| San Marino | SWIFT only | SWIFT only | SEPA, SWIFT |
| Sao Tome and Principe | SWIFT only | SWIFT only | SWIFT only |
| Saudi Arabia | SWIFT only | SWIFT only | SWIFT only |
| Serbia | SWIFT only | SWIFT only | SWIFT only |
| Seychelles | SWIFT only | SWIFT only | SWIFT only |
| Sierra Leone | SWIFT only | SWIFT only | SWIFT only |
| Singapore | SWIFT only | SWIFT only | SWIFT only |
| Sint Maarten (Dutch part) | SWIFT only | SWIFT only | SWIFT only |
| Slovakia | SWIFT only | SWIFT only | SEPA, SWIFT |
| Slovenia | SWIFT only | ✗ | ✗ |
| Solomon Islands | SWIFT only | SWIFT only | SWIFT only |
| South Georgia and the South Sandwich Islands | SWIFT only | SWIFT only | SWIFT only |
| Spain | SWIFT only | SWIFT only | SEPA, SWIFT |
| Sri Lanka | SWIFT only | SWIFT only | SWIFT only |
| Suriname | SWIFT only | SWIFT only | SWIFT only |
| Svalbard and Jan Mayen | SWIFT only | SWIFT only | SWIFT only |
| Sweden | SWIFT only | SWIFT only | SEPA, SWIFT |
| Switzerland | SWIFT only | SWIFT only | SEPA, SWIFT |
| Taiwan, Province of China | SWIFT only | SWIFT only | SWIFT only |
| Tajikistan | SWIFT only | SWIFT only | SWIFT only |
| Thailand | SWIFT only | SWIFT only | SWIFT only |
| Timor-Leste | SWIFT only | SWIFT only | SWIFT only |
| Togo | SWIFT only | SWIFT only | SWIFT only |
| Tokelau | SWIFT only | SWIFT only | SWIFT only |
| Tonga | SWIFT only | SWIFT only | SWIFT only |
| Tunisia | SWIFT only | ✗ | ✗ |
| Turkmenistan | SWIFT only | SWIFT only | SWIFT only |
| Turks and Caicos Islands | SWIFT only | SWIFT only | SWIFT only |
| Tuvalu | SWIFT only | SWIFT only | SWIFT only |
| United Arab Emirates | SWIFT only | SWIFT only | SWIFT only |
| United Kingdom of Great Britain and Northern Ireland | SWIFT only | SWIFT only | SWIFT only |
| United States Minor Outlying Islands | SWIFT only | SWIFT only | SWIFT only |
| United States of America | ACH, Wire, Direct Debit | SWIFT only | SWIFT only |
| Uruguay | SWIFT only | SWIFT only | SWIFT only |
| Uzbekistan | SWIFT only | SWIFT only | SWIFT only |
| Virgin Islands (British) | SWIFT only | SWIFT only | SWIFT only |
| Virgin Islands (U.S.) | SWIFT only | SWIFT only | SWIFT only |
| Wallis and Futuna | SWIFT only | SWIFT only | SWIFT only |
| Western Sahara | SWIFT only | SWIFT only | SWIFT only |
| Zambia | SWIFT only | SWIFT only | SWIFT only |
| Åland Islands | SWIFT only | SWIFT only | SEPA, SWIFT |
***
## High-Risk — EDD Required (34 countries)
Additional identity verification and Enhanced Due Diligence required.
| Country | USD | GBP | EUR |
| :- | :- | :- | :- |
| Albania | SWIFT only | SWIFT only | SWIFT only |
| Angola | SWIFT only | SWIFT only | SWIFT only |
| Barbados | SWIFT only | SWIFT only | SWIFT only |
| Bhutan | ✗ | SWIFT only | SWIFT only |
| Bosnia-Herzegovina | SWIFT only | SWIFT only | SWIFT only |
| Botswana | SWIFT only | SWIFT only | SWIFT only |
| Burkina Faso | SWIFT only | SWIFT only | SWIFT only |
| Burundi | ✗ | SWIFT only | SWIFT only |
| Cameroon | SWIFT only | SWIFT only | SWIFT only |
| Central Republic of Africa | SWIFT only | ✗ | ✗ |
| Cote D’Ivoire | SWIFT only | SWIFT only | SWIFT only |
| Croatia | SWIFT only | SWIFT only | SEPA, SWIFT |
| Eritrea | SWIFT only | SWIFT only | SWIFT only |
| Ethiopia | SWIFT only | SWIFT only | SWIFT only |
| Guinea Bissau | ✗ | SWIFT only | SWIFT only |
| Jamaica | SWIFT only | SWIFT only | SWIFT only |
| Kenya | SWIFT only | SWIFT only | SWIFT only |
| Kosovo | ✗ | SWIFT only | SWIFT only |
| Lao People’s Democratic Republic | SWIFT only | SWIFT only | SWIFT only |
| Monaco | SWIFT only | SWIFT only | SEPA, SWIFT |
| Namibia | SWIFT only | SWIFT only | SWIFT only |
| Niger | ✗ | SWIFT only | SWIFT only |
| Nigeria | SWIFT only | SWIFT only | SWIFT only |
| Pakistan | SWIFT only | SWIFT only | SWIFT only |
| Senegal | SWIFT only | SWIFT only | SWIFT only |
| South Africa | SWIFT only | SWIFT only | SWIFT only |
| Tanzania | SWIFT only | SWIFT only | SWIFT only |
| Trinidad & Tobago | SWIFT only | SWIFT only | SWIFT only |
| Turkey | SWIFT only | SWIFT only | SWIFT only |
| Uganda | SWIFT only | SWIFT only | SWIFT only |
| Ukraine | SWIFT only | SWIFT only | SWIFT only |
| Vanuatu | SWIFT only | SWIFT only | SWIFT only |
| Vietnam | SWIFT only | ✗ | ✗ |
| Zimbabwe | ✗ | SWIFT only | SWIFT only |
***
## Cadana Approval Required (24 countries)
Special approval needed. Exceptions may be made with Enhanced Due Diligence.
| Country | USD | GBP | EUR |
| :- | :- | :- | :- |
| Afghanistan | ✗ | ✗ | ✗ |
| Algeria | ✗ | ✗ | ✗ |
| Bangladesh | ✗ | ✗ | ✗ |
| Belarus | ✗ | ✗ | ✗ |
| China | ✗ | ✗ | ✗ |
| Congo, the Democratic Republic | ✗ | ✗ | ✗ |
| Gaza Strip | ✗ | ✗ | ✗ |
| Haiti | ✗ | ✗ | ✗ |
| Iraq | ✗ | ✗ | ✗ |
| Lebanon | ✗ | ✗ | ✗ |
| Libya | ✗ | ✗ | ✗ |
| Morocco | ✗ | ✗ | ✗ |
| Mozambique | ✗ | ✗ | ✗ |
| Nepal | ✗ | ✗ | ✗ |
| Nicaragua | ✗ | ✗ | ✗ |
| North Macedonia | ✗ | ✗ | ✗ |
| Qatar | ✗ | ✗ | ✗ |
| Russian Federation | ✗ | ✗ | ✗ |
| Somalia | ✗ | ✗ | ✗ |
| South Sudan | ✗ | ✗ | ✗ |
| Sudan | ✗ | ✗ | ✗ |
| Venezuela | ✗ | ✗ | ✗ |
| West Bank (Palestinian Territory, Occupied) | ✗ | ✗ | ✗ |
| Yemen | ✗ | ✗ | ✗ |
***
## Prohibited Jurisdictions (6)
Cadana does not onboard businesses or individuals from these jurisdictions.
| Country | USD | GBP | EUR |
| :- | :- | :- | :- |
| Cuba | ✗ | ✗ | ✗ |
| Iran, Islamic Republic of | ✗ | ✗ | ✗ |
| Myanmar | ✗ | ✗ | ✗ |
| North Korea (Democratic People's Republic of Korea) | ✗ | ✗ | ✗ |
| Syria | ✗ | ✗ | ✗ |
| Ukrainian Territories (Crimea, Sevastopol, Donetsk, Kherson, Luhansk, Zaporizhzhia) | ✗ | ✗ | ✗ |
***
For questions about funding your Cadana account, please contact your account representative.
## Related
Step-by-step guide to funding via bank transfer, ACH, or crypto
Business verification process and required documents
User identity verification flow and statuses