Skip to main content
PayHubDeveloper docs
简体中文

3DS and hosted checkout

Handle 3DS redirects and browser info in the direct API. Create and reuse hosted checkout sessions, register redirect URLs and fulfill from server-side status.

Last updated 2026-10-20 · API contract v1.0.0

What to expect from 3DS

  1. Successful authentication doesn't mean the charge succeeded. Authentication only proves that the payer is the cardholder. The issuer can still decline the charge, so rely on the payment's status.
  2. The platform can't guarantee that payers never see an authentication challenge. Authentication may need no interaction, or the payer may have to complete a challenge on the bank's page. The issuer and the provider decide.
  3. You can't bypass the provider's or the issuer's authentication requirements through merchant configuration.

When authentication is required

ScenarioWhat the platform doesWhat you seeWhat to do
Authentication needs no interactionContinues processing the paymentA status that reflects the actual charge result (there may be no payment.requires_action event)Handle the status
A challenge is requiredCreates an authentication sessionrequires_action + next_actionDo a full-page redirect to the authentication URL
Authentication completes and the charge succeedsQueries the provider for the payment outcome, posts it to the ledger and notifies yousucceededFulfill the order
Authentication succeeds but the charge is declinedRecords two separate results: authentication succeeded and the payment failedfailed + a sanitized failure reasonAsk the payer to use another card. Don't treat a successful authentication as a successful payment
The payer closes the pageDoesn't infer success or failure, and queries the provider for the statusStays requires_action or processingShow "Confirming". Within the validity period, you can prompt the payer to authenticate again
The authentication link has expiredBlocks the old session from being used again, runs a status query on the original attempt first, and creates a new authentication session when it's safe to continueThe actual current payment statusRetrieve the payment and handle the latest next_action or status
A forged authentication return in the browserOnly triggers a server-side status query and doesn't record a successNo change to the funds outcomeOn your page, likewise trust only server-side queries

In the test environment, use the 3DS test cards to simulate outcomes such as no interaction needed, a successful challenge, a failed challenge and a successful authentication followed by a declined charge.

Handle next_action.redirect_to_url

The action the payer's browser needs to complete. url points to the platform's authentication page and carries a short-lived credential that can only view and continue this one authentication; its validity period doesn't exceed that of the provider session. You must take the payer's browser to this URL: your server can't complete the authentication on the cardholder's behalf. Don't write the URL to analytics logs or leak it through the Referer header. After the authentication URL expires, retrieve the payment to get the latest next_action; don't cache the old URL.

NameTypeRequiredDescription
type
stringRequired

The initial release of the public API exposes only redirect_to_url. Clients must tolerate types that are added in the future.

  • redirect_to_url
  • More values may be added in the future. Don't treat a status you don't recognize as success.

Example: redirect_to_url

authentication_session_id
stringRequired

Authentication session ID, linked to a specific payment attempt.

Example: auth_demo_001

url
string(uri)Required

The URL of the platform's authentication page.

Example: https://pay.payhub.example/auth/<SHORT_LIVED_TOKEN>

expires_at
string(date-time)Required

The time the authentication access expires (10 minutes at most).

Example: 2026-09-23T09:10:00Z

