Refund workflows #
qpayd records refund requests and links them to the original invoice. Refunds can be finalized manually after payment, or executed by a configured per-store payout backend.
For on-chain stores, qpayd normally has a watch-only descriptor. For Lightning, invoice creation should use limited credentials. Keep payout credentials on the wallet host or a private qpayd deployment that handles sweeps and refunds.
Read refund state for an invoice:
curl -sS https://pay.example.com/v1/stores/main/invoices/$INVOICE_ID/refund-summary \
-H "Authorization: Bearer $QPAYD_MAIN_ADMIN_TOKEN"
Create an invoice-scoped refund record:
curl -sS https://pay.example.com/v1/stores/main/invoices/$INVOICE_ID/refunds \
-H "Authorization: Bearer $QPAYD_MAIN_PAYOUT_TOKEN" \
-H "Idempotency-Key: refund_order_123" \
-H "Content-Type: application/json" \
-d '{
"amount_sats": 2000,
"destination": "bc1q...",
"reason": "overpayment"
}'
When refund execution is enabled, qpayd claims pending refunds, sends them to the matching payout backend, and finalizes successful payments with the returned transaction id or payment proof.
Finalize it after the refund payment is sent:
curl -sS -X POST https://pay.example.com/v1/stores/main/refunds/$REFUND_ID/finalize \
-H "Authorization: Bearer $QPAYD_MAIN_PAYOUT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "tx_id": "...", "payment_proof": "..." }'
Mark it failed if the operator or refund executor cannot complete the payment:
curl -sS -X POST https://pay.example.com/v1/stores/main/refunds/$REFUND_ID/fail \
-H "Authorization: Bearer $QPAYD_MAIN_PAYOUT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "failure_reason": "expired lightning invoice" }'
Cancel a pending refund:
curl -sS -X POST https://pay.example.com/v1/stores/main/refunds/$REFUND_ID/cancel \
-H "Authorization: Bearer $QPAYD_MAIN_PAYOUT_TOKEN"
Pending and finalized refunds count against the invoice refundable balance.
Canceled and failed refunds do not. Refund responses include approval_status
plus optional destination_type, idempotency_key, payment_proof, and
failure_reason fields.
Use a scoped payout token globally, or override it per store:
[auth]
payout_token_env = "QPAYD_PAYOUT_TOKEN"
[[stores]]
id = "main"
payout_token_env = "QPAYD_MAIN_PAYOUT_TOKEN"
If payout_token_env is configured, refund create, finalize, fail, and cancel
requests must use that token. If it is omitted, refund mutations use
admin_token_env when configured, otherwise api_token_env.
Set admin_token_can_payout = true on a store when the admin token should also
be accepted for payout actions. This enables broader admin privilege; keep it
false unless the deployment needs one operator token.
[[stores]]
id = "main"
admin_token_env = "QPAYD_MAIN_ADMIN_TOKEN"
payout_token_env = "QPAYD_MAIN_PAYOUT_TOKEN"
admin_token_can_payout = true
Verify the setting with one small refund request against
POST /v1/stores/main/invoices/$INVOICE_ID/refunds: use the admin token and
confirm qpayd returns 2xx when admin_token_can_payout = true. Set it back to
false, reload qpayd, repeat the same request, and confirm qpayd returns
401 Unauthorized.
Approve a refund that crosses manual_approval_threshold_sats:
curl -sS -X POST https://pay.example.com/v1/stores/main/refunds/$REFUND_ID/approve \
-H "Authorization: Bearer $QPAYD_MAIN_ADMIN_TOKEN"
Approved refunds can then be finalized or executed with the payout token.
Verify the approval path with a small amount first:
curl -sS https://pay.example.com/v1/stores/main/refunds/$REFUND_ID \
-H "Authorization: Bearer $QPAYD_MAIN_ADMIN_TOKEN"
Confirm the response shows "approval_status":"approved", then finalize or run
the refund executor and confirm the refund reaches "status":"succeeded".
Run refund execution once:
qpayd --config qpayd.toml refunds-once
Then query the refund and confirm it is "status":"processing",
"status":"succeeded", or "status":"failed". A successful payout should
include a tx_id for Bitcoin refunds or payment_proof for Lightning refunds.
A refund left in "status":"processing" has started payout execution and should
be inspected before retrying manually.
Run refund execution continuously:
qpayd --config qpayd.toml refunds
For an end-to-end check, create a small invoice, pay it, create a tiny refund,
run the executor, then query the refund and confirm it is "status":"succeeded"
or "status":"failed" with the expected payout fields.
The normal serve command also starts the refund executor when at least one
store has refund execution enabled. Operators that split public receive traffic
from wallet operations should run the refunds command on the wallet host and
leave refund execution disabled on the public server.
Refund Execution Config #
qpayd has per-store payout config for refund execution. Lightning payouts share the same wallet access used for sweeps. Bitcoin payouts use a bitcoind RPC wallet with spend access.
Tiny sites can run receive, sweeps, and refunds together. Stores that use sweeps or refund execution should run those commands on the wallet host or a private server.
[stores.lightning_payout]
backend = "phoenixd" # or "barkd"
url = "http://127.0.0.1:PORT"
full_api_password_env = "QPAYD_LIGHTNING_REFUND_PASSWORD"
[stores.lightning_payout.refunds]
enabled = true
max_refund_sats = 100000
daily_refund_limit_sats = 500000
manual_approval_threshold_sats = 250000
poll_seconds = 30
[stores.bitcoin_payout]
backend = "bitcoind"
url = "http://127.0.0.1:PORT"
wallet = "refunds"
rpc_auth_env = "QPAYD_BITCOIN_REFUND_RPC_AUTH"
[stores.bitcoin_payout.refunds]
enabled = true
max_refund_sats = 100000
daily_refund_limit_sats = 500000
manual_approval_threshold_sats = 250000
poll_seconds = 30
max_refund_sats applies to one refund. daily_refund_limit_sats applies to
successful refunds finalized by qpayd during the current UTC day.
manual_approval_threshold_sats makes larger refunds wait for an admin approval
before the payout token can execute or finalize them.
Backend-specific wallet configuration is covered in Lightning backends.