stablc / developer docs

Developer docs

stablc lets you accept stablecoin payments (like USDC — a digital dollar) with a simple link. You create a payment link with one API call, your customer opens it and pays from their crypto wallet, and the money goes directly to your wallet address — stablc never holds your funds.

No API key. No signup. No SDK.

The only thing you need is a wallet address to receive funds. Creating a link is a single unauthenticated HTTP call — each link comes with its own secret for managing it afterwards.

What you can build

Invoice a client

Create a link for a fixed amount, email it, get notified the moment it's paid.

Checkout on your site

Embed the hosted checkout as a popup or iframe — no payment UI to build.

Automate billing

Generate links from your backend, listen for webhooks, fulfil orders automatically.

AI agent commerce

Let an AI agent create and track payment links via MCP or plain tool calls.

Choose your path

If you…Start with
just need one payment link, no codeCreate a link in the browser — no API needed
want payments in your app with minimal work — one API call, customers pay on our hosted checkout
want the checkout inside your own page — popup or iframe with events
are building an AI agent or with plain tool calls
Base URL https://api.stablc.xyz/v1
Format application/json

Quick start

Get your first payment link live in two minutes.

Before you start, you need exactly one thing:

A wallet address to receive funds (the 0x… address from MetaMask, Coinbase Wallet, or any Ethereum wallet). No API key, no account. We recommend practising on the ethereum-sepolia testnet first — see .

1. Create a link (server-side)

Call this from your backend — never from the browser, so the response (which contains the management secret) stays private. Use curl or any HTTP client:

curl -X POST https://api.stablc.xyz/v1/payment-links \
  -H "Content-Type: application/json" \
  -d '{
    "amount":        "50",
    "expiresAt":     "2026-06-01T00:00:00Z",
    "note":          "Invoice #42",
    "webhookUrl":    "https://yourapp.com/webhook",
    "webhookSecret": "your-signing-secret",
    "options": [
      { "chain": "ethereum-sepolia", "token": "USDC", "toAddress": "0xYourWalletAddress" }
    ]
  }'

The same call in JavaScript:

// Node.js — no SDK or API key needed, plain fetch works
const res = await fetch('https://api.stablc.xyz/v1/payment-links', {
  method:  'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    amount:    '50',                      // 50 USDC, human-readable
    expiresAt: '2026-06-01T00:00:00Z',    // link stops working after this
    note:      'Invoice #42',
    options: [
      { chain: 'ethereum-sepolia', token: 'USDC', toAddress: '0xYourWalletAddress' }
    ],
  }),
})

const { url, secret } = await res.json()
// url    → send this to your customer
// secret → store it now; it is shown only once

2. Share the URL, store the secret

The url goes to your customer. The secret (clh_sec_…) is shown once only — store it immediately. You'll need it to check the status, cancel the link early, or replay a failed webhook.

{
  "url":    "https://checkout.stablc.xyz/p/018e6b7f-...#sig=a3f9bc2d...",
  "secret": "clh_sec_4a7f2e..."
}

3. Send the URL to your customer

Redirect the customer to the url from the response — the stablc-hosted checkout page handles wallet connection, chain switching, and payment. No frontend code required on your side.

4. Know when you're paid

Two options: poll from your backend until status is PAID, or set a webhookUrl when creating the link and stablc will POST to it the moment payment is confirmed. If you use webhooks, always verify X-Signature before processing — see .

Payment flow

The entire flow is non-custodial — funds move directly from payer to recipient on-chain. stablc coordinates the link, signature verification, and webhook delivery, but never touches the money.

1

Create a payment link

Your server

Your backend calls POST /v1/payment-links with the recipient address, amount, token, and chain. stablc returns a signed URL and a one-time secret.

