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

# Payroll

> Pay a whole team in one action: build a salary batch, validate every beneficiary against their bank, then approve once to disburse.

## Overview

The Payroll API turns a list of staff and salaries into a single approved
disbursement. It exists so you stop looping over
[`POST /money-transfer/send`](/money-transfer) once per employee, which gives you
no batch-level total, no way to check funding before you start, and no way to
stop halfway.

The flow is always the same: build a batch, let Hyparrow validate every
beneficiary against their bank, confirm the wallet covers it, then approve once.

<Note>
  **Base URL:** `https://api.hyparrow.cloud/api/v1`

  **Auth:** every request requires `X-API-Key` and `X-API-Secret` headers.

  **Sandbox:** point at `https://sandbox.hyparrow.cloud/api/v1` and use your
  `pk_test_` key. Sandbox batches run the full lifecycle — reserve, send, settle,
  webhook — without touching a live bank, so you can exercise an entire payroll
  end to end.
</Note>

<Warning>
  **Amounts are in kobo.** Every amount on this API is an integer in minor units:
  `25000000` means ₦250,000.00. Uploaded **files** are the one exception — a
  spreadsheet column is read as Naira, because that is what a person typed into it.
</Warning>

## How the money moves

This is the part worth reading before you integrate, because it differs from a
single transfer.

<Steps>
  <Step title="Approval reserves the whole batch at once">
    When you approve, Hyparrow debits **principal + fees for the entire batch**
    from your wallet in one atomic operation and holds it as a reservation. Your
    `balance` drops immediately; the held amount appears as `reservedBalance`.
  </Step>

  <Step title="Each payment draws from the reservation">
    Beneficiary transfers are submitted one at a time, each drawing its own
    principal and fee out of the reservation. Each gets its **own transfer code**
    while staying linked to the batch reference.
  </Step>

  <Step title="Anything that never leaves is released">
    A beneficiary the bank rejects is reversed back into the reservation. When the
    batch closes, everything unspent is released to your spendable balance.
  </Step>
</Steps>

Reserving up front is what lets Hyparrow promise that an approved payroll can
finish. The alternative — debiting per employee as each goes out — allows an
unrelated spend to land between employee 40 and employee 41 and strand a payroll
half-paid, with no point at which the shortfall could have been refused.

