Menu

Explorer & Settings

Tempo Explorer Submit Project
Back to all projects

AgentPay-SDK-Go

by agent-tech · Updated 33 days ago
Open source alternative to:

In the AI payments ecosystem

AgentPay-SDK-Go is an early-stage Go project in the AI payments / x402 ecosystem. It currently has 0 GitHub stars and 0 forks.

README.md View on GitHub →

Pay SDK (Go)

Go Reference Go Version

Go client for the Agent Tech payment API — create intents, execute USDC / USDT0 / USDT transfers across chains, and query status.

  • One unified client — a single Client that auto-selects the API prefix based on auth:
    • With auth (WithBearerAuth) → /v2 prefix — create intent → execute (backend signs with Agent wallet)
    • Without auth → /api prefix (public mode) — create intent → payer signs X402 & pays → submit settle_proof
  • Zero dependencies beyond the Go standard library
  • Multichain to multichain — pick any supported payer_chain and any supported target_chain (defaults to base when omitted)
  • Multi-asset — pay and settle in usdc (default), usdt0, or usdt; the payer and target legs are independent, so you can pay USDT0 on Arbitrum and settle USDC on Base, or settle USDT0 cross-chain (defaults to usdc when omitted)

Table of Contents

Install

go get github.com/cross402/usdc-sdk-go

Quick Start

Authenticated (v2 API)

package main

import (
    "context"
    "log"

    "github.com/cross402/usdc-sdk-go"
)

func main() {
    client, err := pay.NewClient(
        "https://api-pay.agent.tech",
        pay.WithBearerAuth("your-api-key", "your-secret-key"),
    )
    if err != nil {
        log.Fatal(err)
    }
    ctx := context.Background()

    // 1. Create intent (multichain: pay from Base, settle on Solana)
    resp, err := client.CreateIntent(ctx, &pay.CreateIntentRequest{
        Email:       "merchant@example.com",
        Amount:      "100.50",
        PayerChain:  pay.ChainBase,
        TargetChain: pay.ChainSolanaMainnet, // omit to default to "base"
    })
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("Intent ID: %s (settling on %s)", resp.IntentID, resp.TargetChain)

    // 2. Execute transfer (backend signs with Agent wallet)
    exec, err := client.ExecuteIntent(ctx, resp.IntentID)
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("Status: %s", exec.Status)

    // 3. Query full receipt
    intent, err := client.GetIntent(ctx, resp.IntentID)
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("Final status: %s", intent.Status)
}

Public mode (no auth)

client, err := pay.NewClient("https://api-pay.agent.tech")
if err != nil {
    log.Fatal(err)
}
resp, err := client.CreateIntent(ctx, &pay.CreateIntentRequest{
    Email:      "merchant@example.com",
    Amount:     "100.50",
    PayerChain: "solana",
})
// Use resp.PaymentRequirements for payer to sign X402...
// After payment, submit the settle_proof:
proof, err := client.SubmitProof(ctx, resp.IntentID, settleProof)

Or run the bundled example:

git clone https://github.com/cross402/usdc-sdk-go
cd usdc-sdk-go

PAY_BASE_URL=https://api-pay.agent.tech \
PAY_API_KEY=your-api-key \
PAY_SECRET_KEY=your-secret-key \
go run ./example

Set PAY_INTENT_ID to skip creation and query an existing intent instead.

Authenticated vs Public Mode

Authenticated (/v2) Public (/api)
Auth WithBearerAuth None
Flow CreateIntent → ExecuteIntent → GetIntent CreateIntent → (payer pays) → SubmitProof → GetIntent
Use when Integrator has no wallet; backend Agent signs Integrator has payer's wallet; can sign X402 and submit settle_proof

Both modes use the same Client — the prefix is selected automatically based on whether an auth option is provided.

Authentication

Bearer token (recommended)

Base64-encodes apiKey:secretKey and sends it as Authorization: Bearer <token>.

client, err := pay.NewClient(baseURL, pay.WithBearerAuth("api-key", "secret-key"))

Custom HTTP client

The default HTTP client uses a 30-second timeout. Override with options:

client, err := pay.NewClient(baseURL,
    pay.WithBearerAuth("id", "secret"),
    pay.WithHTTPClient(&http.Client{
        Timeout:   60 * time.Second,
        Transport: customTransport,
    }),
)

Or just change the timeout:

client, err := pay.NewClient(baseURL,
    pay.WithBearerAuth("id", "secret"),
    pay.WithTimeout(60 * time.Second),
)

API Methods

