Skip to main content

Headless SDK

@recoilpay/intent-core is the whole intent flow with no UI. It works in any framework, or none: Vue, Svelte, Angular, vanilla JS, or a Node script. The widget and the React component are both built on it.

npm install @recoilpay/intent-core viem

The flow in five lines​

import { createIntentSession, viemWallet } from '@recoilpay/intent-core';

const session = createIntentSession({ hfAccessToken }); // your Hugging Face token; quotes go to api.recoilpay.com
session.subscribe((state) => render(state)); // called after every change

await session.run('swap 10 USDC on base sepolia for USDC on op sepolia');
session.setWallet(viemWallet(walletClient)); // any time; a waiting flow continues
await session.confirm(); // switch chain → approve → sign → submit → track

A session is a small store. run, setWallet, confirm and reset move it forward. getState() returns the current snapshot, and subscribe tells you when it changes. None of the methods throw: every failure ends up in the state, with a message you can show as-is.


The state​

session.getState() returns an immutable snapshot, and a new object comes after every change. That works directly with Vue's shallowRef, Svelte stores, signals, or React's useSyncExternalStore.

phase​

idle → parsing → quoting → quoted → switchingChain → approving → signing → submitting → tracking → done
↘ offTemplate ↘ invalid ↘ needsWallet ↘ error (from any step)
PhaseMeaningShow
idleNothing runningThe input
parsingReading the sentenceA spinner
offTemplateNot recognisable as an intenthint, an example sentence
invalidParsed, but needs fixingEvery entry in issues
needsWalletReady to quote once a wallet is setYour connect button
quotingSolvers are racing"Asking solvers…"
quotedA quote is readypreview, and a confirm button
switchingChain, approving, signing, submittingWorking through confirm()A busy button. The wallet is prompting
trackingOrder submitted, pollingstatus as a progress bar
doneEvery intent in the sentence finishedSuccess, with explorerUrl
errorSomething failederror

Fields​

FieldType
readybooleanThe live solver coverage has loaded. Disable input until it's true.
assetsErrorbooleanCoverage failed to load (offline or blocked). Call refreshAssets() to retry.
hintstring | nullWith offTemplate.
issues{ field, kind, message }[]With invalid. kind is missing, unknown or unsupported.
previewIntentPreview | nullHuman-readable amounts: payAmount, paySymbol, srcChainName, receiveAmount, receiveSymbol, dstChainName, recipient, etaSeconds, solverCount, solversQueried. No base units, no addresses.
route'solver' | 'direct' | nulldirect means a same-chain send, made straight from the wallet.
needsApprovalbooleanconfirm() will ask for a one-time Permit2 approval first. Worth telling the user.
orderId, status, orderWhile tracking. status is the order status.
explorerUrlstring | nullDestination-chain explorer link: the fill transaction when known, otherwise the recipient.
errorstring | nullWith error, written for the end user.
queueIndex, queueTotalnumberSentences with several intents run one at a time.
isConnectedbooleanA wallet is set.

Wallets​

The session needs an IntentWallet. For a viem WalletClient, use the adapter:

import { viemWallet } from '@recoilpay/intent-core';
session.setWallet(viemWallet(walletClient));
session.setWallet(null); // on disconnect

For anything else, implement the interface yourself. It's six members:

interface IntentWallet {
address: `0x${string}`;
getChainId(): Promise<number>;
switchChain(chainId: number): Promise<void>;
signTypedData(args: { domain; types; primaryType; message }): Promise<`0x${string}`>; // EIP-712
writeContract(req: { chainId; address; abi; functionName; args }): Promise<`0x${string}`>;
sendTransaction(req: { chainId; to; value }): Promise<`0x${string}`>;
}

writeContract is used for the one-time Permit2 approval and for same-chain token sends. sendTransaction is used for same-chain native sends. Everything else is a signature.

Solana​

Two optional members connect a Solana wallet. solanaAddress is where swaps to Solana devnet deliver, and what pays for swaps from it:

session.setWallet({ ...viemWallet(walletClient), solanaAddress: phantom.publicKey?.toBase58() ?? null });

For a swap to Solana, the user still pays and signs with the EVM wallet; the Solana address only receives. Without one, the swap stops with "To receive on Solana, connect a Solana wallet or name a Solana address to send to". A send … to <address> on solana names its own recipient and doesn't need it. The preview's recipient shows the Solana address before the user signs.

For a swap from Solana, the Solana wallet pays, so it also needs sendSolanaInstructions: put the instructions in one transaction, have the Solana account sign and send it, and return its signature.

import { Connection, PublicKey, Transaction, TransactionInstruction } from '@solana/web3.js';

const connection = new Connection('https://api.devnet.solana.com');
session.setWallet({
...viemWallet(walletClient),
solanaAddress: phantom.publicKey.toBase58(),
sendSolanaInstructions: async (ixs) => {
const tx = new Transaction().add(
...ixs.map((ix) => new TransactionInstruction({
programId: new PublicKey(ix.programId),
keys: ix.keys.map((k) => ({ ...k, pubkey: new PublicKey(k.pubkey) })),
data: Buffer.from(ix.data),
})),
);
return wallet.sendTransaction(tx, connection); // a wallet-adapter wallet
},
});