<Warning>
  **Approval always rests on the account owner's two-factor authentication.**
  `POST /payroll/batches/{id}/approve` takes either a current TOTP code (or an unused
  recovery code) from the account owner, or, for integrations, an
  [API approval mandate](#api-approval-mandates) the owner armed from the dashboard
  with their code. An API key on its own can never disburse a payroll.
</Warning>

## Quickstart

Create a batch with beneficiaries inline. Hyparrow validates every row and
resolves every account name before returning.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.hyparrow.cloud/api/v1/payroll/batches" \
    -H "X-API-Key: $HYPARROW_KEY" \
    -H "X-API-Secret: $HYPARROW_SECRET" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "August 2026 salaries",
      "payPeriod": "2026-08",
      "clientReference": "payroll-aug-2026",
      "items": [
        {
          "employeeRef": "EMP001",
          "name": "Adaobi Nwosu",
          "bankCode": "044",
          "accountNumber": "0123456789",
          "amount": 25000000,
          "narration": "August 2026 salary"
        },
        {
          "employeeRef": "EMP002",
          "name": "Musa Ibrahim",
          "bankCode": "058",
          "accountNumber": "0234567891",
          "amount": 18000000
        }
      ]
    }'
  ```

  ```js Node theme={null}
  const res = await fetch("https://api.hyparrow.cloud/api/v1/payroll/batches", {
    method: "POST",
    headers: {
      "X-API-Key": process.env.HYPARROW_KEY,
      "X-API-Secret": process.env.HYPARROW_SECRET,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: "August 2026 salaries",
      payPeriod: "2026-08",
      clientReference: "payroll-aug-2026",
      items: [
        { employeeRef: "EMP001", name: "Adaobi Nwosu", bankCode: "044",
          accountNumber: "0123456789", amount: 25_000_000 },
        { employeeRef: "EMP002", name: "Musa Ibrahim", bankCode: "058",
          accountNumber: "0234567891", amount: 18_000_000 },
      ],
    }),
  });
  const { data: batch } = await res.json();
  ```
</CodeGroup>

```json Response theme={null}
{
  "success": true,
  "data": {
    "id": "6f1c...",
    "reference": "PRL20260820K7XQ2M",
    "name": "August 2026 salaries",
    "payPeriod": "2026-08",
    "clientReference": "payroll-aug-2026",
    "status": "ready",
    "totalItems": 2,
    "validItems": 2,
    "invalidItems": 0,
    "totalAmount": 43000000,
    "totalFees": 10000,
    "items": [
      {
        "id": "a1b2...",
        "rowNumber": 1,
        "employeeRef": "EMP001",
        "name": "Adaobi Nwosu",
        "bankCode": "044",
        "bankName": "Access Bank",
        "accountNumber": "0123456789",
        "resolvedAccountName": "NWOSU ADAOBI",
        "nameMatch": "exact",
        "amount": 25000000,
        "fee": 5000,
        "status": "valid",
        "validationErrors": []
      }
    ]
  }
}
```

`reference` is the batch-level reference every payment links back to. Send your
own `clientReference` too: it is stored verbatim and echoed on every payroll
webhook, so a create call whose response you never received still leaves you
holding a key Hyparrow recognises.

## Validation

Every row is checked before a batch can be approved. Nothing is ever silently
corrected — a row that cannot be paid is returned marked `invalid` with the
reasons in `validationErrors`, and the merchant fixes it.

| Check           | What fails it                                                        |
| --------------- | -------------------------------------------------------------------- |
| Required fields | A missing name, account number or amount                             |
| Account number  | Not exactly 10 digits, or not all digits                             |
| Bank            | A `bankCode` we do not support, or a `bankName` that matches no bank |
| Amount          | Zero, negative, or below the ₦100 minimum per payment                |
| Duplicates      | The same bank + account appearing on more than one row               |
| Account name    | The bank says the account belongs to someone else                    |

### Account name matching

Hyparrow resolves every account number with the bank and compares the returned
name against the name on the row. The result is in `nameMatch`:

| Value        | Meaning                                                                                                                                                                             |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `exact`      | Every token of one name appears in the other. Order and a middle name on one side only are both fine — banks record `SURNAME FIRSTNAME`, payroll sheets record `Firstname Surname`. |
| `partial`    | Some tokens match, some do not. Plausibly the same person; needs a look.                                                                                                            |
| `mismatch`   | The account belongs to someone else. Reported as a validation error.                                                                                                                |
| `unverified` | Neither provider could answer. Not a failure — we do not block your payroll on our own dependency being down.                                                                       |

<Warning>
  Approving a batch containing `partial` or `unverified` rows requires
  `acknowledgeNameMismatches: true`. Without it the request is rejected and lists
  the beneficiaries in question. A salary paid into the wrong account is not
  recoverable through this API, so the acknowledgement is deliberate rather than a
  formality.
</Warning>

## Uploading a file

`POST /payroll/batches/upload` accepts a CSV or XLSX file as multipart form data
under the `file` field. `GET /payroll/template` returns a ready-made CSV template.

```bash theme={null}
curl -X POST "https://api.hyparrow.cloud/api/v1/payroll/batches/upload" \
  -H "X-API-Key: $HYPARROW_KEY" \
  -H "X-API-Secret: $HYPARROW_SECRET" \
  -F "file=@august-payroll.csv" \
  -F "name=August 2026 salaries" \
  -F "payPeriod=2026-08"
```

Column headers are matched loosely — `Account Number`, `account_number`, `Acct No`
and `NUBAN` all resolve to the same field — and the header row is located even
when the export opens with a company name or a title. Currency symbols, thousands
separators and the leading apostrophe Excel adds to preserve a leading zero are
all handled.

A row whose amount is unreadable is **still returned**, marked invalid with the
reason, so you see every problem in one pass rather than one per re-upload. Limits:
5 MB, `.csv` / `.xlsx`.

## Checking funding

Call `GET /payroll/batches/{id}/funding` before you prompt anyone for a code.

```json theme={null}
{
  "success": true,
  "data": {
    "totalAmount": 43000000,
    "totalFees": 10000,
    "totalRequired": 43010000,
    "walletBalance": 40000000,
    "reservedBalance": 0,
    "shortfall": 3010000,
    "funded": false
  }
}
```

If you approve an underfunded batch, the API answers **402 Payment Required** with
the same three figures in `data`, so you can tell the merchant exactly how much to
add without parsing an error string.

## Approving

```bash theme={null}
curl -X POST "https://api.hyparrow.cloud/api/v1/payroll/batches/{id}/approve" \
  -H "X-API-Key: $HYPARROW_KEY" \
  -H "X-API-Secret: $HYPARROW_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "code": "123456", "acknowledgeNameMismatches": false }'
