bluepages.fyi

// API Docs

Programmatic access to bluepages.fyi

Overview

Query crypto address ↔ identity mappings via REST API. Two payment options:

Option 1: API Keys (Recommended)
  • Prepaid credits — faster, no wallet interaction needed
  • Up to 40% cheaper than x402 requests
  • 2x rate limits (60 req/min vs 30)
  • Credits never expire
  • Get your API key →
Option 2: x402 Protocol
  • Pay-per-request with USDC on Base mainnet
  • Requires wallet with USDC + EIP-712 signing
  • Good for occasional use or testing
Are you an AI/LLM?
Use our MCP Server for native integration with Claude and other AI assistants. No HTTP calls needed — the AI can call tools directly.
Jump to MCP Setup →
Machine-readable: OpenAPI spec · this page as Markdown when you send Accept: text/markdown

Quick Start (API Key)

The fastest way to get started. Get your API key first →

# Check your credits balance
curl -H "X-API-KEY: bp_your_key_here" \
  "https://bluepages.fyi/api/me"

# Check if an address exists
curl -H "X-API-KEY: bp_your_key_here" \
  "https://bluepages.fyi/check?address=0x1234567890abcdef1234567890abcdef12345678"

# Get full data (only charged if data found)
curl -H "X-API-KEY: bp_your_key_here" \
  "https://bluepages.fyi/data?address=0x1234567890abcdef1234567890abcdef12345678"

# Batch check 50 addresses at once
curl -X POST -H "X-API-KEY: bp_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"addresses": ["0xd8dA...", "0x742d..."]}' \
  "https://bluepages.fyi/batch/check"

Response headers show credit usage:

X-Credits-Used: 1
X-Credits-Remaining: 49999
X-Points-Earned: 1

Using Your API Key

Add your API key to the X-API-KEY header on every request:

curl -H "X-API-KEY: bp_your_key_here" \
  "https://bluepages.fyi/check?address=0x..."
// JavaScript
fetch('https://bluepages.fyi/check?address=0x...', {
  headers: { 'X-API-KEY': 'bp_your_key_here' }
})
# Python
requests.get('https://bluepages.fyi/check',
  params={'address': '0x...'},
  headers={'X-API-KEY': 'bp_your_key_here'}
)

Response headers show your credit usage:

X-Credits-Used: 1
X-Credits-Remaining: 4999
X-Points-Earned: 1

Don't have an API key? Get one here →

Endpoints

GET

/check

$0.001

Check if an address or identity exists in the database. Works with any username type.

Query params: ?address=0x... or ?identity=@handle

{
  "exists": true,
  "types": ["twitter", "farcaster"],
  "message": "✓ Found in database. Use /data endpoint ($0.05) to get full details."
}

Note: Labels (CEX wallets, etc.) contribute to the exists boolean but are not disclosed on /check. Use /data to see label details.

GET

/data

$0.05

Get all identities, labels, sanctions, and cluster information for a single address or identity.

⚡ Processing multiple addresses? Use /batch/data instead — it's faster and cheaper!

Query params: ?address=0x... or ?identity=@handle

Optional: &maxClusterSize=100, &fullCluster=true

Address Lookup Response:

{
  "found": true,
  "address": "0xabcd1234abcd1234abcd1234abcd1234abcd1234",
  "identities": [
    { "type": "twitter", "value": "example", "source": "uniswap-sybil", "priority": 20 },
    { "type": "twitter", "value": "example", "source": "tally", "priority": 20 }
  ],
  "labels": [
    { "type": "cex", "name": "Binance", "detail": "Binance 1", "source": "hildobby", "priority": 71 }
  ],
  "sanctions": [
    {
      "source": "ofac_sdn",
      "entity": "CRYPTEX OTC S.R.O.",
      "programs": ["CYBER2"],
      "addedAt": "2024-09-26",
      "removedAt": null,
      "active": true
    }
  ],
  "cluster": {
    "id": "twitter:@example",
    "source": "shared_twitter",
    "transitive": false,
    "identified": true,
    "totalAddresses": 2,
    "addresses": ["0xabcd1234...", "0xef567890..."],
    "truncated": false,
    "rawData": { "sharedTwitter": "@example" }
  }
}

Identity Search Response:

{
  "found": true,
  "totalMatches": 2,
  "results": [
    {
      "matchType": "twitter",
      "matchedValue": "example",
      "address": "0xabcd1234...",
      "identities": [{ "type": "twitter", "value": "example", "source": "tally", "priority": 20 }],
      "cluster": { ... }
    },
    {
      "matchType": "twitter",
      "matchedValue": "example",
      "address": "0xef567890...",
      "identities": [...],
      "cluster": { ... }
    }
  ]
}
Response Field Reference
transitive If true, cluster was merged from multiple sources
identified If true, cluster has a known identity (name/username)
truncated If true, address list was cut off. Use fullCluster=true to get all
rawData Additional cluster metadata from the source
source How the cluster was detected: clusters.xyz, shared_twitter, polymarket (proxy wallet ↔ owner) or shared_ip (legacy IP co-location import)
labels Array of labels (e.g., CEX wallet). Each has type, name, detail, source, priority. On /data and /my-data; not on /check.
sanctions Array of sanctions entries. Sources: ofac_sdn (US OFAC SDN), uk_ofsi (UK OFSI), il_nbctf (Israel NBCTF), jp_mof (Japan MOF), fr_tresor (France Trésor). Each has source, entity, programs, addedAt, removedAt, active. On /data and /my-data; not on /check.
POST

/batch/check

$0.04

Batch check up to 50 addresses or identities at once.

// Request
{
  "addresses": ["0x1234...", "0x5678..."],
  "identities": ["@user1", "@user2"]
}

// Response
{
  "success": true,
  "timestamp": "2025-12-23T12:00:00.000Z",
  "totalItems": 4,
  "results": {
    "addresses": {
      "0x1234...": { "exists": true, "types": ["twitter", "farcaster"] },
      "0x5678...": { "exists": false, "types": [] }
    },
    "identities": {
      "@user1": { "exists": true, "types": ["twitter"] },
      "@user2": { "exists": false, "types": [] }
    }
  }
}

Response fields:

  • exists - Whether any identity or label data was found
  • types - Identity types found for this entry (e.g. twitter, farcaster)
  • error - Present instead of the above when the input is invalid

Note: Labels and sanctions contribute to exists but are not individually disclosed on /batch/check.

POST

/batch/data

$2.00

Batch retrieve full data for up to 50 items. API key users only charged for results found.

// Request
{
  "addresses": ["0x1234...", "0x5678..."],
  "identities": ["@user1"]  // optional
}

// Response
{
  "success": true,
  "timestamp": "2025-12-27T12:00:00.000Z",
  "totalItems": 3,
  "results": {
    "addresses": {
      "0x1234...": {
        "found": true,
        "address": "0x1234...",
        "identities": [
          { "type": "twitter", "value": "example_user", "source": "neynar", "priority": 25 },
          { "type": "twitter", "value": "example_alt", "source": "layer3", "priority": 100 }
        ],
        "labels": [
          { "type": "cex", "name": "Binance", "detail": "Binance 1", "source": "hildobby", "priority": 71 }
        ],
        "sanctions": [],
        "cluster": { ... }
      },
      "0x5678...": {
        "found": false
      }
    },
    "identities": {
      "@user1": {
        "found": true,
        "totalMatches": 1,
        "results": [
          {
            "matchType": "twitter",
            "matchedValue": "user1",
            "address": "0xabcd...",
            "identities": [
              { "type": "twitter", "value": "user1", "source": "uniswap-sybil", "priority": 20 }
            ],
            "labels": [],
            "sanctions": [],
            "cluster": null
          }
        ]
      }
    }
  }
}