The only instruction it is asked to sign is the escrow's open, which locks the user's input for this one order. Before asking, the session checks the quote's escrow order is exactly the request: payer, token, amount, destination chain, recipient, the registry's escrow program, and an order id that matches its terms. If anything differs it stops, and nothing is signed. The EVM wallet signs nothing for a swap from Solana. Swaps within Solana are refused.

Cash-outs and purchases on Solana devnet use the other two Solana members, signSolanaMessage and signSolanaTransaction. solanaStandardWallet(wallet, account) provides all of them from any Wallet Standard wallet, and sendSolanaInstructions too when you pass { rpcUrl } and the wallet can sign and send. See Fiat ramps → On Solana.

explorerLink(chainId, kind, id) builds Solana explorer links too, with ?cluster=devnet for devnet.

Changing the wallet mid-flow is safe. A flow waiting in needsWallet continues, and a quote built for a different account is fetched again for the new one.


Options​

createIntentSession({
hfAccessToken, // your Hugging Face token for parsing; setHfAccessToken() to change it
hfModel, // Hugging Face model; defaults to the one the RecoilPay app uses
apiUrl: 'https://api.recoilpay.com', // default
wallet, // or setWallet() later
pollIntervalMs: 2500, // order-status polling
fetch, // custom fetch: SSR, proxies, tests
chainReader, // on-chain reads (Permit2 allowance); defaults to the RPCs from /api/v1/chains
partnerKey, // your partner key, if you have one — see below
});

Call session.destroy() when you're completely done with a session. It stops polling and drops listeners.


Earning a share of the fee​

If you've agreed terms with RecoilPay you'll have a partner key. Pass it as partnerKey and every call carries it:

const session = createIntentSession({ wallet, partnerKey: 'pk_recoil_…' });

It does two things. It credits the trades your app brings, and it applies the fee you agreed on top of RecoilPay's own. Both are charged to the user, not to you and not to the solver, and the total shows on the quote as preview.protocolFee in the token being received — already subtracted from receiveAmount, so what your user sees under "you receive" is what they get.

const { preview } = session.getState();
preview?.protocolFee; // e.g. '0.60' — the whole fee, yours included

Check a key and see your rates with createApiClient({ partnerKey }).getMyPartner().

Two things worth knowing:

  • It is not a credential. An absent, stale or disabled key means the trade is credited to nobody and your fee isn't applied — but it still goes through. Your users never fail to trade because of a billing problem.
  • Payouts are monthly, in USDC. Your share is recorded per trade and paid to the address on your partner record; it isn't a transfer on each trade, which is what keeps an order's gas the same whether or not it came through a partner.

Cash-out and purchase sessions accept partnerKey too, and record which trades came through you. They do not yet apply a partner fee: the fiat escrow charges a single rate fixed when it's deployed, so there's nowhere to add a second per-request fee.


Cash-outs and purchases​

When a sentence is a cash-out or a purchase, the intent session stops at phase: 'offramp' or phase: 'onramp', with the resolved request in state.offramp or state.onramp. Hand it to its own session, createOfframpSession() or createOnrampSession(), which runs the quote, the bank details or identity check, the escrow and the confirmation:

const offramp = createOfframpSession({ wallet });
await offramp.quote(session.getState().offramp!);
await offramp.confirm({ rail: 'ng.nip', bankCode: '058', account: '0123456789', name: 'Ada Obi' });
// …later, once the solver says it paid
await offramp.confirmReceived();

The phases, the identity check and the API underneath are in Fiat ramps.


Building blocks​

Every step is exported on its own, for when you want your own orchestration. For example, a backend or an agent could use them like this:

import {
createApiClient, parseIntent, buildSupportedSet, validateIntent, resolveIntent,
buildQuoteRequest, signQuote, buildOrderRequest,
} from '@recoilpay/intent-core';

const api = createApiClient();
const parsed = await parseIntent('swap 1 USDC on op sepolia for USDC on base sepolia', { hfAccessToken });
if (!parsed.ok) throw new Error(parsed.message);

const supported = buildSupportedSet(await api.getSupportedAssets());
const issues = validateIntent(parsed.intents[0], supported); // [] when executable
const intent = resolveIntent(parsed.intents[0], supported, userAddress);

const { quotes } = await api.getQuotes(buildQuoteRequest(intent));
const signature = await signQuote(quotes[0], signTypedData); // scheme-prefixed, ready to submit
const order = await api.submitOrder(buildOrderRequest(quotes[0], signature));

These are the same calls described in Forward your first intent, with the ERC-7930 encoding (interopAddress), the Permit2 helpers (hasPermit2Allowance, buildPermit2ApproveRequest) and the signature prefixes handled for you.