Developer docs
stablc is a non-custodial stablecoin payment gateway. 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 signature is consumed on first load (single-use) to prevent replay attacks. The checkout page 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.
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": "50000000",
"expiresAt": "2026-06-01T00:00:00Z",
"note": "Invoice #42",
"webhookUrl": "https://yourapp.com/webhook",
"webhookSecret": "your-signing-secret",
"options": [
{ "chain": "arbitrum-one", "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-Signature before processing — see the section.
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": ["USDC", "USDT"]
}
] | Field | Description |
|---|---|
| chainId | EIP-155 chain ID (informational). |
| name | Chain name slug (e.g. "ethereum", "arbitrum-one"). Pass this in options[].chain when creating a link. |
| name | Human-readable slug, e.g. "ethereum-sepolia". Use for display only. |
| mainnet | false = testnet, true = mainnet. |
| tokens | Supported token symbols on this chain, e.g. ["USDC", "USDT"]. |
Authentication
Most endpoints are public. Link-scoped actions (cancel, webhook replay) require your link secret.
| Scope | Header | Value |
|---|---|---|
| Public | — | No auth required |
| Link-scoped | Authorization | Bearer clh_sec_… |
| Checkout (verify only) | X-Link-Sig | 32-char hex value from #sig= URL fragment |
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 | Amount in raw token units as a string. e.g. "50000000" for 50 USDC (6 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"). |
| options[].token | string | Yes | Token symbol — USDC, USDT, or DAI. |
| 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": "50000000",
"expiresAt": "2026-06-01T00:00:00Z",
"note": "Invoice #42",
"webhookUrl": "https://yourapp.com/webhook",
"webhookSecret": "your-signing-secret",
"options": [
{ "chain": "arbitrum-one", "token": "USDC", "toAddress": "0xYourWalletAddress" }
]
}' Response — 201 Created
{
"url": "https://checkout.stablc.xyz/p/018e6b7f-...#sig=a3f9bc2d...",
"secret": "clh_sec_4a7f2e..."
} Amount encoding: always pass raw token units as a string to avoid floating-point precision loss.
50 USDC = "50000000" · 50 USDT = "50000000" · 50 DAI = "50000000000000000000"
/v1/payment-links/:idGet a link
Returns link details and current status. Public. Poll this from your backend until status is PAID.
Request
curl https://api.stablc.xyz/v1/payment-links/018e6b7f-...
Response
{
"id": "018e6b7f-...",
"amount": 50000000,
"note": "Invoice #42",
"webhookUrl": "https://yourapp.com/webhook",
"sigConsumed": true,
"status": "PENDING",
"expiresAt": "2026-06-01T00:00:00Z",
"createdAt": "2026-03-17T10:00:00Z",
"options": [
{
"id": "018e6b80-...",
"paymentLinkId": "018e6b7f-...",
"chainId": 42161,
"token": "USDC",
"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/verifyVerify a link
Used by the checkout page to validate the URL signature before displaying payment details.
Pass the sig value from the URL fragment in the X-Link-Sig header.
curl https://api.stablc.xyz/v1/payment-links/018e6b7f-.../verify \ -H "X-Link-Sig: a3f9bc2d..."
Single-use: the signature is consumed on first call — subsequent calls return 401. Returns 410 if the link is expired, already paid, or cancelled.
Returns: same shape as GET /v1/payment-links/:id — includes full link with options[].
/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": 50000000,
"note": "Invoice #42",
"webhookUrl": "https://yourapp.com/webhook",
"sigConsumed": true,
"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-Signature header before fulfilling orders.
Payload
{
"event": "payment.confirmed",
"id": "pay_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-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=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. |
| 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. |