Response fields:

  • Address entries - Same shape as /data (identities[], labels[], sanctions[], cluster) plus found
  • Identity entries - totalMatches and results[], each result a /data-shaped record plus matchType/matchedValue
  • priority - Source trust ranking (lower = better): tally(20) < neynar(25) < layer3(100)
  • error - Present instead of the above when the input is invalid
POST

/my-data

FREE (1/min)

Look up your own data by signing a SIWE message (EIP-4361) from your wallet. The message carries a one-time nonce from GET /api/nonce, an expiration time, and this domain — so each signature is valid exactly once and cannot be replayed. Rate limited to prevent abuse.

// Sign a SIWE message, then POST it
import { createSiweMessage } from 'viem/siwe';

const { nonce } = await (await fetch('https://bluepages.fyi/api/nonce')).json();
const message = createSiweMessage({
  domain: 'bluepages.fyi',
  address: account.address,
  statement: 'View my Bluepages data.',
  uri: 'https://bluepages.fyi',
  version: '1',
  chainId: 8453,
  nonce,
  issuedAt: new Date(),
  expirationTime: new Date(Date.now() + 5 * 60 * 1000),  // nonce valid 5 min
});
const signature = await walletClient.signMessage({ message });

// Request body
const res = await fetch('https://bluepages.fyi/my-data', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message, signature })  // the nonce is consumed on use
});

Response includes labels and sanctions arrays. Labels and sanctions persist even for opted-out addresses.

Same flow for POST /opt-out — send { message, signature } (plus an optional twitter handle) with the statement Opt out of Bluepages data.

The address is the one recovered from the signature — both endpoints take no separate address field, and a body without message is rejected with 400 Missing required fields: message, signature.

API-key users: GET /my-data with your X-API-KEY header returns the data for the address linked to your key — no signature needed (same 1/min limit).

Free endpoints
  • GET /opt-out/status?address=0x… — whether an address has opted out
  • GET /api/packages — credit packages and current pricing
  • GET /api/me — your credit balance (send X-API-KEY)
  • GET /openapi.json — machine-readable spec for every endpoint
Recommended Flow

x402 users: Use /check first ($0.001) to verify data exists before calling /data ($0.05). This saves money when addresses aren't in the database.

API key users: Call /data directly — you're only charged if data is found. For batch, call /batch/data directly — skip /batch/check.

What are Clusters?
Clusters group addresses controlled by the same entity. We detect them via:
  • clusters.xyz - Named clusters with multiple verified wallets
  • shared_twitter - Addresses linked to the same Twitter handle in our identity data
  • polymarket - Polymarket proxy wallets paired with their owner address
  • shared_ip - Legacy import of addresses seen from the same IP (a few hundred small clusters)
What are Labels?
Labels classify what an address is (e.g., CEX wallet), as opposed to identities which link who is behind it. Labels are sourced from curated datasets and are not affected by opt-out.
  • hildobby - 7,400+ CEX wallet addresses across 350+ exchanges

Pricing

API Key Credits (prepaid, never expire)

Endpoint Credits Effective Price* Note
/check 1 $0.001 Always charged
/data 50 $0.05 Only if data found
/batch/check 40/found + 1/not-found Variable Up to 50 items
/batch/data 40 per result $0.04/each Only for addresses with data

Tip: Skip /check — just call /data directly. You only pay for addresses that have data.

*Based on Starter package: 5,000 credits = $5

Credit Packages

Package Credits Price Per Credit Savings
Starter 5,000 $5 $0.001 —
Pro 50,000 $45 $0.0009 10%
Enterprise 1,000,000 $600 $0.0006 40%

Purchase credits via web →

Programmatic Credit Purchase

Purchase credits via API using x402 v2 payment. Requires: npm install viem @x402/fetch@^2.24.0 @x402/evm@^2.24.0

Your API key is shown only once — on first registration, or when you call /api/regenerate-key. /api/auth does not return it again on later sign-ins; save it somewhere safe when you see it. Lost it? Regenerate — there is no way to retrieve the old one. If you purchased credits via x402 without ever registering (an auto-created account), call /api/regenerate-key to obtain your first key. /api/auth also returns a sessionToken — a short-lived (7-day) credential the web dashboard uses to spend credits; programmatic clients should ignore it and use the API key.

// JavaScript - Authenticate and purchase credits
import { privateKeyToAccount } from 'viem/accounts';
import { createSiweMessage } from 'viem/siwe';
import { wrapFetchWithPaymentFromConfig } from '@x402/fetch';
import { ExactEvmScheme } from '@x402/evm';

// privateKeyToAccount() returns a local account that can sign messages
// directly (SIWE below) — no wallet client / RPC transport needed for that.
const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY);

// Step 1: Authenticate with SIWE (EIP-4361) to get API key
const { nonce } = await (await fetch('https://bluepages.fyi/api/nonce')).json();
const message = createSiweMessage({
  domain: 'bluepages.fyi',
  address: account.address,
  statement: 'Sign in to your Bluepages API dashboard.',
  uri: 'https://bluepages.fyi',
  version: '1',
  chainId: 8453,
  nonce,
  issuedAt: new Date(),
  expirationTime: new Date(Date.now() + 5 * 60 * 1000),  // nonce valid 5 min
});

const signature = await account.signMessage({ message });
const authRes = await fetch('https://bluepages.fyi/api/auth', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message, signature })
});
const { user } = await authRes.json();
console.log('API Key:', user.apiKey);  // Shown once (first registration / regenerate-key only) — save it!

// Step 2: Purchase credits with x402 v2
const paymentFetch = wrapFetchWithPaymentFromConfig(fetch, {
  schemes: [{ network: 'eip155:8453', client: new ExactEvmScheme(account) }]
});
const purchaseRes = await paymentFetch(
  'https://bluepages.fyi/api/credits/purchase?package=starter',  // starter|pro|enterprise
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ address: account.address })
  }
);
const result = await purchaseRes.json();
// Response: { success, creditsAdded, newCredits, transactionHash }
console.log('Credits added:', result.creditsAdded);
console.log('New balance:', result.newCredits);
console.log('TX:', result.transactionHash);
# Python - Authenticate and purchase credits
# pip install eth-account requests
from datetime import datetime, timedelta, timezone
from eth_account import Account
from eth_account.messages import encode_defunct, encode_typed_data
import base64, json, os, secrets, time
import requests

EVM_PRIVATE_KEY = os.environ['EVM_PRIVATE_KEY']  # never hardcode
account = Account.from_key(EVM_PRIVATE_KEY)

# Step 1: Authenticate with SIWE (EIP-4361) to get API key
nonce = requests.get('https://bluepages.fyi/api/nonce').json()['nonce']
iso = lambda d: d.isoformat(timespec='milliseconds').replace('+00:00', 'Z')
now = datetime.now(timezone.utc)
message = f"""bluepages.fyi wants you to sign in with your Ethereum account:
{account.address}

Sign in to your Bluepages API dashboard.

URI: https://bluepages.fyi
Version: 1
Chain ID: 8453
Nonce: {nonce}
Issued At: {iso(now)}
Expiration Time: {iso(now + timedelta(minutes=5))}"""