Method Auth Required Endpoint Description
GetSupportedChains No GET /api/chains List payer and target chains the backend currently accepts
CreateIntent No POST {prefix}/intents Create a payment intent
ExecuteIntent Yes POST /v2/intents/{id}/execute Execute transfer with the Agent wallet (settles on the chosen target chain)
SubmitProof No (public mode only) POST /api/intents/{id} Submit settle_proof after payer completes X402 payment
GetIntent No / Yes GET {prefix}/intents?intent_id=... Get intent status and receipt. With auth, only intents owned by the calling agent are returned (404 otherwise — see below)
ListIntents Yes GET /v2/intents/list Paginated list of intents owned by the calling agent (most recent first)
GetMe Yes GET /v2/me Returns the calling agent's identity (id, number, name, status, wallets)

GetSupportedChains

chains, err := client.GetSupportedChains(ctx)
// chains.Chains       — chains valid as PayerChain
// chains.TargetChains — chains valid as TargetChain

This endpoint is public-only on the backend; the SDK always hits /api/chains even when the client is configured with auth.

CreateIntent

resp, err := client.CreateIntent(ctx, &pay.CreateIntentRequest{
    Email:       "merchant@example.com",   // or Recipient (exactly one required)
    Amount:      "100.50",                 // 0.02–1,000,000 USDC, max 6 decimals
    PayerChain:  pay.ChainBase,            // source chain
    TargetChain: pay.ChainSolanaMainnet,   // optional — omit to settle on "base"
})

CreateIntentRequest fields:

Field JSON Required Description
Email email One of Email/Recipient Recipient email address
Recipient recipient One of Email/Recipient Recipient wallet address
Amount amount One of Amount/ToAmount ExactOut target — the recipient receives exactly this (e.g. "100.50")
ToAmount to_amount One of Amount/ToAmount ExactIn amount — the payer sends exactly this (e.g. "100.50")
PayerChain payer_chain Yes Source chain (see Supported Chains)
TargetChain target_chain No Settlement chain. Omit to default to "base". Validate against GetSupportedChains().TargetChains.
PayerAsset payer_asset No Token the payer sends. Omit to default to usdc. See Assets.
TargetAsset target_asset No Token the recipient receives. Omit to default to usdc. See Assets.
PayerAddress payer_address No Optional payer wallet, screened advisorily against sanctions lists at create time.

ExecuteIntent

No request body — the backend uses the Agent wallet to sign the source-chain transfer and settle on the chosen target chain. Requires auth.

exec, err := client.ExecuteIntent(ctx, resp.IntentID)
// exec.Status is typically StatusTargetSettled

SubmitProof

Submits a settle_proof after the payer completes X402 payment on the source chain. No auth required.

proof, err := client.SubmitProof(ctx, intentID, settleProof)

GetIntent (query status)

intent, err := client.GetIntent(ctx, intentID)
switch intent.Status {
case pay.StatusTargetSettled:
    // use intent.TargetPayment for receipt; intent.TargetChain names the chain
case pay.StatusExpired, pay.StatusVerificationFailed:
    // terminal failure
default:
    // still processing — poll again
}

v2 ownership policy: when the client is configured with WithBearerAuth, GetIntent (and ExecuteIntent/SubmitProof) only return intents owned by the calling agent. Looking up an intent owned by a different agent — or one created via the public /api flow — returns HTTP 404, not 403, so callers can't probe foreign intent IDs.

ListIntents (paginated, owned by the calling agent)

list, err := client.ListIntents(ctx, /*page*/ 1, /*pageSize*/ 20)
if err != nil {
    log.Fatal(err)
}

for _, it := range list.Intents {
    log.Printf("%s — %s (%s → %s)", it.IntentID, it.Status, it.PayerChain, it.TargetChain)
}

log.Printf("total=%d page=%d size=%d", list.Total, list.Page, list.PageSize)
  • Auth required. Returns only intents owned by the calling agent.
  • page is 1-indexed; the server caps it at 1,000,000.
  • pageSize must be in [1, 100]. Pass 0 for either parameter to use the server defaults (page 1, 20 per page).
  • Out-of-range values (pageSize > 100, page < 0) are rejected by the SDK before reaching the API.

GetMe (authenticated identity)

me, err := client.GetMe(ctx)
if err != nil {
    log.Fatal(err)
}

log.Printf("agent %s (%s) status=%s", me.AgentID, me.AgentNumber, me.Status)
log.Printf("base wallet=%s solana wallet=%s", me.WalletAddress, me.SolanaWalletAddress)

GetMe is served from middleware context — it does not hit the database — and is the cheapest way to verify an API key is live and bound to the agent the caller expects.

Intent Lifecycle

