Skip to main content

Buy Now, Pay Later (BNPL) Integration

This guide covers the two-step integration pattern required to support asynchronous BNPL payment methods (Affirm, Klarna, etc.) with CareValidate.

Webhook-free integration

We own the Stripe account. You do not need to configure any webhooks. When the patient completes their BNPL payment, our system automatically transitions the case from ABANDONED to OPEN and sends all notifications.

Why BNPL Requires a Different Flow

Standard card payments are synchronous — the case is created after payment confirmation. BNPL payments are asynchronous: the patient is redirected to the BNPL provider's website to complete payment, so there is no immediate confirmation.

The solution is a two-step flow:

  1. Before redirect — Create the case as ABANDONED and record the Stripe PaymentIntent ID.
  2. After payment — Our webhook (payment_intent.succeeded) detects the completed payment, looks up the case by PaymentIntent ID, and reopens it automatically.

Step 1 — Create a PaymentIntent

Call POST /api/v1/payments/intent with the BNPL payment method types:

{
"amount": 100,
"paymentMethodTypes": ["affirm", "klarna"]
}

Save the returned paymentIntentId (from data.paymentIntentSecret, strip the _secret_... suffix — the PI ID starts with pi_).

Step 2 — Pre-create the ABANDONED Case

Call POST /api/v1/cases with status: ABANDONED before redirecting the patient to the BNPL provider. This registers the case in our system and links it to the PaymentIntent.

Required fields for BNPL pre-creation
statusstringrequired

Must be `ABANDONED` to mark this as a pre-created BNPL case.

Example: ABANDONED
idempotencyKeystringrequired

A unique key for this case request. Can be a UUID or any value unique per payment attempt (e.g. derived from the PaymentIntent ID). Must be a new key on every retry.

Example: a4e1b2c3-... (UUID) or patient@example.com-pi_3SG0...
userobjectrequired

Patient details.

Show 3 child properties
emailstringrequired

Patient email.

firstNamestringrequired

Patient first name.

lastNamestringrequired

Patient last name.

paymentobjectoptional

Payment information including the Stripe PaymentIntent reference.

Show 2 child properties
amountnumberoptional

Payment amount (e.g. 1.15 equals $1.15).

Example: 100
providerReferenceobjectrequired

The Stripe PaymentIntent to associate with this case.

Show 2 child properties
typestringrequired

Must be `PAYMENT_INTENT`.

Example: PAYMENT_INTENT
idstringrequired

The Stripe PaymentIntent ID (starts with `pi_`).

Example: pi_3SG0o8Gkk...

Step 3 — Redirect to BNPL Provider

Use the paymentIntentSecret from Step 1 with Stripe.js to redirect the patient to the BNPL provider's payment page:

const { error } = await stripe.confirmPayment({
clientSecret: paymentIntentSecret,
confirmParams: {
return_url: 'https://yoursite.com/payment/complete',
},
});

Step 4 — Automatic Case Reopening

When the patient completes payment, Stripe fires a payment_intent.succeeded event. Our webhook handler:

  1. Looks up the case by PaymentIntent ID.
  2. Transitions the case from ABANDONEDOPEN.
  3. Updates the payment record to PAID.
  4. Fires all case-open notifications (assignment, emails, etc.).

No action required on your end. The case is available via your normal case-listing endpoints once it is OPEN.

Payment Failure Handling

If the patient declines, cancels, or is rejected by the BNPL provider, Stripe fires a payment_intent.payment_failed event. Our webhook handler:

  1. Looks up the case by PaymentIntent ID.
  2. If the case is ABANDONED and not yet archived, archives it with a note of Payment intent failed.
  3. If the case is already archived or in any other state, does nothing (idempotent).

What this means for your integration: When a BNPL payment fails, the pre-created case is automatically closed. If the patient wants to retry with another payment method, you must restart the payment process from the beginning:

  1. Create a new PaymentIntent — only if the patient wants to retry with another BNPL method. If they want to pay by card instead, switch to the Setup Intent flow and pass stripeSetupId in the case creation request.
  2. Call POST /api/v1/cases with a new idempotencyKey and the new payment.providerReference.id.
  3. Redirect the patient using the new PaymentIntent.
New idempotency key required on failure

After a BNPL payment failure, the original case is archived and cannot be reopened via the API. Reusing the same idempotencyKey on a subsequent attempt will return a 409 IDEMPOTENCY_ERROR because the existing case is no longer ABANDONED. Always generate a new idempotency key for a fresh attempt.

Retry Behavior

If the patient abandons the BNPL provider's page and wants to try again, always treat it the same as a failure — use a new idempotencyKey for every retry attempt.

Even if the patient navigates away before explicitly failing the payment, Stripe may still fire a payment_intent.payment_failed event shortly after (e.g., session expiry on the provider's side). If that webhook arrives before your retry request, the original case is already archived and reusing the same idempotencyKey will return a 409 IDEMPOTENCY_ERROR.

To safely restart the payment process:

  1. Create a new PaymentIntent.
  2. Call POST /api/v1/cases with a new idempotencyKey and the new payment.providerReference.id.
  3. Redirect the patient using the new PaymentIntent.

Request Examples

Step 1: Create PaymentIntent

curl -X POST "https://api.care360-next.carevalidate.com/api/v1/payments/intent" \
-H "cv-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 100,
"paymentMethodTypes": ["affirm", "klarna"]
}'

Step 2: Pre-create ABANDONED Case

curl -X POST "https://api.care360-next.carevalidate.com/api/v1/cases" \
-H "cv-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "ABANDONED",
"idempotencyKey": "patient-email@example.com",
"user": {
"email": "patient-email@example.com",
"firstName": "Jane",
"lastName": "Doe"
},
"payment": {
"amount": 100,
"providerReference": {
"type": "PAYMENT_INTENT",
"id": "pi_3SG0o8GkkQS2eXzh0BKpSaq0"
}
}
}'

Retry After Abandonment or Failure

// Patient abandoned or failed the BNPL flow and is trying again.
// Always use a NEW idempotency key — the original case may already be archived.

// Create a NEW PaymentIntent:
const newPiResponse = await fetch(".../api/v1/payments/intent", { ... });
const newPaymentIntentId = ...; // new pi_ ID

// POST to /cases with a NEW idempotencyKey and new providerReference.id:
await fetch(".../api/v1/cases", {
method: "POST",
body: JSON.stringify({
status: "ABANDONED",
idempotencyKey: `${patient.email}-${newPaymentIntentId}`, // new key per attempt — a UUID also works
user: { ... },
payment: {
amount: 100,
providerReference: { type: "PAYMENT_INTENT", id: newPaymentIntentId },
},
}),
});

Changelog

VersionDateChanges
1.12026-05-29Added Payment Failure Handling section — payment_intent.payment_failed now archives the ABANDONED case; new idempotency key required for retry after failure
1.02026-05-20Initial BNPL Integration Guide