The URL contains a cryptographic HMAC signature in the fragment (#sig=…) that proves the link was legitimately created by you. The signature is never sent to stablc's servers in HTTP requests.

2

Payer opens the checkout URL

Checkout page

Send the URL to your customer. They open it in a browser — the checkout page loads, verifies the signature, and displays the payment details.

The checkout page extracts the sig from window.location.hash and passes it in the X-Link-Sig header. The sig stays in the URL fragment and is used for all subsequent polling calls — it is never sent as part of the HTTP URL. The checkout connects to the payer's browser wallet (MetaMask, Coinbase Wallet, etc.).

3

Payer approves and pays on-chain

Blockchain

The checkout guides the payer through two wallet transactions: first an ERC-20 approve, then the gateway pay() call that sends funds directly to your address.

The PaymentGateway contract forwards tokens directly to the recipient (toAddress) — stablc never holds funds. The payer pays gas; there is no stablc fee.

4

Your server receives a webhook

Your server

stablc detects the on-chain PaymentReceived event and POSTs a signed webhook to your webhookUrl with full payment details.

Verify the X-Signature header using your webhookSecret before fulfilling the order. Respond 2xx to acknowledge. stablc retries failed deliveries with exponential backoff.

Payer wallet flow (step 3 detail)

3a. Connect wallet

Payer clicks "Connect wallet". The checkout requests eth_requestAccounts from their browser wallet.

3b. Approve spending

ERC-20 approve(gatewayAddress, amount) — grants the gateway permission to transfer the exact amount. Skipped if allowance is already sufficient.

3c. Send payment

PaymentGateway.pay(ref, tokenAddress, amount) — the contract pulls tokens from the payer and emits PaymentReceived, which stablc's listener detects.

Guides

Checkout embedding

The checkout URL works in three modes. Choose the one that fits your UX — each has different confirmation mechanics.

ModeHowConfirmation
StandaloneRedirect or link the payer directly to the checkout URLWebhook — poll GET /v1/payment-links/:id from your backend
Popupwindow.open(url + "?popup=1#sig=…", …)Webhook or poll — popup does not emit postMessage
IframeEmbed <iframe src="url"> in your pagepostMessage events (stablc:paid, stablc:error, stablc:close)

Popup mode

Insert ?popup=1 before the #sig= fragment. The checkout renders full-window without a close button. Poll your backend for status.

// The checkout URL from POST /v1/payment-links looks like:
// https://checkout.stablc.xyz/p/<id>#sig=<hex>
// Insert ?popup=1 before the fragment to enable full-window popup mode.

function openCheckoutPopup(checkoutUrl) {
  const url = checkoutUrl.replace(/#/, '?popup=1#')
  return window.open(url, 'stablc-checkout', 'width=420,height=680,resizable=no')
}

const popup = openCheckoutPopup(link.url)

// Popup mode does not emit postMessage events — poll your backend for status
const poll = setInterval(async () => {
  const data = await fetch(`/api/payment-links/${linkId}`).then(r => r.json())
  if (data.status === 'PAID') {
    clearInterval(poll)
    popup?.close()
    onPaymentConfirmed(data)
  }
}, 3000)

Iframe mode

Pass the checkout URL directly as the src. Add allow="ethereum" so the iframe can access the payer's browser wallet. Listen for postMessage events from checkout.stablc.xyz.

<!-- Embed the checkout in your page -->
<iframe
  src="https://checkout.stablc.xyz/p/<id>#sig=<hex>"
  allow="ethereum"
  width="400"
  height="620"
  style="border:none; border-radius:12px;"
></iframe>

<script>
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://checkout.stablc.xyz') return
  const { type, detail } = event.data

  if (type === 'stablc:paid') {
    // detail: { txHash?: string, amount: string, token: string }
    fulfillOrder(detail)
  }
  if (type === 'stablc:close') {
    // payer closed the checkout panel
    hideIframe()
  }
  if (type === 'stablc:error') {
    console.error('Checkout error:', detail.message)
  }
})
</script>
Eventdetail payloadWhen emitted
stablc:paid{ txHash?: string, amount: string, token: string }Backend confirmed PAID status (up to ~60s after on-chain receipt). txHash absent if link was already PAID on load.
stablc:error{ message: string }Unrecoverable checkout error (invalid sig, expired, etc.)
stablc:closePayer clicks the close button on the iframe card

Security: always check event.origin === 'https://checkout.stablc.xyz' before handling any postMessage. Ignore messages from other origins.

txHash timing: txHash may be absent in stablc:paid if the link was already PAID when the page loaded. Always treat a missing txHash as "already confirmed" and fulfil the order.

amount is a string: detail.amount is an integer base-unit string (e.g. "1000000" for 1 USDC), not a number — format it using the token's decimals before displaying.

Confirmation delay: stablc:paid fires after the backend indexer confirms PAID status — typically within 60 s of the on-chain receipt, not immediately.

Webhooks

