Skip to main content
A card spends a smart wallet’s balance. Every partner card is issued against a smart wallet — there is no user-level card — so the wallet must exist and be funded before a card is useful. Card creation goes through the same proposal → approve → sign flow as a transfer, with one difference: the proxy approves the proposal for you and returns the payload to sign straight from the create call.

1. Create the card

Omitting nin for an owner who has never been issued a card returns 400 E101 — Card owner is not enrolled for cards yet. Once enrolled, later cards for that owner do not need it.
The response tells you what to sign:

2. Sign and submit

Sign signPayload with the wallet owner key via bmoni_embedded_sdk, then submit it:
If signPayloadPending came back true, the payload is still being prepared asynchronously. Poll for it first, then sign and submit as above:
A 409 on the sign-payload route means “not ready yet”, not an error — keep polling. Track the issuance itself with GET /v1/users/{userId}/smart-wallets/proposals/{proposalId}.
If the response carries migrationRequired: true instead of a proposal, the wallet is a legacy (pre-Safe) wallet that cannot hold cards. Managed wallets migrate automatically and the create is retried in the same request; when the sweep is still confirming, follow migrationHint — sign the attached payload if present, otherwise retry card creation shortly.

Read cards

Cards are listed and read on the smart-wallet path, not the user path. A card carries status, currency, type (physical or virtual), fundingPolicy (SAFE, PERSONAL, or BOTH), fundLifecycle (ONE_TIME or REPEATABLE), and fundedAtnull until the card is first funded. The detail route adds balanceMinor and the most recent 100 ledger entries, newest first. Cards that have been requested but not yet issued — the issuance proposal is still awaiting signatures, or the card (typically a physical one) is still being provisioned — appear in the same cards list as reserved cards, exactly like the personal card list: status is RESERVED and isReserved is true. A reserved entry has no card id yet, so its id is the issuance proposal id (also exposed as proposalId, matching the create response), alongside the requested cardName, cardColor, currency, and type, plus proposalStatus (PENDING_APPROVALS, PENDING_SIGNATURES, READY_TO_EXECUTE, EXECUTING, or COMPLETED while provisioning finishes). If an entry sits at PENDING_SIGNATURES, the sign-and-submit step from the create flow is still outstanding. Once issued, the reserved entry is replaced by the real card. A physical card then reports status: PENDING — it stays PENDING through delivery until the holder activates it, so the pre-activation card is always visible in the list: first as RESERVED, then as PENDING. A reserved entry can also disappear without producing a card — when the proposal expires unsigned or is rejected — so treat a disappearance without a matching new card as a dead request, and check the proposal via GET /v1/users/{userId}/smart-wallets/proposals/{proposalId} if in doubt.
Two amount formats live side by side. The card detail ledger reports amount as a minor-unit string ("250000" = ₦2,500.00), while GET …/cards/{cardId}/transactions reports amount as a major-unit number (25.5 = $25.50). Do not feed one into a parser written for the other.
The /sensitive route returns only the provider card identity. For the full card number, CVV, expiry, and billing address:
This returns the unmasked PAN and CVV. Treat the response as cardholder data: never log it, never cache it, and pass it straight to the surface that renders it.

Activate a physical card

Two calls. Virtual cards need neither.
channel is required (sms or email); pan is optional when the number is already known upstream. The response returns otpId and expiresInMinutes.
code is exactly 6 digits.

Manage a live card

A PIN is always exactly 4 digits. GET …/limits returns the current totalDailyLimit and maxSingleTransactionAmount alongside availableDailyLimit and the provider’s caps (maxTotalDailyLimit, maxSingleTransactionLimitCap) — read it before setting a limit the provider will reject. GET …/transactions accepts size, status (e.g. COMPLETED), and from / to as ISO 8601 dates.
deactivate permanently cancels the card. It cannot be reversed — freeze with status: "BLOCKED" if you only need to stop spending for now.

Card statuses

A card’s status is set from two different places, and conflating them is the usual cause of a card that appears stuck.

What you can set

PUT /v1/users/{userId}/cards/{cardId}/status accepts exactly two values: Anything else is rejected with 400 before the request leaves BMONI. The values are case-sensitive and upper-case.
status: "ACTIVE" unfreezes a card that is already live. It does not activate a new card. Activation is a separate operation — see Activate a physical card. Setting ACTIVE on a card that has never been activated is one way to end up looking at The current status does not allow it.

What a card can report

Beyond the two you can set, a card carries statuses that originate with the card issuer and that you can only read:
Read these case-insensitively and treat the set as open. They are the issuer’s vocabulary rather than BMONI’s, and an issuer can introduce a state without a change on our side. Branch on the ones you handle and fall through to a neutral “unavailable” state for anything else, rather than asserting an exhaustive match.

Permitted transitions are enforced, not published

There is no published transition matrix. The issuer validates each transition at request time against the card’s current state, and rejects the ones it does not allow with:
Retrying this error cannot succeed. The state will not change on its own, and nothing in the request is at fault. One card was retried four times over sixteen hours against an unchanged state.Read the current status first, and if a legitimate transition is being refused, escalate to developers@bkey.me with the cardId rather than retrying on a timer.

Inspecting a card

This is the only call that reports a live card’s status.
GET /v1/users/{userId}/cards/status?workflowId=… does not read a card’s status. It polls the creation workflow and is only useful between requesting a card and receiving one. The similar name catches people out.
There is no operation that resets a card’s state. POST …/cards/{cardId}/reset resets the PIN, not the status.