diff --git a/plans/2026-07-20-supporter-badges-v2-implementation.md b/plans/2026-07-20-supporter-badges-v2-implementation.md
index 0a9f50045e..59667176ac 100644
--- a/plans/2026-07-20-supporter-badges-v2-implementation.md
+++ b/plans/2026-07-20-supporter-badges-v2-implementation.md
@@ -188,8 +188,8 @@ Rules:
- Client calls `generateMasterKey` once before `Prepare`, persists it encrypted, computes `BadgeKeyCommitment`, and reuses the key for all renewal badges.
- `Prepare` stores `BadgeKeyCommitment` but cannot issue a badge.
- Apple/Google evidence may include `IssueBadge`.
-- Stripe prepare returns Checkout data; a later `ExistingPayment + IssueBadge` issues.
-- Pending response has no grant/result and includes `retryAfter`.
+- Stripe prepare returns Checkout data. The client then sends `IssueBadge`; while payment is pending the bot holds that call and sends no response.
+- The waiting call responds once after verified payment and issuance, or with a terminal payment error. Other retryable operations may still return `retryAfter`.
- Capability and `BadgeKeyCommitment` never enter Stripe metadata or a return URL.
- `grantId` selects only; bot rechecks payment, owner, product, and eligibility.
- Before signing, orchestrator recomputes `BadgeKeyCommitment` from `BadgeMasterKey`; mismatch returns `ownership_conflict` without fulfilling grant.
@@ -209,7 +209,7 @@ Order:
4. verify the request key matches the payment `BadgeKeyCommitment`;
5. pass only grant + request to badge service;
6. cache issuance and mark grant fulfilled atomically;
-7. return one final response.
+7. return the single response.
### Idempotency and audit
@@ -289,7 +289,7 @@ sequenceDiagram
Commit entitlement before outbox acknowledgement/consume. RTDN triggers provider GET; never create a service grant from the notification payload.
-### Stripe Checkout and webhook
+### Stripe Checkout and waiting `IssueBadge`
```mermaid
sequenceDiagram
@@ -298,44 +298,60 @@ sequenceDiagram
participant S as Stripe
Note over C: CPPreparing
C->>B: RPC Prepare Stripe
- Note over B: Payment: BPPrepared
+ Note over B: BPPrepared
B->>S: Create Checkout Session
S-->>B: Session ID + URL
- Note over B: Payment: BPCheckoutOpen
+ Note over B: BPCheckoutOpen
B-->>C: RPC payment ID + capability + URL
Note over C: CPCheckoutReady
C->>S: Open Checkout
Note over C: CPAwaitingPayment
+ C->>B: RPC IssueBadge(requestId, paymentId, key)
+ Note over C: CPAwaitingPayment + CBRequesting
+ B->>B: Register waiter and recheck payment under lock
+ Note over B: BPAwaitingPayment
No response yet
S-->>B: Signed webhook
- Note over B: Payment: BPVerifying
+ Note over B: BPVerifying
B->>S: Retrieve current payment
- S-->>B: Canonical payment
- Note over B: Payment: BPPaidOneTime or BPSubscriptionActive
- B->>B: Create or load due grant period
- Note over B: Grant: GrantReady
- Note over C: Still CPAwaitingPayment, no bot push
+ S-->>B: Canonical paid payment
+ Note over B: BPPaidOneTime or BPSubscriptionActive
+ B->>B: Create GrantReady and wake waiter
+ B->>B: Verify key, sign badge, mark GrantFulfilled
+ B-->>C: RPC credential
+ Note over C: CPEntitled + CBReceived
+ C->>C: Verify and install
+ Note over C: CBInstalled
```
-Webhook handler verifies the raw body, persists/deduplicates event ID, returns `2xx`, then workers reconcile. Client remains pending until its next RPC.
+The second RPC has exactly one response. The bot sends it only after verified payment allows issuance, or after a terminal event such as Checkout expiry. Register-and-recheck under the payment lock prevents a webhook/request race. If the webhook completed first, `IssueBadge` responds immediately.
-### Stripe status while pending
+If Stripe retrieval fails transiently after the webhook, the bot keeps the call open and retries internally. It does not send an intermediate response; only the RPC deadline or a terminal payment result ends the wait.
+
+Persist request ID, hash, and result state; keep the live waiter and raw `BadgeMasterKey` only in memory. Webhook commit wakes live waiters after `GrantReady` is durable. After bot restart, the repeated request rechecks persisted payment/grant state and either returns immediately or installs a new waiter.
+
+### Stripe wait interruption
```mermaid
sequenceDiagram
participant C as Client
participant B as Bot
participant S as Stripe
- Note over C: CPVerifying
- C->>B: RPC ExistingPayment
- Note over B: Payment: BPVerifying
- B->>S: Retrieve Checkout
- S-->>B: Pending / unpaid
- Note over B: Payment: BPAwaitingPayment
- B-->>C: RPC pending + retryAfter
- Note over C: CPAwaitingPayment
+ Note over C: CPAwaitingPayment + CBRequesting
+ C->>B: RPC IssueBadge with stable requestId
+ B->>B: Register waiter, payment still pending
+ Note over B: Hold call, no response
+ Note over C: RPC deadline / app restart
+ C-xB: Exchange removed
+ Note over C: CPAwaitingPayment + CBRetryableFailure
+ Note over B: Remove waiter, preserve payment state
+ S-->>B: Webhook may complete payment later
+ Note over B: Persist payment + GrantReady
+ C->>B: Repeat same IssueBadge on foreground
+ Note over C: CPAwaitingPayment + CBRequesting
+ B-->>C: Credential immediately if ready
otherwise hold this call
```
-Poll from Checkout open and on return/foreground at 5, 15, 30, 60, 120 seconds. Then use normal reconciliation. Deep links are optional; no localhost listener.
+The client persists `requestId`, payment capability, and `BadgeMasterKey` before opening Checkout. It retries only after an interrupted exchange, foreground, or explicit user action—never on a polling timer. A deep link is optional UX; no localhost listener is used.
### Cancellation
@@ -379,7 +395,7 @@ Join by payment/grant ID only. Update active profile only after core installatio
| `payments` | provider-object ownership + `BadgeKeyCommitment`; canonical payment sum |
| `service_grants` | payment + product + grant period |
| `badge_issuances` | grant + master-key hash; cached credential |
-| `rpc_requests` | request ID; request hash + final response |
+| `rpc_requests` | request ID; pending waiter/result + request hash + response |
| `provider_events` | provider event ID; dedupe/result |
| `outbox` | acknowledge, consume, reconciliation, cleanup |
@@ -396,7 +412,8 @@ reconcile(paymentId):
coalesce to one worker
render cached payment + installed badge
submit unseen Apple/Google evidence
- request status for nonterminal payment
+ for pending Stripe Checkout: ensure one IssueBadge call is waiting
+ otherwise request status for nonterminal payment
if GrantReady and no badge covers its grant period: CBNeeded -> request IssueBadge
if credential returned: CBReceived -> CBInstalling -> CBInstalled
schedule next check
@@ -415,9 +432,10 @@ Every input is one of:
| Input/result | Class | Client | Bot |
|---|---|---|---|
-| payment pending | retry | `CPAwaitingPayment`; schedule | `BPAwaitingPayment`; no grant |
-| timeout/429/5xx | retry | `CPPaymentProblem` with prior snapshot | preserve current `BP…`; return `retryAfter` |
-| lost response | retry | preserve state; repeat same ID/body | return cached result/state |
+| Stripe awaiting webhook | wait | `CPAwaitingPayment` + `CBRequesting` | hold `IssueBadge`; no response |
+| timeout/429/5xx in a non-waiting operation | retry | `CPPaymentProblem` with prior snapshot | preserve current `BP…`; return `retryAfter` |
+| Stripe verification timeout/429/5xx while `IssueBadge` waits | wait; retry internally | `CPAwaitingPayment` + `CBRequesting` | preserve canonical payment state; retry Stripe; send no response |
+| deadline/restart/lost response | retry on foreground | preserve state; repeat same ID/body | remove waiter; return cached result or wait again |
| duplicate event/request | idempotent | accept same state/result | preserve state; dedupe/re-fetch |
| ID reused with new body | reject | preserve state; new ID only for new action | preserve state; telemetry |
| invalid capability/binding | reject | preserve state; restore/support | preserve state; rate-limit |
@@ -440,6 +458,7 @@ Stable codes: `bad_request`, `unsupported_version`, `payment_pending`, `payment_
### Crash recovery
- Before provider call: repeat request.
+- Waiting `IssueBadge` lost on deadline/restart: remove waiter; repeat the same logical request on foreground.
- Provider succeeds before commit: retrieve by idempotency key/object binding.
- Payment committed before issuance: `GrantReady` remains unchanged.
- Credential cached before response loss: repeat returns it.
@@ -496,7 +515,7 @@ Rules:
1. **Schema/protocol:** five sums/codecs, migrations, grant boundary, request ledger, Chat Console audit, core install API.
2. **Apple/Google:** bindings, verification/status, Notifications V2/RTDN, acknowledge/consume, native UI.
-3. **Stripe:** Checkout, completion page, webhook, reconciliation, cancel RPC, restricted Portal.
+3. **Stripe:** Checkout, waiting `IssueBadge`, webhook wake-up, reconciliation, cancel RPC, restricted Portal.
4. **UX/hardening:** scheduler, all Product states, rollout compatibility, telemetry, cleanup.
Tests:
diff --git a/plans/2026-07-20-supporter-badges-v2-product.md b/plans/2026-07-20-supporter-badges-v2-product.md
index 996eb92a3d..9335640894 100644
--- a/plans/2026-07-20-supporter-badges-v2-product.md
+++ b/plans/2026-07-20-supporter-badges-v2-product.md
@@ -50,7 +50,7 @@ Show **Badge valid until** separately from **Renews on** or **Subscription ends
- Core signature and expiry decide badge validity.
- Bot/provider verification decides payment status and grant eligibility.
- Store state and Stripe redirects are hints only.
-- Client asks through RPC; bot returns one final response and never pushes.
+- Each client RPC call receives exactly one bot response; the bot never initiates a call.
- Before payment, the client generates one 32-byte BBS `BadgeMasterKey`. The payment and every badge issued from it are bound to that key; renewals reuse it.
- Payment capability authorizes bot requests. The raw BBS key, capability, and provider proofs are redacted.
@@ -208,48 +208,56 @@ sequenceDiagram
participant C as Client
participant B as Bot
participant S as Stripe
- C->>B: Prepare Stripe payment
+ C->>B: RPC Prepare Stripe
B->>S: Create Checkout Session
- B-->>C: Checkout URL + capability
+ B-->>C: RPC Checkout URL + capability
C->>S: Open Checkout
- Note over C: Payment pending, start polling
+ Note over C: CPAwaitingPayment
+ C->>B: RPC IssueBadge
+ Note over B: Hold call, no response yet
S-->>B: Signed webhook
- Note over B: Payment entitled, grant available
- C->>B: Status + badge request
- B-->>C: Status + badge
- Note over C: Entitled, badge installed
+ B->>S: Retrieve and verify payment
+ S-->>B: Paid
+ Note over B: GrantReady → badge issued
+ B-->>C: RPC badge credential
+ Note over C: CPEntitled + CBReceived
+ C->>C: Verify and install
+ Note over C: CBInstalled
```
-#### Still pending
+#### RPC ends before payment
+
+```mermaid
+sequenceDiagram
+ participant C as Client
+ participant B as Bot
+ C->>B: RPC IssueBadge
+ B->>B: Persisted payment is still pending
+ Note over B: Hold call, no response
+ Note over C: RPC deadline / app closes
+ C-xB: Cancel waiting call
+ Note over C: CPAwaitingPayment + CBRetryableFailure
+ C->>B: Repeat same IssueBadge on foreground
+ Note over C: CPAwaitingPayment + CBRequesting
+ Note over B: Respond immediately if webhook already completed
otherwise wait again
+```
+
+#### Checkout expired
```mermaid
sequenceDiagram
participant C as Client
participant B as Bot
participant S as Stripe
- C->>B: Status request
- B->>S: Retrieve Checkout
- S-->>B: Pending
- Note over B: Pending, no grant
- B-->>C: Pending + retry time
- Note over C: Poll later, badge unchanged
+ C->>B: RPC IssueBadge
+ Note over B: Hold call
+ S-->>B: Signed Checkout expired event
+ Note over B: BPExpired, no grant
+ B-->>C: RPC checkout expired
+ Note over C: CPExpired, new Checkout requires user action
```
-#### Expired
-
-```mermaid
-sequenceDiagram
- participant C as Client
- participant B as Bot
- participant S as Stripe
- S-->>B: Checkout expired
- Note over B: Expired, no grant
- C->>B: Status request
- B-->>C: Checkout expired
- Note over C: New checkout requires user action
-```
-
-Start polling when Checkout opens and on return/foreground: 5, 15, 30, 60, 120 seconds, then normal refresh. The return link is optional routing, never proof.
+There is no payment polling. The pending `IssueBadge` call is the completion signal. A deep link may return the user to the app but is not required and is never payment proof.
### Cancel subscription
@@ -312,7 +320,7 @@ If `GrantReady` exists without the current badge, request issuance. Cache the re
| Condition | Client action |
|---|---|
| Store canceled | restore previous screen |
-| Payment pending | show pending; poll/schedule |
+| Payment pending | keep `IssueBadge` waiting; retry the same call after deadline/restart |
| Network/provider failure | keep cached state and active badge; retry |
| Paid, issuance failed | show “Payment confirmed. Badge is being prepared”; retry |
| Cancel failed | keep **Renews on**; retry |