Skip to content
DexterDocs
Tabs / Node.js Tabs
Sections

Node.js Tabs

An agent uses a session key that the wallet owner authorizes for one seller under a cap and expiry. The agent keeps that secret; the owner approves its public key through Dexter's hosted consent page.

Use Node.js 22 or newer with @dexterai/x402@6.0.0 and @dexterai/vault@0.43.4. The client signs context-bound V2 vouchers. Before using a grant, provide reserveFinalVoucherV2: your authenticated payment backend must reserve the exact signed amount and return its confirmed transaction receipt.

Prepare the Callback

You need the seller's settlement address on Solana, a session secret held by your agent, and an HTTPS callback endpoint you control. The callback receives a browser-originated JSON POST. Allow the https://dexter.cash origin and Content-Type header in its CORS preflight.

Correlate each approval with an unpredictable, single-use requestId stored on your server. The callback contains no server authentication signature. Treat its contents as untrusted until you compare them with the pending request and the on-chain session.

Callback delivery is best-effort. Keep the pending request and session secret so a missing callback can be reconciled with the owner and chain state.

Build an Approval URL

This Node 22 example uses @dexterai/vault@0.43.4 and creates a session key locally. It writes the pending request and secret with owner-only file permissions, refusing to overwrite an existing file.

mkdir tab-grant-example
cd tab-grant-example
npm init -y
npm install @dexterai/x402@6.0.0 @dexterai/vault@0.43.4 @solana/web3.js@1 tweetnacl@1.0.3
printf '\n.pending-grant.json\n' >> .gitignore
propose-grant.mjs
import { randomUUID } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import nacl from 'tweetnacl';
import { PublicKey } from '@solana/web3.js';
import {
  requestSpendGrant,
  encodeSpendGrantRequest,
} from '@dexterai/vault/grant';

const counterparty = process.env.SELLER_ADDRESS;
const callbackUrl = process.env.GRANT_CALLBACK_URL;
if (!counterparty || !callbackUrl) {
  throw new Error('Set SELLER_ADDRESS and GRANT_CALLBACK_URL.');
}
const callback = new URL(callbackUrl);
if (callback.protocol !== 'https:') throw new Error('Use an HTTPS callback.');

const key = nacl.sign.keyPair();
const request = requestSpendGrant({
  app: { name: 'Research Agent', domain: callback.hostname },
  counterparty,
  capAtomic: '5000000',
  expiresAtUnix: Math.floor(Date.now() / 1000) + 3600,
  sessionPubkey: new PublicKey(key.publicKey).toBase58(),
  requestId: randomUUID(),
  callback: { url: callback.href, method: 'post' },
});

writeFileSync(
  '.pending-grant.json',
  JSON.stringify({ request, secretKey: Array.from(key.secretKey) }),
  { mode: 0o600, flag: 'wx' },
);
key.secretKey.fill(0);

const url = new URL('https://dexter.cash/tabs/new');
url.searchParams.set('req', encodeSpendGrantRequest(request));
console.log(url.href);
export SELLER_ADDRESS='YOUR_SELLER_SOLANA_ADDRESS'
export GRANT_CALLBACK_URL='https://your-app.example.com/grants/callback'
node propose-grant.mjs

The output is an approval URL proposing a 5 USDC cap and a one-hour expiry. Generating it makes no on-chain change. Opening it lets the owner review the seller and approve or reduce the proposed authority. The hosted flow handles registration sponsorship subject to its eligibility checks.

Use /tabs/new?req=... for an encoded grant request. /tabs/connect?url=...&agent=... is a separate page that looks up an API's terms and builds a request from them.

Read the Approval Result

The POST body has flat fields. registerTx is present for a new registration; reusing an existing session can return reused: true without it.

grant-result.ts
import type { ApprovedSpendGrantParams } from '@dexterai/vault/grant';

export type SpendGrantResult = ApprovedSpendGrantParams & {
  kind: 'dexter.spendGrantResult';
  vaultPda: string;
  requestId: string | null;
  registerTx?: string;
  replaced?: boolean;
  reused?: boolean;
};

ApprovedSpendGrantParams contains:

FieldMeaning
counterpartySeller settlement address.
sessionPubkeyPublic key authorized to sign vouchers.
maxAmountAtomicApproved lifetime cap, in atomic USDC.
expiresAtUnixApproved expiry, in Unix seconds.
nonceSession registration nonce, preserved exactly as returned.
maxRevolvingCapacityAtomicApproved outstanding-exposure cap.

Before accepting a callback, validate its shape and match requestId to a pending request. Require the expected session public key and seller; reject a raised cap or extended expiry. Read the session under vaultPda and compare its stored authority and limits. A returned TypeScript type or transaction string alone establishes none of those checks.

Use the POST callback when you need all grant parameters. The redirect callback omits the nonce and revolving-capacity fields needed to reconstruct the grant.

Connect the Approved Grant

After validating the callback, pass its approved parameters unchanged to tabFromGrant. This helper connects the session and returns a payment handle:

connect-grant.ts
import { Connection } from '@solana/web3.js';
import { tabFromGrant, type ReserveFinalVoucherV2 } from '@dexterai/x402/tab';
import type { ApprovedSpendGrantParams } from '@dexterai/vault/grant';

export async function connectGrant(input: {
  rpcUrl: string;
  vaultPda: string;
  params: ApprovedSpendGrantParams;
  sessionSecretKey: Uint8Array;
  reserveFinalVoucherV2: ReserveFinalVoucherV2;
}) {
  return tabFromGrant({
    connection: new Connection(input.rpcUrl, 'confirmed'),
    vaultPda: input.vaultPda,
    params: input.params,
    sessionSecretKey: input.sessionSecretKey,
    perUnitCapAtomic: '2000', // 0.002 USDC per request
    reserveFinalVoucherV2: input.reserveFinalVoucherV2,
  });
}

Your reservation provider receives the complete signed voucher, its digest, the exact increment, and an idempotency key. Persist that request before submission. Repeated calls with the same identity must recover the same reservation. Return a FinalVoucherV2ReservationReceipt after confirmation; the SDK independently verifies its transaction, voucher-binding Memo, and account state before releasing the voucher.

The callback implements a backend contract, rather than a bundled SDK HTTP endpoint. It requires the reservation authority configured for the Vault. See the reservation receipt contract for its fields. A generic successful HTTP response cannot substitute for that receipt.

Send the resulting voucher with its receipt intact:

request-with-tab.ts
import { voucherToHeader, type Tab, type TabSignedVoucher } from '@dexterai/x402/tab';

export async function requestWithTab(
  tab: Tab,
  serviceUrl: string,
  persistVoucher: (voucher: TabSignedVoucher) => Promise<void>,
) {
  const voucher = await tab.signNextVoucher('2000');
  await persistVoucher(voucher);
  const response = await fetch(serviceUrl, {
    headers: { 'X-Tab-Voucher': voucherToHeader(voucher) },
  });
  return { response, voucher };
}

Choose the increment from the seller's quote and persist the released voucher before dispatch in your application. If delivery is uncertain, recover that request's reservation and delivery state before authorizing another charge.

Client Lifecycle

Preserve the grant's nonce exactly. Historical V1 buyer grants raise HistoricalV1MigrationRequiredError and need an owner-approved replacement before this client can use them.

MethodBehavior
tabFromGrant(...)Checks the session secret against params.sessionPubkey, reads the on-chain session, and creates a local handle. Requires an RPC connection and a per-voucher cap.
signNextVoucher(incrementAtomic)Signs an increase, confirms its exact reservation, then advances local cumulative state before delivery.
stream(url)Signs the entire perUnitCap once, then requests an SSE stream.
close()Attempts settlement and clears the local key after success. A grant handle does not revoke the owner's session.

Serialize spending under one session key. The initial on-chain resume point can omit outstanding off-chain vouchers, so retain those vouchers and reconcile them before resuming after a crash. tab.state.spent includes the session's previous cumulative spend, not just the current process's activity.

A reservation failure leaves local counters unchanged so the same signing request can recover the same voucher identity. Preserve the provider journal, voucher, and transaction identifiers before retrying; avoid switching to another payment method while the first outcome is uncertain. The wallet owner controls revocation; closing and revoking have different effects.

Last Updated:

On this page