See next_action in the API reference.

  • Full-page redirect: Return next_action.url from your server to the payer's browser and redirect the whole page. Don't put it in an iframe (the authentication page can't be embedded in third-party pages). In an app, open it in the system browser or an in-app browser tab.
  • The issuer challenge always completes inside the platform's authentication page, which shows the issuer's page in an embedded frame or through a full-page redirect, depending on the provider's requirements. You don't need to integrate the provider's 3DS SDK on your page, and you can't.
  • Validity period: At most 10 minutes, as set by next_action.expires_at (the shorter of the platform's cap and the provider session's validity period).
  • Security: The authentication URL carries a short-lived credential that is bound to this one authentication only, so it can't be used for other orders. Don't write it to logs, analytics tools or error reporting. Don't put it in the URL of your own page (query parameter or hash), or it can leak through the Referer header or analytics scripts.
  • Don't collect issuer passwords or SMS verification codes on your page. The payer enters challenge information only in the authentication flow that the issuer or the provider supplies.
  • Authentication that needs interaction can't be completed by your server on behalf of the cardholder: the payer must be present when you start the payment.
  • For now, type has only one value, redirect_to_url. If you get a type you don't recognize, treat the payment as "not completed" and query it. Don't treat it as success.

Caution: Authentication URL expired

Don't call confirm again. Retrieve the payment (GET/v1/payments/{payment_id}) to get the latest next_action or status. The platform first runs a status query on the original attempt and creates a new authentication session when it's safe to continue.

3DS with the direct API
3DS with the direct API

After authentication

After the payer completes authentication, the browser returns to the return_url you provided when you created or confirmed the payment.

Actual outcomeStatus from a server-side queryWhat to do
Authentication succeeds and the charge succeedssucceeded, followed by a payment.succeeded eventFulfill the order
Authentication succeeds but the issuer declines the chargefailed, with a failure.code such as card_declined. authentication_status shows that authentication passedAsk the payer to use another card. Don't fulfill the order just because authentication succeeded
Authentication failsfailed (for example, authentication_failed)Ask the payer to use another card or try again
The payer doesn't finish or closes the pagerequires_action or processingShow "Confirming your payment". Within the validity period, you can prompt the payer to continue authenticating
Still being confirmedprocessingWait for the webhook, or query at the recommended polling frequency

You can reproduce each outcome above in the test environment with the 3DS test cards.

Collect browser information

browser_info is used for 3DS risk assessment and must come from the payer's real browser. Whether a field is required depends on the provider's current capabilities. If a required field is missing, the API returns 400 before it submits anything to the provider and names the field in error.field. We recommend always providing every field.

The payer's real browser context, used for 3DS. It must come from the payer's browser and can't be replaced with merchant server information. Whether it's required depends on the capabilities of the current provider adapter; if required information is missing, a clear error is returned before anything is submitted to the provider.

NameTypeRequiredDescription
user_agent
stringNo

The payer's browser User-Agent.

Constraints: Max length 2048

Example: Mozilla/5.0

accept_header
stringNo

The Accept request header of the payer's browser.

Constraints: Max length 2048

Example: text/html,application/xhtml+xml

language
stringNo

Browser language (BCP 47).

Constraints: Max length 35

Example: zh-CN

screen_width
integer(int32)No

Constraints: ≥ 0

Example: 1366

screen_height
integer(int32)No

Constraints: ≥ 0

Example: 768

color_depth
integer(int32)No

Constraints: 1–48

Example: 24

timezone_offset_minutes
integer(int32)No

Time zone offset in minutes, following the sign convention of JavaScript's getTimezoneOffset(). For example, UTC+8 is -480.

Constraints: -840–720

Example: -480

javascript_enabled
booleanNo

Example: true

ip_address
stringNo

The payer's IPv4 or IPv6 address. Resolve it through a trusted proxy chain, and don't use the merchant server's IP address in its place.

Constraints: Max length 45

Example: 203.0.113.10

See browser_info in the API reference.

FieldSourceNotes
user_agentThe payer's browser navigator.userAgent, or the User-Agent header of the payer's requestDon't use the user agent of your server's HTTP client
accept_headerThe Accept header of the page navigation request when the payer opens the payment page (recorded on your server)Not the Accept header of an XHR request, which is usually application/json
languagenavigator.languageFor example, en-US
screen_width / screen_height / color_depthwindow.screen—
timezone_offset_minutesnew Date().getTimezoneOffset()See the table below for the sign convention
javascript_enabledtrue whenever the collection script runs—
ip_addressThe payer's IP address, resolved by your server through a trusted proxy chainDon't use your server's IP address. Don't trust an X-Forwarded-For header sent by the client as is
Browser
// Collect this in the payer's browser and submit it to your server together with the card details
export function collectBrowserInfo() {
  return {
    user_agent: navigator.userAgent,
    language: navigator.language,
    screen_width: window.screen.width,
    screen_height: window.screen.height,
    color_depth: window.screen.colorDepth,
    timezone_offset_minutes: new Date().getTimezoneOffset(), // UTC+8 is -480
    javascript_enabled: true
  };
}

timezone_offset_minutes follows the sign convention of JavaScript's getTimezoneOffset(), which is the opposite of how "UTC+8" is written:

Payer's time zonetimezone_offset_minutes
UTC+8 (Beijing, Singapore)-480
UTC+9 (Tokyo)-540
UTC+5:30 (India)-330
UTC±00
UTC−5 (US Eastern Standard Time)300
UTC+14 / UTC−12 (the two ends of the range)-840 / 720

The value for the same region changes during daylight saving time, so collect it live when the payer pays and never hard-code it. 203.0.113.x in the examples is a reserved documentation address. Don't use it in the live environment.

How hosted checkout works

  • Checkout runs on a separate platform domain. Card details are received inside checkout by an isolated collection component: either a provider-hosted component or an isolated iframe on a separate platform subdomain. The platform chooses based on the provider's capabilities, and it makes no difference to your integration. Neither your page nor your server touches card data, and the create request contains none either.
  • Checkout shows the merchant display name, the order summary (description), the amount and the currency. It never shows your server-side key.
Hosted checkout
Hosted checkout

Two ways to create a checkout session

MethodRequest fieldsUse whenRules
A: pass payment_idpayment_id + return_url (required) + optional cancel_url, locale, descriptionYou've already created a payment through the API (for example, with confirm=false) and now hand it to checkoutDon't also pass merchant_order_no, amount or currency
B: pass the order, amount and currency (most common)merchant_order_no + amount + currency + return_url (required) + optional fieldsYou create a checkout session directly for an orderThe platform creates the order's payment or reuses the existing one. When it reuses one, the amount and currency must match

Both methods require an Idempotency-Key. For an example of method B, see the Quickstart. Method A:

curl
# Method A: create a checkout session for an existing payment (for example, one created with confirm=false)
curl https://api.payhub.example/v1/checkout/sessions \
  -H "Authorization: Bearer <TEST_SERVER_API_KEY>" \
  -H "Idempotency-Key: order-demo-008-session-v1" \
  -H "Content-Type: application/json" \
  -d '{
    "payment_id": "<PAYMENT_ID>",
    "return_url": "https://merchant.example/payment/return",
    "cancel_url": "https://merchant.example/payment/cancel",
    "locale": "en"
  }'