A webhook is a notification stablc sends to your server: when a payment is confirmed on-chain, stablc POSTs a JSON payload to the webhookUrl you set at link creation — so you know you've been paid without polling. Each delivery is signed with your webhookSecret; always verify the X-Signature header before fulfilling orders, so an attacker can't fake a "you got paid" call.

Payload

{
  "event":          "payment.confirmed",
  "id":             "019d0083-8675-7b6e-8947-5191658119e4",
  "paymentLinkId":  "019d0082-6a0f-7be0-8052-ab524cc752df",
  "ref":            "0x019d00826a0f7be08052ab524cc752df00000000000000000000000000000000",
  "status":         "confirmed",
  "amount":         "0.430000",
  "amountRaw":      "430000",
  "currency":       "USDC",
  "from":           "0xb127a98e5d7cf9872f790e08b5ddb2fbb6dfb818",
  "to":             "0xb456df8038e1261f7432b6a7b9ae423883de6bb7",
  "note":           "Invoice #42",
  "network":        "ethereum-sepolia",
  "txHash":         "0x29976005c50389501f26b887f59f437571d0eae661d851ea4bb36cd8f4d00d2f",
  "explorerUrl":    "https://sepolia.etherscan.io/tx/0x29976005c50389501f26b887f59f437571d0eae661d851ea4bb36cd8f4d00d2f",
  "createdAt":      "2026-03-18T10:35:13.768586Z",
  "confirmedAt":    "2026-03-18T10:35:13.768586Z"
}

Signature verification (Node.js)

const crypto = require('crypto')

function verifyWebhook(rawBody, signature, secret) {
  // signature = first 32 hex chars of the HMAC-SHA256 digest
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')
    .slice(0, 32)
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  )
}

// Express example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-signature']
  if (!verifyWebhook(req.body, sig, process.env.STABLC_WEBHOOK_SECRET)) {
    return res.sendStatus(401)
  }
  const event = JSON.parse(req.body)
  if (event.event === 'payment.confirmed') {
    // fulfill order, send receipt, etc.
  }
  res.sendStatus(200)
})

Signature format: X-Signature: sha256=<first 32 hex chars of HMAC-SHA256(rawBody, webhookSecret)>. An HMAC is a tamper-proof checksum computed with a shared secret — only someone who knows your webhookSecret can produce a valid one.

Use raw body: always read the request as raw bytes before parsing JSON — the HMAC is computed over the exact bytes stablc sent, and re-serialising the JSON can change them.

Timing-safe comparison: use crypto.timingSafeEqual (Node) or equivalent — never ===.

Replay protection: check that confirmedAt is within the last 5 minutes. Reject stale events.

Expect duplicates: if your server is down or slow to respond, stablc retries the same delivery (up to 10 times, waiting longer each time, up to 1 hour between attempts). Deduplicate on id or txHash so a retried event can't fulfil the same order twice.

Acknowledge: return any 2xx status to stop retries. Non-2xx triggers a retry.

Test, then go live

Practise the full flow with worthless test tokens before touching real money. Everything works identically — only the chain name changes.

1

Get a test wallet ready