signed = account.sign_message(encode_defunct(text=message))
signature = '0x' + signed.signature.hex()

res = requests.post('https://bluepages.fyi/api/auth', json={
    'message': message,
    'signature': signature
})
api_key = res.json()['user']['apiKey']
print(f'API Key: {api_key}')  # Shown once (first registration / regenerate-key only) — save it!

# Step 2: Purchase credits with x402 v2
# Note: the x402 PyPI package's confirmed v2 API doesn't document a full
# "decode 402 -> sign -> retry" HTTP wrapper, so this builds the
# PAYMENT-SIGNATURE header manually (full helper: /examples/api-key-purchase.py)
def build_payment_header(payment_required):
    accepted = payment_required['accepts'][0]
    now = int(time.time())
    nonce = '0x' + secrets.token_hex(32)
    authorization = {
        'from': account.address, 'to': accepted['payTo'], 'value': accepted['amount'],
        'validAfter': str(now - 600), 'validBefore': str(now + accepted['maxTimeoutSeconds']),
        'nonce': nonce,
    }
    extra = accepted.get('extra') or {}
    typed_data = {
        'types': {
            'EIP712Domain': [
                {'name': 'name', 'type': 'string'}, {'name': 'version', 'type': 'string'},
                {'name': 'chainId', 'type': 'uint256'}, {'name': 'verifyingContract', 'type': 'address'},
            ],
            'TransferWithAuthorization': [
                {'name': 'from', 'type': 'address'}, {'name': 'to', 'type': 'address'},
                {'name': 'value', 'type': 'uint256'}, {'name': 'validAfter', 'type': 'uint256'},
                {'name': 'validBefore', 'type': 'uint256'}, {'name': 'nonce', 'type': 'bytes32'},
            ],
        },
        'domain': {
            'name': extra.get('name', 'USD Coin'), 'version': extra.get('version', '2'),
            'chainId': int(accepted['network'].split(':')[1]), 'verifyingContract': accepted['asset'],
        },
        'primaryType': 'TransferWithAuthorization',
        'message': {
            'from': authorization['from'], 'to': authorization['to'],
            'value': int(authorization['value']), 'validAfter': int(authorization['validAfter']),
            'validBefore': int(authorization['validBefore']), 'nonce': nonce,
        },
    }
    signed = Account.sign_message(encode_typed_data(full_message=typed_data), EVM_PRIVATE_KEY)
    signature = signed.signature.hex()
    if not signature.startswith('0x'):
        signature = '0x' + signature
    payload = {
        'x402Version': 2, 'resource': payment_required['resource'], 'accepted': accepted,
        'payload': {'signature': signature, 'authorization': authorization},
    }
    return base64.b64encode(json.dumps(payload).encode()).decode()

purchase_url = 'https://bluepages.fyi/api/credits/purchase?package=starter'
purchase_body = {'address': account.address}
res = requests.post(purchase_url, json=purchase_body)
if res.status_code == 402:
    h = res.headers.get('PAYMENT-REQUIRED')  # authoritative (base64 JSON); the body mirrors it
    payment_required = json.loads(base64.b64decode(h)) if h else res.json()
    headers = {'PAYMENT-SIGNATURE': build_payment_header(payment_required)}
    res = requests.post(purchase_url, json=purchase_body, headers=headers)
result = res.json()
if not res.ok:  # a 402 here means the payment was rejected — see the server's reason
    raise SystemExit(f"Purchase failed ({res.status_code}): {result.get('message') or result.get('error')}")
# Response: { success, creditsAdded, newCredits, transactionHash }
print(f"Credits: {result['creditsAdded']}, TX: {result['transactionHash']}")

Download full JS example → | Download full Python example →

x402 Pay-per-request

Endpoint Price
/check $0.001
/data $0.05
/batch/check $0.04
/batch/data $2.00

Rate Limits

  • x402 users: 30 requests/minute per IP
  • API Key users: 60 requests/minute per IP (2x)
  • Batch endpoints: Up to 50 items per request

Recommended Workflow (Multiple Addresses)

Always use batch endpoints for multiple addresses!
Individual /data calls cost $0.05 each. Batch endpoints are faster and cheaper.

API Key Users: Skip the check phase

Call /batch/data directly — you're only charged for items that return data (40 credits each). No need to check first.

// API Key: Direct batch lookup (no check phase needed)
const API_KEY = 'bp_your_key_here';
const BASE_URL = 'https://bluepages.fyi';

async function lookupMany(addresses) {
  // Go straight to /batch/data — only charged for found items
  const dataRes = await fetch(`${BASE_URL}/batch/data`, {
    method: 'POST',
    headers: {
      'X-API-KEY': API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ addresses })
  });

  return await dataRes.json();
}

// Usage — up to 50 addresses per batch
const results = await lookupMany([
  '0x1234...',
  '0x5678...',
]);

x402 Users: Two-phase workflow

/batch/data costs a flat $2.00 regardless of results, so check first to avoid paying for empty batches.

  1. POST /batch/check — Find which addresses have data ($0.04 per batch of 50)
  2. POST /batch/data — Get full data for found addresses only ($2.00 per batch)
Why batch?
  • 50 individual /data calls = 50 × $0.05 = $2.50
  • x402 batch/check + batch/data = $0.04 + $2.00 = $2.04
  • API key /batch/data (10 found) = 10 × $0.04 = $0.40
Rate Limits: Add delays between batch calls!
  • API Key: 60 req/min → 1 second between calls
  • x402: 30 req/min → 2 seconds between calls

Cost Estimation

Use these formulas to estimate costs before running batch jobs:

API Key Users (Recommended) — Skip the check phase
Call /batch/data directly — only charged for found items:
  cost = found_items × 40 credits × $0.001/credit = found × $0.04

Example: 400 addresses, 80 have data (20% hit rate)
  Direct: 80 × $0.04 = $3.20

Compare with two-phase (unnecessary for API key users):
  Phase 1: /batch/check charges 40 credits per found + 1 per not-found
    80 × 40 credits = $3.20  +  320 × 1 credit = $0.32  →  $3.52
  Phase 2: 80 × $0.04 = $3.20
  Total: $6.72 (vs $3.20 direct — two-phase costs more than double)
x402 Users
Phase 1 - Check:
  batches = ceil(addresses / 50)
  cost = batches × $0.04

Phase 2 - Get data:
  batches = ceil(found_addresses / 50)
  cost = batches × $2.00  (flat rate per batch)

Example: 400 addresses, 80 have data (20% hit rate)
  Phase 1: 8 × $0.04 = $0.32
  Phase 2: ceil(80/50) × $2.00 = 2 × $2.00 = $4.00
  Total: $4.32
Key Difference: API key users should skip /batch/check entirely — /batch/data only charges for found items (40 credits each). x402 users should use /batch/check first to avoid paying $2.00 for mostly-empty batches.

Which Payment Method?

Use Case Recommendation Why
Batch processing (50+ addresses) API Key Pay only for found data, skip check phase, 2x rate limits
Single lookups / testing Either Similar cost, x402 needs no account
AI/MCP integration API Key No wallet signing during conversations
One-time scripts x402 No account needed, pay-as-you-go

