Skip to main content

Payments API

The payments API allows agents to request authorization and execute stablecoin payments.

Overview

The payment flow has two steps:
  1. Request - Request authorization and policy evaluation
  2. Execute - Execute the approved payment onchain
Or use autoExecute: true to request and execute in a single API call. You can also use the SDK convenience method pay() to do both in one call.

Choose The Right Flow

Wallet modelUse this flowWhat Conto can stop
Managed (PRIVY, SPONGE)request -> executeConto stays in the execution path and can block the spend
External (EXTERNAL)approve -> transfer -> confirmConto governs the Conto-routed flow, but cannot block a direct self-signed transfer outside Conto
payments.execute() is for managed wallets. If your agent holds the signing keys, use the external-wallet approve -> confirm flow instead.

payments.request()

Request authorization for a payment. This evaluates policies without executing.
const request = await conto.payments.request({
  amount: 100,
  recipientAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f...',
  recipientName: 'OpenAI',
  purpose: 'GPT-4 API credits',
  category: 'AI_SERVICES',
});

Parameters

ParameterTypeRequiredDescription
amountnumberYesPayment amount
recipientAddressstringYesWallet address — 0x + 40 hex chars for EVM, or base58 (32-44 chars) for Solana. Validated via Zod schema.
recipientNamestringNoHuman-readable name
purposestringNoWhy this payment is needed
categorystringNoSpending category
contextobjectNoAdditional metadata
walletIdstringNoSpecific wallet to use
urgencystringNoLOW, NORMAL, HIGH, CRITICAL
autoExecutebooleanNoIf true, automatically execute the payment when approved. Returns the transaction result directly instead of requiring a separate execute() call.
targetContractAddressstringNoSmart contract address for contract interaction policy evaluation
functionSelectorstringNo4-byte function selector (e.g., 0xa9059cbb) for contract allowlist rules
idempotencyKeystringNoClient-supplied key that makes retries safe. Reusing the same key with the same request returns the original request; reusing it with different request parameters returns a conflict.
External wallet users: If your agent controls its own wallet keys and uses the /api/sdk/payments/approve endpoint instead, chainId is a required parameter. See the Agent Skills guide (OpenClaw and Hermes) for the external-wallet flow.
If you include context.invoice, Conto preserves that payload in the raw request context and also stores a typed invoice record on the PaymentRequest. Supported invoice fields are vendorId, vendorAddress, id, hash, sourceUrl, payload, expectedAmount, currency, and dueDate. Delta verification workflows and payment.executed webhook payloads prefer this typed invoice record for new requests.
If the policy result is REQUIRES_APPROVAL and an approval workflow matches, Conto opens the approval request immediately and includes approvalRequestId in the API response. This is the handle external verifier systems such as Delta use for callback decisions.

External Wallet Approve/Confirm Flow

If your agent holds the signing keys, use the external-wallet flow instead of payments.execute():
  1. Call POST /api/sdk/payments/approve to evaluate policy and receive an approvalToken
  2. Submit the onchain transfer through your own signer or wallet integration
  3. Call POST /api/sdk/payments/{requestId}/confirm with the final txHash, plus the approvalToken when the original /approve response returned one
This keeps the same policy checks and audit trail while leaving execution in the agent’s control. It does not cryptographically block a direct transfer signed outside Conto.
If the senderAddress has not been seen before, Conto auto-creates an external wallet record for that address and links it to the agent’s organization. That record still counts toward the organization’s wallet limit, so approval can fail with WALLET_LIMIT_REACHED when the org is already at capacity.
The external-wallet approve flow accepts the same optional context.invoice object as payments.request() and stores the same typed invoice fields on the resulting PaymentRequest.
If /api/sdk/payments/approve returns requiresHumanApproval: true and a workflow matches, Conto opens the approval request immediately and includes approvalRequestId in the response. After that workflow approves the payment, POST /api/sdk/payments/{requestId}/confirm can be called with just the final txHash; human-approved external payments do not require an approvalToken.

Response

interface PaymentRequestResult {
  requestId: string; // Use this to execute
  status: 'APPROVED' | 'DENIED' | 'REQUIRES_APPROVAL' | 'EXECUTED';
  idempotent?: boolean; // Present when a retry returns an existing request
  approvalRequestId?: string; // Present when the request is routed into an approval workflow

  // If approved or executed
  wallet?: {
    id: string;
    address: string;
    chainId: string;
    custodyType: string;
    availableBalance: number;
  };
  walletSelectionReason?: string;
  expiresAt?: string; // ISO timestamp
  currency?: string; // e.g., "USDC", "USDT", "pathUSD"
  chain?: {
    chainId: string;
    chainName: string;
    chainType: string;
    explorerUrl?: string;
  };

  // If executed (autoExecute was true)
  execution?: {
    transactionId: string;
    txHash: string;
    explorerUrl: string;
    status: string;
  };

