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

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);

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 }

Rules