Decision Tree:

Do you need to process more than 50 addresses?
  YES → Use API Key (cheaper batch pricing)
  NO  → Do you want to skip creating an account?
          YES → Use x402 (pay from a funded wallet, no sign-up)
          NO  → Use API Key (no per-request signing)

AI Agent Quick Start

Have a private key + USDC on Base? Here's everything you need.

Prerequisites:
  • Private key for an Ethereum wallet
  • USDC on Base mainnet (at least $0.10 for testing)
  • Node.js 18+ or Python 3.10+

Complete JavaScript Script (copy-paste ready):

// batch-lookup.js - Look up identities for a list of addresses
// Install: npm install viem @x402/fetch@^2.24.0 @x402/evm@^2.24.0

import { privateKeyToAccount } from 'viem/accounts';
import { wrapFetchWithPaymentFromConfig } from '@x402/fetch';
import { ExactEvmScheme } from '@x402/evm';

// CONFIG - Set these!
const EVM_PRIVATE_KEY = process.env.EVM_PRIVATE_KEY; // Your private key (with 0x prefix)
const ADDRESSES = [
  '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', // vitalik.eth
  '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B',
  // Add more addresses here
];

// Setup x402 v2 payment for Base mainnet (eip155:8453)
const account = privateKeyToAccount(EVM_PRIVATE_KEY);
const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, {
  schemes: [{ network: 'eip155:8453', client: new ExactEvmScheme(account) }]
});

// Rate limit: x402 allows 30 req/min = 2 second delay
const sleep = (ms) => new Promise(r => setTimeout(r, ms));

async function lookupAddresses(addresses) {
  const results = {};

  // ============================================================
  // PHASE 1: Check ALL addresses, collect found ones globally
  // This is critical for cost efficiency!
  // ============================================================
  console.log('Phase 1: Checking which addresses exist...');
  const allFoundAddresses = [];  // Collect ALL found addresses across batches

  for (let i = 0; i < addresses.length; i += 50) {
    const batch = addresses.slice(i, i + 50);
    console.log(`  Checking batch ${Math.floor(i/50) + 1}/${Math.ceil(addresses.length/50)}...`);

    const checkRes = await fetchWithPayment('https://bluepages.fyi/batch/check', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ addresses: batch })
    });
    const checkData = await checkRes.json();

    // Collect found addresses and store initial results
    for (const [addr, info] of Object.entries(checkData.results?.addresses || {})) {
      if (info.exists) {
        allFoundAddresses.push(addr);  // Add to global list
        results[addr] = { exists: true };
      } else {
        results[addr] = { exists: false };
      }
    }

    // Rate limit between check batches
    if (i + 50 < addresses.length) await sleep(2000);
  }

  console.log(`Found ${allFoundAddresses.length} addresses with data`);

  // ============================================================
  // PHASE 2: Fetch data for ALL found addresses in batches of 50
  // batch/data costs $2.00 flat - maximize addresses per call!
  // ============================================================
  if (allFoundAddresses.length === 0) {
    console.log('No addresses found, skipping data fetch');
    return results;
  }

  console.log('Phase 2: Fetching full data...');

  for (let i = 0; i < allFoundAddresses.length; i += 50) {
    const batch = allFoundAddresses.slice(i, i + 50);
    console.log(`  Fetching data ${Math.floor(i/50) + 1}/${Math.ceil(allFoundAddresses.length/50)} (${batch.length} addresses)...`);

    await sleep(2000);  // Rate limit

    const dataRes = await fetchWithPayment('https://bluepages.fyi/batch/data', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ addresses: batch })
    });
    const dataJson = await dataRes.json();

    // Update results with full data
    for (const [addr, info] of Object.entries(dataJson.results?.addresses || {})) {
      if (info.found) {
        // Identities are sorted by priority (lower = more trusted)
        results[addr] = {
          exists: true,
          identities: info.identities.map(id => ({
            type: id.type, value: id.value, source: id.source
          }))
        };
      }
    }
  }

  return results;
}

// Run it
lookupAddresses(ADDRESSES).then(results => {
  console.log('\n=== Results ===');
  for (const [addr, data] of Object.entries(results)) {
    if (data.identities?.length) {
      const summary = data.identities.map(id => `${id.type}:${id.value} (${id.source})`).join(', ');
      console.log(`${addr.slice(0,10)}... → ${summary}`);
    } else if (data.exists) {
      console.log(`${addr.slice(0,10)}... → found but no identities`);
    } else {
      console.log(`${addr.slice(0,10)}... → not found`);
    }
  }
});

Complete Python Script:

# batch_lookup.py - Look up identities for a list of addresses
# Install: pip install eth-account requests
#
# Note: the x402 PyPI package's confirmed v2 API builds a payload from an
# already-decoded PaymentRequired object, but doesn't document a full
# "decode 402 -> sign -> retry" HTTP wrapper for requests. So make_request()
# below builds and sends the PAYMENT-SIGNATURE header manually.

import base64
import json
import os
import secrets
import time
import requests
from eth_account import Account
from eth_account.messages import encode_typed_data

# CONFIG - Set these!
EVM_PRIVATE_KEY = os.environ['EVM_PRIVATE_KEY']  # Your private key
ADDRESSES = [
    '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',  # vitalik.eth
    '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B',
    # Add more addresses here
]

BASE_URL = 'https://bluepages.fyi'
account = Account.from_key(EVM_PRIVATE_KEY)

def _select_requirements(payment_required):
    for accepted in payment_required['accepts']:
        if accepted.get('network') == 'eip155:8453':
            return accepted
    return payment_required['accepts'][0]

def _build_payment_header(payment_required):
    """Sign an EIP-3009 TransferWithAuthorization and base64-encode the v2 payload."""
    accepted = _select_requirements(payment_required)
    now = int(time.time())
    nonce = '0x' + secrets.token_hex(32)
    authorization = {
        'from': account.address, 'to': accepted['payTo'], 'value': accepted['amount'],
        'validAfter': str(now - 600), 'validBefore': str(now + accepted['maxTimeoutSeconds']),
        'nonce': nonce,
    }
    extra = accepted.get('extra') or {}
    typed_data = {
        'types': {
            'EIP712Domain': [
                {'name': 'name', 'type': 'string'}, {'name': 'version', 'type': 'string'},
                {'name': 'chainId', 'type': 'uint256'}, {'name': 'verifyingContract', 'type': 'address'},
            ],
            'TransferWithAuthorization': [
                {'name': 'from', 'type': 'address'}, {'name': 'to', 'type': 'address'},
                {'name': 'value', 'type': 'uint256'}, {'name': 'validAfter', 'type': 'uint256'},
                {'name': 'validBefore', 'type': 'uint256'}, {'name': 'nonce', 'type': 'bytes32'},
            ],
        },
        'domain': {
            'name': extra.get('name', 'USD Coin'), 'version': extra.get('version', '2'),
            'chainId': int(accepted['network'].split(':')[1]), 'verifyingContract': accepted['asset'],
        },
        'primaryType': 'TransferWithAuthorization',
        'message': {
            'from': authorization['from'], 'to': authorization['to'],
            'value': int(authorization['value']), 'validAfter': int(authorization['validAfter']),
            'validBefore': int(authorization['validBefore']), 'nonce': nonce,
        },
    }
    signed = Account.sign_message(encode_typed_data(full_message=typed_data), EVM_PRIVATE_KEY)
    signature = signed.signature.hex()
    if not signature.startswith('0x'):
        signature = '0x' + signature
    payload = {
        'x402Version': 2, 'resource': payment_required['resource'], 'accepted': accepted,
        'payload': {'signature': signature, 'authorization': authorization},
    }
    return base64.b64encode(json.dumps(payload).encode()).decode()

