> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cadanapay.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Pay Workers via Payroll

> Create, save, and approve payrolls to pay workers through Cadana

export const ApiExample = ({method = "GET", path, params, body, reference, tenantKey}) => {
  const baseUrl = "https://api.cadanapay.com";
  const query = params ? "?" + Object.entries(params).map(([k, v]) => `${k}=${v}`).join("&") : "";
  const url = `${baseUrl}${path}${query}`;
  const isPlatformPath = (/^\/(v1\/)?platform(\/|$)/).test(path || "");
  const includeTenantKey = tenantKey === undefined ? !isPlatformPath : tenantKey;
  let curl = `curl -X ${method} '${url}' \\\n  -H 'Authorization: Bearer YOUR_API_KEY'`;
  if (includeTenantKey) {
    curl += ` \\\n  -H 'X-MultiTenantKey: YOUR_BUSINESS_TENANT_KEY'`;
  }
  if (body) {
    const formatted = JSON.stringify(body, null, 2);
    curl += ` \\\n  -H 'Content-Type: application/json' \\\n  -d '${formatted}'`;
  }
  return <div>
      <CodeBlock language="bash" filename="bash" wrap>
        {curl}
      </CodeBlock>
      {reference && <div style={{
    marginTop: "-0.5rem",
    marginBottom: "1rem"
  }}>
          <a href={reference} style={{
    fontSize: "0.875rem"
  }}>
            Try it in the playground →
          </a>
        </div>}
    </div>;
};

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

<Steps>
  <Step title="API key from Dashboard">
    Get your API key from the [Cadana Dashboard](https://app.cadanapay.com). See [Authentication](/authentication) for details.
  </Step>

  <Step title="Compliance-approved business">
    Your business must have completed KYB verification. See [KYB Requirements](/platform/kyb-requirements).
  </Step>

  <Step title="Account funded">
    Your business account must have sufficient funds to cover the payroll. See [Fund Your Account](/fund-your-account).
  </Step>

  <Step title="Onboarded workers">
    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>
</Steps>

***

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

<ApiExample method="POST" path="/v1/payrolls" body={{ workerType: "CONTRACTOR", type: "ONE_OFF" }} reference="/api-reference/workforce/payrolls/create" />

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

<ApiExample method="POST" path="/v1/payrolls/a1b2c3d4-e5f6-7890-abcd-ef0123456789/save" body={{ payrollDate: "2026-02-28", payPeriod: { fromDate: "2026-02-01", toDate: "2026-02-28" }, entries: [{ personId: "8ef9a712-cdae-4110-b1ea-9ba95abbee6e", salary: { amount: 100000, currency: "USD" } }, { personId: "3c7d9e21-fa4b-4a12-bc89-0d1234567890", salary: { amount: 75000, currency: "USD" } }] }} reference="/api-reference/workforce/payrolls/save" />

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

<Tip>
  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.
</Tip>

<Note>
  Amounts are in the **lowest denomination** of the currency. For USD, `100000` = \$1,000.00. For GHS, `500000` = GHS 5,000.00.
</Note>

<Tip>
  You can re-save a payroll to update entries before approving. Each save replaces the previous entries.
</Tip>

<Tip>
  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.
</Tip>

### 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`.

<Note>
  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.
</Note>

<Note>
  **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)
</Note>

