Skip to main content
@ensc/sdk is the official client SDK for the ENSC API: typed end to end, server-side, with automatic request encryption, Ed25519 request signing and sealed-response verification. The current release is 0.4.1, which requires API date version 2026-09-15. If you integrate from a language without an SDK, the exact wire formats are in Encrypting and signing requests; the SDK is the reference implementation.

Install

Requires Node 20.19+ or 22.12+ (or any modern runtime with fetch and Web Crypto, such as Deno or Bun).
The SDK is built for backend use. Every credential it holds is a secret and must never reach a browser bundle. EnscClient refuses to construct in a browser context. Do not ship it to end users.

Credentials: three secrets and three identifiers

The dashboard issues all six values together when you generate keys (Sandbox once your business profile is complete; Live after business verification is approved). Pass them to the constructor; the SDK does the rest. What happens on the wire, for every call:
  1. Writes are serialized to JSON, encrypted with encryptionKey into an envelope bound to the method, path, merchant and key id, then the envelope bytes are signed with signingPrivateKey. The API refuses plaintext merchant writes; there is no downgrade.
  2. Every successful response arrives sealed to your signing key (HPKE, RFC 9180: X25519 + HKDF-SHA256 + ChaCha20-Poly1305) and signed by ENSC. The SDK verifies ENSC’s signature against the published key set, checks the timestamp window, and only then opens the body. A response that is not sealed, not signed, or sealed to another key is rejected.
  3. Errors are never sealed, so a 4xx/5xx is always readable and maps to a typed EnscError.
ENSC’s response-signing public keys are fetched once per EnscClient from GET /v1/.well-known/ensc-public-keys.json and cached. To remove that dependency (locked-down egress), pin them with enscPublicKeys: { [kid]: publicKey }. The SDK cannot issue, rotate or revoke credentials: those paths are gated server-side to the dashboard (ENSC_DASHBOARD_ONLY). What the SDK can do is inventory them: ensc.apiKeys.list(), ensc.signingKeys.list(), ensc.encryptionKeys.list() and ensc.origins.list(). Each takes ListByEnvParams: the pagination fields plus an optional env filter ('test' or 'live'; the default is both). Rotation keeps the previous key working for 24 hours (expiresAt on the rotated key); revocation is immediate. How to generate and rotate keys is in Generating keys.

Quick start

balance.get takes account, chain, and one of asset or contractAddress (one is required).

Conversions

A conversion is one operation on the ENSC converter. Four types: ENSC never holds a wallet key and never broadcasts. create returns a signed voucher with the calldata your wallet must sign: an optional approvalTransaction (ERC-20 approve to the converter) and then transaction (the converter call). After broadcasting, report the hash back; the API verifies the receipt and completes the conversion or starts the Naira payout.
A fiat-redeem needs payout: { bankCode, accountNumber, accountName }; resolve the account first with ensc.accounts.resolve(...) so the name matches the bank record (the API refuses a mismatch), and list banks with ensc.banks.list(). Once the burn is verified on chain the payout is initiated for you; payout.succeeded arrives by webhook. A fiat-issue needs payer: { email, name?, phone? } and returns paymentInstructions (a bank account to transfer the Naira to) instead of a voucher. When the transfer is confirmed the voucher is issued; fetch it with get or force it with voucher(reference). A conversion can come back with status: 'screening_hold' (HTTP 202) while transaction screening reviews it; poll screening(reference) or listen for conversion.voucher_issued, then call voucher(reference). Vouchers expire after about ten minutes; voucher(reference) issues a fresh one (crypto legs are re-quoted). quote({ type, chain, pair, amount }) prices a crypto leg without creating anything. transfer.create builds a plain ENSC transfer in the same { from, to, data, value, chainId } shape. If create is cut off between the insert and the voucher (a timeout, a signer or bank-rail error), the conversion stays created with lastError set. Re-posting the same reference finishes it (HTTP 200) instead of returning the stuck row; voucher(reference) does the same. The full flow, statuses and events are in Conversions.

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 have more decimals than the asset (ENSC_VALIDATION_FAILED otherwise). Amounts the API 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) with the Naira principal in fiatAmountNgn (2 dp). Do arithmetic on the base units with BigInt, never on the formatted strings and never with floating point.

The web3 helper and signers

@ensc/sdk/web3 is optional and needs viem; without it the helper throws ENSC_NOT_IMPLEMENTED.
  • executeVoucher(issuedVoucher, signer, { rpcUrl }) sends the approval (if any), then the converter call, and returns both hashes. When the converter call (or the approval) reverts it throws ENSC_UPSTREAM_FAILED with the hash in details.txHash, instead of returning a result you might report as confirmed; report the conversion failed instead.
  • signAndBroadcast({ from, to, data, value, chainId }, signer, { rpcUrl, gas?, gasMarginPercent?, waitForReceipt? }) fills gas, fees and nonce from the RPC and returns { txHash, status?, blockNumber? }; waitForReceipt: false returns as soon as the hash is known. A receipt that cannot be read raises ENSC_UPSTREAM_FAILED with details.txHash.
The second argument is the signer (the exported Signer type): 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 wallet must be the wallet named on the conversion; the helper refuses a signer for another address, and an RPC endpoint on another chain. A browser wallet (wallet-connect style) signs the same { from, to, data, value, chainId } calldata directly; nothing about ENSC requires exporting a private key. See Signing in the browser.

Unsigned transactions

