Crypto payment API.
Make a checkout with one call. Your customer pays in any coin; you get your coin at your wallet; your server hears about it in a signed webhook. No account, no email, no ID: your merchant key is the whole login.
Quickstart
- Set up your shop on the setup page: payout coin, payout wallet, shop name. Copy the merchant key it shows you once.
- Create an invoice from your server and send the customer to its
checkoutUrl. - Handle
invoice.paidat your webhook, after checking its signature. Then ship the order.
curl https://silentmule.com/api/v1/invoices \
-H "Authorization: Bearer $SILENTMULE_KEY" \
-H "Content-Type: application/json" \
-d '{"amount":"49.90","reference":"A-1001","expiresInMinutes":60}'Authentication
Every call carries your merchant key as a bearer token: Authorization: Bearer mk_.... Keep it on your server, never in a browser or an app. We store only a one-way hash of it, so we can't show it again or send it to you. A missing, wrong or deleted key gets 401 with the same message every time.
Lost your key? There's no email to reset it with, by design. Set up again with the same payout wallet and switch your server to the new key.
Create an invoice
POST /api/v1/invoices
| Field | Type | What it is |
|---|---|---|
amount | string, required | The price in your payout coin, like "49.90". Each payment is worth $50 to $29,999. |
reference | string, optional | Your own order number, up to 64 characters. Comes back in every webhook. |
expiresInMinutes | integer, optional | How long the checkout can be started: 15 to 10,080 (7 days). Default 60. |
The answer, 201 Created:
{
"id": "inv_5ftmwfeg7th5h4f3",
"status": "open",
"amount": "49.9",
"asset": "usdt-tron",
"reference": "A-1001",
"checkoutUrl": "https://silentmule.com/checkout/inv_5ftmwfeg7th5h4f3",
"createdAt": "2026-11-04T12:00:00.000Z",
"expiresAt": "2026-11-04T13:00:00.000Z",
"paidAt": null,
"landed": null,
"payoutTransaction": null,
"orders": []
}The checkout shows your shop name and the price, never your payout address. The customer picks any coin; each attempt is its own order with a fresh deposit address, and it pays your wallet.
Read and list invoices
GET /api/v1/invoices/{id} returns one invoice, if it's yours.
GET /api/v1/invoices?status=paid&limit=50&offset=0 lists yours, newest first. Use it to reconcile if your server missed a webhook. limit goes up to 200.
Reusable payment links
A link with no end date, for a product, a service or a donation. POST /api/v1/links with label (what it's for, up to 60 characters) and an optional amount. Leave the amount out and the customer types what they want to pay, inside the $50 to $29,999 range.
curl https://silentmule.com/api/v1/links \
-H "Authorization: Bearer $SILENTMULE_KEY" \
-H "Content-Type: application/json" \
-d '{"label":"Annual licence","amount":"120"}'
{ "id": "lnk_1dwgfh9ekaye", "label": "Annual licence", "amount": "120", "active": true,
"url": "https://silentmule.com/checkout/l/lnk_1dwgfh9ekaye", "createdAt": "2026-11-04T12:00:00.000Z" }Every payment through a link becomes its own invoice, with reference set to the link's label and link set to its id, so webhooks and reconciliation work exactly as above. GET /api/v1/links lists yours; PATCH /api/v1/links/{id} with {"active": false} switches one off, and customers then see a plain "switched off" page.
The invoice and its states
| Status | Means | Event |
|---|---|---|
open | Waiting for the customer to pay. | none |
pending | A deposit is on the network and on its way to you. | invoice.pending |
paid | The payout to your wallet is sent. Final: it never changes after this. | invoice.paid |
needs_attention | A short, late or wrong-network deposit, or a failed payout. A person sorts it out; the customer can also try again. | invoice.needs_attention |
expired | Nobody paid before expiresAt. | invoice.expired |
landed is what actually reached your wallet, in your coin, and payoutTransaction is its hash. Paid in the same coin as your payout coin, landed equals the price. Between two coins the checkout aims a little above your price, so landed is usually the price or slightly more; check it if a few cents matter to you.
Webhooks
Set your endpoint in the dashboard. We POST JSON to it when an invoice moves, with a SilentMule-Signature header. Answer with any 2xx within 10 seconds. Anything else, or no answer, and we retry after 1, 5, 30, 120, 360, 720 and 720 minutes, about 24 hours in all, then mark the delivery failed in your dashboard.
Deliveries can repeat and arrive out of order: deduplicate on the event id, and treat paid as final.
{
"id": "evt_Qm8s2kT4nV7xY1aB3cD5eF6g",
"type": "invoice.paid",
"createdAt": "2026-11-04T12:31:07.000Z",
"data": {
"id": "inv_5ftmwfeg7th5h4f3",
"status": "paid",
"amount": "49.9",
"asset": "usdt-tron",
"reference": "A-1001",
"checkoutUrl": "https://silentmule.com/checkout/inv_5ftmwfeg7th5h4f3",
"paidAt": "2026-11-04T12:31:07.000Z",
"landed": "50.03",
"payoutTransaction": "0f3a9c..."
}
}The other events carry the same data, with the status they announce. The dashboard's test button sends {"type":"test","data":null}.
Verify a signature
The header looks like t=1793750400,v1=d9e3...c680. To check it:
- Take
tand the raw request body, exactly as received, before any JSON parsing. - Compute HMAC-SHA256 of
t + "." + bodywith your webhook secret (whsec_...). - Compare it with
v1in constant time, and reject anything wheretis more than 5 minutes from now.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret, toleranceSec = 300) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
if (!m) return false;
if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSec) return false;
const want = createHmac("sha256", secret).update(`${m[1]}.${rawBody}`).digest();
const got = Buffer.from(m[2], "hex");
return got.length === want.length && timingSafeEqual(got, want);
}Python
import hmac, hashlib, re, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
if not m or abs(time.time() - int(m.group(1))) > tolerance:
return False
want = hmac.new(secret.encode(), m.group(1).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, m.group(2))Test vector
Check your code against this before going live. Secret whsec_test_6YxkQ3m9Vt2LpR8sNa4Wz1, timestamp 1793750400, and this exact body:
{"id":"evt_test_0001","type":"invoice.paid","createdAt":"2026-11-04T12:00:00.000Z","data":{"id":"inv_test0000000000","status":"paid","amount":"49.9","asset":"usdt-tron","reference":"A-1001","paidAt":"2026-11-04T12:00:00.000Z","landed":"50.03","payoutTransaction":"0f3a9c"}}Expected header: t=1793750400,v1=d9e3c64fbaabd468085854451af25fd20265b24fdca5ef984f1ca4eec232c680. Turn the 5-minute check off for this test only.
Errors and limits
Errors are JSON, { "error": "plain words", "field": "amount" }, where field is set when one input is the problem.
| Status | When |
|---|---|
| 401 | The merchant key is missing, wrong or deleted. |
| 404 | No such invoice, or it isn't yours. |
| 422 | An input doesn't work: amount outside $50 to $29,999, expiry outside 15 minutes to 7 days, a payout address that can't receive. |
| 429 | More than 120 calls a minute on one merchant key. Wait a few seconds and retry. |
Testing
Use the dashboard's Send a test button to check your endpoint and your signature code. To test on your own machine, point the endpoint at a tunnel to it. There are no fake payments: to see the full path end to end, make a small real checkout of $50 and pay it.
No code at all? Make a checkout link in the dashboard for a price and share it anywhere. How merchant payments work.
Changelog
4 November 2026: v1: invoices, hosted checkout, four webhook events, HMAC-SHA256 signatures. Reusable payment links and the pay button. Invoices made from a link carry link.