  // Always present
  reasons: string[];

  // If denied - includes enriched context
  violations?: {
    type: string;
    limit: number;
    current: number;
    message: string; // Prefixed with source, e.g. "[Wallet limit]" or "[Policy: Name]"
    source?: 'wallet_limit' | 'policy_rule';
    policyName?: string; // Present when source is "policy_rule"
  }[];
  context?: {
    wallets?: Array<{
      id: string;
      address: string;
      chainId: string;
      custodyType: string;
      balance: number;
    }>;
    nextSteps?: string[];
  };

  // If autoExecute failed
  autoExecuteError?: string;
}
All payment responses now include currency (e.g., “USDC”, “USDT”, “USDC.e”, or “pathUSD”) and chain (with chainId, chainName, chainType, and explorerUrl) so agents know exactly which network and token the payment uses.
Currency depends on chain: The currency field reflects the stablecoin used on the wallet’s blockchain. On Base, Ethereum, Arbitrum, and Polygon, both USDC and USDT are supported. On Tempo Testnet, it is pathUSD. On Tempo Mainnet, it is USDC.e. The amount is always denominated in the stablecoin configured for the payment.

Idempotent Retries

Use idempotencyKey when your caller may retry the same payment request because of network timeouts or uncertain client state.
  • Same idempotencyKey + same request payload: returns the original requestId with idempotent: true
  • Same idempotencyKey + different request payload: returns HTTP 409 with code: "IDEMPOTENCY_CONFLICT"
  • Different idempotencyKey: creates a new payment request
const request = await conto.payments.request({
  amount: 100,
  recipientAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f...',
  purpose: 'Top up API credits',
  idempotencyKey: 'payment-req-2026-04-17-001',
});

Example

const request = await conto.payments.request({
  amount: 50,
  recipientAddress: '0x...',
  purpose: 'API credits',
});

switch (request.status) {
  case 'APPROVED':
    console.log('Payment approved');
    console.log('Wallet:', request.wallet?.address);
    console.log('Expires:', request.expiresAt);
    break;

  case 'DENIED':
    console.log('Payment denied:', request.reasons);
    break;

  case 'REQUIRES_APPROVAL':
    console.log('Needs manual approval');
    break;
}

payments.execute()

Execute an approved payment request.
const result = await conto.payments.execute(requestId);

Parameters

ParameterTypeRequiredDescription
requestIdstringYesThe requestId from payment request

Response

interface PaymentExecuteResult {
  transactionId: string;
  txHash: string; // Blockchain transaction hash
  status: 'CONFIRMING' | 'CONFIRMED' | 'FAILED';
  amount: number;
  currency: string; // Stablecoin type
  recipient: string;
  recipientName?: string;
  wallet: {
    address: string;
  };
  explorerUrl: string; // Block explorer link
}

Example

const request = await conto.payments.request({
  amount: 50,
  recipientAddress: '0x...',
});

if (request.status === 'APPROVED') {
  const result = await conto.payments.execute(request.requestId);

  console.log('Transaction hash:', result.txHash);
  console.log('Explorer:', result.explorerUrl);
}

payments.pay()

Convenience method that requests and executes in one call.
const result = await conto.payments.pay({
  amount: 50,
  recipientAddress: '0x...',
  purpose: 'API credits',
});

Behavior

  • If approved: Executes immediately and returns result
  • If denied: Throws ContoError with code PAYMENT_DENIED
  • If requires approval: Throws ContoError with code REQUIRES_APPROVAL

Example

try {
  const result = await conto.payments.pay({
    amount: 50,
    recipientAddress: '0x...',
    purpose: 'API credits',
  });

  console.log('Paid! TX:', result.txHash);
} catch (error) {
  if (error.code === 'PAYMENT_DENIED') {
    console.log('Payment denied:', error.message);
  } else if (error.code === 'REQUIRES_APPROVAL') {
    console.log('Payment needs manual approval');
  }
}

autoExecute Flag

The autoExecute flag lets you request authorization and execute the payment in a single API call, without needing a separate execute() call.
Requires both payments:request and payments:execute scopes. Since payments:execute is not included in default scopes, you must explicitly grant it when creating the SDK key. If the key only has payments:request, the flag is silently ignored and the response is a normal APPROVED status.

How It Works

  • If APPROVED + autoExecute: true: Executes immediately, returns status: "EXECUTED" with execution object containing txHash
  • If DENIED or REQUIRES_APPROVAL: autoExecute is ignored, normal response returned
  • If execution fails: Returns status: "APPROVED" with autoExecuteError message and executeUrl for manual retry

Example

// Single-call payment: request + execute
const result = await fetch('/api/sdk/payments/request', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${apiKey}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    amount: 50,
    recipientAddress: '0x...',
    recipientName: 'OpenAI',
    purpose: 'API credits',
    autoExecute: true
  })
}).then(r => r.json());

