Skip to main content
Applies to self-custody accounts. (New to that term? See Venly-managed vs self-custody.) Do this after the account is verified and its wallet’s AML check is approved.

Why an allowance is needed

A self-custody account holder controls their own wallet. For Venly to move that wallet’s tokens during a payment or transfer, the wallet must grant Venly’s orchestration wallet an ERC-20 allowance — permission to pull funds via transferFrom. Supported stablecoins are USDC, EURC, USDS, and USDT (all support gasless permits).

Two wallets, two responsibilities

Each account has two wallets, and each needs an allowance to the orchestration wallet:
WalletWho grants the permit
Account wallet (holds the customer’s balance)You — the account holder signs it (self-custody). On Venly-managed accounts Venly signs it automatically.
Escrow wallet (used during settlement)Venly, automatically — the escrow wallet is always Venly-managed.
So on a self-custody account the only permit you sign is the account wallet’s. The escrow wallet — and, on Venly-managed accounts, the account wallet too — is permitted for you automatically.

Why a permit instead of a plain approve

A normal approve() costs gas and needs the wallet to hold the chain’s native coin. Instead, the holder signs an EIP-2612 message (off-chain, no gas) and Venly’s orchestration wallet submits it on-chain and pays the gas.
A direct on-chain approve(spender, amount) does set the allowance — but only a confirmed permit moves the wallet to ACTIVE (see below). An approve alone leaves the wallet PENDING, so payments stay blocked. Use the permit flow.

The permit flow (self-custody account wallet)

1

Get the message to sign

GET /accounts/{accountId}/wallets/{walletId}/permits returns, per asset, a supportedAssetId and an EIP-712 typedData object. walletId sets the chain (e.g. Base vs Avalanche). The nonce and the token’s domain name/version are read from the contract for you.
2

Sign it with the owner's key

Sign typedData with the key for the wallet’s owner address. The signature must recover to the owner — a signature from any other wallet makes the permit FAILED. Produces v, r, s.
3

Submit the signature

POST .../permits with the supportedAssetId and the signature. Returns HTTP 200 with result.status — check the status, not just the code. Re-submitting an already-confirmed permit returns 409.
4

Wait for CONFIRMED

Settlement is asynchronous — poll GET .../permits until CONFIRMED (or FAILED). On CONFIRMED the wallet activates and the allowance goes live.

Permit status lifecycle

PENDING ──submit──▶ SUBMITTED ──on-chain confirmed──▶ CONFIRMED
   │                    │
   └────────── FAILED ◀─┘
statusMeaning
PENDINGNot yet submitted
SUBMITTEDSignature accepted; settling on-chain
CONFIRMEDConfirmed on-chain — triggers wallet activation
FAILEDExecution/confirmation failed — most often the signature didn’t recover to the wallet owner. Re-fetch the permit and sign again with the correct key.

Wallet status lifecycle

A wallet starts PENDING and becomes ACTIVE only once all its permits are CONFIRMED:
PENDING ──all permits CONFIRMED──▶ ACTIVE
Payments and transfers require the wallet to be ACTIVE — until then they’re rejected with account-wallet-not-active.
The wallet’s status isn’t currently exposed by GET .../wallets (which shows amlStatus only) — use the permit status as your activation signal: once the account wallet’s permit is CONFIRMED, the wallet is ACTIVE.

Signing the message

Take the typedData from the permit response and sign it with the owner’s key. With ethers.js:
import { ethers } from "ethers";

// `typedData` is result[].typedData from GET .../permits
const { domain, types, message } = typedData;

// ethers builds the domain separator itself, so remove EIP712Domain before signing
const { EIP712Domain, ...signTypes } = types;

const wallet = new ethers.Wallet(PRIVATE_KEY); // MUST be the wallet `owner` key
const signature = await wallet.signTypedData(domain, signTypes, message);

const { v, r, s } = ethers.Signature.from(signature); // submit these below
The signature must recover to the wallet owner. Signing with a different wallet (or signing the JSON as a personal message instead of EIP-712 typed data) produces a signature that the token contract rejects — the permit silently becomes FAILED.
Then submit it — supportedAssetId is the value from the GET .../permits response:
curl --request POST \
  --url https://api.venlyfinance.com/v1/accounts/{accountId}/wallets/{walletId}/permits \
  --header 'Authorization: Bearer <token>' \
  --header 'Content-Type: application/json' \
  --data '{
    "supportedAssetId": "7c32877a-cd0c-422a-a0fd-5af0815fc280",
    "signature": { "v": "27", "r": "0x...", "s": "0x..." }
  }'

Verifying the allowance

Before initiating a payment or transfer, confirm the allowance is in place:
curl https://api.venlyfinance.com/v1/accounts/{accountId}/wallets/{walletId}/allowances \
  -H "Authorization: Bearer <token>"
Each entry returns the asset, the orchestrationWallet that holds the allowance, and the allowance as a human-readable decimal (on-chain raw value ÷ 10^decimals). A very large value (uint256-max) means an unlimited allowance — which is what a confirmed permit grants.
Once a permit is CONFIRMED, the allowance persists until it’s used up — the holder doesn’t sign again for every transfer.