Payments
Payments
How a payment moves from creation through on-chain confirmation to a settled balance — the lifecycle, status model, timing controls, checkout, redirects, and edge cases in one place.
Payment lifecycle
A payment moves through five stages. Each transition is driven by a real on-chain event — Suward never advances a payment speculatively.
curl -X POST https://api.suward.com/v1/payments \ -H "X-Api-Key: $SUWARD_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": "100000000", "asset": "USDT_ETHEREUM", "externalId": "order-42", "serviceFeePayer": "merchant", "underpaymentTolerance": "500000" }'# amount and underpaymentTolerance are smallest-unit integer strings.# USDT has 6 decimals: 100000000 = 100 USDT, 500000 = 0.50 USDT.- 1CreatePOST /v1/payments. Suward returns a deposit address unique to this payment and a paymentPageUrl for the hosted checkout. Status is pending / created.
- 2DepositThe buyer sends the asset to the deposit address. Suward detects the transaction in the mempool and transitions to pending / confirming.
- 3ConfirmingThe block lands. Suward waits for the configured number of confirmations (network-dependent). Status moves to accepted / confirming while confirmations accumulate.
- 4FinalityThe chain reaches a safe checkpoint — typically Safe on Ethereum-equivalent networks. Suward applies reorg protection: if a reorg removes the transaction, the payment rolls back and your webhook fires again.
- 5SettledStatus reaches success / completed. The amount is credited to your project balance as safe funds. The payment is terminal — nothing can change it from here.
externalId is your own order reference and must be unique — reusing a value that already exists returns an error rather than a second payment, so it also guards against creating a duplicate by accident.
Status model & transitions
Every payment has two status fields: status (the top-level state machine) and subStatus (the detail within that state). Poll GET /v1/payments/{id} or receive state changes via webhook — never hardcode status names, read them from GET /v1/paymentStatuses so your integration survives future additions.
Status and subStatus reference
status is the top line; subStatus is the detail. Terminal states are success and failed.
- pendingcreatedactivatedawaitingPaymentconfirming
- acceptedcompletedoverpaidunderpaid
- successcompletedoverpaidunderpaid
- failedexpiredcancelledpartiallyPaid
Transitions — when they fire and what changes
Each row is a real transition: what triggers it, which timestamps Suward stamps on the payment, and which webhook is delivered (only if a webhookUrl is set). Pending-stage moves (create, activate) never send a webhook.
—pending / awaitingPaymentCreate without activationFlowSeconds — deposit address issued immediately.
sets: createdAt, activatedAt, expiresAtno webhook—pending / createdCreate with activationFlowSeconds — no address yet, window not counting.
sets: createdAt, expiresAtno webhookpending / createdpending / awaitingPaymentPOST /activate (or the buyer opens the hosted page) — address issued, window starts.
sets: activatedAtno webhookpending / awaitingPaymentpending / confirmingFirst on-chain transfer detected in a block.
sets: —no webhookpending / confirmingaccepted / {completed | overpaid | underpaid}Received amount ≥ amount − tolerance reaches the Safe checkpoint.
sets: acceptedAt (credits the accepted balance)payment.acceptedaccepted / *success / {completed | overpaid | underpaid}Confirmed amount ≥ amount − tolerance reaches Finalized.
sets: confirmedAt (credits the safe balance)payment.successaccepted / *pending / awaitingPaymentReorg removes a Safe transfer before finality — reverts, accepted balance rolled back.
sets: acceptedAt clearedpayment.failed → later payment.acceptedpending / *failed / expiredpaymentWindowSeconds elapses with less than amount − tolerance received.
sets: —payment.failedpending / * (early)failed / cancelledPOST /cancel while the payment is still cancellable.
sets: —payment.failed
Register a webhookUrl on the project or the payment and Suward POSTs an Ed25519-signed event on every accepted / success / failed transition — you never need to poll. Ordering between events is not guaranteed: read Payment.updatedAt and status, not the arrival order.
Timing controls
Three optional fields shape how long a payment lives and how forgiving it is about the exact amount. Set them per payment on create, or set project-level defaults for paymentWindowSeconds and activationFlowSeconds.
paymentWindowSeconds300–86400 s (5 min – 24 h) · default: project setting- How long the payment stays open for funds once its window is counting. A tight window creates checkout urgency and frees the deposit address sooner; a long window suits invoices the buyer pays later. When the window elapses unpaid, the payment moves to failed / expired and the address accepts nothing more.
activationFlowSeconds1–3600 s (1 s – 1 h) · optional, off by default- Turns creation into a two-step flow: the payment is created but its paid window does not start counting until you activate it (POST /v1/payments/{id}/activate, or the buyer opening the hosted page). Use it so the countdown does not burn while the buyer is still on your cart or deciding to pay — the strict window only starts when they commit.
underpaymentToleranceinteger string, smallest unit · ≥ 0 and < amount · default 0- The shortfall you will still accept as paid in full, in the asset’s smallest unit (same scale as amount). It absorbs dust, gas, and rounding differences so a near-exact transfer settles to success instead of stalling at underpaid. For amount "10000000" (10 USDT) a tolerance of "500000" accepts a 0.50 USDT shortfall.
activationFlowSeconds — with vs without
The only difference is when the clock starts and when the deposit address appears. Without it, the window is already ticking the moment you create the payment; with it, nothing counts until you activate.
POST /v1/payments{ "amount": "10000000", "asset": "USDT_ETHEREUM", "paymentWindowSeconds": 900 } → status pending / awaitingPayment→ address 0xabc…def (issued immediately)→ expiresAt createdAt + 900s ← clock is already tickingPOST /v1/payments{ "amount": "10000000", "asset": "USDT_ETHEREUM", "activationFlowSeconds": 600, "paymentWindowSeconds": 900 } → status pending / created→ address none yet→ expiresAt createdAt + 600s + 900s # later, when the buyer commits to pay:POST /v1/payments/{id}/activate→ status pending / awaitingPayment→ address 0xabc…def (issued now)→ the 900s window starts counting from HEREexpiresAt reflects both fields: without activation it is createdAt + paymentWindowSeconds; with activation it is createdAt + activationFlowSeconds + paymentWindowSeconds until you activate, then the real paymentWindowSeconds counts from the activate call. All three fields are fixed at creation — you cannot change them on an open payment.
Checkout options
After create, hand your buyer one of two checkout experiences. Both start from the same payment object.
Hosted checkout
Redirect to paymentPageUrl — the Suward-hosted page with a QR code, a live mempool indicator, and a countdown timer. No UI work on your side. Best for getting paid fast.
Your own UI
Build from address, amount, and asset in the create response. You control the design. Poll GET /v1/payments/{id} or listen on your webhook to update status in real time.
The hosted page is a server-side redirect and the API is called from your backend, so no secret ever touches the browser. paymentPageUrl is valid for the life of the payment.
Redirects — return to store
redirectConfig controls where the buyer goes after they pay. Set it on the project as a default, or per payment on create to override. It has three parts.
url- The base "return to store" URL. Must be https and ≤ 2048 characters.
params- Which payment identifiers to append as query parameters. Allowed values: "id" (the Suward payment id) and "externalId" (your own reference).
data- An opaque string (≤ 512 chars) passed through unchanged as the data query param — handy for a cart token or a signed nonce of your own.
Without redirectConfig
The hosted page has nowhere to send the buyer. After paying they simply see the success screen and stay on the Suward page — there is no "return to store" button.
With redirectConfig
The hosted page shows a "Return to store" button and, on success, auto-redirects after a few seconds to your url with the chosen identifiers and data appended, e.g. https://shop.com/return?id=pay_123&externalId=order-42&data=cart_abc.
Using the Suward hosted page
Just set redirectConfig — Suward renders the button and performs the redirect for you. Nothing to build. Because the redirect is unsigned browser navigation, its query params are only identifiers: your return endpoint must call GET /v1/payments/{id} to read the authoritative status before it shows "paid".
Building your own payment page
You already own navigation, so you do not need redirectConfig to bring the buyer back. Set it only when you sometimes hand a buyer to the hosted paymentPageUrl. Either way, follow the same rule on your own return flow: treat redirect params as hints, and confirm the payment via the API (or your webhook) — never trust the redirect alone as proof of payment.
The redirect is not a payment confirmation. It is unsigned and can be forged or replayed; the only sources of truth are the signed webhook and GET /v1/payments/{id}.
Amounts & edge cases
Money values are always integer strings in the asset’s smallest unit, never floats. The API validates and stores them as Decimal128 to avoid rounding loss. Several edge cases are handled automatically.
- Underpaid: the buyer sends less than the requested amount. The payment reaches accepted / underpaid, or fails as partiallyPaid. Set underpaymentTolerance (see Timing controls) to accept small shortfalls as fully paid and advance to success.
- Overpaid: the buyer sends more than the requested amount. The payment reaches success / overpaid. The excess is credited to your balance along with the expected amount.
- Payment window: payments expire after their configured window (see paymentWindowSeconds under Timing controls). An expired payment transitions to failed / expired. The deposit address accepts no further funds.
- Double deposit: if a second transfer arrives after the first settles, it is treated as a separate overpayment and recorded on the same payment object.
Timing and tolerance are covered in full under Timing controls above — this section is the amount-side view of the same fields.
Fees & finality
Two fee dimensions apply to every payment. You control who absorbs each one at creation time.
- serviceFeePayer: who pays the Suward service fee — "merchant" (default, deducted from your balance) or "customer" (the required amount is grossed up so the buyer covers it).
- networkFeePayer: who pays the on-chain gas cost for settlement transactions — "merchant" or "customer". Applies when Suward moves funds during the custody step.
- Finality and reorg protection: Suward holds a payment at accepted / confirming until the block reaches a Safe checkpoint on the chain (e.g. Safe on Ethereum). If a reorg removes the deposit transaction before finality, the payment reverts and your webhook fires with the rolled-back status. You never see a false success.
- Balance credit: funds land in the safe balance bucket only after finality. The accepted balance bucket reflects confirmed-but-not-yet-final deposits.
Finality depth is configured per network. Chains with probabilistic finality (PoW) use a block-count threshold; chains with economic finality (PoS checkpoints) use the Safe/Finalized checkpoint.