Use any Ethereum wallet (MetaMask, Coinbase Wallet). Get free Sepolia ETH for gas from a Sepolia faucet, and free testnet USDC from a USDC faucet (e.g. Circle's).

2

Create links on ethereum-sepolia

Use "chain": "ethereum-sepolia" in your options. Call GET /v1/chains to see every chain this deployment supports — mainnet: false marks the test networks.

3

Run the whole flow once

Open your link, pay it with the test wallet, watch status flip to PAID, and check your webhook fired (use a tool like webhook.site if you don't have an endpoint yet).

4

Switch to mainnet

Change the chain name to a mainnet chain from GET /v1/chains and use your real receiving wallet address. That's the entire migration.

Before going live, double-check: you store each link's secret at creation · you verify webhook signatures · you deduplicate webhook events · your toAddress is a wallet you control on the mainnet chain.

API reference

GET /v1/chains

Chains

Start here — returns all chains and tokens supported by this deployment. Use the name value when creating a payment link.

Request

curl https://api.stablc.xyz/v1/chains

Response

[
  {
    "chainId": 11155111,
    "name":    "ethereum-sepolia",
    "mainnet": false,
    "tokens":  [
      { "symbol": "USDC", "decimals": 6 },
      { "symbol": "USDT", "decimals": 6 }
    ]
  }
]
FieldDescription
chainIdEIP-155 chain ID (informational).
nameChain name slug (e.g. "ethereum-sepolia"). Pass this in options[].chain when creating a link.
mainnetfalse = testnet, true = mainnet.
tokensSupported tokens on this chain — array of { symbol, decimals } objects. Use symbol when creating links; use decimals for amount formatting.

Authentication

Three auth mechanisms exist. Most endpoints are public. Link-scoped actions require either a link secret or a wallet signature — whichever ownership model was used at link creation.

MechanismHeader(s)Value
PublicNo auth required
Link secretAuthorizationBearer clh_sec_…
Wallet signature agent-readyX-Wallet-Address
X-Wallet-Signature
0x… wallet address
EIP-191 signature of raw body
CheckoutX-Link-Sig32-char hex from #sig= URL fragment

Wallet signature auth (agent-ready)

AI agents with an Ethereum wallet can call the API directly — no pre-registration, no link secret to store. The wallet address is the merchant identity.

Sign the raw request body bytes using EIP-191 personal_sign. For body-less requests (GET, DELETE) sign over an empty byte array.

Ownership is exclusive: a link created with wallet auth cannot be managed with a Bearer secret, and vice versa. The ownership model is fixed at creation time.

# Sign the request body with your Ethereum private key (EIP-191 personal_sign)
# Then pass both headers on authenticated endpoints

curl -X POST https://api.stablc.xyz/v1/payment-links \
  -H "Content-Type: application/json" \
  -H "X-Wallet-Address: 0xYourWalletAddress" \
  -H "X-Wallet-Signature: 0x<eip191_signature>" \
  -d '{
    "amount":    "50",
    "expiresAt": "2026-06-01T00:00:00Z",
    "options": [
      { "chain": "ethereum-sepolia", "token": "USDC", "toAddress": "0xYourWalletAddress" }
    ]
  }'
import { privateKeyToAccount } from 'viem/accounts'

const account = privateKeyToAccount('0xYourPrivateKey')

async function signedFetch(url, options = {}) {
  const method  = options.method ?? 'GET'
  const body    = options.body ?? ''

  // EIP-191: personal_sign of raw body bytes (empty for GET/DELETE)
  const signature = await account.signMessage({ message: { raw: Buffer.from(body) } })

  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      'Content-Type':      'application/json',
      'X-Wallet-Address':  account.address,
      'X-Wallet-Signature': signature,
    },
  })
}

// Create a link — wallet address is stored as the link owner
const res = await signedFetch('https://api.stablc.xyz/v1/payment-links', {
  method: 'POST',
  body: JSON.stringify({
    amount:    '50',
    expiresAt: '2026-06-01T00:00:00Z',
    options: [{ chain: 'ethereum-sepolia', token: 'USDC', toAddress: account.address }],
  }),
})
const { url, secret } = await res.json()
The clh_sec_ secret is returned once at link creation and cannot be retrieved again. Store it immediately in your database. You will need it to:
  • Cancel the link early (DELETE /v1/payment-links/:id)
  • Extend the expiry date (PATCH /v1/payment-links/:id)
  • Replay a failed webhook delivery (POST /v1/payment-links/:id/replay)
PATCH /v1/payment-links/:id

Extend expiry

Pushes the expiry date forward on a PENDING link. Requires the clh_sec_ secret. Returns 200 with the updated link object.

FieldTypeRequiredDescription
expiresAtstringYesISO 8601 UTC timestamp. Must be in the future and later than the current expiry.

Request

curl -X PATCH https://api.stablc.xyz/v1/payment-links/018e6b7f-... \
  -H "Authorization: Bearer clh_sec_4a7f2e..." \
  -H "Content-Type: application/json" \
  -d '{ "expiresAt": "2026-07-01T00:00:00Z" }'

Response — 200 OK

{
  "id":          "018e6b7f-...",
  "amount":      "50",
  "note":        "Invoice #42",
  "webhookUrl":  "https://yourapp.com/webhook",
  "status":      "PENDING",
  "expiresAt":   "2026-07-01T00:00:00Z",
  "createdAt":   "2026-03-17T10:00:00Z",
  "options": [...]
}

Errors: 400 if the date is in the past or before the current expiry · 401 if the secret is wrong · 410 if the link is not PENDING.

POST /v1/payment-links/:id/replay

Replay webhook

Re-enqueues the webhook delivery for the most recent confirmed payment on this link. Use this when your endpoint was temporarily unavailable and you need stablc to re-POST the payment event. Requires the clh_sec_ secret. Returns 202 Accepted on success.

