Volley / Templates / Stripe PaymentIntents with 3D Secure

Payments template

Stripe PaymentIntent sequence diagram

A custom checkout on your own page with the Payment Element, including the 3D Secure challenge. Click an arrow to see the PaymentIntent request, the return_url redirect or the payment_intent.succeeded webhook body.

Sequence diagram of Stripe PaymentIntents across Customer, Browser, Server, Stripe and Card Issuer: create a PaymentIntent, return the client secret, confirmPayment, requires_action and a 3D Secure challenge, the return_url, then the payment_intent.succeeded webhook and a 200 OK.

Click any arrow in the editor to see its request and response as a table or the raw body. Change the hosts, ids and fields to match your integration, then share a link or embed it in your README.

Step by step

  1. Your server creates a PaymentIntent. A form-encoded POST /v1/payment_intents with amount, currency and automatic_payment_methods[enabled]=true. Stripe answers with the intent and its client_secret.
  2. The browser mounts the Payment Element with the client secret, and the customer enters a card.
  3. The browser confirms the payment with stripe.confirmPayment and a return_url. Stripe.js talks to Stripe directly.
  4. The bank asks for 3D Secure. The intent comes back as requires_action, Stripe.js shows the challenge, and the customer approves it.
  5. Stripe.js redirects to your return_url with payment_intent and redirect_status in the query.
  6. Stripe calls your webhook with payment_intent.succeeded. Verify Stripe-Signature, fulfil once and answer 200.

Easy to get wrong

  • Fulfil from the webhook. The redirect to return_url never happens if the customer closes the tab during 3D Secure.
  • Set the amount on the server. Anything the browser sends can be changed.
  • Treat the client secret as sensitive. It can complete the payment, so do not log it or store it.

The source

The whole diagram is this plain text. Copy it into your repo, or open it in the editor.

stripe-payment-intents.volley
title: Stripe PaymentIntents with the Payment Element and 3D Secure
order: Customer, Browser, Server, Stripe, Card Issuer

Customer -> Browser: Open the checkout page
Browser -> Server: POST /create-payment-intent
in: Cart
```json
{ "items": [{ "price": "price_1QAbc2Def3", "quantity": 1 }] }
```

Server -> Stripe: POST /v1/payment_intents
in: PaymentIntent request (form encoded)
  amount: 4900
  currency: usd
  automatic_payment_methods[enabled]: true
  metadata[order_id]: ord_42
note: Work out the amount on the server from the cart. Send an `Idempotency-Key` header so a retried request does not create a second PaymentIntent.

Stripe --> Server: 200 OK
out: PaymentIntent
```json
{
  "id": "pi_3QAbc2Def3Ghi4",
  "object": "payment_intent",
  "amount": 4900,
  "currency": "usd",
  "status": "requires_payment_method",
  "client_secret": "pi_3QAbc2Def3Ghi4_secret_Jk5Lm6No7Pq8"
}
```

Server --> Browser: 200 OK
out: Client secret
```json
{ "clientSecret": "pi_3QAbc2Def3Ghi4_secret_Jk5Lm6No7Pq8" }
```

Browser -> Browser: Mount the Payment Element with the client secret
Customer -> Browser: Enter a card and click **Pay**

Browser -> Stripe: stripe.confirmPayment()
in: Call
```text
stripe.confirmPayment({
  elements,
  confirmParams: { return_url: "https://shop.example.com/order/complete" }
})
```
note: Stripe.js confirms the PaymentIntent straight from the browser with the client secret. Your server is not involved.

Stripe --> Browser: Needs authentication
out: PaymentIntent
```json
{ "id": "pi_3QAbc2Def3Ghi4", "status": "requires_action", "next_action": { "type": "use_stripe_sdk" } }
```

=: {fas-shield-alt} 3D Secure

Browser -> Card Issuer: Open the 3DS challenge in a modal
...: {fas-mobile-alt} Customer approves the payment in their banking app
Card Issuer --> Browser: Challenge complete

Stripe -> Card Issuer: Authorize $49.00
Card Issuer --> Stripe: Approved

Browser -> Server: GET /order/complete (the return_url)
in: Query parameters
  payment_intent: pi_3QAbc2Def3Ghi4
  payment_intent_client_secret: <redacted>
  redirect_status: succeeded
note: Stripe.js redirects here after the challenge. Retrieve the PaymentIntent to show its status, but do not fulfil here.

Server --> Browser: Order confirmation page

=: {fab-stripe} Fulfilment

Stripe -> Server: POST /webhooks/stripe (payment_intent.succeeded)
in: Event
```http
POST /webhooks/stripe HTTP/1.1
Content-Type: application/json
Stripe-Signature: t=1760886000,v1=<redacted>

{
  "id": "evt_3QAbc9Xyz",
  "object": "event",
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      "id": "pi_3QAbc2Def3Ghi4",
      "object": "payment_intent",
      "status": "succeeded",
      "amount_received": 4900,
      "currency": "usd",
      "latest_charge": "ch_3QAbc2Def3Ghi4",
      "metadata": { "order_id": "ord_42" }
    }
  }
}
```

Server -> Server: Verify the signature and fulfil ord_42 once
note: Check `Stripe-Signature` against the raw body with the endpoint's signing secret. Stripe can send the same event more than once.

Server --> Stripe: 200 OK

Related

Make this Stripe PaymentIntents with 3D Secure diagram yours.

Open it in the editor, change it to match your integration and share the link. Free, with no account.