def _decode_payment_required(resp):
    """Read the v2 402: the PAYMENT-REQUIRED header is authoritative (base64 JSON);
    the JSON body is a same-shape mirror kept as a fallback."""
    header = resp.headers.get('PAYMENT-REQUIRED')
    if header:
        return json.loads(base64.b64decode(header))
    body = resp.json()
    if body.get('x402Version') == 2 and body.get('accepts'):
        return body
    raise RuntimeError(f'402 without payment requirements: {body}')

def make_request(method, endpoint, data=None):
    """Try unpaid, pay on 402, retry once with PAYMENT-SIGNATURE."""
    url = f'{BASE_URL}{endpoint}'
    resp = requests.post(url, json=data) if method == 'POST' else requests.get(url)
    if resp.status_code != 402:
        return resp
    headers = {'PAYMENT-SIGNATURE': _build_payment_header(_decode_payment_required(resp))}
    if method == 'POST':
        return requests.post(url, json=data, headers=headers)
    return requests.get(url, headers=headers)

def lookup_addresses(addresses):
    results = {}

    # ============================================================
    # PHASE 1: Check ALL addresses, collect found ones globally
    # This is critical for cost efficiency!
    # ============================================================
    print('Phase 1: Checking which addresses exist...')
    all_found_addresses = []  # Collect ALL found addresses across batches

    for i in range(0, len(addresses), 50):
        batch = addresses[i:i+50]
        print(f'  Checking batch {i//50 + 1}/{-(-len(addresses)//50)}...')

        check_resp = make_request('POST', '/batch/check', {'addresses': batch})
        check_data = check_resp.json()

        for addr, info in check_data.get('results', {}).get('addresses', {}).items():
            if info.get('exists'):
                all_found_addresses.append(addr)  # Add to global list
                results[addr] = {'exists': True}
            else:
                results[addr] = {'exists': False}

        # Rate limit between check batches
        if i + 50 < len(addresses):
            time.sleep(2)

    print(f'Found {len(all_found_addresses)} addresses with data')

    # ============================================================
    # PHASE 2: Fetch data for ALL found addresses in batches of 50
    # batch/data costs $2.00 flat - maximize addresses per call!
    # ============================================================
    if not all_found_addresses:
        print('No addresses found, skipping data fetch')
        return results

    print('Phase 2: Fetching full data...')

    for i in range(0, len(all_found_addresses), 50):
        batch = all_found_addresses[i:i+50]
        print(f'  Fetching data {i//50 + 1}/{-(-len(all_found_addresses)//50)} ({len(batch)} addresses)...')

        time.sleep(2)  # Rate limit

        data_resp = make_request('POST', '/batch/data', {'addresses': batch})
        data_json = data_resp.json()

        for addr, info in data_json.get('results', {}).get('addresses', {}).items():
            if info.get('found'):
                # Identities are sorted by priority (lower = more trusted)
                results[addr] = {
                    'exists': True,
                    'identities': [
                        {'type': i['type'], 'value': i['value'], 'source': i['source']}
                        for i in info.get('identities', [])
                    ]
                }

    return results

# Run it
if __name__ == '__main__':
    results = lookup_addresses(ADDRESSES)
    print('\n=== Results ===')
    for addr, data in results.items():
        if data.get('identities'):
            summary = ', '.join(f"{i['type']}:{i['value']} ({i['source']})" for i in data['identities'])
            print(f"{addr[:10]}... → {summary}")
        elif data.get('exists'):
            print(f"{addr[:10]}... → found but no identities")
        else:
            print(f"{addr[:10]}... → not found")
Why Two-Phase is Critical for Cost Efficiency:

/batch/data costs $2.00 flat whether you fetch 1 or 50 addresses!

ScenarioInefficientTwo-PhaseSavings
1,000 addrs, 30 found (3%) 20×$0.04 + 20×$2 = $40.80 20×$0.04 + 1×$2 = $2.80 93%
500 addrs, 50 found (10%) 10×$0.04 + 10×$2 = $20.40 10×$0.04 + 1×$2 = $2.40 88%

The lower your hit rate, the more you save. Most real-world use cases have 3-10% hit rates.

JavaScript Example

// With API Key (recommended) — no need to /check first
const API_KEY = process.env.BLUEPAGES_API_KEY;

async function getData(address) {
  const res = await fetch(
    `https://bluepages.fyi/data?address=${address}`,
    { headers: { 'X-API-KEY': API_KEY } }
  );
  // Only charged if data is found!
  return res.json();
}

async function batchData(addresses) {
  const res = await fetch('https://bluepages.fyi/batch/data', {
    method: 'POST',
    headers: {
      'X-API-KEY': API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ addresses })
  });
  // Only charged for found items (40 credits each)
  return res.json();
}

// Usage — skip /check, go straight to /data
const result = await getData('0x1234567890abcdef1234567890abcdef12345678');
console.log(result);

// Batch — skip /batch/check, go straight to /batch/data
const batch = await batchData(['0x1234...', '0x5678...']);
console.log(batch);

With x402 (pay-per-request):

// Install: npm install viem @x402/fetch@^2.24.0 @x402/evm@^2.24.0
// Requires: USDC on Base mainnet in your wallet

import { privateKeyToAccount } from 'viem/accounts';
import { wrapFetchWithPaymentFromConfig } from '@x402/fetch';
import { ExactEvmScheme } from '@x402/evm';

// Create an x402 v2 client for Base mainnet (eip155:8453)
const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY);
const x402Fetch = wrapFetchWithPaymentFromConfig(fetch, {
  schemes: [{ network: 'eip155:8453', client: new ExactEvmScheme(account) }]
});

// Check if address exists ($0.001)
async function checkAddress(address) {
  const res = await x402Fetch(
    `https://bluepages.fyi/check?address=${address}`,
    { method: 'GET' }
  );
  return res.json();
}

// Get full data ($0.05)
async function getData(address) {
  const res = await x402Fetch(
    `https://bluepages.fyi/data?address=${address}`,
    { method: 'GET' }
  );
  return res.json();
}

// Batch check up to 50 addresses ($0.04)
async function batchCheck(addresses) {
  const res = await x402Fetch('https://bluepages.fyi/batch/check', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ addresses })
  });
  return res.json();
}

// Batch get data for up to 50 addresses ($2.00)
async function batchData(addresses) {
  const res = await x402Fetch('https://bluepages.fyi/batch/data', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ addresses })
  });
  return res.json();
}

// Usage example
const exampleAddr = '0x1234567890abcdef1234567890abcdef12345678';

// First check if data exists ($0.001)
const check = await checkAddress(exampleAddr);
console.log('Exists:', check.exists);
console.log('Types found:', check.types); // e.g. ['twitter', 'farcaster']

