Skip to main content

Error Handling

The Conto SDK provides detailed error information to help you handle failures gracefully.

ContoError Type

All SDK errors include code and status properties alongside the standard Error fields:
interface ContoError extends Error {
  code: string;    // Error code (e.g., 'PAYMENT_DENIED')
  status: number;  // HTTP status code
  message: string; // Human-readable message
}
ContoError is a TypeScript interface, not a class. The SDK attaches code and status to standard Error objects. Use property checks ('code' in error) instead of instanceof to detect SDK errors.

Error Codes

Authentication Errors

CodeStatusDescriptionSolution
AUTH_FAILED401Missing, invalid, inactive, revoked, or expired API keyCheck key is correct and active
INSUFFICIENT_SCOPE403Key lacks required permissionUse key with required scope

Payment Errors

CodeStatusDescriptionSolution
PAYMENT_DENIED403SDK pay() helper saw a denied requestInspect the request response’s reasons and violations
REQUIRES_APPROVAL202SDK pay() helper saw an approval-required requestWait for human approval
INSUFFICIENT_BALANCE400Wallet has insufficient fundsFund the wallet
EXPIRED400Payment request expiredRequest new approval
NOT_FOUND404Request ID not foundCheck the request ID
INVALID_STATUS400Cannot execute in current statusCheck payment status
NO_WALLET400No wallet assignedLink a wallet to the agent
LIMIT_EXCEEDED400Spend limit or balance re-check failed at execution timeRe-request after reducing amount or freeing budget
WALLET_CONFIG_ERROR400Wallet is misconfigured (e.g. missing custody provider)Contact your admin
CUSTODY_NOT_CONFIGURED503The custody provider is not configured on the serverContact support
MANUAL_EXECUTION_REQUIRED400External or smart contract wallets cannot be auto-executedUse the approve/confirm flow instead
ALREADY_EXECUTED409This payment request was already executedCheck transaction status instead

Validation Errors

CodeStatusDescriptionSolution
VALIDATION_ERROR400Invalid request bodyCheck request parameters
INVALID_AMOUNT400Amount must be positiveUse a positive number
INVALID_ADDRESS400Malformed wallet addressUse valid 0x address (EVM) or base58 address (Solana)
INVALID_JSON400Malformed request bodySend valid JSON

System Errors

CodeStatusDescriptionSolution
RATE_LIMITED429Too many requestsWait and retry
TIMEOUT0Request timeoutRetry with longer timeout
INTERNAL_ERROR500Server errorRetry or contact support

Handling Errors

Basic Error Handling

import { Conto } from '@conto_finance/sdk';
import type { ContoError } from '@conto_finance/sdk';

try {
  const result = await conto.payments.pay({
    amount: 100,
    recipientAddress: '0x...'
  });
} catch (error) {
  if (error instanceof Error && 'code' in error) {
    const sdkError = error as ContoError;
    console.error(`Error [${sdkError.code}]:`, sdkError.message);
    console.error('HTTP Status:', sdkError.status);
  } else {
    console.error('Unexpected error:', error);
  }
}

Handling Specific Error Codes

try {
  await conto.payments.pay({ ... });
} catch (error) {
  if (error instanceof Error && 'code' in error) {
    switch (error.code) {
      case 'PAYMENT_DENIED':
        console.log('Payment was denied by policy');
        // Check why it was denied
        break;

      case 'REQUIRES_APPROVAL':
        console.log('Payment needs human approval');
        // Notify approvers
        break;

      case 'INSUFFICIENT_BALANCE':
        console.log('Wallet needs funding');
        // Alert treasury team
        break;

      case 'LIMIT_EXCEEDED':
        console.log('Limit or balance changed before execution');
        // Re-check setup and request a smaller payment
        break;

      case 'RATE_LIMITED':
        console.log('Too many requests. The SDK already retried automatically before throwing.');
        // Back off before issuing more requests.
        break;

      default:
        console.error('Unhandled error:', error.code);
    }
  }
}

Enriched Denial And Error Responses

Some SDK responses include additional context to help agents recover programmatically. Policy denials from POST /api/sdk/payments/request are normal 200 responses with status: "DENIED", plus hint, context, and nextSteps fields. Execution failures are non-2xx error responses with an error and code.

Denial Response Structure

interface EnrichedDenial {
  requestId: string;
  status: 'DENIED';
  reasons: string[];
  violations?: object[];
  hint?: string;
  context?: {
    wallets?: Array<{
      id: string;
      address: string;
      chainId: string;
      custodyType: string;
      balance: number;
    }>;
    nextSteps?: string[];
  };
}

Example: Manual Execution Required

When trying to /execute a payment assigned to an external wallet:
{
  "error": "EXTERNAL wallets require manual execution. Execute the transfer from your custody provider and confirm with a txHash.",
  "code": "MANUAL_EXECUTION_REQUIRED"
}

Example: Policy Denial With Recovery Context

{
  "requestId": "cmm...",
  "status": "DENIED",
  "reasons": ["Would exceed daily limit"],
  "hint": "Review the violations below. You may need to request a policy exception or adjust the payment parameters.",
  "context": {
    "nextSteps": [
      "Reduce the payment amount",
      "Request a policy exception",
      "Use GET /api/sdk/setup to check current balances"
    ]
  }
}

Handling Enriched Errors

