SDK Overview
Install, configure, and operate the SpendSafe SDK.
Installation
Coming Soon
The SDK package will be available on npm soon. For now, local development requires workspace installation.
Initialising PolicyWallet
import { createEthersAdapter, PolicyWallet } from '@spendsafe/sdk';
export async function createPolicyWallet() {
const adapter = await createEthersAdapter(
process.env.PRIVATE_KEY!,
process.env.RPC_URL!
);
return new PolicyWallet(adapter, {
apiUrl: process.env.SPENDSAFE_API_URL!,
apiKey: process.env.SPENDSAFE_API_KEY!,
expectedPolicyHash: process.env.SPENDSAFE_POLICY_HASH, // optional
metadata: {
orgId: process.env.ORG_ID!,
walletId: 'my-wallet',
agentId: 'my-agent'
},
logLevel: process.env.NODE_ENV === 'production' ? 'error' : 'info',
});
}
Required Parameters
apiUrl– SpendSafe API base URL (e.g.http://localhost:3001for local development)apiKey– Required agent key; scopes policies and counters
Optional Parameters
expectedPolicyHash– SHA-256 of the active policy for integrity checks (fails closed if mismatch)metadata– Object forwarded to API for analytics/audit logs (orgId,walletId,agentId, custom fields)logLevel– Controls verbosity:'silent' | 'error' | 'warn' | 'info' | 'debug'(default:'info')assetDecimals– Map of token symbol/address → decimals; overrides formatting for custom assetsfetch– Provide fetch implementation for Node 18- environments (Node 18+ includes global fetch)logger– Supply custom logger (pino/winston) to redirect SDK logs
Configuration Reference
| Field | Type | Required | Description |
|---|---|---|---|
apiUrl | string | ✅ | SpendSafe API base URL |
apiKey | string | ✅ | Agent authentication credential |
expectedPolicyHash | string | ❌ | Optional SHA-256 for integrity verification |
metadata | object | ❌ | Custom fields for audit logs (orgId, walletId, agentId, etc.) |
assetDecimals | Object (symbol → decimals) | ❌ | Custom decimal overrides per asset |
fetch | typeof fetch | ❌ | Fetch polyfill (required for Node 18-) |
logger | Logger | ❌ | Custom logger instance |
logLevel | LogLevel | ❌ | Log verbosity level |
Note: The SDK requires a working
fetch. Node 18+ exposes it globally; older runtimes must passconfig.fetch.
⚠️ Deprecated:
policyIdparameter is deprecated (policy group now derived from API key server-side). Remove from new integrations.
Sending a Transaction
const wallet = await createPolicyWallet();
try {
const result = await wallet.send({
chain: 'ethereum', // lowercase: 'ethereum', 'base', etc.
asset: 'eth', // lowercase: 'eth', 'usdc', 'dai', etc.
to: '0xRecipient...',
amount: '25000000000000000', // ALWAYS string in base units (wei)
memo: 'Invoice #982', // optional, included in intent fingerprint
});
console.log('Hash:', result.hash);
console.log('Fee (wei):', result.fee.toString());
console.log('Allowed:', result.allowed);
if (result.counters) {
console.log('Counters:', result.counters);
}
if (result.decisionProof) {
console.log('Decision proof:', result.decisionProof);
}
} catch (error) {
if (error instanceof PolicyError) {
console.error(error.code, error.message);
console.error('Counters:', error.details?.counters);
console.error('Decision proof:', error.details?.decisionProof);
} else {
throw error;
}
}
⚠️ CRITICAL: Amount Format
Amounts MUST be strings in base units (smallest denomination):
- ETH:
"1000000000000000000"= 1 ETH (18 decimals)- USDC:
"1000000"= 1 USDC (6 decimals)- DAI:
"1000000000000000000"= 1 DAI (18 decimals)The SDK validates this format and throws
INVALID_AMOUNT_FORMATfor non-string values.
SendIntent Structure
interface SendIntent {
chain: string; // Chain identifier (lowercase): 'ethereum', 'base', 'base-sepolia', etc.
asset: string; // Asset identifier (lowercase): 'eth', 'usdc', 'dai', 'wbtc', etc.
to: string; // Recipient address
amount: string; // Amount in base units (wei/smallest unit) as string
memo?: string; // Optional memo (included in intent fingerprint via SHA-256)
}
SendResult Structure
interface SendResult {
hash: string; // Transaction hash
fee: bigint; // Gas fee paid (in base units, e.g. wei)
allowed: boolean; // Whether transaction was policy-approved
counters?: PolicyCounters; // Optional: Live spending counters (if available)
decisionProof?: DecisionProof; // Optional: Decision proof components (if available)
}
See TypeScript Interface Reference below for complete PolicyCounters and DecisionProof structures.
Note:
result.feeis the gas fee paid for the transaction (in wei for EVM chains), not the transaction amount.
Fail-Closed Behaviour
PolicyWallet.send fails closed—no signing occurs until both policy gates succeed. If Gate 1, Gate 2, or blockchain broadcast fails, the SDK throws an error and never signs the transaction.
Error Codes
Policy Errors
| Code | Meaning | Next Step |
|---|---|---|
POLICY_DECISION_DENY | Policy blocked the transfer | Check counters; adjust limits or whitelist |
POLICY_DECISION_REVIEW | Manual review required (future) | Wait for human approval |
POLICY_DECISION_UNKNOWN | Unrecognised decision from policy API | Check API logs; contact support |
POLICY_HASH_MISMATCH | Dashboard hash ≠ SDK expectation | Update expectedPolicyHash in config |
POLICY_HASH_MISSING | Gate 1 didn't return policy hash | Check API implementation |
Authentication Errors
| Code | Meaning | Next Step |
|---|---|---|
AUTH_INVALID | Gate 2 rejected authorisation token | Token tampered or invalid; retry send |
AUTH_EXPIRED | Token expired (>60s since Gate 1) | Generate fresh token via new send |
AUTH_USED | Token already consumed (replay attempt) | Generate fresh token via new send |
AUTH_MISMATCH | Intent fingerprint mismatch | Tampering detected; investigate |
AUTH_TOKEN_MISSING | Gate 1 didn't return auth token | Check API implementation |
Validation Errors
| Code | Meaning | Next Step |
|---|---|---|
VALIDATION_ERROR | Gate 1/2 validation failed | Check request format |
INTENT_FINGERPRINT_MISMATCH | Intent modified between gates | Tampering detected; investigate |
INVALID_AMOUNT_TYPE | Amount not provided as string | Fix: amount: "1000000" (string) |
INVALID_AMOUNT_EMPTY | Amount is empty string | Provide valid amount |
INVALID_AMOUNT_FORMAT | Amount contains non-numeric chars | Use only digits: "1000000" |
INVALID_INTENT_FIELD | Required field missing/invalid | Check SendIntent structure |
Network/System Errors
| Code | Meaning | Next Step |
|---|---|---|
NETWORK_ERROR | Network request failed | Check API connectivity; retry |
FETCH_UNAVAILABLE | No fetch implementation found | Provide config.fetch for Node 18- |
VERIFICATION_ERROR | Gate 2 verification failed | Check logs; retry |
UNKNOWN_ERROR | Unexpected failure | Inspect error details; contact support |
Error Object Structure
class PolicyError extends Error {
code: string; // Error code from table above
message: string; // Human-readable description
details?: {
counters?: PolicyCounters; // Current spending state (if available)
decisionProof?: DecisionProof; // Decision proof (if available)
};
}
See TypeScript Interface Reference below for complete PolicyCounters and DecisionProof structures.
Decision Proofs
Successful wallet.send() responses include decision proof components:
{
"policyHash": "ba500a3fee18ba269cd...",
"decision": "allow",
"signature": "0x...",
"signatureIssuedAt": "2025-02-14T12:34:56Z",
"apiKeyId": "api_key_123",
"intentFingerprint": "7c5b..."
}
Store proofs for:
- Compliance reporting
- Audit trail reconciliation
- Dashboard log verification
Retrieving Last Decision Proof
// Get last decision proof without making new transaction
const proof = wallet.getLatestDecisionProof();
if (proof) {
console.log('Last decision:', proof.decision);
console.log('Policy hash:', proof.policyHash);
}
Helper Methods
Access Underlying Adapter
// Get direct access to wallet adapter
const adapter = wallet.getAdapter();
const balance = await adapter.getBalance();
console.log('Balance:', balance.toString());
Get Wallet Address
// Get wallet address
const address = await wallet.getAddress();
console.log('Address:', address);
Get Wallet Balance
// Get native token balance (e.g. ETH)
const balance = await wallet.getBalance();
console.log('Balance:', balance.toString(), 'wei');
Adapters
- ethers – Default, broad ecosystem support
- viem – Modern TypeScript-first option (bundled)
- Solana – Includes lamports helper utilities (bundled)
- Coinbase, Dynamic, Privy – Embedded or custodial options
All adapters implement the WalletAdapter interface defined in the SDK (packages/sdk/src/adapters/types.ts). You can create custom adapters by implementing these methods.
Advanced Topics
Automatic Features
The SDK automatically handles:
- Nonce generation – Unique
nonce(UUID) generated per request to prevent fingerprint collisions - Idempotency keys – Automatic
idempotencyKeygeneration to prevent duplicate transactions on retry - Single-use tokens – Authorization tokens consumed at Gate 2, preventing replay attacks
- Intent fingerprinting – SHA-256 hash of canonicalized intent for tamper detection
Memo Usage
The memo field is optional but useful for:
- Correlation IDs (invoice numbers, order IDs)
- Audit trail context
- Customer reference numbers
const result = await wallet.send({
chain: 'ethereum',
asset: 'usdc',
to: '0xCustomer...',
amount: '5000000', // 5 USDC
memo: 'Refund for order #12345'
});
The memo is included in the intent fingerprint (as SHA-256 hash) and appears in audit logs.
Retry Behaviour
Important: Once Gate 1 succeeds, spending counters are updated immediately (hard reservation). If Gate 2 or blockchain broadcast fails afterward, the transaction still consumes your budget.
For failed transactions after Gate 1:
- Counters remain updated (not rolled back)
- Generate new transaction to retry
- Budget already consumed
This prevents race conditions and "double spending" via concurrent requests.
Transaction Lifecycle
- SDK generates nonce + idempotency key
- Gate 1 evaluates policy → reserves amount → issues token
- Gate 2 verifies token + fingerprint → marks consumed
- SDK signs transaction locally with your keys
- SDK broadcasts to blockchain
- SDK returns hash + fee + counters + decision proof
If any step fails, SDK throws error and does not proceed to next step.
Integration Tips
- Use memo for correlation IDs – Intent fingerprint includes SHA-256 hash of memo
- Store decision proofs – Reconcile SDK responses with dashboard audit logs
- Handle errors gracefully – Check
error.details.countersto diagnose policy blocks - Rotate API keys – When agent roles change, only latest key remains valid
- Test with small amounts – Use testnet/sepolia for integration testing
TypeScript Interface Reference
Complete type definitions from the SDK (packages/sdk/src/types.ts).
PolicyCounters
export interface PolicyCounters {
todaySpent?: string; // Amount spent today (optional, in base units)
remainingDaily?: string; // Remaining daily budget (optional, in base units)
[key: string]: string | undefined; // Extensible for custom counters
}
Note: All fields are optional. The API may return additional counter fields beyond todaySpent and remainingDaily (e.g. hourly counters, transaction counts). Use index signature [key: string] to access them.
DecisionProof
export interface DecisionProof {
policyHash: string; // Required: SHA-256 hash of active policy
decision: 'allow' | 'deny' | 'review'; // Required: Policy decision
intentFingerprint: string; // Required: SHA-256 hash of transaction intent
signature?: string; // Optional: HMAC signature of decision
signatureIssuedAt?: string; // Optional: ISO timestamp when signature issued
apiKeyId?: string; // Optional: API key that authorised the decision
}
Note: Only policyHash, decision, and intentFingerprint are guaranteed. Other fields (signature, signatureIssuedAt, apiKeyId) may be absent depending on API configuration.
PolicyMetadata
export interface PolicyMetadata {
orgId?: string;
walletId?: string;
agentId?: string;
[key: string]: string | undefined; // Extensible for custom metadata
}
Usage: Custom metadata fields forwarded to the policy API for analytics and audit logs. All fields are optional and user-defined.
SendIntent
export interface SendIntent {
chain: string; // Chain identifier (lowercase): 'ethereum', 'base', 'base-sepolia', etc.
asset: string; // Asset identifier (lowercase): 'eth', 'usdc', 'dai', 'wbtc', etc.
to: string; // Recipient address
amount: string; // Amount in base units (wei/smallest unit) as string
memo?: string; // Optional memo (included in intent fingerprint via SHA-256)
}
SendResult
export interface SendResult {
hash: string; // Transaction hash
fee: bigint; // Gas fee paid (in base units, e.g. wei)
allowed: boolean; // Whether transaction was policy-approved
counters?: PolicyCounters; // Optional: Live spending counters
decisionProof?: DecisionProof; // Optional: Decision proof components
}
PolicyError
export class PolicyError extends Error {
code: string; // Error code (see Error Codes section)
message: string; // Human-readable description
details?: {
counters?: PolicyCounters; // Current spending state (if available)
decisionProof?: DecisionProof; // Decision proof (if available)
};
}
Next Steps
- Core Concepts – Understand two-gate enforcement
- Adapter Guides – Detailed adapter documentation
- Error Handling – Common errors and solutions