// If exists, get full data ($0.05)
if (check.exists) {
  const data = await getData(exampleAddr);

  // New response format with identities array
  console.log('Address:', data.address);
  console.log('Identities:', data.identities);
  // e.g. [{ type: 'twitter', value: 'example', source: 'tally' }]

  // Get Twitter handle
  const twitter = data.identities.find(i => i.type === 'twitter');
  if (twitter) console.log('Twitter:', twitter.value);

  // Check cluster info
  if (data.cluster) {
    console.log('Cluster ID:', data.cluster.id);
    console.log('Cluster addresses:', data.cluster.addresses);
    console.log('Truncated:', data.cluster.truncated);
  }
}

// Batch example
const addresses = [exampleAddr, '0xabcdef1234567890abcdef1234567890abcdef12'];
const batchResult = await batchCheck(addresses);
console.log('Batch results:', batchResult.results);

Python Example

# With API Key (recommended) - pip install requests
import os
import requests
import time

API_KEY = os.environ['BLUEPAGES_API_KEY']
BASE_URL = 'https://bluepages.fyi'
HEADERS = {'X-API-KEY': API_KEY}

# Your addresses to look up
addresses = ['0x1234567890abcdef1234567890abcdef12345678', ...]

# Skip /batch/check — go straight to /batch/data (only charged for found items)
all_data = []
for i in range(0, len(addresses), 50):
    batch = addresses[i:i+50]
    resp = requests.post(f'{BASE_URL}/batch/data',
                        headers=HEADERS,
                        json={'addresses': batch})
    if resp.status_code == 200:
        data = resp.json()
        for addr, info in data['results']['addresses'].items():
            if info.get('found'):
                labels = [l['name'] for l in info.get('labels', [])]
                # One row per identity; sorted by priority (lower = more trusted)
                for identity in info.get('identities', []):
                    all_data.append({
                        'address': addr,
                        'type': identity['type'],
                        'value': identity['value'],
                        'source': identity['source'],
                        'labels': labels
                    })
    # Check remaining credits
    print(f"Credits remaining: {resp.headers.get('X-Credits-Remaining')}")
    time.sleep(1.0)  # API key rate limit: 60 req/min

print(f'Saved {len(all_data)} records')

With x402 (pay-per-request):

⚠️ Python x402 v2 note: The x402 PyPI package's confirmed v2 API builds a payload from an already-decoded PaymentRequired object, but doesn't document a full "decode 402 → sign → retry" HTTP wrapper for requests. This example builds and sends the PAYMENT-SIGNATURE header manually.
# Install: pip install eth-account requests
# Requires: Python 3.10+, USDC on Base mainnet

import base64
import json
import os
import secrets
import time
import requests
from eth_account import Account
from eth_account.messages import encode_typed_data

# Your private key (use env vars, never hardcode!)
EVM_PRIVATE_KEY = os.environ['EVM_PRIVATE_KEY']
BASE_URL = 'https://bluepages.fyi'

# Rate limit: x402 = 30 req/min = 2 seconds between requests
RATE_LIMIT_DELAY = 2.0

# Create account from private key
account = Account.from_key(EVM_PRIVATE_KEY)

def _select_requirements(payment_required):
    for accepted in payment_required['accepts']:
        if accepted.get('network') == 'eip155:8453':
            return accepted
    return payment_required['accepts'][0]

def _build_payment_header(payment_required):
    """Sign an EIP-3009 TransferWithAuthorization and base64-encode the v2 payload."""
    accepted = _select_requirements(payment_required)
    now = int(time.time())
    nonce = '0x' + secrets.token_hex(32)
    authorization = {
        'from': account.address, 'to': accepted['payTo'], 'value': accepted['amount'],
        'validAfter': str(now - 600), 'validBefore': str(now + accepted['maxTimeoutSeconds']),
        'nonce': nonce,
    }
    extra = accepted.get('extra') or {}
    typed_data = {
        'types': {
            'EIP712Domain': [
                {'name': 'name', 'type': 'string'}, {'name': 'version', 'type': 'string'},
                {'name': 'chainId', 'type': 'uint256'}, {'name': 'verifyingContract', 'type': 'address'},
            ],
            'TransferWithAuthorization': [
                {'name': 'from', 'type': 'address'}, {'name': 'to', 'type': 'address'},
                {'name': 'value', 'type': 'uint256'}, {'name': 'validAfter', 'type': 'uint256'},
                {'name': 'validBefore', 'type': 'uint256'}, {'name': 'nonce', 'type': 'bytes32'},
            ],
        },
        'domain': {
            'name': extra.get('name', 'USD Coin'), 'version': extra.get('version', '2'),
            'chainId': int(accepted['network'].split(':')[1]), 'verifyingContract': accepted['asset'],
        },
        'primaryType': 'TransferWithAuthorization',
        'message': {
            'from': authorization['from'], 'to': authorization['to'],
            'value': int(authorization['value']), 'validAfter': int(authorization['validAfter']),
            'validBefore': int(authorization['validBefore']), 'nonce': nonce,
        },
    }
    signed = Account.sign_message(encode_typed_data(full_message=typed_data), EVM_PRIVATE_KEY)
    signature = signed.signature.hex()
    if not signature.startswith('0x'):
        signature = '0x' + signature
    payload = {
        'x402Version': 2, 'resource': payment_required['resource'], 'accepted': accepted,
        'payload': {'signature': signature, 'authorization': authorization},
    }
    return base64.b64encode(json.dumps(payload).encode()).decode()

def _decode_payment_required(resp):
    """Read the v2 402: the PAYMENT-REQUIRED header is authoritative (base64 JSON);
    the JSON body is a same-shape mirror kept as a fallback."""
    header = resp.headers.get('PAYMENT-REQUIRED')
    if header:
        return json.loads(base64.b64decode(header))
    body = resp.json()
    if body.get('x402Version') == 2 and body.get('accepts'):
        return body
    raise RuntimeError(f'402 without payment requirements: {body}')

def _raise_for_api_error(resp):
    """Surface the server's error instead of letting callers index an error body.
    A 404 from /data means "not in the database" (never charged): a result, not an error."""
    if resp.ok or resp.status_code == 404:
        return resp
    try:
        body = resp.json()
        detail = body.get('message') or body.get('error') or resp.text
    except ValueError:
        detail = resp.text
    raise RuntimeError(f'{resp.request.method} {resp.url} -> {resp.status_code}: {detail}')

def make_request(method, endpoint, data=None):
    """Make an x402 v2 request: try unpaid, pay on 402, retry once with PAYMENT-SIGNATURE."""
    url = f'{BASE_URL}{endpoint}'
    resp = requests.get(url) if method == 'GET' else requests.post(url, json=data)
    if resp.status_code != 402:
        return _raise_for_api_error(resp)

    # A 402 on the paid retry means the payment itself was rejected (verify or
    # settle failed); _raise_for_api_error surfaces the server's reason.
    headers = {'PAYMENT-SIGNATURE': _build_payment_header(_decode_payment_required(resp))}
    if method == 'GET':
        return _raise_for_api_error(requests.get(url, headers=headers))
    return _raise_for_api_error(requests.post(url, json=data, headers=headers))

# Check if address exists ($0.001)
def check_address(address):
    resp = make_request('GET', f'/check?address={address}')
    return resp.json()

# Get full data ($0.05)
def get_data(address):
    resp = make_request('GET', f'/data?address={address}')
    return resp.json()

# Batch check up to 50 addresses ($0.04)
def batch_check(addresses):
    resp = make_request('POST', '/batch/check', {'addresses': addresses})
    return resp.json()

