Documentation
Build on StealthPaid
A payment link is a URL your customer opens and pays on. Everything else on this page, the API, the webhooks, the statuses, exists to tell your own system what happened.
Overview
The API lives at your platform origin (its own BASE_URL). Every request is authenticated with Authorization: Bearer sp_live_…, a key you create in your dashboard's Settings, API keys page.
All request and response bodies are JSON. Errors come back as {"detail": "…"} with a 4xx or 5xx status; see Errors for the codes you will actually see.
id field from the payment object, for example k3m9q2w7x1zp) and, if it concerns a webhook, the delivery id (the X-Delivery-Id header, which matches the id field of the webhook body). Those two ids are enough for us to look up exactly what happened without back and forth.Quickstart
Four steps from nothing to a working integration.
1. Create an account
Sign up in the dashboard, or with the API directly:
curl -X POST https://your-domain.example/v1/auth/signup \
-H "Content-Type: application/json" \
-d '{"name": "Olamide", "email": "olamide@example.com", "password": "a-strong-password"}'
2. Set a payout wallet
A USDC (Polygon) address you control. Payments cannot be created until this is set. Set it in the dashboard, or:
curl -X PUT https://your-domain.example/v1/merchant/wallet \
-H "Authorization: Bearer sp_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"payout_wallet": "0xYOUR40CHARACTERPOLYGONADDRESS"}'
Create an API key first in the dashboard's Settings, API keys page; a fresh signup session also works for this call.
3. Create your first link
See Payment links below for the full request and response. Redirect your customer to the url it returns.
4. Receive the webhook
Register an endpoint URL in the dashboard's Settings, Webhooks page. The moment a payment settles, we POST a signed event to it; see Webhooks for the envelope and how to verify it.
Payment links
A payment link can come from the dashboard, the API below, or the WooCommerce plugin. All three create the same kind of payment.
Create a payment
{ "amount": "103.78", "currency": "USD", "description": "Order 42", "customer_email": "j@example.com",
"return_url": "https://shop.example.com/thanks", "metadata": { "order_id": 42 } }
{ "id": "k3m9q2w7x1zp", "url": "https://stealthpaid.com/p/k3m9q2w7x1zp", "status": "created", "amount": "103.78", "currency": "USD",
"expected_usd": "103.78", "description": "Order 42", "customer_email": "j@example.com", "return_url": "…", "metadata": {"order_id": 42},
"receiving_address": null, "value_coin": null, "coin": null, "txid_in": null, "txid_out": null, "paid_at": null,
"expires_at": "2026-09-03T03:00:00Z", "created_at": "2026-09-02T03:00:00Z" }
Redirect your customer to url. Links expire after 24 hours. A 409 means you have not set a payout wallet yet; a 422 means the amount is below the minimum for that currency.
API reference
Read a payment
Returns the same shape as the create response above, with whatever the current status is.
List payments
limit defaults to 50 and is capped at 200. before pages backward from an ISO-8601 timestamp.
Statuses
created → pending (customer started) → paid | underpaid (received less than 98% of expected) → expired.
paid_at is the time of receipt and is set for both paid and underpaid, so it does not on its own mean the amount was correct; check status.
Customer return
After paying, the customer sees a receipt page and a "Return to you" button to return_url?payment=<id>&status=<status>. Never trust that query string for fulfilment: use the webhook or GET /v1/payments/{id} instead.
Payouts
Settlement is instant USDC on Polygon to your payout wallet, net of the platform fee. There are no refunds via the platform.
Webhooks
Register endpoint URLs in your dashboard's Settings, Webhooks page. When a payment settles, we POST this JSON body to every active endpoint:
{ "id": "<delivery id>", "event": "payment.paid", "created_at": "…", "data": { …payment as above… } }
Headers
| Header | What it carries |
|---|---|
X-Signature | sha256=<hex HMAC-SHA256(secret, raw body)> |
X-Timestamp | Unix seconds the delivery was sent |
X-Delivery-Id | Same value as the body's id; your idempotency key |
Verify by recomputing the HMAC over the raw request bytes, not over a re-serialized copy of the JSON. Respond with any 2xx status within 10 seconds; anything else counts as a failed attempt.
Verify the signature
Both examples take the secret you were given when you registered the endpoint, the raw request body exactly as received, and the X-Signature header, then compare in constant time.
import hashlib
import hmac
def verify_signature(secret: str, raw_body: bytes, signature_header: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)
# raw_body must be the exact bytes of the request body, read before any JSON parsing
# signature_header is the value of the X-Signature request header
if not verify_signature(webhook_secret, raw_body, request.headers["X-Signature"]):
raise ValueError("signature mismatch")
<?php
function verify_signature(string $secret, string $raw_body, string $signature_header): bool {
$expected = 'sha256=' . hash_hmac('sha256', $raw_body, $secret);
return hash_equals($expected, $signature_header);
}
// $raw_body must come from file_get_contents('php://input'), not $_POST
$raw_body = file_get_contents('php://input');
$signature_header = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
if (!verify_signature($webhook_secret, $raw_body, $signature_header)) {
http_response_code(400);
exit('signature mismatch');
}
Retries
A delivery that does not get a 2xx response is retried at 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours and 24 hours: six attempts, about 39 hours in total, before we give up.
Idempotency
Deliveries are at-least-once. Treat the body's id (equal to X-Delivery-Id) as an idempotency key and ignore an id you have already processed, since a retry can arrive after your first attempt already succeeded.
Statuses
| Status | Meaning |
|---|---|
created | The link exists. The customer has not opened it yet. |
pending | The customer opened the checkout and started paying. |
paid | Payment received in full (at least 98% of the expected amount) and verified server side. |
underpaid | Payment received but below 98% of the expected amount. The funds still settle; review the amount before fulfilling. |
expired | Nobody paid within 24 hours of the link being created. |
WooCommerce
The StealthPaid for WooCommerce plugin drives your store's order status from the same signed webhook described above, not from the customer's browser.
Download the pluginSetup, in order
- Install and activate the plugin, then open WooCommerce, Settings, Payments, StealthPaid.
- Create an API key in your dashboard's Settings, API keys page and paste it into the plugin.
- Copy the plugin's read-only Webhook URL field, then register it in your dashboard's Settings, Webhooks page.
- Paste the secret that registration gives you back into the plugin's Webhook secret field. Both directions have to match, or delivery fails closed.
- Choose the order status to apply once a payment is confirmed paid (default Processing), save, and enable the gateway.
An order that underpays moves to On hold rather than your paid status; the order note states the amount received against the amount expected. Full install steps, the order status table and troubleshooting live in the WooCommerce guide.
Errors
Every error response is {"detail": "a human-readable reason"} with one of these statuses.
| Status | Meaning |
|---|---|
401 | Missing or invalid API key, or an expired session. |
403 | The merchant account is suspended. |
404 | No payment with that id belongs to your account. |
409 | A conflict, most often creating a payment before a payout wallet is set. |
422 | The request body failed validation, or the amount is below the minimum for that currency. |
429 | Too many requests in a short window; wait and retry. |
502 | The payment service is temporarily unavailable. Retry with backoff; nothing was charged. |
FAQ
Which authentication should my server use?
An API key (Authorization: Bearer sp_live_…) for anything running outside a browser. The dashboard itself uses a session cookie, which also works against the API for quick testing.
Can I test without moving real money?
There is no separate sandbox mode; the smallest supported payment is around 2 USD for card and similar methods, so a real small-amount payment is the practical way to test end to end.
How do I know a webhook is really from StealthPaid?
Recompute the HMAC in X-Signature over the raw request body using your endpoint's secret, and reject anything that does not match. See Webhooks for worked examples.
What happens if my endpoint is down when a webhook is sent?
We retry on a fixed schedule for about 39 hours across six attempts. You can also poll GET /v1/payments/{id} at any time instead of waiting.
Why did my payment come back as underpaid?
The amount received was below 98% of the amount expected, most often because a payment method deducted its own fee before forwarding funds. The funds still settle to your wallet; the payment object shows both amounts.
Can I cancel or refund a payment through the API?
No. Settlement is final on-chain and there is no refund endpoint; a refund is something you arrange with your customer directly, outside the platform.