curl -X POST https://api.stablc.xyz/v1/payment-links/018e6b7f-.../replay \
  -H "Authorization: Bearer clh_sec_4a7f2e..."

Idempotency: replaying delivers the same payload as the original — deduplicate on id or txHash in your handler.

Errors: 401 if the secret is wrong · 404 if the link is not found · 422 if no confirmed payment exists for this link.

Payment-level replay: if you need to replay by payment ID rather than link ID, use POST /v1/payments/:paymentId/replay instead.

Errors

All errors return a JSON body with an error field describing the problem.

{
  "error": "Payment link not found: 01959f3a-..."
}
StatusMeaning
400Bad request — invalid fields, bad Ethereum address, unsupported chain or token, or duplicate (chain, token) option.
401Unauthorized — missing or invalid Authorization bearer token or X-Link-Sig header.
403Forbidden — direct browser request blocked by Origin header check. The /checkout endpoint must be called via the API proxy, not directly from a browser.
404Not found — link or payment ID does not exist.
410Gone — link is expired, already paid, or cancelled.
422Unprocessable — invalid webhook URL, or no confirmed payment found for replay.

Advanced

Preflight checks

Nothing to implement here — this section explains what the hosted checkout already does for you. Before submitting any on-chain transaction, the checkout UI runs six read-only checks in parallel against the smart contract. A transaction is only initiated when all six pass — eliminating failed transactions and wasted gas.

#CheckWhat it prevents
1Contract not pausedSubmitting against a paused contract — transaction would revert.
2Token is whitelistedSending a token the contract doesn't accept — would revert on-chain.
3Reference not already usedDuplicate payments — each (payer, ref) pair is scoped to prevent front-running and double-pay.
4Payer balance ≥ amountSubmitting a transaction the payer's wallet cannot cover.
5Allowance ≥ amount (or will approve)Skipping approve when the payer doesn't have sufficient ERC-20 allowance.
6Payer ≠ recipientSelf-payment — paying yourself through the contract is blocked.

Not configurable: preflight is always on — the checkout will not submit a transaction that would fail. If a check fails, the payer sees an error and can retry after resolving the issue (e.g. adding balance, switching wallet).

Front-running protection: reference scoping is per-payer-address. Another wallet cannot burn a reference that belongs to a different payer, so a front-runner cannot invalidate a legitimate payment.

POST /mcp Streamable HTTP

MCP server

The API exposes a native Model Context Protocol server. Add stablc to Claude Desktop, Cursor, or any MCP-compatible client with a single config line — no API key, no SDK, no glue code. AI agents can create payment links and check status directly through the MCP tools.

ToolMaps toDescription
listChainsGET /v1/chainsReturns all supported chains and tokens. Call before creating a link.
createPaymentLinkPOST /v1/payment-linksCreates a new payment link. Returns checkout URL and secret.
getPaymentLinkStatusGET /v1/payment-links/:idGets current status and details. Pass linkSecret from creation.

Add to Claude Desktop

Edit your claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/):

// Claude Desktop — claude_desktop_config.json
{
  "mcpServers": {
    "stablc": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://api.stablc.xyz/mcp"]
    }
  }
}

Direct HTTP (advanced)

Most users connect via an MCP client. For custom integrations, the transport is JSON-RPC 2.0 over POST /mcp:

# MCP uses streamable HTTP — the client sends JSON-RPC over POST /mcp
# Most users connect via an MCP client (Claude Desktop, Cursor, etc.)
# Direct HTTP example (initialize):
curl -X POST https://api.stablc.xyz/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0"}}}'

No auth for MCP negotiation: the MCP protocol handshake requires no authentication. Tool calls that manage links will require the linkSecret parameter where applicable.

Wallet auth alternative: if your MCP client controls an Ethereum wallet (e.g. an on-chain agent), use wallet signature auth instead of link secrets — pass X-Wallet-Signature + X-Wallet-Address headers. The wallet address becomes the merchant identity with zero registration friction.

Compatible clients: Claude Desktop · Cursor · Windsurf · any client implementing MCP Streamable HTTP transport (2024-11-05+).

AI agents

If you're building your own agent rather than using an MCP client, give your model three tools that map 1-to-1 onto the REST API. Because creating a link needs no API key, an agent can start accepting payments with zero credential management — it only has to remember each link's secret.

Tool definitions