# Batch get data for up to 50 addresses ($2.00)
def batch_data(addresses):
    resp = make_request('POST', '/batch/data', {'addresses': addresses})
    return resp.json()

# === Usage Example ===

# Single address lookup
example_addr = '0x1234567890abcdef1234567890abcdef12345678'

# First check if exists ($0.001)
result = check_address(example_addr)
print(f"Exists: {result['exists']}")
print(f"Types: {result.get('types', [])}")  # e.g. ['twitter', 'farcaster']

# If exists, get full data ($0.05)
if result['exists']:
    data = get_data(example_addr)

    # New response format
    print(f"Address: {data['address']}")
    print(f"Identities: {data['identities']}")

    # Extract Twitter handle
    twitter = next((i for i in data['identities'] if i['type'] == 'twitter'), None)
    if twitter:
        print(f"Twitter: {twitter['value']}")

    # Check cluster info
    if data.get('cluster'):
        print(f"Cluster: {data['cluster']['id']}")
        print(f"Cluster size: {data['cluster']['totalAddresses']}")
        print(f"Truncated: {data['cluster']['truncated']}")

# === Batch Processing Example ===

addresses = [
    '0x1234567890abcdef1234567890abcdef12345678',
    '0xabcdef1234567890abcdef1234567890abcdef12',
    # ... more addresses
]

# Phase 1: Find which addresses have data
found = []
for i in range(0, len(addresses), 50):
    batch = addresses[i:i+50]
    result = batch_check(batch)

    for addr, info in result['results']['addresses'].items():
        if info.get('exists') and info.get('types'):
            found.append(addr)

    time.sleep(RATE_LIMIT_DELAY)  # x402: 30 req/min

print(f'Found {len(found)} addresses with data')

# Phase 2: Get full data for found addresses
all_data = []
for i in range(0, len(found), 50):
    batch = found[i:i+50]
    result = batch_data(batch)

    for addr, info in result['results']['addresses'].items():
        if info.get('found'):
            all_data.append({'address': addr, 'identities': info['identities'], 'labels': info['labels']})

    time.sleep(RATE_LIMIT_DELAY)  # x402: 30 req/min

# Save results
with open('results.json', 'w') as f:
    json.dump(all_data, f, indent=2)

print(f'Saved {len(all_data)} records to results.json')

Download full example →

x402 v2 Error Handling

When using x402 payments, you may encounter these errors:

Payment Required (402)

{
  "x402Version": 2,
  "error": "PAYMENT-SIGNATURE header is required",
  "resource": {
    "url": "https://bluepages.fyi/check",
    "description": "Check if an address or identity exists in database...",
    "mimeType": "application/json"
  },
  "accepts": [{
    "scheme": "exact",
    "network": "eip155:8453",
    "amount": "1000",
    "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
    "payTo": "0xc21bDbba05d3662f0F63AC6d209C6cb17E61aAD3",
    "maxTimeoutSeconds": 60,
    "extra": { "name": "USD Coin", "version": "2", "assetTransferMethod": "eip3009" }
  }]
}

Solution: Include the PAYMENT-SIGNATURE header with a valid v2 payment payload. Use @x402/fetch (JavaScript) or build the header manually per the protocol spec below.

x402 v1 Clients (Legacy)

{
  "x402Version": 2,
  "error": "x402 v1 (X-PAYMENT) is no longer supported. Send an x402 v2 PAYMENT-SIGNATURE header.",
  "resource": { "url": "...", "description": "...", "mimeType": "application/json" },
  "accepts": [{ "...": "same 7-field object as above" }]
}

Solution: This API no longer accepts the v1 X-PAYMENT header — upgrade to an x402 v2 client (@x402/fetch 2.24.0+).

Unsupported Version

{
  "x402Version": 2,
  "error": "Unsupported x402 version: this server requires x402Version 2",
  "resource": { "...": "..." },
  "accepts": [{ "...": "..." }]
}

Solution: Your PAYMENT-SIGNATURE payload's x402Version field must be 2.

Payment Verification Failed

{
  "x402Version": 2,
  "error": "<facilitator invalidReason, e.g. insufficient_funds>",
  "payer": "0x...",
  "resource": { "...": "..." },
  "accepts": [{ "...": "..." }]
}

Solution: Add more USDC to your wallet on Base mainnet, or check that your EIP-712 signature and authorization fields match the accepted requirements exactly.

Payment Settlement Failed

{
  "x402Version": 2,
  "error": "<facilitator errorReason, or 'Payment settlement failed'>",
  "payer": "0x...",
  "resource": { "...": "..." },
  "accepts": [{ "...": "..." }]
}

Solution: Create a fresh payment authorization (new nonce). Don't reuse a signed authorization across requests — nonces are single-use on-chain.

Why "fresh authorization for each request"?
x402 payment authorizations are single-use — the EIP-3009 nonce is consumed on-chain by settlement. Each request needs a new nonce signed by your wallet. @x402/fetch handles this automatically; if you're implementing manually, generate a new random 32-byte nonce for every API call.

x402 v2 Protocol Specification

For those implementing x402 manually (without @x402/fetch), here's the complete v2 protocol spec. x402 uses USDC's EIP-3009 TransferWithAuthorization for gasless payments.

PAYMENT-SIGNATURE Header Format

The header must be a base64-encoded JSON string. The accepted field is the exact entry chosen from the 402's accepts[] array, echoed back verbatim:

{
  "x402Version": 2,
  "resource": { "url": "...", "description": "...", "mimeType": "application/json" },
  "accepted": {
    "scheme": "exact",
    "network": "eip155:8453",
    "amount": "1000",
    "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
    "payTo": "0xc21bDbba05d3662f0F63AC6d209C6cb17E61aAD3",
    "maxTimeoutSeconds": 60,
    "extra": { "name": "USD Coin", "version": "2", "assetTransferMethod": "eip3009" }
  },
  "payload": {
    "signature": "0x...",           // EIP-712 signature (65 bytes hex)
    "authorization": {
      "from": "0x...",              // Your wallet address (payer)
      "to": "0x...",                // accepted.payTo
      "value": "1000",              // accepted.amount (USDC atomic units, 6 decimals)
      "validAfter": "1703700000",   // Unix timestamp (usually now - 600)
      "validBefore": "1703703600",  // Unix timestamp (usually now + accepted.maxTimeoutSeconds)
      "nonce": "0x..."              // 32-byte random hex (unique per request)
    }
  }
}

On success the response carries a PAYMENT-RESPONSE header (base64 JSON settlement result) — the v1 X-PAYMENT-RESPONSE name is no longer used.

EIP-712 Typed Data (for signing)

USDC on Base uses EIP-3009 TransferWithAuthorization. Domain name/version come from accepted.extra; chainId is the numeric part of accepted.network ("eip155:8453".split(":")[1]); verifyingContract is accepted.asset:

// Domain
{
  name: "USD Coin",                 // accepted.extra.name
  version: "2",                     // accepted.extra.version
  chainId: 8453,                    // Number(accepted.network.split(":")[1])
  verifyingContract: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"  // accepted.asset
}

// Types
{
  TransferWithAuthorization: [
    { name: "from", type: "address" },
    { name: "to", type: "address" },
    { name: "value", type: "uint256" },
    { name: "validAfter", type: "uint256" },
    { name: "validBefore", type: "uint256" },
    { name: "nonce", type: "bytes32" }
  ]
}

