Payments from U Balance
Let users pay your app from their U Balance with one confirmation screen per payment. The model is PSD2-style strong authentication: a consent never moves money, every payment is individually confirmed by the user with fresh MFA, and the completion code is bound to the exact frozen amount.
Enable
- Confidential client (a secret; public SPA/mobile apps cannot take payments yet).
- The user grants the
balancescope (read access + payment eligibility). - Each payment needs the user's confirmation with MFA fresh in the last 5 minutes.
1. Read (optional)
GET /oauth2?op=balance Authorization: Bearer <balance-scoped token>
-> { "sub": "...", "credit_usd": 87.50, "ucash": 0, "currency": "USD" }
2. Create a payment intent
POST /oauth2?op=intent&act=create (client_secret + Bearer balance token)
amount=12.50 currency=USD description="Pro plan, 1 month"
idempotency_key=order-9871 state=... redirect_uri=<registered URI>
-> { "intent_id": "pi...", "state": "pending", "confirm_url": "https://accounts.u.cash/oauth2?op=pay_confirm..." }
Same idempotency_key always returns the SAME intent. Redirect (or popup) the user to confirm_url. They see the app, the amount, and the description, approve with MFA, and are returned to your redirect URI with payment=<id>&code=...&state.
3. Complete
POST /oauth2?op=intent&act=complete
client_id, client_secret, intent_id, code
-> { "status": "completed", "ledger_ref": "oidcpay-pi..." }
The debit executes exactly once (replays return the stored result with duplicate: true; one payment ever produces one receipt). The user sees it in their account history. A receipt is posted to your back-channel endpoint with the same retry ladder as logout notifications - signed with your app's webhook signing secret once you generate one in the portal, unsigned otherwise.
Verifying receipts
Every completed payment POSTs one receipt to your registered back-channel endpoint (the same endpoint that receives logout notifications). The request is application/x-www-form-urlencoded with a single field, payload, holding this JSON:
{
"event": "u.balance.debit",
"event_id": "<32 hex, unique per receipt>",
"intent_id": "pi...",
"client_id": "your-client-id",
"amount": 12.5,
"currency": "USD",
"ledger_ref": "oidcpay-pi..."
}
With a webhook signing secret set (app page, "Payment receipts" card), every delivery also carries:
X-Webhook-Signature: t=<unix time>,v1=<hex hmac>
v1 is HMAC-SHA256 of "<t>.<payload string>" keyed with your webhook secret - signed over the EXACT payload JSON string (the value of the payload form field as received, never a re-encoded copy). The timestamp is fresh per delivery attempt, so a retried receipt still verifies. Verify like this (PHP):
$sig = $_SERVER["HTTP_X_WEBHOOK_SIGNATURE"] ?? "";
if (!preg_match("/t=(\d+),v1=([0-9a-f]{64})/", $sig, $m)) exit("no header");
if (abs(time() - (int)$m[1]) > 300) exit("stale"); // replay window
if (!hash_equals(hash_hmac("sha256", $m[1] . "." . $_POST["payload"], MY_WEBHOOK_SECRET), $m[2])) exit("bad sig");
$e = json_decode($_POST["payload"], true);
if (($e["client_id"] ?? "") !== MY_CLIENT_ID) exit("wrong app");
if (seen_already($e["event_id"])) exit("dup"); // idempotency on event_id
http_response_code(200);
- No signing secret set (or a transient key issue on our side): the receipt arrives unsigned - integrity is then TLS to your exact registered endpoint plus
event_iddedupe on your side. - Deduplicate on
event_id; your handler should be idempotent regardless. - Rotating the secret is instant on our side: from that moment every delivery (including retries of older receipts) signs with the new secret. Update your verifier first and accept old and new signatures during your own transition.
Add to balance
Two ways value can flow INTO a user's balance from your app:
App-funded credits (you pay)
Prepay platform credit for your app (arranged with U.CASH), then push credits to users: refunds, rewards, promotions. Receiving needs no user confirmation, but pushes are authenticated with a DEDICATED credit push key (your client secret can never credit anyone), idempotent by ref, and both ledgers record every cent. Your app page shows the credit balance and its ledger.
POST /oauth2?op=credit&act=push Authorization: Bearer <balance token>
client_id, credit_push_key, amount=5.00, description="Refund", ref=refund-123
-> { "status": "credited", "ref": "oidcpush-refund-123" }
Overdrawing your app credit fails closed (402 app-credit-insufficient). Generate and rotate the push key on your app page ("App credit" card); after a rotation the previous key stays valid for 10 minutes.
User topup with attribution (user pays)
Send the user to fund their own balance through the standard rails with your app attached; they return to your registered redirect when the checkout flow completes. The money always rides the user's own topup, never through you. Settlement status is reconciled exactly (the hosted checkout stores your reference) and reported two ways: poll op=topup&act=status, and - if you have a back-channel endpoint - a signed u.balance.topup receipt arrives when the funds settle, verified exactly like the debit receipt above.
POST /oauth2?op=topup&act=status (client_id, client_secret, topup_id)
-> { "topup_id": "tu...", "state": "completed", "amount": 25, "completed_at": "..." }
POST /oauth2?op=topup&act=start
client_id, client_secret, amount=25.00, redirect_uri=<registered>, state=...
-> { "topup_url": "https://pay.u.cash/topup?..." } // redirect or popup the user here
On return you receive topup=<id>&state=... at your redirect URI.
Standing orders
Recurring payments without a confirmation screen per debit. The user grants a standing order ONCE on an MFA-confirmed screen (the mandate): they see the per-debit limit, the total cap, the schedule or cooldown, and the 12-month validity. After that you debit within those limits; every debit is emailed to the user, appears in their history, and arrives at your back-channel endpoint as a signed receipt (source: standing-order, mandate_id included).
1. Propose (the user then approves on our screen)
POST /oauth2?op=mandate&act=create Authorization: Bearer <balance token>
mode=adhoc|scheduled per_debit_cap=100 lifetime_cap=500 cooldown=monthly|weekly|none
[scheduled: sched_amount=9.99 sched_interval=monthly|weekly]
description="Pro plan" idempotency_key=... redirect_uri=<registered> state=...
-> { "mandate_id": "so...", "state": "pending", "confirm_url": ".../oauth2?op=mandate_confirm..." }
Send the user to confirm_url (redirect or popup). On approval you are returned with mandate=<id>&state; declining returns access_denied.
2. Debit
POST /oauth2?op=mandate&act=debit (client_id, client_secret - the mandate IS the authorization)
mandate_id, amount, description, idempotency_key
-> { "status": "completed", "ledger_ref": "oidcso-sd...", "remaining_cap": 490 }
- Adhoc mode: you choose each amount (up to the per-debit cap); the cooldown (default one debit per month) and the lifetime cap are enforced server-side - a refused debit returns
409 mandate-limit, insufficient balance returns402(nothing rolls forward). - Scheduled mode: the fixed
sched_amountis billed automatically on each interval (first debit at approval); you receive the receipts. - Caps: per-debit up to $1,000, lifetime up to $5,000, fixed 12-month expiry. Revoking the user's app access (their account hub) or your
act=revokeends the order;act=listshows your mandates with live counters.
Rules
- Amounts USD 0.01 to 10,000; intents live 1 hour; confirmations must complete within 10 minutes.
- Declining returns
error=access_deniedto your redirect URI. - Revoking balance access in the user's account kills the app's pending intents.
- Insufficient balance fails the completion with
payment_failed / insufficient- no overdraft, ever.