@ensc/sdk 0.4.x exposes all of this under ensc.conversions.
The four types
Amounts are decimal strings in major units. Crypto legs are priced by the converter’s on-chain oracle at the moment the voucher is issued;
GET /v1/conversions/quote (ensc.conversions.quote()) gives you the same figure in advance, plus oracleRate (NGN per one pair token, the 8-decimal scaled integer the oracle reports), oracleUpdatedAt (Unix seconds of the oracle’s last update, or null), redeemCapacityIn, the most ENSC that can currently be redeemed into that pair, and validForSeconds, how long a voucher issued now would hold. Fiat legs have no rate. There is a minimum payout of NGN 100 on fiat-redeem.
Conversions run on the converter chain of your environment: celo-sepolia with a Sandbox key, celo with a Live key. See Environments.
The flow
- Create.
POST /v1/conversionswith the type, chain, the wallet that will sign, the amount and the type-specific fields. You may pass your ownreference(op:<type>:<hex>, 8 to 64 hex characters or dashes after the type, so a UUID fits); otherwise ENSC mints one; a malformed reference is400 ENSC_VALIDATION_FAILED. Re-posting a reference you already used returns the existing conversion (200), so a retried create can never double-issue. If the first attempt was cut off between the insert and the voucher (a timeout, a signer or bank-rail error), the conversion iscreatedwithlastErrorset, and re-posting the reference finishes it. - Sign. The response carries
voucher: the signed authorisation, an optionalapprovalTransaction(an ERC-20approveto the converter, needed when the converter must pull a token from your wallet) andtransaction(the converter call). Both are{ from, to, data, value: "0", chainId }:fromis the wallet that must sign,tothe token or the converter,valuealways"0". Your signer fills gas, fees and nonce; estimate gas with an explicit limit (see Signing the calldata). Send the approval first when it is present, wait for it, then send the transaction. The voucher is bound to the wallet you named and expires atvoucher.expiresAt(about ten minutes); a transaction sent by another wallet or after the deadline reverts. - Report. Tell ENSC what happened:
ensc.conversions.events.submitted(reference, txHash)once broadcast (optional but recommended),ensc.conversions.events.confirmed(reference, txHash)once mined, orensc.conversions.events.failed(reference, error)if your wallet could not send it. You may reportfailedonly before a transaction is recorded for the conversion (and not while it is inrequires_manual_review); otherwise the answer is409 ENSC_INVALID_STATE. Onconfirmed, ENSC fetches the receipt and verifies that it succeeded, that ENSC moved to or from your wallet, and that the converter emitted the event for exactly this conversion. The receipt must also be as many blocks deep as the chain’s confirmation depth requires; until it is,confirmedanswers409 ENSC_SETTLEMENT_VERIFICATION_FAILEDwithdetails.reasonnot_enough_confirmationsanddetails.retriabletrue, so report it again shortly. Crypto legs andfiat-issuethen reachsucceeded; afiat-redeemmoves on to the payout.
executeVoucher and signAndBroadcast are conveniences; any EVM signer works with the calldata. Their second argument, signer, is a raw wallet private key or any viem account: privateKeyToAccount, mnemonicToAccount, toAccount around an HSM, KMS or custody signer, or the JSON-RPC account of a wallet a user connected. It is a separate secret from the ENSC credentials, never touches the ENSC API and is never stored by the SDK; pass it per call and never put it in EnscClient config. The helper refuses a signer for another address than the conversion’s wallet and an RPC endpoint on another chain. A browser wallet signs the same { from, to, data, value, chainId } calldata directly (see Signing in the browser); nothing about ENSC requires exporting a private key.
executeVoucher throws ENSC_UPSTREAM_FAILED with the hash in details.txHash when the converter call (or the approval) reverts, instead of returning a result you might report as confirmed; report the conversion failed instead. A receipt that cannot be read is wrapped the same way, with details.txHash.
Signing the calldata
With your own signer, for each ofapprovalTransaction (when present) and transaction:
- Check
chainIdagainst the chain your RPC endpoint serves, andfromagainst the account you are signing with. The voucher is bound to that wallet. - Estimate gas first, and send with an explicit gas limit (the estimate plus a margin). Without a limit some nodes estimate at the block gas limit and charge that much gas up front during the simulation. On Celo the native balance is also the CELO ERC-20 balance, so a
crypto-issuewithpair: "CELO"then reverts in simulation withtransfer value exceeded balance of senderunless the wallet holds several CELO more than the amount.@ensc/sdk/web3does this for you. - If the simulation fails, broadcast nothing and report
events.failed(reference, error)so the conversion does not linger. - Send
value: 0. ENSC never asks for native value.
Signing in the browser with a connected wallet
When your customer signs with a wallet connected to your web page, split the work: your backend holds the ENSC credentials, creates the conversion and reports its events; the page only asks the connected wallet to signvoucher.approvalTransaction (when present) and then voucher.transaction. The browser never holds ENSC credentials, and nobody exports a private key.
celo-sepolia with a Sandbox key, celo with a Live key). Treat the hash the page sends back as a claim, not a fact: ENSC verifies the receipt on confirmed, and your backend should act on the conversion status ENSC returns.
Amounts and decimals
Amounts you send are decimal strings in the asset’s own units:"0.1" CELO, "100" USDC, "1000.00" NGN. Fiat amounts take at most 2 decimals; a crypto amount may not carry more decimals than the asset has (ENSC_VALIDATION_FAILED otherwise). No commas, signs or exponents.
Amounts ENSC returns are base-unit integer strings (amountIn, amountOut, balance, the voucher fields) with a decimal twin already divided by the asset’s decimals (amountInFormatted, amountOutFormatted, formatted). Decimals: ENSC 18, CELO 18, USDC and USDT 6. NGN legs are stored as ENSC units (18 decimals) with the Naira principal in fiatAmountNgn (2 decimals). Do arithmetic on the base units with an arbitrary-precision integer, never on the formatted strings and never in floating point.
Type-specific fields
crypto-issue,crypto-redeem:pair(USDC,USDT,CELO). The pair must be listed on the converter of the chain you name.fiat-issue:payerwithemail(required) and optionalnameandphone; the payment rail needs a contact for the collection.fiat-redeem:payoutwithbankCode,accountNumber(10 digits) andaccountName. Resolve the account first withPOST /v1/accounts/resolve(ensc.accounts.resolve({ bankCode, accountNumber })) and pass back the name it returns; the create refuses a name that does not match the bank record (ENSC_ACCOUNT_RESOLUTION_FAILED). Bank codes come fromGET /v1/banks(ensc.banks.list()).- Any type:
counterparty(typeindividualorcompany,name,wallet) for transaction screening, andmetadata(up to 16 string keys, key up to 40 and value up to 200 characters) echoed back on the conversion object only (webhook payloads carry the summary, withoutmetadata).
Paying in Naira (fiat-issue)
A fiat-issue has no voucher at first. The create returns status: "awaiting_payment" and paymentInstructions:
transferAmount is the principal plus the rail’s collection fee. Only a bank transfer is accepted; there are no cards or USSD. When the transfer is confirmed the conversion moves to payment_confirmed, ENSC issues the voucher and the conversion reaches voucher_issued; you receive conversion.payment_confirmed and conversion.voucher_issued, and GET /v1/conversions/{reference} now carries voucher. Sign and report it as in step 2 and 3 above. A payment that arrives short of the principal, above the amount, or in a currency other than NGN parks the conversion in requires_manual_review.
Being paid in Naira (fiat-redeem)
The voucher for a fiat-redeem commits to the payout account you gave: the converter records a hash of the bank, account number and name when your wallet burns the ENSC. After confirmed, ENSC checks that the recorded hash matches the account it stored and initiates the payout to that account and no other. The conversion moves payout_pending, payout_in_progress, payout_confirmed, succeeded, and you receive payout.initiated and payout.succeeded.
The conversion’s payout object shows bankCode, accountLast4, status (pending, initiated, in_progress, successful, failed, requires_manual_review) and providerTransferId. A transient failure at the rail is retried with backoff for up to five attempts; a payout that still fails, or a definitive refusal, parks the conversion in requires_manual_review and sends payout.failed. Your ENSC has already been burned at that point, so a person at ENSC resolves the case; contact support with the reference. POST /v1/conversions/{reference}/payout (ensc.conversions.payout(reference)) nudges a payout that is payout_pending ahead of the next scheduled retry; it does nothing once a payout is in progress.
Transaction screening holds
Conversions may be screened before a voucher is issued. Most are approved immediately. When one needs review the create answers202 with status: "screening_hold" and screening.status of IN_REVIEW or AWAITING_USER; you receive conversion.screening_hold. Poll GET /v1/conversions/screening?reference=… (ensc.conversions.screening(reference)) or get, or wait for the webhook: an approval moves the conversion on automatically (conversion.voucher_issued, or conversion.awaiting_payment for a fiat-issue), and a decline fails it (conversion.failed). Asking for the voucher while the hold stands returns 409 ENSC_KYT_HOLD with retryAfterSeconds. A conversion declined at create time is stored as failed and the create answers 403 ENSC_KYT_DECLINED with the reference in details.
Vouchers that expire
A voucher not used beforeexpiresAt is simply dead; the transaction would revert. Ask for a new one with POST /v1/conversions/{reference}/voucher (ensc.conversions.voucher(reference)). Crypto legs are re-quoted at the current rate (so amountOut may change); fiat legs keep their amounts. Nothing can be re-issued once a transaction hash has been recorded for the conversion.
Statuses
failed and requires_manual_review can be reached from any status; failed is terminal for you, requires_manual_review is resolved by ENSC. Reporting the same event twice is harmless. stages on the conversion lists every status it passed through with a timestamp.
The conversion object
GET /v1/conversions/{reference} and every write return the same shape:
Listing and pagination
GET /v1/conversions?status=&type=&limit=&cursor= (ensc.conversions.list()) lists your conversions newest first.
Every list method (conversions.list(), events.list(), webhookEndpoints.list(), and the credential and origin lists) is paginated the same way:
limit: page size, 1 to 200; the default is 50.cursor: the value ofpagination.nextCursorfrom the previous page.
pagination.nextCursor (null on the last page) and pagination.hasMore.
Webhook events
Register an endpoint from the dashboard or withensc.webhookEndpoints.create(). Every delivery is { id, type, apiVersion, created, data }, signed as described in Security. data is the conversion summary (id, reference, type, status, chain, wallet, tokenIn, tokenOut, amountIn, amountOut, fiatAmountNgn, txHash, lastError, updatedAt) for conversion.* events and the payout summary (conversionId, reference, payoutId, status, amountNgn, bankCode, accountLast4, providerTransferId, lastError) for payout.* events.
How to receive, verify and process these is in Webhooks. In Sandbox,
POST /v1/webhook-endpoints/{id}/test and POST /v1/test-data/events emit any of these with a realistic payload so you can exercise your receiver end to end.