```

Approval re-validates the batch before reserving, so the numbers being held are
the numbers being approved even if days have passed since the upload. Invalid rows
are marked `skipped` and never paid. The response returns immediately with the
batch in `approved`; submission continues in the background.

Send **exactly one** of `code` or `mandateId`. Sending both, or neither, is a `400`.

## API approval mandates

If your product assembles payroll and your customer (the employer) approves it
**in your product**, your server cannot type the Hyparrow owner's TOTP code, and
it should not hold their authenticator secret. An **approval mandate** covers this.
The owner arms it once from the dashboard with their code, and it then lets **one
API key** approve the batches that key creates, within limits the owner sets.

<Steps>
  <Step title="The owner arms a mandate in the dashboard">
    Under **Payroll → Approval mandates**, the owner picks the API key, sets the
    limits, and confirms with their authenticator code. The mandate's `id` is what
    your integration sends. Arming is **dashboard-only**: no API key can arm a
    mandate, including its own.
  </Step>

  <Step title="Your integration creates and validates the batch with that key">
    Use the normal batch endpoints. A mandate only ever approves batches created
    with the same API key it is bound to.
  </Step>

  <Step title="Approve with the mandate and name your approver">
    Send `mandateId` plus `externalApproval`, which records who approved the
    payroll in your product. It is stored on the batch and echoed on
    `payroll.batch.approved`, so every run is attributable to a person at the
    employer.
  </Step>
</Steps>

```bash theme={null}
curl -X POST "https://api.hyparrow.cloud/api/v1/payroll/batches/{id}/approve" \
  -H "X-API-Key: $HYPARROW_KEY" \
  -H "X-API-Secret: $HYPARROW_SECRET" \
  -H "Idempotency-Key: approve-PRL20260925K7XQ2M" \
  -H "Content-Type: application/json" \
  -d '{
    "mandateId": "3f9a1c2e-5b7d-4e8f-9a0b-1c2d3e4f5a6b",
    "externalApproval": {
      "approverName": "Ada Obi",
      "approverEmail": "ada@employer.ng",
      "approvedAt": "2026-09-25T09:12:00Z",
      "reference": "enp-approval-881"
    },
    "acknowledgeNameMismatches": false
  }'