Intents expire 10 minutes after creation.

                          ┌──────────────────┐
                          │ AWAITING_PAYMENT  │
                          └────────┬─────────┘
                                   │
                      ┌────────────┼────────────┐
                      │            │             │
                      ▼            ▼             ▼
               ┌──────────┐ ┌──────────┐ ┌─────────────────────┐
               │ EXPIRED  │ │ PENDING  │ │ VERIFICATION_FAILED │
               └──────────┘ └────┬─────┘ └─────────────────────┘
                                 │
                                 ▼
                        ┌────────────────┐
                        │ SOURCE_SETTLED │
                        └───────┬────────┘
                                │
                                ▼
                       ┌─────────────────┐
                       │ TARGET_SETTLING │
                       └────────┬────────┘
                                │
                                ▼
                        ┌────────────────┐
                        │ TARGET_SETTLED │
                        └────────────────┘

Use the status constants instead of bare strings:

Constant Value Description
pay.StatusAwaitingPayment AWAITING_PAYMENT Intent created, waiting for execution
pay.StatusPending PENDING Execution initiated, processing
pay.StatusVerificationFailed VERIFICATION_FAILED Source payment verification failed (terminal)
pay.StatusSourceSettled SOURCE_SETTLED Source chain payment confirmed
pay.StatusTargetSettling TARGET_SETTLING USDC transfer on the target chain in progress
pay.StatusTargetSettled TARGET_SETTLED Transfer complete — check target_payment for receipt (terminal)
pay.StatusPartialSettlement PARTIAL_SETTLEMENT Source settled but target failed — manual reconciliation required
pay.StatusExpired EXPIRED Intent was not executed within 10 minutes (terminal)
pay.StatusBlocked BLOCKED Compliance reject (OFAC SDN hit) — never retried (terminal)

Supported Chains

The set of payer and target chains is configured at runtime by the backend. Call GetSupportedChains to discover what's currently enabled — the two lists are independent (a chain can be a valid payer without being a valid settlement destination, and vice versa).

chains, err := client.GetSupportedChains(ctx)
// chains.Chains       — valid as PayerChain
// chains.TargetChains — valid as TargetChain (omitted defaults to "base")

Use the Chain* constants instead of bare strings:

Chain Testnet Constant Mainnet Constant
Solana pay.ChainSolanaDevnet ("solana-devnet") pay.ChainSolanaMainnet ("solana-mainnet-beta")
Base pay.ChainBaseSepolia ("base-sepolia") pay.ChainBase ("base")
BSC pay.ChainBSCTestnet ("bsc-testnet") pay.ChainBSC ("bsc")
Polygon pay.ChainPolygonAmoy ("polygon-amoy") pay.ChainPolygon ("polygon")
Arbitrum pay.ChainArbitrumSepolia ("arbitrum-sepolia") pay.ChainArbitrum ("arbitrum")
Ethereum pay.ChainEthereumSepolia ("ethereum-sepolia") pay.ChainEthereum ("ethereum")
Monad pay.ChainMonadTestnet ("monad-testnet") pay.ChainMonad ("monad")
HyperEVM pay.ChainHyperEVMTestnet ("hyperevm-testnet") pay.ChainHyperEVM ("hyperevm")
SKALE Base pay.ChainSKALEBase ("skale-base", payer-only)
MegaETH pay.ChainMegaETH ("megaeth", payer-only)

ChainSKALEBase and ChainMegaETH are accepted only as PayerChain; the backend rejects them as TargetChain.

resp, err := client.CreateIntent(ctx, &pay.CreateIntentRequest{
    Email:       "merchant@example.com",
    Amount:      "100.50",
    PayerChain:  pay.ChainBase,           // use constants instead of bare strings
    TargetChain: pay.ChainSolanaMainnet,  // optional — omit to settle on "base"
})

Assets (USDT0 / USDT)

Each intent carries an asset on both legs — PayerAsset (what the payer sends on the source chain) and TargetAsset (what the recipient receives on the settlement chain). The two are independent of each other and of the chains, so you can mix them freely. Omit either to default it to usdc.

Use the Asset constants instead of bare strings:

