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.
https://api.stablc.xyz/v1application/jsonPayment 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-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.
| 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.
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.
| # | 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.
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-Webhook-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/:idGet a link
Returns link details and current status. Requires the clh_sec_ secret. 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
Used by the checkout page to validate the URL signature and retrieve payment details for the payer.
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 a top-level explorerUrl (block explorer base URL) and transactionHash (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.
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-..."
} | 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. |
/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+).