Placeholders to replace

  • <TEST_SERVER_API_KEY>: Test-environment server API key, created on the "API Key" page of the merchant portal
  • <PAYMENT_ID>: The id returned when you create a payment (starts with pay_)

The API returns these errors: 400 invalid_request if you pass both groups of parameters or neither, 403 capability_disabled if the currency isn't enabled in the current environment (this check runs before the amount check), 422 order_amount_mismatch if the amount or currency doesn't match the existing order, 422 url_not_allowed if the redirect URL isn't registered, and 409 if the order can't be paid. For all fields and response examples, see POST/v1/checkout/sessions.

Warning

checkout_url contains a checkout token (the token stays in the URL so that the payer can refresh, go back and reopen the page). Use it only for a full-page redirect of the payer's browser. Don't write it to logs, analytics tools or error reporting, and don't share or forward it. Changing the parameters of checkout_url can't change the amount, the currency or the merchant that receives the payment.

Session reuse and validity period

SituationWhat the platform does
You create another session for the same order and the order can still be paidReuses the same payment and generates a new short-lived session. The amount doesn't change
The linked payment has already succeededReturns 200 and a read-only session with status=completed. It doesn't create a new entry point for charging
The order can't be paid (expired, canceled, out of attempts, and so on)Returns 409
The amount or currency doesn't match the existing orderReturns 422
The payer submits in two tabs at the same timeOnly one in-flight attempt is created
The session expiresIt only means the session no longer accepts new submissions. It doesn't mean the payment failed, and any in-flight payment keeps being checked with status queries. During the read-only period after expiry (24 hours by default, which is also the maximum), the payer can still open the original link to see the session and the payment result (read-only). After that, the link stops working
Session validity periodAt most 30 minutes, and never longer than the time the order has left to be paid
The payer refreshes, goes back or reopens the link after a successful paymentNo second charge happens
The payer's payment failsWithin the attempt limit and the validity period, the payer can enter card details again in checkout and retry

Checkout language

locale uses BCP 47. A value that starts with zh shows Chinese and a value that starts with en shows English. For any other value, or if you don't pass one, checkout uses the merchant's default language. Payers can also switch the language in the checkout footer.

Redirect URL allowlist

  • return_url and cancel_url (and return_url in the direct API) must match a rule you registered in advance (Register redirect URLs (opens the merchant portal in a new tab) (Test environment; Requires Owner, Admin or Developer)). Otherwise the API returns 422 url_not_allowed.
  • Rules are registered per environment. Each rule consists of a scheme + a host (an exact host, or a one-level wildcard such as *.example.com) + an optional port + an optional path prefix.
  • Path prefixes match by path segment: /pay matches /pay/return but not /payment. The query string and the fragment aren't part of the match.
  • The live environment allows only https. In the test environment, you can also register local addresses such as http://localhost.
  • When the platform redirects the payer back, it uses the URL you provided exactly as is and appends no parameters.

Returning to the merchant doesn't cancel the charge

When the payer clicks "Return to merchant" (cancel_url), that doesn't cancel a charge that was already submitted. Your cancel_url page must retrieve the payment first. If the payment is still in flight, show "Confirming". If you've confirmed that no charge is in flight and you need to close the order, call POST/v1/payments/{payment_id}/cancel and rely on status=canceled.

Fulfill based on server-side status

  • The browser returning to return_url is for the payer experience only.
  • The only valid basis for fulfillment is status=succeeded from a server-side query, or a payment.succeeded event whose signature you've verified (for the verification method, see Verify webhook signatures).
  • URL parameters (including an order ID you added yourself) are never a result. Treat them only as a hint about which payment to look up.
  • Authentication and checkout credentials are bound to a single object: the credential for order A can't act on order B. Don't reuse these URLs across orders in your own system either.
Recommended return_url page
// return_url / cancel_url page: display only. The server-side query decides the outcome; URL parameters are only a hint for finding the order
app.get('/payment/return', async (req, res) => {
  const order = await findOrderForSession(req.session.userId); // Find the order from your session or order table
  const payment = await getPayment(order.paymentId); // Retrieve the latest status on the server
  switch (payment.status) {
    case 'succeeded':
      return res.render('paid', { order }); // A webhook or a server-side query triggers fulfillment; the page only displays the result
    case 'failed':
      return res.render('failed', { order, message: payment.failure?.message }); // The payer can reopen checkout and use another card
    case 'canceled':
    case 'expired':
      return res.render('closed', { order });
    default:
      return res.render('confirming', { order }); // processing / requires_action / unknown: show "Confirming"
  }
});
Recommended return_url and cancel_url pages
Recommended return_url and cancel_url pages

FAQ

Was this page helpful?