Every piece of calldata ENSC returns has the same shape: { from, to, data, value: '0', chainId }. from is the wallet that must sign it, to is the token (approval) or the converter (the call), value is always '0' (ENSC never asks for native value), and chainId names the chain. Gas, fees and nonce are yours to fill. signAndBroadcast estimates gas first, without fee fields, and sends with that estimate plus 30 percent (gasMarginPercent), or with the limit you pass as gas. Do not skip the limit when signing with your own infrastructure: without one, 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 converter call that pulls CELO then sees an almost empty wallet and reverts with transfer value exceeded balance of sender unless the wallet holds several CELO more than the amount. If the estimate fails, nothing is broadcast and the node’s reason is in the ENSC_UPSTREAM_FAILED message. Report the conversion failed (events.failed) so it does not linger.

Webhooks

ENSC tells your backend what happened by POSTing signed events to an https URL you own. Register an endpoint in the dashboard (Webhooks tab) or with ensc.webhookEndpoints.create({ env, url, eventTypes }); verify each delivery before parsing it.
EnscClient.fetchPublicKeys() (also exported as fetchEnscPublicKeys) loads ENSC’s webhook keys from GET /v1/.well-known/ensc-public-keys.json as { [kid]: publicKey }; pass that map to the verifier and a key rotation needs no redeploy. There is no shared secret to store or rotate, and deliveries older than five minutes are refused by the verifier. constructEvent throws EnscError('ENSC_INVALID_SIGNATURE') when verification fails (including a key id that is not in your map) and ENSC_VALIDATION_FAILED when the verified body is not an event envelope. For non-throwing checks use EnscClient.verifyWebhookSignature(...), which returns { valid, reason? }; reason is unknown_key_id when the key id is not in your map. Answer 2xx within 15 seconds and do the work afterwards. A non-2xx answer, a redirect or a timeout is retried after 1 min, 5 min, 15 min, 1 h, 2 h, 4 h and 8 h (8 attempts in total over about 15 hours), then marked gave_up; disabling the endpoint ends its retries. The same event id is reused on every attempt, so de-duplicate on it, and do not rely on order. Testing your receiver: ensc.webhookEndpoints.sendTest(id, { eventType }) queues one signed event of that type, with the fields a real one carries, in that endpoint’s environment; every active endpoint there that subscribes to the type receives it, and the response says whether the endpoint you named is among them (willDeliverToTargetEndpoint). A Live endpoint accepts only synthetic.test_event. ensc.testEvents.emit({ eventType }) does the same for the whole Sandbox stream, and ensc.testEvents.list() shows the catalogue. ensc.events.get(eventId) shows each delivery attempt with your endpoint’s HTTP answer, and ensc.events.list() is the log. Scopes: a secret key manages webhooks and reads the event log. A restricted key needs webhooks:read to list and read, webhooks:manage to create, change, test or delete endpoints. The receiver guide is Webhooks.

Error handling

Every failure, including network errors, throws a single EnscError type with the same code taxonomy the API uses.
Every EnscError carries status (the HTTP status received), requestId (from X-ENSC-Request-Id; quote it to support) and, where the API sent them, details. Codes the SDK itself raises on the response path: ENSC_INVALID_SIGNATURE (response not signed by a known ENSC key, outside the timestamp window, or an empty 2xx body), ENSC_DECRYPTION_FAILED (sealed body could not be opened with your signing key, usually a mismatched signingKeyId/signingPrivateKey pair) and ENSC_UPSTREAM_FAILED (a network error or timeout, an unreadable body, or a non-ENSC answer from a gateway). Transient failures (network errors, timeouts, 500, 502, 503 and 504) are retried automatically (maxRetries, default 2); 4xx and 429 are never retried. Every write carries a stable idempotency key across those retries, and the API honours it on every write route, so a transparently retried POST cannot double-execute. See Errors.

Pagination

Every list() method takes limit (1 to 200; the API defaults to 50) and cursor (from a previous response’s pagination.nextCursor). Responses carry pagination.nextCursor and pagination.hasMore. The credential and origin lists take ListByEnvParams, which adds the env filter. See Listing and pagination.

Chains

chain is a string at the API boundary. The SDK exports a ChainSlug union and KNOWN_CHAINS for autocomplete, but any string is accepted; an unknown or disabled chain comes back as ENSC_INVALID_CHAIN. Conversions run on CONVERTER_CHAINS.live (celo) with a live key and CONVERTER_CHAINS.test (celo-sepolia) with a test key; a key never reaches the other environment’s chain. The API is multichain by registry: a chain is added by configuration, with no change to your integration beyond naming the new slug, and a new converter chain is announced in the changelog.

API surface

Verifying the package

@ensc/sdk is staged only by the release workflow of the public SDK repository, from sdk-v* release tags, using npm trusted publishing (OIDC), and goes live only when a maintainer approves the staged version with 2FA. There is no long-lived npm token. Every published version is registry-signed; run npm audit signatures after installing to verify it. A legitimate release has exactly four runtime dependencies (@noble/ciphers, @noble/curves, @noble/hashes, and zod for the exported API types), an optional viem peer, and no install scripts; anything else is a red flag. From 0.4.0 every version also carries a provenance attestation linking it to the commit and workflow run in github.com/prosperavest/ensc-sdk; npm shows it on the version page, and npm audit signatures checks it.

Building with an AI agent

This site publishes /llms.txt and a Markdown version of every page. Point a coding agent at the SDK rather than at raw HTTP: every write must be encrypted and signed and every response is sealed, so hand-written requests do not succeed.