stablc

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.

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

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.

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

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":  ["USDC", "USDT"]
  }
]
FieldDescription
chainIdEIP-155 chain ID (informational).
nameChain name slug (e.g. "ethereum", "arbitrum-one"). Pass this in options[].chain when creating a link.
nameHuman-readable slug, e.g. "ethereum-sepolia". Use for display only.
mainnetfalse = testnet, true = mainnet.
tokensSupported token symbols on this chain, e.g. ["USDC", "USDT"].

Authentication

Most endpoints are public. Link-scoped actions (cancel, webhook replay) require your link secret.

ScopeHeaderValue
PublicNo auth required
Link-scopedAuthorizationBearer clh_sec_…
Checkout (verify only)X-Link-Sig32-char hex value from #sig= URL fragment
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":      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.

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-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-..."
}
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.
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.