StealthPaid StealthPaid

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.

Quoting a payment to support. If you ever need to contact us about a specific payment, quote its payment id (the 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:

Shell
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:

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

API reference

Read a payment

GET /v1/payments/{id}

Returns the same shape as the create response above, with whatever the current status is.

List payments

GET /v1/payments?limit=50&before=<iso>

limit defaults to 50 and is capped at 200. before pages backward from an ISO-8601 timestamp.

Statuses

createdpending (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:

Envelope
{ "id": "<delivery id>", "event": "payment.paid", "created_at": "…", "data": { …payment as above… } }

Headers

HeaderWhat it carries
X-Signaturesha256=<hex HMAC-SHA256(secret, raw body)>
X-TimestampUnix seconds the delivery was sent
X-Delivery-IdSame 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.

Python
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
<?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

StatusMeaning
createdThe link exists. The customer has not opened it yet.
pendingThe customer opened the checkout and started paying.
paidPayment received in full (at least 98% of the expected amount) and verified server side.
underpaidPayment received but below 98% of the expected amount. The funds still settle; review the amount before fulfilling.
expiredNobody 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 plugin

Setup, in order

  1. Install and activate the plugin, then open WooCommerce, Settings, Payments, StealthPaid.
  2. Create an API key in your dashboard's Settings, API keys page and paste it into the plugin.
  3. Copy the plugin's read-only Webhook URL field, then register it in your dashboard's Settings, Webhooks page.
  4. Paste the secret that registration gives you back into the plugin's Webhook secret field. Both directions have to match, or delivery fails closed.
  5. 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.

StatusMeaning
401Missing or invalid API key, or an expired session.
403The merchant account is suspended.
404No payment with that id belongs to your account.
409A conflict, most often creating a payment before a payout wallet is set.
422The request body failed validation, or the amount is below the minimum for that currency.
429Too many requests in a short window; wait and retry.
502The 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.