Constant Value Notes
pay.AssetUSDC usdc Circle USDC. The default when an asset is omitted.
pay.AssetUSDT0 usdt0 LayerZero USDT0 omnichain token (on Polygon, Tether's renamed legacy deployment).
pay.AssetUSDT usdt Native Tether USDT. Some deployments are gated by the backend operator.

USDT0 cross-chain payment

resp, err := client.CreateIntent(ctx, &pay.CreateIntentRequest{
    Email:       "merchant@example.com",
    Amount:      "100.50",
    PayerChain:  pay.ChainArbitrum,
    PayerAsset:  pay.AssetUSDT0,    // pay in USDT0 on Arbitrum
    TargetChain: pay.ChainPolygon,
    TargetAsset: pay.AssetUSDT0,    // settle in USDT0 on Polygon
})

You can also cross assets — e.g. pay USDT0 on Arbitrum and settle USDC on Base — by setting TargetAsset: pay.AssetUSDC.

USDT0 chain coverage

USDT0 is deployed on these mainnet chains at the time of writing:

Chain USDT0
Arbitrum (pay.ChainArbitrum)
Polygon (pay.ChainPolygon)
Monad (pay.ChainMonad)
HyperEVM (pay.ChainHyperEVM)
MegaETH (pay.ChainMegaETH) ✓ (payer-only — rejected as TargetChain)

There is no runtime discovery endpoint for per-chain assets, so an unsupported (chain, asset) pair is rejected by the backend with HTTP 400 (a *RequestError) — treat that as the source of truth if coverage changes.

PayerAsset and TargetAsset are echoed back on both CreateIntentResponse and GetIntentResponse.

Fee Breakdown

The FeeBreakdown struct is returned in all intent response types (embedded via IntentBase):

Field JSON Description
SourceChain source_chain Source chain identifier
SourceChainFee source_chain_fee Gas/network fee on the source chain
TargetChain target_chain Settlement chain identifier
TargetChainFee target_chain_fee Gas/network fee on the target chain
PlatformFee platform_fee Platform service fee
PlatformFeePercentage platform_fee_percentage Platform fee as a percentage
TotalFee total_fee Sum of all fees

Amount rules:

  • Minimum: 0.02 USDC
  • Maximum: 1,000,000 USDC
  • Up to 6 decimal places (e.g. "0.000001", "123.45")

Error Handling

The SDK uses typed errors and sentinel values for precise error matching.

Error types

RequestError — returned for HTTP 4xx/5xx responses from the API:

var reqErr *pay.RequestError
if errors.As(err, &reqErr) {
    log.Printf("HTTP %d: %s", reqErr.StatusCode, reqErr.Body)
}

ValidationError — returned when the SDK rejects a request before it reaches the API (e.g. empty intent ID). Wraps a sentinel error for errors.Is matching:

var valErr *pay.ValidationError
if errors.As(err, &valErr) {
    log.Printf("Invalid input: %s", valErr.Message)
}

UnexpectedError — wraps unexpected internal errors (JSON marshal failure, request creation, etc.):

var unexpErr *pay.UnexpectedError
if errors.As(err, &unexpErr) {
    log.Printf("Unexpected: %v", unexpErr.Err)
}

Sentinel errors

Use errors.Is to check for specific validation failures:

if errors.Is(err, pay.ErrEmptyIntentID) {
    // intent ID was empty
}
Sentinel Meaning
ErrEmptyBaseURL baseURL was empty in NewClient
ErrEmptyIntentID intentID was empty
ErrEmptySettleProof settleProof was empty in SubmitProof
ErrMissingAuth ExecuteIntent, ListIntents, or GetMe called without auth
ErrNilParams CreateIntentRequest was nil
ErrSubmitProofNotAllowed SubmitProof called with WithBearerAuth (use ExecuteIntent instead)
ErrInvalidPagination page < 0, pageSize < 0, or pageSize > 100 in ListIntents

HTTP status codes

Status Code Meaning
400 Bad request — invalid parameters, amount out of range, malformed input, or out-of-range pagination (page, page_size)
401 Unauthorized — missing or invalid credentials
402 Payment required — agent wallet has insufficient USDC to satisfy ExecuteIntent
403 Forbidden — reserved for future use; v2 ownership rejection returns 404, not 403
404 Not found — intent does not exist or is owned by a different agent (uniform response prevents existence-leak probing)
429 Rate limited — too many requests (60 req/min/IP typical)
503 Service unavailable — temporary backend issue (e.g. proxy wallet temporarily out of funds)

Advanced

Custom HTTP client with retry

client, err := pay.NewClient(baseURL,
    pay.WithBearerAuth(apiKey, secretKey),
    pay.WithHTTPClient(&http.Client{
        Timeout:   60 * time.Second,
        Transport: &retryTransport{base: http.DefaultTransport},
    }),
)

Rate limiting

The API allows approximately 60 requests per IP per minute. On HTTP 429, implement exponential backoff:

var reqErr *pay.RequestError
if errors.As(err, &reqErr) && reqErr.StatusCode == 429 {
    time.Sleep(backoff)
    // retry
}
All Go libraries →