This is the implementation material behind the hotel booking interview case. It covers:
- local tables, REST calls and the booking states
- the hold clock, idempotency (doing a request twice has the same result as doing it once), wallets and 3-D Secure (an extra check that the bank asks for)
- search, paging, deep links and the ride contrast
- platform limits and the iOS version
None of it belongs in the 45 minutes. I draw it only if the interviewer asks. The snippets use Room 2.x (androidx.room), Android's local database library. The official Room guide now shows Room 3 (androidx.room3, KSP only). The tables and the architecture are the same in both versions.
1. Local tables (Room)
booking_attempt(attemptId, idempotencyKey, offerId, holdId, status, version): the app writes it before the first network call. It is the record that lets the app recover after the system stops it.trip(bookingId, status, propertyJson, dates, voucherCode, syncedAt): confirmed bookings, which the user can read offline.recent_search(query, placeId, usedAt)andresult_page(queryKey, pageIndex, itemsJson, quotedAt): the search side.
The idempotency key (a unique ID that lets the server spot a repeat request) has several names in this guide. They are one value, except attemptId:
| Where | Name | What it is |
|---|---|---|
| HTTP header | Idempotency-Key | The key as the server sees it |
| Room column | idempotencyKey | The key, stored before the first send |
Kotlin BookingApi.confirm | key | The same value, passed as a header |
Swift PayDelegate | attemptKey and idempotencyKey | The same value, property and parameter |
| Room column | attemptId | A local row ID, never sent to the server |
From here on, "the key" means this idempotency key.
2. REST
GET /v1/places/suggest?q=lis
GET /v1/offers?placeId=..&dates=..&cursor=.. → items + nextCursor (opaque), each with quoteId, price, quotedAt
GET /v1/offers?bbox=..&dates=.. (map viewport)
POST /v1/holds { offerId, quoteId } Idempotency-Key → { holdId, expiresInMs, price }
POST /v1/bookings { holdId, paymentToken } Idempotency-Key → { bookingId, state, version, nextAction? }
GET /v1/bookings/{id}
GET /v1/bookings?attempt=<idempotencyKey> (recovery lookup)
A cursor is a saved position in a list. The server gives the cursor to the client, and the client does not read it ("opaque"). A quote is a price offer with an ID (quoteId) and a time (quotedAt).
The server returns expiresInMs, a duration that it computes when it sends the response. It does not send only an absolute end time. A duration does not depend on the clock of the device.
3. States, quotes, dates and money
Server-driven states:
- Normal path:
HELD,PAYMENT_REQUIRED,ACTION_REQUIRED(only when the bank asks for 3-D Secure),PROCESSING,CONFIRMED. - Before
CONFIRMED, a booking can end inEXPIREDorFAILED. - After
CONFIRMED, it can becomeCANCELLED.
The client copies this list as a sealed type (a Kotlin type with a fixed set of cases). The client never moves the state on its own.
Each response carries a version, a counter for each booking that the server increases. I only compare it. Unlike the seq of the chat app (a sequence number), a booking has no gaps to detect. I ignore any response that is not newer, so late responses cannot move the state backwards.
Dates and money need the same care:
- A check-in date is a calendar date in the time zone of the hotel. It is not an instant. I send
2026-11-03as a plain date string. I never send it as a UTC timestamp, because that can move the date by one day in another time zone. - Prices travel as a whole number in minor units (the smallest unit of a currency, such as cents) plus a currency code.
- The server returns the full breakdown: nightly rate, taxes, fees and total. The client formats these numbers and never adds them up.
4. The hold clock
A hold means that the server keeps the room for the user for a short time. The common mistake is to use System.currentTimeMillis(). A user whose clock is 20 minutes fast sees a hold that has already ended. The server decides when the hold ends, and the client only shows it.
POST /holdsreturnsexpiresInMs.- Record a monotonic timestamp before you send the request, and compute the time left from it. Network delay then makes the screen show slightly less time than the server grants, never more.
- Use a monotonic clock (
SystemClock.elapsedRealtime()). It does not jump when the user changes the date. - At zero, lock the pay button, but read the hold again from the server. The server may have extended it.
- The server still checks the pay request. On
EXPIRED, show "Room no longer held" with an action to check again.
// Sketch: countdown anchored to a monotonic clock
data class HoldClock(val expiresInMs: Long, val sentAtElapsed: Long) {
fun remainingMs(now: Long = SystemClock.elapsedRealtime()): Long =
(expiresInMs - (now - sentAtElapsed)).coerceAtLeast(0)
}
5. Idempotency in detail
This is the classic failure. The request reaches the server, the response is lost, and a retry (a second try) or a second tap sends the request again. Without protection, that is two charges.
- Generate the key once for each checkout attempt (a UUID, a random unique ID). Write it to
booking_attemptbefore the first send. Use the same key on every retry of that attempt. - Send it as
Idempotency-Key. The docs of Stripe describe this pattern. The client makes a unique key (V4 UUIDs are suggested, up to 255 characters). For API v1, the server saves the first result and returns it on retries. - A saved failure is sent again. For API v1, the saved response includes
500errors. Requests that fail validation, or that collide with a request still running, are not saved. You can retry them. - A changed cart needs a new key. Stripe compares the new parameters with the original request. It returns an error if they do not match.
- A fresh attempt is a new key. Some providers say to start a new key after the user leaves the bank page, after a failed challenge or after a change of payment method. I have not verified that rule on the page of Stripe, so I follow the rules of my provider.
- Keys expire. Stripe says API v1 keys can be pruned (deleted) once they are at least 24 hours old. Your backend picks its own time window.
- Double tap: the ViewModel (the class that holds the screen state) ignores
Paywhile the state isSubmitting. The key is the safety net, not the first protection. - After a timeout, look before you send again:
GET /bookings?attempt=<key>. If a booking exists, use it. If not, send again with the same key. - Retry rule (my choice, not Stripe's): retry network errors, 408, 429 and 5xx, and obey
Retry-Afterwhen it is present. Any other 4xx is final. The exceptions are answers that this API defines as states, such as409 PRICE_CHANGEDandSOLD_OUT. Those go to the user.
This is the same pattern as the send path of the chat app and the second mistake in the offline-first guide. The difference here is that a retry costs money. The app saves the key before the first send. Unlike the chat app, there is no outbox (a table of waiting writes). There is only the one attempt record.
// Sketch: the key comes from the persisted attempt (wrapped by a data source; not called from the ViewModel)
interface BookingApi {
@POST("v1/bookings")
suspend fun confirm(
@Header("Idempotency-Key") key: String,
@Body body: ConfirmRequest,
): BookingDto
}
The pay call runs in a scope that the repository owns (the repository is the class that reads and writes data). It does not run in viewModelScope, because leaving the screen must not cancel a charge in flight. The ViewModel only waits for the result. BookingApi sits behind a remote data source and BookingRepository, so the ViewModel never calls it.
The recovery lookup at launch could be a WorkManager job (Android's scheduler for work that survives restarts). A plain call at startup is enough here.
6. Wallets, 3-D Secure and card data
The wallet sheet produces a token, which is a code that stands for the card. The client never charges anyone. The sheet or the SDK of the payment provider collects the card. My app and my servers only handle a token.
As I understand it, this keeps raw card numbers out of my systems and makes PCI scope smaller (PCI is the card industry's security rules). I have not verified the exact scope rules, so confirm them with your payment provider.
- Google Pay: the user picks a method in the sheet. Google Pay returns a token to the app, which sends it with the purchase details to your backend. The backend passes it to the payment provider.
PaymentsClient.isReadyToPaydecides whether to show the button, andloadPaymentDataopens the sheet. - Apple Pay:
PKPaymentAuthorizationControllershows the sheet. IndidAuthorizePayment, you send the token to your server and report the result. - 3-D Secure: in the flow of Stripe, the PaymentIntent (the record of one payment) moves to
requires_action. The SDK shows the challenge. Or a web view uses areturn_urlthat routes back into the app. Stripe says 3-D Secure is not supported for every transaction, and wallets are one example. In practice, you meet it with cards.
The result of the redirect is a hint, not the truth. The user may finish the bank page and never return. Or the user may return to a new process (a new running copy of the app). On resume, or on the return link, move to Processing and ask the server for the booking state.
- Poll (ask the server again and again) with backoff (wait longer after each failure), or wait for a push. A push is only a hint, so I still poll once.
- The server learns the real result from provider events. Stripe says the 3-D Secure result typically arrives in a
charge.succeededevent. The server shows it as the booking state. - In its web flow, the page of Stripe says to get the updated PaymentIntent after the challenge. This is the same idea: ask, and do not assume.
Polling budget, as arithmetic on an assumption: delays of 1, 2, 4 and then 8 seconds (the longest delay) give 9 polls in 55 seconds. After about a minute, I show "still checking" and rely on push and the next launch.
7. Process death mid-payment
Process death means that the system stops the running copy of the app. SavedStateHandle survives when the system stops the app. It does not survive a force stop by the user or a swipe away from Recents, and it is meant for small values. The page of Android says saved state is tied to the task stack.
Keep only the attemptId there, and keep the rest in Room. At startup, find the booking_attempt rows that are not in a final state. Reconcile each one with GET /bookings?attempt=<key>. To reconcile means to compare the phone with the server and fix differences. After a force stop, the Room row still lets this run at the next launch. Only the screen that was open is lost.
8. Search and autocomplete
Debounce (wait a short time after the last key press, for example 250 ms, tuned with data), trim, drop duplicates, and cancel the previous request. Then a slow "lis" cannot replace "lisbon".
- Show recent searches from Room when the field is short, and cache suggestion responses by query.
- Short input skips the debounce, so the recents appear at once.
- If the suggest call fails with an
IOException(for example when offline), the list becomes empty. Without this, the error would reachviewModelScopeand crash the app on a key press. - Other exceptions still go up, because I would rather see a real bug than hide it.
The snippet needs import java.io.IOException, which the block does not show.
// Sketch: debounce + cancel-previous with flatMapLatest
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(private val repo: PlaceRepository) : ViewModel() {
private val query = MutableStateFlow("")
val suggestions: StateFlow<List<Place>> = query
.debounce { if (it.trim().length < 2) 0L else 250L } // recents show at once
.map { it.trim() }
.distinctUntilChanged()
.flatMapLatest { q ->
if (q.length < 2) repo.recents() // Flow<List<Place>> from Room
else flow { emit(repo.suggest(q)) } // cancelled by the next query
.catch { if (it is IOException) emit(emptyList()) else throw it }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun onQueryChanged(q: String) { query.value = q }
}
9. List, map, staleness and offline
List and map. Use Paging 3 with a RemoteMediator. The news feed case explains it. The database is the source of truth. The mediator is the signal that the cached data ran out, so it fetches the next page into Room.
- Put the whole query (place, dates, guests, filters) in the page key, so a change of filter starts a new list.
- Load the next page before the user reaches the end (
PagingConfig.prefetchDistance). - For the map, query by viewport (the visible part of the map) once the camera stops. Debounce, cancel the previous request for a map box, and group nearby pins on the server.
Staleness. Treat every price as a quote. Items carry quoteId and quotedAt.
- Show "Prices may have changed" on old cached pages.
- Refresh when the user pulls down, and when the app returns to the foreground after a long gap.
- Always check the price again at checkout.
POST /holdstakes thequoteId. If the price differs, the server answers409 PRICE_CHANGEDwith the new price, and the user accepts it or goes back. SOLD_OUTreturns the user to the results, with the item marked. The client never computes the final total.
Offline. Confirmed bookings sit in trip with the confirmation code and the address, so the user can show them at the door. Search shows cached pages with a banner that says the data is old. Hold and pay are disabled, and the screen shows the reason.
10. Deep links
A deep link is a link that opens a screen inside the app. A confirmation email opens https://example.com/trips/<id>.
- Android: use verified App Links. That means an intent filter with
autoVerify, plusassetlinks.jsonon your domain. On Android 15 and later, the system checks domains again from time to time. Allow up to a week for changes to that file to reach devices. - iOS: use universal links. The guide of Apple calls them a possible attack path and says to discard malformed URLs.
- Both: check every parameter. Route by ID. If the user is signed out, sign in first. Show the cached trip, then refresh from the server. Never trust state that is encoded in the link.
11. Ride hailing: what changes
I treat this as a contrast, not as a second design. The base is the same: state owned by the server, idempotent requests and a state machine. The scarce thing changes. In a hotel it is rooms. In a ride it is drivers who are moving. This is industry practice, not a documented source.
| Aspect | Hotel | Ride |
|---|---|---|
| Reserved | A room for dates, held for minutes | A driver, matched in real time |
| Timeout | Hold expiry | Matching timeout; show "no drivers", offer retry |
| Price | Quote, checked again at checkout | Estimate shown before the request |
| Live data | Almost none | Driver location stream |
| Payment | At booking | Often authorized up front, finalized after the trip |
| Offline | Saved bookings are useful | Little value |
For live location, open a WebSocket (a two-way open connection; OkHttp on Android, or URLSessionWebSocketTask on iOS) only while the trip screen is visible. Move the marker smoothly between points. On reconnect, fetch the latest trip state by ID instead of replaying the stream. The ride request is idempotent, like a booking. After the app is stopped, the client resumes by asking for the active trip.
12. Platform and store limits
| Limit | Android | iOS | So the design changes by |
|---|---|---|---|
| App stopped while the user is in the bank page or wallet sheet | SavedStateHandle survives when the system stops the app, not force stop or a Recents swipe, and holds small values only | Not checked here | Save the attempt row; restore it at launch on both |
| Work after the screen is gone | Coroutines stop when the app closes; WorkManager persists across restarts and reboots | Not checked here | The pay call runs in a repository scope; the lookup at startup repairs what the scope lost |
| Wallet result | Google Pay returns a token; how the result reaches the activity is not on the pages I read | The sheet waits for your handler; the delegate is main-actor | Keep the server call short; an unknown outcome fails the sheet and starts a lookup |
| Rollout | A halted Play rollout reaches no more users; users who have the build keep it | Phased release runs seven days; it can be paused 30 days in total; manual updates always work | Old versions stay in the field; the server keeps accepting the old shape |
| Deep link checks | autoVerify and assetlinks.json; Android 15 checks again from time to time | Universal links; discard malformed URLs | Check every parameter on both |
| Payment rules in the store | Google's matching policy not checked | App Review 3.1.3(e) says goods and services consumed outside the app use something other than in-app purchase; hotels and rides are not named | Pay by wallet or card token, not store billing; read the rule again before shipping |
13. How to build a booking app on iOS (Swift)
The architecture carries over. What changes is the platform tools.
| Concern | Android | iOS |
|---|---|---|
| Local DB | Room | SwiftData or Core Data (or GRDB) |
| Wallet | Google Pay API (PaymentsClient) | PKPaymentAuthorizationController |
| 3-D Secure return | PSP SDK; return_url via App Link | PSP SDK; return_url via universal link or custom scheme |
| Process death | SavedStateHandle (ids) + Room | Persisted attempt record, restored on launch |
| Deep links | App Links | Universal links (associated domains, NSUserActivity) |
| Monotonic clock | SystemClock.elapsedRealtime() | ContinuousClock (counts through sleep; its behaviour when the date changes is not on the Apple page, so treat that as unverified) |
| Cancel-previous search | flatMapLatest | Cancel the previous Task |
| Paging | Paging 3 + RemoteMediator | Cursor loading; no official paging library |
| Live location | OkHttp WebSocket | URLSessionWebSocketTask |
PSP means payment service provider, the company that takes the payment.
What changes in practice:
- No durable retry framework is needed. Booking works online only on both platforms. There is no outbox of waiting writes. There is only the saved attempt record and the check against the server at launch.
- Scene phase. SwiftUI's
ScenePhasereportsactive,inactiveandbackground. When the app returns toactiveafter the bank page, fetch the booking state. Close the driver socket when the app leaves the foreground. - The Apple Pay result blocks the flow. Apple documents that the controller waits for your handler before the next delegate method. In the async variant used below, it waits for the method to return. Apple declares the delegate
@MainActor, so the sketch does too.
Keep the server call short. The sketch sets no timeout, so add a short one on confirm. Then the three outcomes are:
- A definite error from the server fails the sheet.
- A
URLError, such as a timeout, means I do not know whether the card was charged. I still fail the sheet, because I will not show a success mark for a payment that I have not confirmed. - The delegate also tells the screen to move to
PROCESSING, and the screen looks the booking up by the attempt key. If the card was charged, the lookup finds the booking. If not, a retry uses the same key, so the server cannot charge twice.
The other option is to report success and reconcile later. That looks smoother, but it is wrong whenever the charge failed.
// Sketch: Apple Pay delegate sending the wallet token with the persisted attempt key
@MainActor
final class PayDelegate: NSObject, PKPaymentAuthorizationControllerDelegate {
let api: BookingAPI
let attemptKey: String
let onUnknownOutcome: () -> Void // the screen moves to Processing and looks the booking up by key
init(api: BookingAPI, attemptKey: String, onUnknownOutcome: @escaping () -> Void) {
self.api = api; self.attemptKey = attemptKey; self.onUnknownOutcome = onUnknownOutcome
}
func paymentAuthorizationController(
_ controller: PKPaymentAuthorizationController,
didAuthorizePayment payment: PKPayment
) async -> PKPaymentAuthorizationResult {
let token = payment.token.paymentData
do {
_ = try await api.confirm(idempotencyKey: attemptKey, walletToken: token)
return PKPaymentAuthorizationResult(status: .success, errors: nil)
} catch is URLError {
// Timeout or dropped connection: the server may already have charged.
// Fail the sheet, then look the booking up by the attempt key (a retry reuses it).
onUnknownOutcome()
return PKPaymentAuthorizationResult(status: .failure, errors: nil)
} catch {
return PKPaymentAuthorizationResult(status: .failure, errors: nil)
}
}
func paymentAuthorizationControllerDidFinish(_ controller: PKPaymentAuthorizationController) {
controller.dismiss()
}
}
14. Edge cases checklist
| Scenario | Handling |
|---|---|
| Device clock wrong | Server duration plus monotonic clock; server enforces expiry |
| Double tap on Pay | ViewModel ignores while Submitting; idempotency key collapses duplicates |
| Response lost after booking created | Look up by attempt key; use the server state; keep the same key |
| Hold expires during 3-D Secure | Server returns EXPIRED or voids per its rules; client shows server state, offers a re-check |
| App stopped in the bank app | booking_attempt in Room; check against the server at startup |
| User returns without finishing | Treat as unknown; fetch state; offer resume or cancel |
| Price changed since search | 409 with new price; user confirms |
| Last room sold | SOLD_OUT at hold; back to results |
| Late response for old search | Cancelled by flatMapLatest; keyed by query |
| Out-of-order updates | Compare version; ignore older |
15. More practice prompts
These are practice prompts, not a claim about what any interviewer asks. They are my reconstruction.
- How do you support modify and cancel? Use the same idempotent pattern with a version check first. The refund state comes from the server.
- How do you handle two devices on one account? Server state is the truth. Trips sync by version. A hold belongs to the user.
- How do you test checkout? Use a fake API that drops the response after success, forces
PRICE_CHANGEDandEXPIRED, and sends versions out of order. Stop the process in the middle of an attempt. - What happens after the booking? Cancel and modify with refund state, push updates and a calendar entry. The ride contrast adds matching, a cancellation fee and surge (higher prices when demand is high).
Further reading (official docs)
- Hotel booking interview case: the 45 minutes this guide supports
- Idempotent requests (Stripe API): key generation, length, expiry, parameter comparison
- Authenticate with 3D Secure (Stripe):
requires_action, return URLs, SDK challenge flow - Google Pay API for Android: overview
- PaymentsClient reference (Google Pay)
- Offline-first guidance (Android): online-only, queued and lazy writes
- Architecture recommendations (Android)
- Data layer (Android): repositories, data sources, work lifetimes
- Paging 3 from network and database (Android)
- Save UI states with ViewModel and SavedStateHandle (Android)
- Android App Links
- Verify Android App Links:
autoVerify,assetlinks.json, periodic re-verification on Android 15+ - Apple Pay (PassKit)
- PKPaymentAuthorizationController (Apple)
- PKPaymentAuthorizationControllerDelegate (Apple)
- Supporting universal links in your app (Apple)
- ScenePhase (Apple)
- URLSessionWebSocketTask (Apple)
- Chat app case study, offline-first guide and staff mobile loop guide on this site
Verification notes (last reviewed 13 Oct 2026)
this is the build guide for the booking case. The implementation material moved here from the old single page on 2026-10-14, and the interview part is a separate page. The code blocks (REST sketch, HoldClock, BookingApi, SearchViewModel, PayDelegate) are byte-identical to the audited page and were diffed after the split. The prose around them was reflowed for readability and the sections renumbered. Everything marked Verified, Unverified or compiled in the earlier notes still applies, and is repeated here. Verified against fetched docs on 2026-10-13: Stripe idempotency and 3DS (re-read again on 2026-10-14: V4 UUID suggestion, 255 character limit, API v1 saves the first result including 500 errors, keys can be pruned once at least 24 hours old, parameters are compared, validation failures and concurrent conflicts are not saved, wallets are listed as not always supporting 3DS, charge.succeeded is named as the event that carries the authentication outcome, and the page says to retrieve the updated PaymentIntent after the challenge); Google Pay token flow, isReadyToPay, loadPaymentData; Android offline-first write strategies (re-read 2026-10-14: online-only writes fit work that must happen online in near real time, with a bank transfer as the example, and the page suggests not offering or disabling the write when offline), RemoteMediator behaviour, SavedStateHandle (re-read 2026-10-14: survives system-initiated process death, lost on force stop or removal from Recents, state must be simple and lightweight, no numeric limit given); App Links autoVerify, assetlinks and the Android 15 periodic re-verification with up to seven days to propagate; Apple PKPaymentAuthorizationController and its delegate, including that both didAuthorizePayment variants are @MainActor, universal link handling and security warning, ScenePhase, URLSessionWebSocketTask. Play Console staged rollout help and the App Store Connect phased release page were re-read on 2026-10-14: a halt stops further installs and installed users keep the version; the Apple page gives seven days, a 30 day total pause and manual updates at any time, and is silent about users who already have the update. The persistent-work page was re-read on 2026-10-14 (WorkManager work persists across restarts and reboots; coroutines stop when the app closes). SystemClock.elapsedRealtime ("milliseconds since boot, including time spent in sleep") was confirmed in the Android audit pass. The SystemClock and ContinuousClock reference pages did not return their body text on 2026-10-14, so those two clock claims stand on the earlier audit, and the ContinuousClock date-change behaviour is unverified. Unverified: the 10 minute hold, the 250 ms debounce and the polling delays are illustrative; PagingConfig.prefetchDistance is from general knowledge; how Google Pay returns its result to the activity; Apple Pay sheet time limits; the "new key for a fresh attempt" convention some providers state (taken from a coverage note, not re-read on Stripe's page); the PCI scope paragraph is my understanding, not a checked source; App Review 3.1.3(e) was read through a tool summary only and Google's matching policy was not checked; the ride-hailing table (pre-authorization, matching) is industry practice, not a documented source. The polling budget is arithmetic on an assumption: 1 + 2 + 4 = 7 s, then six polls at 8 s is 48 s, so 9 polls in 55 s. BookingAPI.confirm, ConfirmRequest, BookingDto and PlaceRepository are placeholders. Code checks, as before: the HoldClock, BookingApi and SearchViewModel snippets were compiled offline with Gradle 9.8.1, Kotlin 2.3.21, AGP 9.1.0, compileSdk 36, kotlinx.coroutines 1.10.2 and lifecycle 2.10.0 (compileDebugKotlin clean). Stubbed: Retrofit (not in the offline cache, so the BookingApi annotations are stubs, and Retrofit was not run), and the app types ConfirmRequest, BookingDto, Place and PlaceRepository; Hilt is not used in these snippets. The search pipeline (instant recents, IOException giving an empty list) was also run in a small JVM probe on coroutines 1.10.2 outside the ViewModel, not on a device. Swift: the PayDelegate was type-checked with Swift 6.4 against the iOS 27.0 simulator SDK, language modes 5 and 6, both clean, with a stub for BookingAPI. Not run: the Apple Pay flow itself, so what the sheet does when the handler returns .success for an unknown outcome is from reading the docs, not from a device, and the timeout is not in the sketch. Newer releases of these libraries exist and the check was not re-run against them. No iOS engineer has reviewed the iOS sections yet. Second review pass on 2026-10-14: code blocks diffed byte-identical to the audited copy; the Room 3 note was re-checked on the official Room page (Room 3 artifacts, KSP only); the Stripe, Android App Links (up to seven days on Android 15 and higher), Play and App Store Connect pages were re-read and every external link returned 200. The charge.succeeded sentence now says the 3-D Secure result typically arrives in that event, and the PaymentIntent retrieval is scoped to Stripe's web flow. The retry rule is labelled my choice. The Apple Pay and universal link pages returned no body text, so those claims rest on the earlier audit. A plain-English pass on 2026-10-14 used simpler words and defined each technical term at first use. It changed no fact, number, link, code block or marker.
Questions or corrections?
Add them to the discussion thread for this case on GitHub. To post, you need a free GitHub account. What you write there is public and follows GitHub's terms. Good answers are added to this page.
Keep reading
Get the next case study when it is published.
You get an email when a new case study is published, and nothing else.
You can unsubscribe at any time. Privacy.