if (result.status === 'EXECUTED') {
  console.log('Payment complete!');
  console.log('TX Hash:', result.execution.txHash);
  console.log('Explorer:', result.execution.explorerUrl);
} else if (result.status === 'APPROVED' && result.autoExecuteError) {
  // Auto-execute failed, retry manually
  console.log('Auto-execute failed:', result.autoExecuteError);
  const execResult = await fetch(result.executeUrl, { method: 'POST', ... });
}

payments.status()

Check the status of a payment request.
const status = await conto.payments.status(requestId);

Response

interface PaymentStatusResult {
  requestId: string;
  status: 'PENDING' | 'APPROVED' | 'DENIED' | 'COMPLETED' | 'EXPIRED';
  policyResult: string;
  amount: number;
  currency: string;
  recipient: string;
  recipientName?: string;
  purpose?: string;
  category?: string;
  wallet?: { address: string };
  requiresApproval: boolean;
  approvedAt?: string;
  deniedAt?: string;
  denialReason?: string;
  expiresAt?: string;
  createdAt: string;

  // If executed
  transaction?: {
    id: string;
    txHash: string;
    status: 'PENDING' | 'CONFIRMING' | 'CONFIRMED' | 'FAILED';
    confirmedAt?: string;
    blockNumber?: number;
  };
}

Example: Polling for Confirmation

async function waitForConfirmation(requestId: string) {
  while (true) {
    const status = await conto.payments.status(requestId);

    if (status.transaction?.status === 'CONFIRMED') {
      console.log('Confirmed at block:', status.transaction.blockNumber);
      return status;
    }

    if (status.transaction?.status === 'FAILED') {
      throw new Error('Transaction failed');
    }

    await new Promise((r) => setTimeout(r, 2000)); // Wait 2 seconds
  }
}

Status Reference

Payment requests and transactions use different status values:
Payment Request StatusDescription
APPROVEDPolicies passed, ready to execute
DENIEDBlocked by a policy or wallet limit
REQUIRES_APPROVALNeeds manual human approval
EXECUTEDAuto-executed successfully (when autoExecute: true)
PENDINGAwaiting human approval (maps to REQUIRES_APPROVAL in SDK response)
COMPLETEDPayment fully confirmed onchain
EXPIREDApproval window elapsed without execution
Transaction StatusDescription
PENDINGTransaction submitted, awaiting confirmation
EXECUTINGTransaction is being processed
CONFIRMINGTransaction broadcast, awaiting block confirmation
CONFIRMEDTransaction confirmed onchain
FAILEDTransaction failed
REJECTEDTransaction rejected
CANCELLEDTransaction cancelled before execution
Policy ResultDescription
ALLOWEDAll policies passed
DENIEDAt least one policy denied the payment
REQUIRES_APPROVALPolicies passed but approval threshold triggered
FLAGGEDPayment flagged for review
PENDINGPolicy evaluation not yet complete

Categories

Use standard categories for better analytics:
CategoryDescription
INFRASTRUCTURECloud, hosting, compute
AI_SERVICESAI APIs, model training
MARKETINGAdvertising, promotions
OPERATIONSGeneral operations
VENDORVendor payments
EMPLOYEEEmployee reimbursements
TESTINGTest transactions

Urgency Levels

LevelDescription
LOWCan wait, batch if possible
NORMALStandard priority (default)
HIGHProcess quickly
CRITICALImmediate processing

Best Practices

Including purpose improves audit trails and analytics:
await conto.payments.pay({
  amount: 100,
  recipientAddress: '0x...',
  purpose: 'AWS EC2 instance for training job #1234',  // Specific
  category: 'INFRASTRUCTURE'
});
Approvals from /request expire after 5 minutes. Approvals from /approve (external wallets) expire after 10 minutes. Check expiration before executing:
const request = await conto.payments.request({ ... });

if (request.status === 'APPROVED') {
  const expiresAt = new Date(request.expiresAt!);

  if (expiresAt > new Date()) {
    await conto.payments.execute(request.requestId);
  } else {
    // Request a new approval
    const newRequest = await conto.payments.request({ ... });
  }
}
Use separate request/execute when you need to:
  • Validate before executing
  • Show user confirmation
  • Handle requires_approval status
const request = await conto.payments.request({ ... });

if (request.status === 'REQUIRES_APPROVAL') {
  // Store requestId, notify approvers
  await notifyApprovers(request.requestId);
  return { pending: true, requestId: request.requestId };
}

if (request.status === 'APPROVED') {
  return conto.payments.execute(request.requestId);
}
Use the context field for debugging info:
await conto.payments.pay({
  amount: 100,
  recipientAddress: '0x...',
  purpose: 'API subscription',
  context: {
    jobId: '1234',
    userId: 'user_abc',
    environment: 'production'
  }
});

Next Steps

Error Handling

Handle payment errors gracefully

Examples

See complete integration examples