### 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" } }
  ]
}
```

<Note>
  Custom fee items do not participate in revenue share calculations. Validation returns `400` for empty names, non-positive amounts, or missing currency.
</Note>

### 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                            |

<Tip>
  To extract the error type programmatically, split the param value on `"] "` — the text before it is the error type (without the leading `[`).
</Tip>

***

## Step 3: Approve

Once you're satisfied with the entries, approve the payroll. This triggers fund collection and schedules disbursement.

<ApiExample method="POST" path="/v1/payrolls/a1b2c3d4-e5f6-7890-abcd-ef0123456789/approve" reference="/api-reference/workforce/payrolls/approve" />

Returns `204` on success. The payroll status moves to `approved`.

After approval:

* If the business has **insufficient funds**, the payroll moves to `awaiting funds`. If a bank account is connected, an ACH debit initiates automatically.
* If the scheduled date is **in the future**, the payroll will be processed on the scheduled day.
* Once funds are available, the payroll processes and disburses payments to workers automatically.

***

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

<ApiExample method="GET" path="/v1/payrolls/a1b2c3d4-e5f6-7890-abcd-ef0123456789" reference="/api-reference/workforce/payrolls/get" />

**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" }
    }
  ]
}
```

<Note>
  Enum values are returned in mixed case (`Contractor`, `One_off`, `Completed`) — compare them case-insensitively.
</Note>

<Tip>
  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.
</Tip>

<Note>
  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.
</Note>

### 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/global-tax/calculate-taxes-for-person) and [`POST /v1/tax/estimate`](/api-reference/global-tax/estimate-taxes).

**Employee response example:**

```json theme={null}
{
  "payrollId": "7c4f1d36-2b9a-4bca-91e7-2b1f1e6e7c11",
  "workerType": "EMPLOYEE",
  "type": "REGULAR",
  "status": "Pending Submission",
  "payrollDate": "2021-06-26",
  "debit": { "amount": 31947900, "currency": "PHP" },
  "gross": { "amount": 31947900, "currency": "PHP" },
  "net":   { "amount": 31947900, "currency": "PHP" },
  "tax":                   { "amount": 0, "currency": "PHP" },
  "pension":               { "amount": 0, "currency": "PHP" },
  "statutoryDeductions":   { "amount": 0, "currency": "PHP" },
  "employerContributions": { "amount": 0, "currency": "PHP" },
  "payPeriod": { "fromDate": "2021-06-01", "toDate": "2021-06-30" },
  "entries": [
    {
      "personId": "04a8977d-5d99-4b28-8de4-8161401ca3fa",
      "salary": { "amount": 75000, "currency": "PHP" },
      "bonus":  { "amount": 10000, "currency": "PHP" },
      "gross":  { "amount": 85000, "currency": "PHP" },
      "net":    { "amount": 75704, "currency": "PHP" },
      "allowances": [
        { "name": "Transport", "isTaxable": true, "amount": { "amount": 5000,  "currency": "PHP" } },
        { "name": "Housing",   "isTaxable": true, "amount": { "amount": 10000, "currency": "PHP" } }
      ],
      "deductions": [
        { "name": "SSS",             "isStatutory": true,  "amount": { "amount": 4125, "currency": "PHP" } },
        { "name": "Bonus Tax",       "isStatutory": false, "amount": { "amount": 500,  "currency": "PHP" } },
        { "name": "Withholding Tax", "isStatutory": true,  "amount": { "amount": 4671, "currency": "PHP" } }
      ],
      "employerContributions": [
        { "name": "SSS", "isStatutory": false, "amount": { "amount": 9750, "currency": "PHP" } }
      ]
    }
  ]
}
```

<Note>
  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.
</Note>

***

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

<ApiExample method="GET" path="/v1/payrolls/a1b2c3d4-e5f6-7890-abcd-ef0123456789/payslip-links" reference="/api-reference/workforce/payrolls/get-payslip-links" />

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

<ApiExample method="GET" path="/v1/payrolls/a1b2c3d4-e5f6-7890-abcd-ef0123456789/payslip-links" params={{ format: "zip" }} reference="/api-reference/workforce/payrolls/get-payslip-links" />

<Warning>
  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.
</Warning>

<Note>
  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.
</Note>

<Tip>
  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).
</Tip>

## List All Payrolls

<ApiExample method="GET" path="/v1/payrolls" reference="/api-reference/workforce/payrolls/list" />

**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 → Scheduled → Processing → Completed
                                   ↑
                             Awaiting Funds
```

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

<CardGroup cols={2}>
  <Card title="Payroll Lifecycle" icon="arrows-rotate" href="/workforce/payroll-lifecycle">
    Full status flow, webhooks, and multi-currency handling
  </Card>

  <Card title="Onboard Workers" icon="user-plus" href="/workforce/onboarding-workers">
    Set up Person records and payment methods
  </Card>

  <Card title="Webhooks" icon="bell" href="/reference/webhooks">
    Real-time notifications for payroll and transactions
  </Card>

  <Card title="Workforce API Reference" icon="code" href="/api-reference/workforce">
    Full API documentation
  </Card>
</CardGroup>
