Payments and refunds
Pay a merchant instantly, or hold the money and capture it later, then refund without unwinding fees or cashback yourself.
A payment moves money from a user's wallet to a merchant's settlement account, minus a platform fee. There are two modes, and picking the right one is most of the integration.
| Mode | Use when | Money moves |
|---|---|---|
instant | You know the final amount now: a coffee, a ticket, a checkout | Immediately |
authorize | The final amount comes later: a hotel, a fuel pump, a rental | Reserved now, captured later |
Register the merchant first
A payment needs a merchant to pay. Register each one once.
curl -sS -X POST "$WALLETD_API/v1/merchants" \
-H "Authorization: Bearer $WALLETD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"external_id": "cafe-9", "name": "Bloom Coffee", "category": "restaurants"}'category is what reward rules can scope to, so set it even if you have no rules yet.
Instant payment
curl -sS -X POST "$WALLETD_API/v1/payments" \
-H "Authorization: Bearer $WALLETD_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payment-order-4471" \
-d '{
"payer_user_id": "'$USER_ID'",
"merchant_id": "'$MERCHANT_ID'",
"amount": 1840,
"mode": "instant",
"line_items": [{"name": "Flat white", "amount": 450}, {"name": "Sandwich", "amount": 1390}]
}'{
"id": "019fee0f-4e5f-7a6b-8c7d-9e0f1a2b3c4d",
"status": "captured",
"mode": "instant",
"payer_user_id": "019f...",
"merchant_id": "019f...",
"amount": 1840,
"captured_amount": 1840,
"refunded_amount": 0,
"fee": 19,
"line_items": [{ "name": "Flat white", "amount": 450 }, { "name": "Sandwich", "amount": 1390 }],
"created_at": "2026-08-11T05:31:07.442Z"
}fee is the platform's cut, taken from the merchant's side: the payer is debited amount, the merchant is credited amount - fee. Fees are basis points and round up, so a 1% fee on 650 is 7, not 6.
line_items must sum to amount. They are for the receipt and the support screen; the money moves as one posting.
Authorize and capture
When the final amount is not known yet, hold the money and capture later.
Place the hold
curl -sS -X POST "$WALLETD_API/v1/payments" \
-H "Authorization: Bearer $WALLETD_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payment-booking-771" \
-d '{
"payer_user_id": "'$USER_ID'",
"merchant_id": "'$MERCHANT_ID'",
"amount": 65000,
"mode": "authorize",
"hold_ttl_seconds": 172800
}'The payment comes back requires_capture and the user's held rises by 65000 while available falls by the same. The money is still theirs; it is spoken for.
hold_ttl_seconds is how long you have. When it passes, the hold is released automatically and the payment becomes expired. Set it to slightly longer than your real business window: a two-day hotel hold, a one-hour ride.
Capture
# Full amount
curl -sS -X POST "$WALLETD_API/v1/payments/$PAYMENT_ID/capture" \
-H "Authorization: Bearer $WALLETD_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: capture-booking-771" \
-d '{}'
# Or less than was held: the difference is released, not kept
curl -sS -X POST "$WALLETD_API/v1/payments/$PAYMENT_ID/capture" \
-H "Authorization: Bearer $WALLETD_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: capture-booking-771" \
-d '{"amount": 58000}'You can capture up to the authorized amount, never more. Capturing less releases the remainder immediately.
Void
curl -sS -X POST "$WALLETD_API/v1/payments/$PAYMENT_ID/void" \
-H "Authorization: Bearer $WALLETD_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: void-booking-771" \
-d '{}'Releases the hold and ends the payment. Use it when the booking is cancelled before service.
// Authorize now, capture the real amount when checkout happens.
payment, err := c.Authorize(ctx, "booking-771", payer, merchant, 65000, 48*time.Hour)
if err != nil {
return err
}
// ... the stay happens ...
final, err := c.Capture(ctx, payment.ID, "capture-booking-771", 58000)
if err != nil {
return err
}
// final.CapturedAmount == 58000; the remaining 7000 is back in available.func (c *Client) Authorize(ctx context.Context, orderID, payer, merchant string, amount int64, ttl time.Duration) (*Payment, error) {
var payment Payment
body := map[string]any{
"payer_user_id": payer,
"merchant_id": merchant,
"amount": amount,
"mode": "authorize",
"hold_ttl_seconds": int(ttl.Seconds()),
}
err := c.Do(ctx, http.MethodPost, "/v1/payments", body, &payment, "payment-"+orderID)
return &payment, err
}
// Capture takes amount == 0 to mean the full authorized amount.
func (c *Client) Capture(ctx context.Context, paymentID, key string, amount int64) (*Payment, error) {
body := map[string]any{}
if amount > 0 {
body["amount"] = amount
}
var payment Payment
err := c.Do(ctx, http.MethodPost, "/v1/payments/"+paymentID+"/capture", body, &payment, key)
return &payment, err
}Refunds
Refund a captured payment, fully or partly. Cumulative refunds can never exceed what was captured.
curl -sS -X POST "$WALLETD_API/v1/refunds" \
-H "Authorization: Bearer $WALLETD_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: refund-order-4471-1" \
-d '{"payment_id": "'$PAYMENT_ID'", "amount": 950, "reason": "wrong item"}'Two partial refunds of 600 against a 1000 capture: the first succeeds, the second is refused, because 1200 > 1000. Give each refund its own idempotency key; they are separate operations.
Refunds reverse proportionally. The fee comes back, and if the payment earned cashback, the cashback is clawed back with it. You do not have to unwind anything yourself.
How a refund looks when you reconcile
Worth knowing before you write a reconciliation, because the obvious guess is wrong: a refund does not erase or amend the capture. The original payment transaction stays exactly as it was, and the refund appears as its own transaction of type refund carrying a reference_id back to the payment.
So in GET /v1/transactions you will see both, and your gross volume still includes the full capture. That is deliberate. A ledger that rewrote the capture would make the money right and the history wrong, and you would have no way to answer "how much did we sell in March" once refunds arrived in April.
Practically:
- Net a payment from the payment, not from the ledger:
captured_amount - refunded_amount - fee. Both fields are on the payment and both are authoritative. - Match a refund to its payment by
reference_id. It is not linked by any reversal field. - Expect two transactions per refunded sale, and do not treat the second as a duplicate of the first.
Reading a payment back
curl -sS "$WALLETD_API/v1/payments/$PAYMENT_ID" -H "Authorization: Bearer $WALLETD_API_KEY"captured_amount and refunded_amount are the two numbers your reconciliation cares about. Net revenue for a payment is captured_amount - refunded_amount - fee.
What can go wrong
| Code | Status | What happened |
|---|---|---|
insufficient_funds | 422 | Not enough available balance plus credit line |
payer_not_found / merchant_not_found | 404 | Bad id, or not yours |
payment_not_found | 404 | Bad payment id on capture, void, or refund |
invalid_request | 400 | Capture above the authorized amount, refund above the captured amount, or line items that do not sum to amount |
Capturing an already-captured payment with the same key returns the original result. Voiding a captured payment is an error: the money is gone, so refund it instead.
Next
- Rewards for cashback and points on capture.
- Subscriptions for recurring charges.
- Payouts to get the merchant's settlement balance out.
Transfers: wallet to wallet
Wallet to wallet inside one tenant: synchronous settlement, aliases, a fee charged on top of the sender, and the caps that refuse a send.
Subscriptions and dunning
Recurring charges against a wallet, with dunning retries and a billing period that can be charged at most once, structurally.