How to Gate Payment Authorization
Use onRequestStart when your backend has to approve a payment before Payrails authorizes it —
revalidating a voucher, confirming wallet balance, or re-checking loyalty points at the moment the
customer commits.
For why the gate sits where it does, see SDK Concepts.
sequenceDiagram
participant Customer
participant App as Merchant App
participant SDK as Payrails SDK
participant Backend as Merchant Backend
participant API as Payrails API
Customer->>App: Taps pay
App->>SDK: Element starts the payment
SDK->>App: onRequestStart(context)
App->>Backend: POST /pre-payment
Backend-->>App: valid or invalid
App-->>SDK: true or false
alt true
SDK->>API: POST authorize
API-->>SDK: Authorized
else false
SDK->>App: onAuthorizeFailed(VALIDATION_FAILED)
end
1. Register the handler
Supply it on Options, beside redirectSessionLifecycle:
val configuration = Configuration(
initData = initData,
option = Options(
onRequestStart = { context ->
if (myBackend.validatePrePayment(context.executionId)) {
RequestStartDecision.Proceed
} else {
RequestStartDecision.Refuse("Your basket is no longer valid.")
}
}
)
)The handler is a suspend function, so call your backend directly — no callback plumbing.
2. Gate only the methods you care about
The handler fires for every payment method on the session. Return Proceed on the branches you are
not gating, or those payments are blocked too:
onRequestStart = { context ->
if (context.paymentMethodCode != "payPal") {
RequestStartDecision.Proceed // don't gate anything else
} else {
val check = myBackend.validatePrePayment(context.executionId)
if (check.ok) RequestStartDecision.Proceed else RequestStartDecision.Refuse(check.reason)
}
}3. Decide what an unreachable backend means
The SDK waits ten seconds, then blocks the payment. A handler that throws blocks it too. Both are
deliberate — a gate that fails open is not a gate — but they mean your error path is a decision, so
make it explicitly:
onRequestStart = { context ->
try {
val check = myBackend.validatePrePayment(context.executionId)
if (check.ok) RequestStartDecision.Proceed else RequestStartDecision.Refuse(check.reason)
} catch (e: IOException) {
// Your service is unreachable. Refuse blocks the payment; Proceed accepts the risk of an
// unvalidated basket. Letting the exception escape also refuses it, but without a message.
RequestStartDecision.Refuse("We couldn't confirm your basket. Please try again.")
}
}The handler runs on a background dispatcher, so a suspending network call needs no extra
withContext. Switch context only to touch UI:
onRequestStart = { context ->
val allowed = myBackend.validatePrePayment(context.executionId) // already off the main thread
if (!allowed) {
withContext(Dispatchers.Main) { showBasketExpiredDialog() }
}
allowed
}One caveat about the 10-second bound: it can only interrupt a handler that suspends. A handler
that blocks its thread — a synchronous HTTP client, Thread.sleep, a blocking database read — runs
to completion no matter how long it takes, because coroutine cancellation is cooperative. Use a
suspending client, or wrap a blocking one so it can be cancelled.
4. Handle the block in your delegate
A blocked payment arrives as onAuthorizeFailed with failure.code == VALIDATION_FAILED. Separate
it from a decline — nothing reached the backend, so there is no failed payment to explain:
override fun onAuthorizeFailed(button: PayPalButton, failure: AuthorizationFailure) {
when (failure.code) {
AuthorizationFailureReason.VALIDATION_FAILED ->
// Your own check refused it. failure.message is the reason you passed to
// Refuse(message), so you can show it directly instead of mapping a code.
showBasketChangedMessage(failure.message)
AuthorizationFailureReason.AUTHORIZATION_ERROR ->
showDeclineMessage() // the issuer refused it
else ->
showGenericError(failure.message)
}
}This is why Refuse carries a message rather than the handler returning a bare false: the reason
travels with the refusal to the one place you already read failure text from, instead of having to
be stashed somewhere and correlated back by executionId.
A note on Google Pay
Google Pay's sheet is opened by the button, not by the SDK core, so the gate is consulted the moment
the customer taps — before the sheet appears. A refusal means they never see it. Every other method
reaches the gate through Session.authorize, which also runs before any provider UI.
The practical consequence: for Google Pay your handler is called before the customer has chosen a
card. If your check depends only on the basket — a voucher, a wallet balance, loyalty points — that
is what you want. It cannot depend on which card they picked.
Reference
| Context field | Type | Notes |
|---|---|---|
executionId | String? | The Payrails execution, when known |
paymentMethodCode | String | "card", "payPal", "googlePay", … |
action | Action | Always AUTHORIZE in this version |
| Handler behaviour | Result |
|---|---|
returns Proceed | Authorization proceeds |
returns Refuse(message) | Stopped as VALIDATION_FAILED, carrying your message |
returns Refuse() | Stopped as VALIDATION_FAILED with a generic description |
| no answer within 10 seconds | Stopped with a generic description, warning logged |
| throws | Stopped with a generic description, warning logged |
Only a deliberate Refuse(message) reaches failure.message. The timeout and throw cases log their
diagnostic instead of surfacing it — both describe an integration fault rather than anything phrased
for a customer, and an exception string can carry internals.
Blocking never sends an authorization request, never presents provider UI, and returns the element
to ButtonState.ENABLED.
onRequestStart is a Kotlin-only API, like onSessionExpired: suspend functions are not
implementable from Java.
Related
Updated about 3 hours ago