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

# Attach a virtual account to a wallet 🔒

> Issue a NUBAN so the wallet can receive external money. Requires the customer to have reached KYC tier 1 — the wallet works for internal value without one.



## OpenAPI

````yaml /openapi/numero-public-api.yaml post /wallets/{walletReference}/virtual-account
openapi: 3.1.0
info:
  title: Numero Public API
  version: 1.0.0
  summary: >-
    Server-to-server API for transfers, virtual accounts, bill payments (VAS)
    and identity verification.
  description: >
    The Numero Public API is the API-key-authenticated, server-to-server surface
    that powers

    payouts, collections (virtual accounts), bill payments and identity
    verification for your

    business. It is the `/business/*` surface — distinct from the dashboard
    (JWT) endpoints your

    own staff use in the Numero merchant dashboard.


    ## Quickstart


    1. **Get your API keys** — issued on business approval; reveal/rotate them
    in
       **Dashboard → Settings → Developer → API keys** (step-up-OTP gated). You get two secret keys, and
       **the key decides the mode** — there is no separate sandbox host:
       `test_key_...` = Test (simulated providers + an isolated test wallet, live balances untouched),
       `live_key_...` = Live (real money). Build with your test key first.
    2. **First call** — read your wallet balance:
       ```
       curl https://api.usenumero.com/numeroaccount/api/v1/business/balance \
         -H "X-Numero-Api-Key: test_key_xxxxxxxxxxxxxxxxxxxxxxxx"
       ```
    3. **Send money** — `POST /business/validate` (name enquiry) → `POST
    /business/single`
       with a unique `Idempotency-Key` header and the HMAC `X-Numero-Signature`.
    4. **Collect money** — create a virtual account, then handle the
    `FUNDING_NOTIFICATION` webhook
       when a customer funds it.
    5. **Go live** — swap your `test_key_` for your `live_key_`. Same base URL,
    same endpoints,
       identical behaviour — nothing else changes.

    ## Money & amounts


    **All amounts are in major currency units — naira, NOT kobo** — as JSON
    numbers with up to 2

    decimal places. `1000` and `1000.00` both mean ₦1,000.00 (never ₦10.00).
    This applies to every

    `amount`, `fee`, `stampDuty`, and to balances (`available`, `pending`,
    `total`). Default currency

    is **NGN**; a `currency` / `feeCurrency` field names the ISO code where
    relevant.


    ## Internal transfers (Numero-to-Numero)


    To pay another Numero merchant **instantly** with no provider or settlement
    delay, send a normal

    `POST /business/transfer/single` but set **`destinationBankCode: "NUMERO"`**
    and

    `destinationAccountNumber` to the recipient's Numero account number. Run
    name enquiry the same way

    (`POST /business/transfer/validate` with `bankCode: "NUMERO"`) — the
    recipient resolves from

    Numero's own records, so `destinationAccountName` is optional. The transfer
    posts as an instant

    book entry; the **Numero fee is waived** (stamp duty still applies on
    amounts ≥ ₦10,000).


    ## Authentication


    Every request must carry your **API key** in the `X-Numero-Api-Key` header.
    Your keys are

    issued on business approval and are managed in **Dashboard → Settings →
    Developer → API keys**

    (reveal/rotate are step-up-OTP gated).


    ```

    X-Numero-Api-Key: live_key_xxxxxxxxxxxxxxxxxxxxxxxx

    ```


    ## Request signing (write endpoints)


    Money-movement and other state-changing endpoints additionally require an
    **HMAC-SHA256

    signature** so a leaked-but-read-only key can't move funds. Signed endpoints
    are marked with

    the 🔒 **(signed)** note and require the `NumeroSignature` scheme below.


    Compute the signature as:


    1. Take the **exact raw request body** you are about to send (for `POST`),
    or the
       **canonicalized query string** (for signed `GET`s — keys sorted, `&`-joined `name=value`).
    2. `HMAC_SHA256(publicApiKey, input)` → take the 32-byte digest →
    **Base64-encode** it.
       The HMAC secret is your **Public Key**, used as its **raw UTF-8 bytes** — do not Base64-decode
       it first. Despite the name it is a SECRET: it is the signing key and must never be published.
       It is a different value from the `X-Numero-Api-Key` you send as the authentication header.
    3. Send it as `X-Numero-Signature`, and set `X-Numero-Signature-Version:
    v2`.


    ```

    X-Numero-Api-Key: live_key_...

    X-Numero-Signature: 9b2c...base64...==

    X-Numero-Signature-Version: v2

    ```


    > Helper: `POST /business/generatesignature` returns the signature for a
    given body during

    > integration/testing. Don't rely on it in production — sign locally with
    your own secret.


    ## Response envelope


    Every response is wrapped in a canonical envelope. **Success** carries
    `data` (and `error: null`):


    ```json

    {
      "data": { },
      "error": null,
      "meta": { "request_id": "N20260603120000123", "pagination": null }
    }

    ```


    **Failure** carries `error` (and `data: null`) with a **real HTTP status
    code** (4xx/5xx) — not a

    `200` with a flag:


    ```json

    {
      "data": null,
      "error": { "code": "invalid_signature", "message": "Invalid signature.", "param": null },
      "meta": { "request_id": "N20260603120000123", "pagination": null }
    }

    ```


    `meta.request_id` is also returned as the `X-Request-Id` response header —
    quote it in support

    requests. `error.code` is a stable snake_case machine code (branch on it,
    not on `message`).


    ## Pagination


    List endpoints return the rows as a plain array in `data`, and the page info
    in `meta.pagination`:


    ```json

    { "data": [ ... ], "error": null,
      "meta": { "request_id": "...", "pagination": { "page": 1, "page_size": 50, "total": 240, "has_more": true } } }
    ```


    ## Error codes


    `error.code` is one of a stable set including: `missing_field`,
    `validation_failed`,

    `invalid_request`, `invalid_api_key`, `invalid_signature`, `unauthorized`,
    `forbidden`,

    `insufficient_balance`, `invalid_amount`, `limit_exceeded`, `rate_limited`,
    `duplicate_request`,

    `idempotency_conflict`, `business_not_found`, `account_not_found`,
    `transaction_not_found`,

    `customer_not_found`, `virtual_account_not_found`, `invalid_otp`,
    `otp_required`, `invalid_pin`,

    `kyc_required`, `approval_required`, `activation_required`, `invalid_bank`,

    `invalid_account_number`, `invalid_bvn`, `provider_error`,
    `provider_unavailable`,

    `provider_timeout`, `not_found`, `method_not_allowed`, `payload_too_large`,
    `internal_error`,

    `server_error`, `request_failed`.


    ## Fees


    Responses for billable events surface the fee **you were charged** (`fee` /
    `feeCurrency`).


    ## Idempotency


    Money-movement endpoints (single & bulk transfer, FX conversion, VAS
    purchases) **require** a

    Stripe-style **`Idempotency-Key`** request header — a unique string you
    generate per logical

    operation (a UUID is ideal). Retrying with the **same key and the same
    body** returns the

    original response instead of acting twice; reusing a key with a **different
    body** is rejected

    (`idempotency_conflict`). Omitting the header on a required endpoint returns
    `400`. Keys are

    scoped to your business and retained for replay — keep them unique per
    transaction.


    ```

    Idempotency-Key: 5f1c0b6e-9a2d-4c3a-8b7e-1f2a3b4c5d6e

    ```


    ## Service pauses


    Numero can temporarily pause a service (or all services) for maintenance.
    While paused, the

    affected endpoints return **HTTP `503`** with `error.code`
    **`service_paused`** and a

    user-facing `error.message`. Treat it as transient — surface the message and
    retry later.


    ## Webhooks


    Subscribe a URL + signing secret in **Dashboard → Settings → Developer →
    Webhooks**. Every event

    is delivered as a `POST` with the envelope:


    ```json

    { "event": "FUNDING_NOTIFICATION", "data": { } }

    ```


    **Verify the signature before trusting any payload.** Each delivery carries:


    ```

    X-Numero-Signature: t=1717412400123,v1=<base64 HMAC-SHA256>

    ```


    The signed string is **`"{t}.{rawRequestBody}"`**, HMAC-SHA256-keyed by your
    subscription's

    signing secret, then Base64-encoded. Recompute it from the `t` value + the
    exact raw body,

    compare in constant time, and reject deliveries whose `t` is older than your
    tolerance (replay

    guard).


    > A legacy `X-Webhook-Signature` (bare-body HMAC, Base64) is sent in
    parallel until **2026-06-26**

    > for back-compat — migrate to `X-Numero-Signature`.


    Events: `FUNDING_NOTIFICATION` (inbound credit landed),
    `TRANSFER_NOTIFICATION` (outbound transfer

    status change), `BILLS_PURCHASE_NOTIFICATION` (VAS success/failure),
    `VERIFICATION_NOTIFICATION`

    (verification completed/failed), `VIRTUAL_ACCOUNT_CREATED`. For money
    events, `data` is the

    transaction object (reference, amount, status, fee, and the account/customer
    involved). Respond

    `2xx` to acknowledge — failed deliveries are persisted and **retried
    automatically** (and can be

    re-sent from the dashboard), so dedupe on the envelope `id`.


    **Read `data.status`** (`Successful` / `Unsuccessful` / `Pending`), not the
    event name, to decide

    the outcome. The async money movements — `TRANSFER_NOTIFICATION`,
    `PAYOUT_NOTIFICATION` — fire on

    **both** success and failure (a failed one carries `status: "Unsuccessful"`,
    `requestState:

    "Failed"`; the debit is returned via the reversal workflow), as do
    `BILLS_PURCHASE_NOTIFICATION`

    and `VERIFICATION_NOTIFICATION`. FX conversion and virtual-account creation
    are **synchronous** —

    failures come back in the call's own response, not a webhook. See
    `docs/webhooks.md` for the full

    contract.


    ## Bill payments (VAS)


    Each VAS vertical follows **discover → (validate) → purchase**:


    - **Discover** the billers/networks at runtime (don't hardcode them):
    airtime
      `GET /business/vas/airtime/fetch-providers`; data `…/internet/data/fetch-providers` (+ `…/lookup`
      for plans); electricity `…/electricity/billers`; cable `…/cable-tv/fetch-providers` and
      `…/cable-tv/fetch-billers`; betting `…/betting/billers`.
    - **Validate** the customer where applicable (electricity meter, cable
    smartcard, betting id)
      before charging.
    - **Purchase**, then reconcile via the `BILLS_PURCHASE_NOTIFICATION` webhook
    or by polling
      `GET /business/vas/status?reference=…`.

    Common codes — **airtime networks**: `MTN`, `AIRTEL`, `GLO`, `9MOBILE` (data
    products use the

    `MTNDATA` / `GLODATA` / `AIRTELDATA` / `9MOBILEDATA` provider codes returned
    by lookup). **Cable**:

    `DSTV`, `GOTV`, `STARTIMES`. **Electricity discos**: AEDC, BEDC, EEDC,
    EKEDC, IBEDC, IKEDC, JEDC,

    KAEDCO, KEDCO, PHEDC, YEDC. Always prefer the discovery endpoints — the
    catalog can change.


    ## Not documented here (in flight)


    The cross-border surface — `/business/fx/*`, `/business/payout/*`,
    `/business/usd/*` — is being

    rebuilt and is intentionally omitted from this reference until it
    stabilises.
  contact:
    name: Numero Developer Support
    email: developers@usenumero.com
servers:
  - url: https://api.usenumero.com/numeroaccount/api/v1
    description: >-
      The only base URL. Mode is decided by your key, not the host: send a
      live_key_ for Live (real money) or a test_key_ for Test (simulated
      providers + an isolated test wallet).
security:
  - NumeroApiKey: []
tags:
  - name: Balance
    description: Wallet balance.
  - name: Transfers
    description: NGN bank transfers (payouts) — name enquiry, fee preview, single & bulk.
  - name: Virtual Accounts
    description: Issue and look up virtual accounts (collections).
  - name: Airtime
  - name: Data
  - name: Electricity
  - name: Cable TV
  - name: Betting
  - name: Verification
    description: Identity & business verification (KYC/KYB).
  - name: Cards
    description: >-
      Issue and manage virtual USD cards. Two ways to use it: issue directly
      (the card endpoints below auto-create a cardholder from the details you
      pass), or run the customer-first model — create a Cardholder, then issue
      cards to them (see Cardholders). The card issuer is abstracted; you
      integrate one Numero card API. Requires an approved card-product
      entitlement + a funded USD card wallet (fund-first).
  - name: Cardholders
    description: >-
      Your customers (cardholders) as a first-class resource — create a
      cardholder once (you attest their KYC), then issue them cards, list their
      cards, and pull their statement.
  - name: Webhooks
    description: >-
      Events Numero delivers to your endpoint. Subscriptions (URL + event types
      + signing secret) are managed in Dashboard → Settings → Developer →
      Webhooks — that surface is JWT/dashboard-only, not callable with an API
      key. Verify the X-Numero-Signature on every delivery before trusting it.
  - name: Customers
    description: >-
      End-customer records — attach virtual accounts, transactions and KYC to a
      customer you create once.
  - name: Customer Wallets
    description: >-
      Wallet-as-a-Service. Give each customer one or more currency wallets,
      attach a virtual account so a wallet can receive external money, move
      value between wallets, pay out to a bank, and back a card. Wallet balances
      are a strictly-maintained sub-ledger of your pooled balance: a wallet can
      never spend another customer's money.
  - name: Invoices
    description: >-
      Invoices, each with a dedicated one-time virtual account for
      pay-by-transfer.
  - name: Credit
    description: >-
      Credit-bureau reports and scores, keyed by BVN. credit-summary aggregates
      all four bureaus in one call; the individual bureau endpoints are there
      when you need a specific source.
  - name: Sandbox
    description: >-
      Test-mode helpers, callable only with a `test_key_`: fund or reset your
      isolated test wallet and fire sample webhook deliveries. They can never
      touch live balances.
  - name: Utilities
paths:
  /wallets/{walletReference}/virtual-account:
    post:
      tags:
        - Customer Wallets
      summary: Attach a virtual account to a wallet 🔒
      description: >-
        Issue a NUBAN so the wallet can receive external money. Requires the
        customer to have reached KYC tier 1 — the wallet works for internal
        value without one.
      operationId: attachWalletVirtualAccount
      parameters:
        - $ref: '#/components/parameters/WalletReference'
      responses:
        '200':
          $ref: '#/components/responses/VirtualAccountSuccess'
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'
      security:
        - NumeroApiKey: []
          NumeroSignature: []
components:
  parameters:
    WalletReference:
      name: walletReference
      in: path
      required: true
      description: The wallet's reference, as returned when the wallet was created.
      schema:
        type: string
  responses:
    VirtualAccountSuccess:
      description: The wallet's virtual account
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/ApiResponse'
              - type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WalletVirtualAccount'
    Error:
      description: >-
        Auth, signature, or validation failure (real 4xx/5xx status; envelope
        below)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiResponse'
          examples:
            missingApiKey:
              summary: No API key (HTTP 400)
              value:
                data: null
                error:
                  code: missing_field
                  message: Header key is missing.
                  param: null
                meta:
                  request_id: N20260603120000123
                  pagination: null
            invalidSignature:
              summary: Bad signature (HTTP 400)
              value:
                data: null
                error:
                  code: invalid_signature
                  message: Invalid signature.
                  param: null
                meta:
                  request_id: N20260603120000123
                  pagination: null
  schemas:
    ApiResponse:
      type: object
      description: >-
        Canonical response envelope for the public surface. `data` on success,
        `error` on failure.
      required:
        - data
        - error
        - meta
      properties:
        data:
          description: Endpoint payload on success (object or array); null on failure.
        error:
          oneOf:
            - $ref: '#/components/schemas/ApiError'
            - type: 'null'
        meta:
          $ref: '#/components/schemas/Meta'
    WalletVirtualAccount:
      type: object
      properties:
        reference:
          type: string
          nullable: true
        accountName:
          type: string
          nullable: true
        accountNumber:
          type: string
          nullable: true
        bankName:
          type: string
          nullable: true
    ApiError:
      type: object
      properties:
        code:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: string
          description: Human-readable reason (don't branch on this — use code).
        param:
          type:
            - string
            - 'null'
          description: The offending field, when applicable.
    Meta:
      type: object
      properties:
        request_id:
          type: string
          description: Also returned as the X-Request-Id header.
        pagination:
          oneOf:
            - $ref: '#/components/schemas/Pagination'
            - type: 'null'
    ErrorCode:
      type: string
      description: Stable machine-readable error code.
      enum:
        - missing_field
        - validation_failed
        - invalid_request
        - invalid_api_key
        - invalid_signature
        - unauthorized
        - forbidden
        - insufficient_balance
        - invalid_amount
        - limit_exceeded
        - rate_limited
        - duplicate_request
        - idempotency_conflict
        - already_exists
        - business_not_found
        - business_inactive
        - account_not_found
        - transaction_not_found
        - customer_not_found
        - virtual_account_not_found
        - recipient_not_found
        - invalid_otp
        - otp_expired
        - otp_required
        - invalid_pin
        - pin_not_set
        - kyc_required
        - approval_required
        - activation_required
        - invalid_bank
        - invalid_account_number
        - invalid_bvn
        - provider_error
        - provider_unavailable
        - provider_timeout
        - not_found
        - not_implemented
        - internal_error
        - request_failed
    Pagination:
      type: object
      properties:
        page:
          type: integer
        page_size:
          type: integer
        total:
          type: integer
        has_more:
          type: boolean
  securitySchemes:
    NumeroApiKey:
      type: apiKey
      in: header
      name: X-Numero-Api-Key
      description: >-
        Your business API key (`live_key_...`). Issued on approval; managed in
        Dashboard → Settings → Developer.
    NumeroSignature:
      type: apiKey
      in: header
      name: X-Numero-Signature
      description: >
        Base64(HMAC-SHA256(publicApiKey, input)) over the raw request body
        (POST) or canonicalized

        query string (signed GET). Also send `X-Numero-Signature-Version: v2`.
        Required on

        money-movement / state-changing endpoints (marked 🔒).


        The HMAC key is your **Public Key** as raw UTF-8 bytes — a SECRET
        despite the name, and a

        different value from the `X-Numero-Api-Key` authentication header.
        Signing with the API key

        will never produce a valid signature.

````