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 code | Create 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 |
https://api.stablc.xyz/v1application/jsonQuick 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.
Create a payment link
Your serverYour 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.
Payer opens the checkout URL
Checkout pageSend 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.).
Payer approves and pays on-chain
BlockchainThe 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.
Your server receives a webhook
Your serverstablc 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.
| Mode | How | Confirmation |
|---|---|---|
| Standalone | Redirect or link the payer directly to the checkout URL | Webhook — poll GET /v1/payment-links/:id from your backend |
| Popup | window.open(url + "?popup=1#sig=…", …) | Webhook or poll — popup does not emit postMessage |
| Iframe | Embed <iframe src="url"> in your page | postMessage 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> | Event | detail payload | When 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:close | — | Payer 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.
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).
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.
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).
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
/v1/chainsChains
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 }
]
}
] | Field | Description |
|---|---|
| chainId | EIP-155 chain ID (informational). |
| name | Chain name slug (e.g. "ethereum-sepolia"). Pass this in options[].chain when creating a link. |
| mainnet | false = testnet, true = mainnet. |
| tokens | Supported 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.
| Mechanism | Header(s) | Value |
|---|---|---|
| Public | — | No auth required |
| Link secret | Authorization | Bearer clh_sec_… |
| Wallet signature agent-ready | X-Wallet-Address X-Wallet-Signature | 0x… wallet address EIP-191 signature of raw body |
| Checkout | X-Link-Sig | 32-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() 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)
/v1/payment-linksCreate a link
Generates a signed payment URL. Public endpoint — no auth required. Call from your server, not from the browser.
| Field | Type | Required | Description |
|---|---|---|---|
| amount | string | Yes | Human-readable decimal amount as a string (e.g. "50" for 50 USDC, "5.50" for 5.50 USDC). Must be a positive decimal — no scientific notation. The API calculates the raw on-chain amount per token using its configured decimals. |
| expiresAt | string | Yes | ISO 8601 UTC timestamp. Must be in the future. |
| options | object[] | Yes | At least one payment option. No duplicate (chain, token) pairs. |
| options[].chain | string | Yes | Chain name slug (must match a name from GET /v1/chains, e.g. "ethereum-sepolia"). Call GET /v1/chains first to get valid slugs. |
| options[].token | string | Yes | Token symbol supported by the chain (e.g. "USDC"). Use tokens[].symbol from GET /v1/chains. |
| options[].toAddress | string | Yes | Recipient Ethereum address (0x + 40 hex chars). Normalised to lowercase. |
| note | string | No | Free-text note shown to the payer on the checkout page. |
| webhookUrl | string | No | HTTPS URL to receive payment events. Must be a public HTTPS URL. |
| webhookSecret | string | No | Secret used to sign X-Signature headers on webhook calls. |
Request
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" }
]
}' Response — 201 Created
{
"url": "https://checkout.stablc.xyz/p/018e6b7f-...#sig=a3f9bc2d...",
"secret": "clh_sec_4a7f2e..."
} Human-readable amount: pass the amount as a human-readable decimal string (e.g. "50" for 50 USDC). The API calculates the raw on-chain amount (amountRaw) per token at creation time and returns it in the checkout response for use in on-chain transactions — no client-side calculation needed.
/v1/payment-links wallet auth onlyList your links
Returns a paginated list of every link created by your wallet — a ready-made dashboard query. Only available with ; links created anonymously (without wallet auth) have no owner identity and never appear here.
| Query param | Default | Description |
|---|---|---|
| page | 0 | Zero-based page number. |
| size | 20 | Items per page (1–100). |
| sortBy | createdAt | One of createdAt, expiresAt, status, amount. |
| order | desc | desc (newest first) or asc. |
Request
# Requires wallet signature auth (see Authentication) curl "https://api.stablc.xyz/v1/payment-links?page=0&size=20&sortBy=createdAt&order=desc" \ -H "X-Wallet-Address: 0xYourWalletAddress" \ -H "X-Wallet-Signature: 0x<eip191_signature_of_empty_body>"
Response
{
"items": [
{
"id": "018e6b7f-...",
"amount": "50",
"note": "Invoice #42",
"status": "PENDING",
"expiresAt": "2026-06-01T00:00:00Z",
"createdAt": "2026-03-17T10:00:00Z",
"options": [...],
"walletAddress": "0xYourWalletAddress"
}
],
"total": 42,
"page": 0,
"size": 20
} /v1/payment-links/:idGet a link
Did I get paid? Returns link details and current status. Requires the clh_sec_ secret (or wallet signature auth, for links created that way). Poll this from your backend until status is PAID.
Request
curl https://api.stablc.xyz/v1/payment-links/018e6b7f-... \ -H "Authorization: Bearer clh_sec_4a7f2e..."
Response
{
"id": "018e6b7f-...",
"amount": "50",
"note": "Invoice #42",
"webhookUrl": "https://yourapp.com/webhook",
"status": "PENDING",
"expiresAt": "2026-06-01T00:00:00Z",
"createdAt": "2026-03-17T10:00:00Z",
"walletAddress": null,
"options": [
{
"chainId": 11155111,
"token": "USDC",
"decimals": 6,
"toAddress": "0xyourwalletaddress"
}
]
} | Status | Meaning |
|---|---|
| PENDING | Awaiting payment — payer can still complete the transaction. |
| PAID | Payment confirmed on-chain — webhook has been (or is being) delivered. |
| EXPIRED | expiresAt has passed — the link can no longer be paid. |
| CANCELLED | Cancelled by the creator using the clh_sec_ secret. |
/v1/payment-links/:id/checkoutCheckout a link
You normally never call this yourself — it's the endpoint the stablc-hosted checkout page uses
to validate the URL signature and retrieve payment details for the payer. Documented here for completeness.
Pass the sig value from the URL fragment in the X-Link-Sig header.
curl https://api.stablc.xyz/v1/payment-links/018e6b7f-.../checkout \ -H "X-Link-Sig: a3f9bc2d..."
Sig is persistent: the X-Link-Sig value lives in the URL fragment and can be used for repeated polling calls (e.g. polling for PAID status) — it is not consumed on first use. Returns 410 if the link is expired, already paid, or cancelled.
Returns: link details with enriched options[] — each option includes contractAddress, tokenAddress, and decimals needed for the on-chain payment. Also includes top-level transactionHash and explorerUrl (full block explorer URL for the transaction) — both null until payment is confirmed on-chain.
Errors: 400 missing sig · 401 invalid sig · 403 direct browser access blocked (Origin header check — only API proxy calls are allowed) · 404 not found · 410 not PENDING or expired.
/v1/payment-links/:idCancel a link
Cancels a payment link, setting its status to CANCELLED.
Requires the clh_sec_ secret returned at creation time.
Returns 204 No Content on success.
curl -X DELETE https://api.stablc.xyz/v1/payment-links/018e6b7f-... \ -H "Authorization: Bearer clh_sec_4a7f2e..."
/v1/payment-links/:idExtend expiry
Pushes the expiry date forward on a PENDING link.
Requires the clh_sec_ secret.
Returns 200 with the updated link object.
| Field | Type | Required | Description |
|---|---|---|---|
| expiresAt | string | Yes | ISO 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.
/v1/payment-links/:id/replayReplay 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-..."
} | Status | Meaning |
|---|---|
| 400 | Bad request — invalid fields, bad Ethereum address, unsupported chain or token, or duplicate (chain, token) option. |
| 401 | Unauthorized — missing or invalid Authorization bearer token or X-Link-Sig header. |
| 403 | Forbidden — direct browser request blocked by Origin header check. The /checkout endpoint must be called via the API proxy, not directly from a browser. |
| 404 | Not found — link or payment ID does not exist. |
| 410 | Gone — link is expired, already paid, or cancelled. |
| 422 | Unprocessable — 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.
| # | Check | What it prevents |
|---|---|---|
| 1 | Contract not paused | Submitting against a paused contract — transaction would revert. |
| 2 | Token is whitelisted | Sending a token the contract doesn't accept — would revert on-chain. |
| 3 | Reference not already used | Duplicate payments — each (payer, ref) pair is scoped to prevent front-running and double-pay. |
| 4 | Payer balance ≥ amount | Submitting a transaction the payer's wallet cannot cover. |
| 5 | Allowance ≥ amount (or will approve) | Skipping approve when the payer doesn't have sufficient ERC-20 allowance. |
| 6 | Payer ≠ recipient | Self-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.
/mcp Streamable HTTPMCP 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.
| Tool | Maps to | Description |
|---|---|---|
| listChains | GET /v1/chains | Returns all supported chains and tokens. Call before creating a link. |
| createPaymentLink | POST /v1/payment-links | Creates a new payment link. Returns checkout URL and secret. |
| getPaymentLinkStatus | GET /v1/payment-links/:id | Gets 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.