Anthropic tool-use format shown; the same schemas work with any function-calling model.

{
  "name": "create_payment_link",
  "description": "Create a stablc payment link. Returns a checkout URL to send to the payer and a secret for managing the link.",
  "input_schema": {
    "type": "object",
    "required": ["amount", "expiresAt", "options"],
    "properties": {
      "amount": {
        "type":        "string",
        "description": "Human-readable decimal amount (e.g. '10' for 10 USDC or '10.50' for 10.50 USDC). Do not pass raw base units — the API calculates those internally."
      },
      "note": {
        "type":        "string",
        "description": "Human-readable label shown on the checkout page (e.g. 'Invoice #42')."
      },
      "expiresAt": {
        "type":        "string",
        "description": "ISO-8601 expiry datetime (UTC). Must be in the future."
      },
      "webhookUrl": {
        "type":        "string",
        "description": "HTTPS URL to POST payment.confirmed events to."
      },
      "options": {
        "type":        "array",
        "description": "One entry per accepted (chain, token, wallet) combination.",
        "items": {
          "type":     "object",
          "required": ["chain", "token", "toAddress"],
          "properties": {
            "chain":     { "type": "string", "description": "Chain slug, e.g. 'ethereum-sepolia'. Call get_supported_chains first to get valid slugs." },
            "token":     { "type": "string", "description": "Token symbol, e.g. 'USDC'." },
            "toAddress": { "type": "string", "description": "Ethereum wallet address to receive funds." }
          }
        }
      }
    }
  }
}
{
  "name": "get_payment_link",
  "description": "Fetch the current state of a payment link. Use this to check whether a payment has been received (status = PAID).",
  "input_schema": {
    "type": "object",
    "required": ["id", "secret"],
    "properties": {
      "id":     { "type": "string", "description": "Payment link UUID returned by create_payment_link." },
      "secret": { "type": "string", "description": "clh_sec_ bearer token returned by create_payment_link." }
    }
  }
}
{
  "name": "get_supported_chains",
  "description": "Returns all chains and tokens supported by stablc. Call this before creating a payment link if you are unsure which chain slugs are valid.",
  "input_schema": { "type": "object", "properties": {} }
}

Complete agent example (Node.js)

A minimal tool-use loop: the model decides which tool to call, your code executes the HTTP request, and the result is fed back until the task is done.

import Anthropic from '@anthropic-ai/sdk'

const client = new Anthropic()

// ── Tool implementations ────────────────────────────────────────────────────

async function createPaymentLink(args) {
  const res = await fetch('https://api.stablc.xyz/v1/payment-links', {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify(args),
  })
  return res.json()  // { url, secret }
}

async function getPaymentLink({ id, secret }) {
  const res = await fetch(`https://api.stablc.xyz/v1/payment-links/${id}`, {
    headers: { Authorization: `Bearer ${secret}` },
  })
  return res.json()
}

async function getSupportedChains() {
  return fetch('https://api.stablc.xyz/v1/chains').then(r => r.json())
}

// ── Agent loop ──────────────────────────────────────────────────────────────

async function runAgent(userMessage) {
  const tools = [
    /* paste the three tool definitions here */
  ]

  const messages = [{ role: 'user', content: userMessage }]

  while (true) {
    const response = await client.messages.create({
      model:      'claude-opus-4-6',
      max_tokens: 1024,
      tools,
      messages,
    })

    messages.push({ role: 'assistant', content: response.content })

    if (response.stop_reason === 'end_turn') {
      return response.content.find(b => b.type === 'text')?.text
    }

    // Execute tool calls
    const results = []
    for (const block of response.content) {
      if (block.type !== 'tool_use') continue

      let output
      if (block.name === 'create_payment_link')   output = await createPaymentLink(block.input)
      if (block.name === 'get_payment_link')       output = await getPaymentLink(block.input)
      if (block.name === 'get_supported_chains')   output = await getSupportedChains()

      results.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(output) })
    }

    messages.push({ role: 'user', content: results })
  }
}

// Example
const reply = await runAgent(
  'Create a 50 USDC payment link on ethereum-sepolia to wallet 0xYourAddress, valid until 2026-06-01. Label it "Invoice #42".'
)
console.log(reply)

Store the secret: the agent must persist the secret from each create call — it's the only way to check status or cancel later.

On-chain agents: if your agent controls an Ethereum wallet, use instead — the wallet owns its links and can list them all with one call, no secrets to track.