const response = await fetch(`/api/sdk/payments/${id}/execute`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.CONTO_API_KEY}` },
});

const body = await response.json();

if (!response.ok) {
  if (body.code === 'MANUAL_EXECUTION_REQUIRED') {
    // Execute with your own wallet and confirm with txHash.
    console.log('Use approve/confirm for this wallet');
  }

  if (body.context?.nextSteps) {
    console.log('Suggested actions:', body.context.nextSteps);
  }

  throw new Error(body.error || 'Payment execution failed');
}

Using Request and Execute for Better Control

The pay() method throws on denial. For more control, use separate request/execute:
// Request first (never throws for denied payments)
const request = await conto.payments.request({
  amount: 100,
  recipientAddress: '0x...'
});

if (request.status === 'DENIED') {
  console.log('Denied reasons:', request.reasons);
  console.log('Violations:', request.violations);
  return null;
}

if (request.status === 'REQUIRES_APPROVAL') {
  console.log('Awaiting approval...');
  return { pending: true, requestId: request.requestId };
}

// Only execute if approved
try {
  return await conto.payments.execute(request.requestId);
} catch (error) {
  // Handle execution errors
  if (error.code === 'INSUFFICIENT_BALANCE') {
    // Balance changed between request and execute
  }
}

Handling Rate Limits

The SDK already handles rate limits for you before throwing (see Retry Strategy below for the exact behavior). retryAfter is consumed internally and is not exposed on the thrown error, so do not read error.retryAfter. If you need an additional application-level retry after the SDK has exhausted its own, wrap the call and back off on your own schedule:
async function payWithRetry(params: PaymentRequestInput) {
  const maxRetries = 3;

  for (let i = 0; i < maxRetries; i++) {
    try {
      return await conto.payments.pay(params);
    } catch (error) {
      const isRateLimited = error instanceof Error && 'code' in error && error.code === 'RATE_LIMITED';
      if (isRateLimited && i < maxRetries - 1) {
        const waitTime = 2 ** i; // 1s, 2s, 4s
        console.log(`Still rate limited. Waiting ${waitTime}s...`);
        await new Promise((r) => setTimeout(r, waitTime * 1000));
        continue;
      }
      throw error;
    }
  }
}

Handling Timeouts

For long-running requests, handle timeouts:
const conto = new Conto({
  apiKey: process.env.CONTO_API_KEY!,
  timeout: 60000  // 60 seconds
});

try {
  await conto.payments.pay({ ... });
} catch (error) {
  if (error.code === 'TIMEOUT') {
    console.log('Request timed out');
    // Don't automatically retry payments - check status first!
    const status = await conto.payments.status(requestId);
    if (status.transaction) {
      console.log('Payment was actually submitted:', status.transaction.txHash);
    }
  }
}

Retry Strategy

Built-in Automatic Retry

The SDK automatically retries transient failures with exponential backoff. You don’t need to implement retry logic yourself for most cases. Built-in behavior:
  • Retries up to 3 times on 429 (rate limited) and 5xx (server errors)
  • Respects Retry-After headers from the server
  • Exponential backoff: 1s → 2s → 4s (capped at 10s)
  • Does not retry client errors (4xx except 429) or auth failures
// The SDK handles retries automatically, just call the method
const result = await conto.payments.pay({
  amount: 100,
  recipientAddress: '0x...'
});
// If the server returns 429 or 503, the SDK waits and retries automatically

Custom Retry for Application Logic

For application-level retry logic (e.g., re-requesting after a denial), use a custom wrapper:
async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3,
  baseDelay = 1000
): Promise<T> {
  let lastError: Error;

  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;

      // Don't retry certain errors
      if (error instanceof Error && 'code' in error) {
        const sdkError = error as ContoError;
        if (['AUTH_FAILED', 'PAYMENT_DENIED', 'VALIDATION_ERROR'].includes(sdkError.code)) {
          throw error;  // Non-retryable
        }

        if (sdkError.code === 'RATE_LIMITED') {
          const delay = baseDelay * Math.pow(2, i);
          await new Promise(r => setTimeout(r, delay));
          continue;
        }
      }

      // Exponential backoff for other errors
      const delay = baseDelay * Math.pow(2, i);
      await new Promise(r => setTimeout(r, delay));
    }
  }

  throw lastError!;
}

// Usage for application-level retries
const result = await withRetry(() => conto.payments.pay({
  amount: 100,
  recipientAddress: '0x...'
}));

Logging Errors

async function loggedPayment(params: PaymentRequestInput) {
  try {
    const result = await conto.payments.pay(params);
    console.log('Payment successful', {
      txHash: result.txHash,
      amount: result.amount
    });
    return result;
  } catch (error) {
    const sdkError = error as ContoError;
    console.error('Payment failed', {
      code: sdkError.code,
      message: sdkError.message,
      params: {
        amount: params.amount,
        recipient: params.recipientAddress,
        purpose: params.purpose
      }
    });
    throw error;
  }
}

Best Practices

Never let payment errors crash your application:
// Bad
await conto.payments.pay({ ... });

// Good
try {
  await conto.payments.pay({ ... });
} catch (error) {
  // Handle gracefully
}
Don’t just catch generic errors:
// Bad
catch (error) {
  console.log('Something went wrong');
}

// Good
catch (error) {
  if (error.code === 'INSUFFICIENT_BALANCE') {
    // Specific handling
  }
}
Be careful with retries on payment execution:
// Dangerous - might double-pay
await withRetry(() => conto.payments.execute(requestId));

// Safe - check status first
const status = await conto.payments.status(requestId);
if (!status.transaction) {
  await conto.payments.execute(requestId);
}
Always log error details for debugging:
catch (error) {
  console.error('Payment error', {
    code: error.code,
    status: error.status,
    message: error.message
  });
}

Next Steps

Examples

See complete integration examples

API Reference

View the REST API (Swagger UI)