stablc / developer docs

Developer docs

stablc is a non-custodial stablecoin payment router. Generate signed payment links, embed a one-click checkout widget on your site, and receive on-chain payments directly to your wallet. No merchant accounts. No KYC.

Base URL https://api.stablc.xyz/v1
Format application/json

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-Webhook-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.

Quick start

Get your first payment link live in two minutes.

1. Create a link (server-side)

Call this from your backend — never expose your link creation logic in the browser.

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" }
    ]
  }'

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 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. Receive the webhook

When the payer completes payment, stablc POSTs to your webhookUrl. Always verify X-Webhook-Signature before processing — see the section.

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.

Preflight checks

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.

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.

Webhooks

When a payment is confirmed on-chain, stablc POSTs a signed JSON payload to your webhookUrl. Always verify the X-Webhook-Signature header before fulfilling orders.

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) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')
  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-webhook-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-Webhook-Signature: sha256=HMAC-SHA256(rawBody, webhookSecret)

Use raw body: always read the request as raw bytes before parsing JSON — HMAC is computed over the exact bytes stablc sent.

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

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

Idempotency: stablc retries failed deliveries with exponential backoff. Deduplicate on id or txHash before fulfilling.

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

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.
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+).