// Message (same as authorization object above)
{
  from: "0xYourWallet...",
  to: "0xPayToAddress...",
  value: 1000n,                     // BigInt
  validAfter: 1703700000n,
  validBefore: 1703703600n,
  nonce: "0x..." // 32 random bytes
}

Complete Manual Implementation (JavaScript)

import { privateKeyToAccount } from 'viem/accounts';
import crypto from 'crypto';

const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';

// Picks the Base USDC entry from accepts[] (falls back to first)
function selectRequirements(paymentRequired) {
  return paymentRequired.accepts.find(
    r => r.network === 'eip155:8453' && r.asset.toLowerCase() === USDC_ADDRESS.toLowerCase()
  ) || paymentRequired.accepts[0];
}

async function createPaymentHeader(account, paymentRequired) {
  const accepted = selectRequirements(paymentRequired);
  const now = Math.floor(Date.now() / 1000);
  const nonce = '0x' + crypto.randomBytes(32).toString('hex');

  const authorization = {
    from: account.address,
    to: accepted.payTo,
    value: BigInt(accepted.amount),
    validAfter: BigInt(now - 600),      // 10 min before
    validBefore: BigInt(now + accepted.maxTimeoutSeconds),
    nonce
  };

  // Sign with EIP-712 — domain from accepted.extra + accepted.network + accepted.asset
  const signature = await account.signTypedData({
    domain: {
      name: accepted.extra?.name || 'USD Coin',
      version: accepted.extra?.version || '2',
      chainId: Number(accepted.network.split(':')[1]),
      verifyingContract: accepted.asset
    },
    types: {
      TransferWithAuthorization: [
        { name: 'from', type: 'address' },
        { name: 'to', type: 'address' },
        { name: 'value', type: 'uint256' },
        { name: 'validAfter', type: 'uint256' },
        { name: 'validBefore', type: 'uint256' },
        { name: 'nonce', type: 'bytes32' }
      ]
    },
    primaryType: 'TransferWithAuthorization',
    message: authorization
  });

  // Build the x402 v2 payload: {x402Version, resource, accepted, payload}
  const payload = {
    x402Version: 2,
    resource: paymentRequired.resource,
    accepted,
    payload: {
      signature,
      authorization: {
        from: authorization.from,
        to: authorization.to,
        value: authorization.value.toString(),
        validAfter: authorization.validAfter.toString(),
        validBefore: authorization.validBefore.toString(),
        nonce
      }
    }
  };

  return Buffer.from(JSON.stringify(payload)).toString('base64');
}

// Decodes the v2 402: the PAYMENT-REQUIRED header is authoritative (base64
// JSON); the JSON body is a same-shape mirror kept as a fallback.
function parsePaymentRequired(response, body) {
  const h = response.headers.get('PAYMENT-REQUIRED');
  if (h) return JSON.parse(Buffer.from(h, 'base64').toString('utf-8'));
  if (body?.x402Version === 2 && body.accepts) return body;
  throw new Error('No payment requirements');
}

// Usage
async function makePayment(url) {
  const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY);

  // Step 1: Get 402 response
  const res = await fetch(url);
  if (res.status !== 402) return res;

  const body = await res.json().catch(() => null);
  const paymentRequired = parsePaymentRequired(res, body);

  // Step 2: Create and sign payment
  const paymentSignature = await createPaymentHeader(account, paymentRequired);

  // Step 3: Retry with payment
  return fetch(url, { headers: { 'PAYMENT-SIGNATURE': paymentSignature } });
}

Pre-flight Checklist

Before using x402:

  1. Have USDC on Base mainnet (not Ethereum mainnet)
  2. Estimate costs using formulas above
  3. Ensure wallet has at least 10% buffer for gas and failed attempts
  4. Test with a single /check request ($0.001) first

Check your USDC balance:

// JavaScript (using viem - same library as @x402/fetch / @x402/evm)
import { createPublicClient, http, formatUnits } from 'viem';
import { base } from 'viem/chains';

const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const client = createPublicClient({ chain: base, transport: http() });

const balance = await client.readContract({
  address: USDC_BASE,
  abi: [{ name: 'balanceOf', type: 'function', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }] }],
  functionName: 'balanceOf',
  args: [YOUR_ADDRESS]
});
console.log('USDC Balance:', formatUnits(balance, 6));
# Python
from web3 import Web3
USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
w3 = Web3(Web3.HTTPProvider('https://mainnet.base.org'))
usdc = w3.eth.contract(address=USDC_BASE, abi=[{"constant":True,"inputs":[{"name":"account","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"type":"function"}])
balance = usdc.functions.balanceOf(YOUR_ADDRESS).call()
print(f'USDC Balance: {balance / 1e6}')

Before using API Keys:

  1. Get an API key at /api-keys
  2. Purchase credits (minimum $5 for 5,000 credits)
  3. Check your balance: curl -H "X-API-KEY: bp_..." https://bluepages.fyi/api/me

MCP Server (AI Integration)

Bluepages provides an MCP (Model Context Protocol) server that allows AI assistants like Claude to look up addresses and identities directly.

What is MCP?
MCP is a protocol that lets AI assistants connect to external tools. With the Bluepages MCP server, Claude can run address lookups during conversations without you writing any code.
Claude Code Users:
Use the Bluepages plugin to install both the MCP server and a guided /bluepages skill in one step:
/plugin marketplace add bluepagesdoteth/agent-plugins
      /plugin install bluepages

Other MCP Clients (Claude Desktop, Cursor, etc.):

1. Add the MCP server to your client config (e.g. claude_desktop_config.json):

{
  "mcpServers": {
    "bluepages": {
      "command": "npx",
      "args": ["-y", "github:bluepagesdoteth/bluepages-mcp"],
      "env": {
        "BLUEPAGES_API_KEY": "your-api-key-here",
        "PRIVATE_KEY": "your_eth_private_key_here"
      }
    }
  }
}

2. Optionally, install the /bluepages skill for guided lookups:

npx skills add bluepagesdoteth/agent-plugins
Authentication: You can use either (or both):
  • BLUEPAGES_API_KEY — Prepaid credits, up to 40% cheaper than x402 requests (get one here)
  • PRIVATE_KEY — Ethereum private key for x402 pay-per-request (USDC on Base)

Available MCP Tools:

Tool Description Cost
check_address Check if address exists 1 credit
check_identity Check if identity (Twitter, email, Farcaster…) exists 1 credit
get_data_for_address Get identities for address 50 credits*
get_data_for_identity Get addresses for identity 50 credits*
batch_check Check up to 50 items 40/found + 1/not-found
batch_get_data Get data for up to 50 items 40/found*
batch_check_streaming Same as batch_check, for large lists (100+) with progress 40/found + 1/not-found
batch_get_data_streaming Same as batch_get_data, for large lists (100+) with progress 40/found*
check_credits Check remaining credits Free
get_api_key Get or create your API key by signing a message (needs PRIVATE_KEY) Free
purchase_credits Buy a credit package via x402 (needs PRIVATE_KEY) $5–$600 USDC
set_credit_alert Set low-credit warning threshold Free

* Only charged if data is found

Claude Code Plugin →  |  MCP Server →

Downloadable Examples

API Key Examples (Recommended)

x402 Examples

Security: Never hardcode private keys or API keys. Use environment variables.