```

| `externalApproval` field | Required         | Description                                      |
| ------------------------ | ---------------- | ------------------------------------------------ |
| `approverName`           | yes              | The person who approved in your product          |
| `approverEmail`          | one of these two | Their email                                      |
| `approverId`             | one of these two | Their id in your product                         |
| `approvedAt`             | yes              | When they approved (RFC 3339), not in the future |
| `reference`              | yes              | Your own id for that approval                    |

### What a mandate bounds

| Limit             | Set by the owner when arming                                                            |
| ----------------- | --------------------------------------------------------------------------------------- |
| API key           | Exactly one. Other keys on the account cannot use it                                    |
| Per-batch ceiling | `maxBatchAmount`, principal + fees, in kobo. Cannot exceed your per-batch payroll limit |
| Monthly ceiling   | `maxPeriodAmount`, optional, per UTC calendar month, in kobo                            |
| Number of batches | `maxBatches`, 1 to 100                                                                  |
| Expiry            | `expiresAt`, at most 366 days ahead                                                     |

Your [account limits](#limits) still apply on top. Name-mismatch rows still need
`acknowledgeNameMismatches: true`, exactly as with a code. A mandate is spent
atomically with the approval: two approvals racing on one mandate cannot both use
its last batch or its remaining monthly allowance, and a failed approval (for
example a `402` for insufficient funds) does not use it up.

A batch the mandate does not cover is refused with **`403`** and
`code: "MANDATE_NOT_USABLE"`, and the message says which limit it hit. Nothing is
reserved or sent. Fall back to an approval with a code, or ask the owner to arm a
new mandate.

The owner gets an in-app notification when a mandate is armed and every time one
approves a payroll.

### Managing mandates

| Method | Path                                      | Who       | Purpose                                                                         |
| ------ | ----------------------------------------- | --------- | ------------------------------------------------------------------------------- |
| `GET`  | `/payroll/mandates`                       | API key   | Mandates bound to the calling key, with `batchesUsed`, `expiresAt`, `revokedAt` |
| `POST` | `/payroll/mandates/{id}/revoke`           | API key   | Revoke a mandate bound to the calling key, effective immediately                |
| `GET`  | `/dashboard/payroll/mandates`             | Dashboard | Every mandate on the account                                                    |
| `POST` | `/dashboard/payroll/mandates`             | Dashboard | Arm a mandate (needs `code`)                                                    |
| `POST` | `/dashboard/payroll/mandates/{id}/revoke` | Dashboard | Revoke any mandate                                                              |

<Warning>
  **A mandate turns an API key into a payroll approver, within its limits.** Anyone
  holding that key can approve payrolls the key creates, up to the mandate's
  ceilings, until it expires or is revoked. Use a dedicated key for payroll, keep its
  secret server-side, restrict it by IP if you can, set the ceilings to what one real
  payroll needs, and revoke the mandate the moment the key may be exposed. Revoking
  takes effect immediately and needs no code.
</Warning>

## Batch statuses

| Status                    | Meaning                                                                      |
| ------------------------- | ---------------------------------------------------------------------------- |
| `draft`                   | Being assembled. Items can be added, edited and removed.                     |
| `validating`              | A validation pass is running.                                                |
| `needs_attention`         | Validation finished and at least one row is invalid, or a limit is exceeded. |
| `ready`                   | Every row passed. Awaiting approval.                                         |
| `approved`                | Authorized, funds reserved, queued for submission.                           |
| `processing`              | Payments are being submitted.                                                |
| `awaiting_settlement`     | All submitted; waiting on bank verdicts.                                     |
| `completed`               | Every payment delivered.                                                     |
| `completed_with_failures` | Terminal, but at least one payment did not deliver.                          |
| `failed`                  | The run could not start.                                                     |
| `canceled`                | Withdrawn before any money moved.                                            |

## Item statuses

| Status              | Meaning                                                                                                       |
| ------------------- | ------------------------------------------------------------------------------------------------------------- |
| `valid` / `invalid` | Passed or failed validation.                                                                                  |
| `queued`            | Approved, waiting for submission.                                                                             |
| `sent`              | Accepted by the bank network. **Not** a confirmed credit.                                                     |
| `delivered`         | The bank confirmed the beneficiary was credited.                                                              |
| `failed`            | The bank declined, or submission failed.                                                                      |
| `unresolved`        | Submitted, but the bank still has no record past our give-up window. Escalated to a human — never guessed at. |
| `skipped`           | Excluded from the run (invalid at approval time).                                                             |

<Note>
  `sent` is not success. Like a single transfer, the local record is written when
  your wallet is debited, before the network confirms anything. Wait for
  `delivered`, or for the `payroll.item.settled` webhook.
</Note>

## Webhooks

Configure your URL with `POST /webhooks/settings` (see [Webhooks](/webhooks)).
Payroll emits four events, all using the standard `event` / `timestamp` / `data`
envelope.

| Event                     | When                                       |
| ------------------------- | ------------------------------------------ |
| `payroll.batch.approved`  | Funds reserved, the run has started        |
| `payroll.item.settled`    | One beneficiary was confirmed credited     |
| `payroll.item.failed`     | One beneficiary payment did not go through |
| `payroll.batch.completed` | Every item reached a terminal state        |

```json theme={null}
{
  "event": "payroll.item.settled",
  "timestamp": "2026-08-25T09:14:07Z",
  "data": {
    "batchReference": "PRL20260820K7XQ2M",
    "batchId": "6f1c...",
    "clientReference": "payroll-aug-2026",
    "itemId": "a1b2...",
    "transferCode": "23191775370145881",
    "employeeRef": "EMP001",
    "beneficiaryName": "Adaobi Nwosu",
    "accountNumber": "0123456789",
    "bankCode": "044",
    "bankName": "Access Bank",
    "currency": "NGN",
    "amount": 25000000,
    "fee": 5000,
    "status": "delivered",
    "responseCode": "90000",
    "providerRef": "ISW-...",
    "sentAt": "2026-08-25T09:11:02Z",
    "settledAt": "2026-08-25T09:14:05Z"
  }
}
```

Every item event carries **both** `transferCode` and `batchReference`, so you can
reconcile per employee or per run without maintaining your own mapping.

## The staff directory

Instead of sending beneficiaries with every batch, keep them in a directory and
generate a batch from it. Accounts are verified once and reused, so a monthly run
does not re-resolve the same account names every month.

| Method   | Path                        | Purpose                        |
| -------- | --------------------------- | ------------------------------ |
| `GET`    | `/payroll/employees`        | List staff                     |
| `POST`   | `/payroll/employees`        | Add one (verifies the account) |
| `POST`   | `/payroll/employees/import` | Bulk add from CSV/XLSX         |
| `PUT`    | `/payroll/employees/{id}`   | Update one                     |
| `DELETE` | `/payroll/employees/{id}`   | Remove from the directory      |

Then create a batch with `{"name": "...", "payPeriod": "2026-08", "fromDirectory": true}`
to bill every active employee at their `defaultAmount`.

## Limits

`GET /payroll/limits` returns the caps in force for your account.

```json theme={null}
{
  "success": true,
  "data": {
    "maxBeneficiaries": 500,
    "maxBatchAmount": 1000000000,
    "maxDailyAmount": 5000000000,
    "approvedToday": 0,
    "remainingToday": 5000000000,
    "minAmountPerItem": 10000,
    "requiresTwoFactor": true
  }
}
```

Three independent caps, because they fail differently: beneficiaries per batch,
amount per batch, and amount approved per calendar day. The daily cap counts what
has been **approved**, not what has settled — settlement lands minutes later, and
counting settled money would let the day's ceiling be approved several times over
inside the settlement window. Contact support to have any of them raised.

## Full endpoint reference

| Method         | Path                                   | Purpose                                                                       |
| -------------- | -------------------------------------- | ----------------------------------------------------------------------------- |
| `GET`          | `/payroll/limits`                      | Caps and today's usage                                                        |
| `GET`          | `/payroll/template`                    | Download the CSV upload template                                              |
| `GET`          | `/payroll/batches`                     | List batches                                                                  |
| `POST`         | `/payroll/batches`                     | Create a batch                                                                |
| `POST`         | `/payroll/batches/upload`              | Create a batch from a file                                                    |
| `GET`          | `/payroll/batches/{id}`                | Get a batch with its items                                                    |
| `POST`         | `/payroll/batches/{id}/items`          | Add beneficiaries                                                             |
| `PUT`          | `/payroll/batches/{id}/items/{itemId}` | Edit a beneficiary                                                            |
| `DELETE`       | `/payroll/batches/{id}/items/{itemId}` | Remove a beneficiary                                                          |
| `POST`         | `/payroll/batches/{id}/validate`       | Re-validate (`?resolveNames=true` re-checks accounts)                         |
| `GET`          | `/payroll/batches/{id}/funding`        | Funding position                                                              |
| `POST`         | `/payroll/batches/{id}/approve`        | Approve and disburse (a 2FA `code`, or a `mandateId` with `externalApproval`) |
| `POST`         | `/payroll/batches/{id}/cancel`         | Cancel before approval                                                        |
| `POST`         | `/payroll/batches/{id}/duplicate`      | Clone into a new draft                                                        |
| `GET`/`POST`   | `/payroll/employees`                   | Staff directory                                                               |
| `POST`         | `/payroll/employees/import`            | Import staff from a file                                                      |
| `PUT`/`DELETE` | `/payroll/employees/{id}`              | Update / remove a staff member                                                |
| `GET`          | `/payroll/mandates`                    | Approval mandates bound to the calling key                                    |
| `POST`         | `/payroll/mandates/{id}/revoke`        | Revoke one of them                                                            |

## Errors

| Status | Meaning                                                                                                                                 |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Validation failed, or the batch is in a status that does not allow the action                                                           |
| `401`  | The 2FA code was wrong or expired                                                                                                       |
| `402`  | The wallet cannot cover the batch — `data` carries `available`, `required` and `shortfall`                                              |
| `403`  | The account has no 2FA enrolled (payroll approval requires it), or the mandate does not cover this batch (`code: "MANDATE_NOT_USABLE"`) |
| `404`  | No such batch, item or employee on this account                                                                                         |

<Note>
  Scheduled payroll — a recurring run that disburses under a bounded standing
  authorization — is available in the Hyparrow dashboard. It is not exposed on the
  API, because arming it requires a live second factor from the account owner.
</Note>
