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.
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:
- Before redirect — Create the case as
ABANDONEDand record the Stripe PaymentIntent ID. - 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.
statusstringrequiredMust be `ABANDONED` to mark this as a pre-created BNPL case.
ABANDONEDidempotencyKeystringrequiredA 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.
a4e1b2c3-... (UUID) or patient@example.com-pi_3SG0...userobjectrequiredPatient details.
Show 3 child properties
emailstringrequiredPatient email.
firstNamestringrequiredPatient first name.
lastNamestringrequiredPatient last name.
paymentobjectoptionalPayment information including the Stripe PaymentIntent reference.
Show 2 child properties
amountnumberoptionalPayment amount (e.g. 1.15 equals $1.15).
100providerReferenceobjectrequiredThe Stripe PaymentIntent to associate with this case.
Show 2 child properties
typestringrequiredMust be `PAYMENT_INTENT`.
PAYMENT_INTENTidstringrequiredThe Stripe PaymentIntent ID (starts with `pi_`).
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:
- Looks up the case by PaymentIntent ID.
- Transitions the case from
ABANDONED→OPEN. - Updates the payment record to
PAID. - 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:
- Looks up the case by PaymentIntent ID.
- If the case is
ABANDONEDand not yet archived, archives it with a note ofPayment intent failed. - 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:
- 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
stripeSetupIdin the case creation request. - Call
POST /api/v1/caseswith a newidempotencyKeyand the newpayment.providerReference.id. - Redirect the patient using the new PaymentIntent.
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:
- Create a new PaymentIntent.
- Call
POST /api/v1/caseswith a newidempotencyKeyand the newpayment.providerReference.id. - Redirect the patient using the new PaymentIntent.
Request Examples
Step 1: Create PaymentIntent
- cURL
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
- JavaScript
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"
}
}
}'
// Step 1: Create PaymentIntent
const piResponse = await fetch(
"https://api.care360-next.carevalidate.com/api/v1/payments/intent",
{
method: "POST",
headers: { "cv-api-key": "YOUR_API_KEY", "Content-Type": "application/json" },
body: JSON.stringify({ amount: 100, paymentMethodTypes: ["affirm", "klarna"] }),
}
);
const { data: { paymentIntentSecret } } = await piResponse.json();
const paymentIntentId = paymentIntentSecret.split("_secret_")[0]; // e.g. pi_3SG0o8...
// Step 2: Pre-create the ABANDONED case
const caseResponse = await fetch(
"https://api.care360-next.carevalidate.com/api/v1/cases",
{
method: "POST",
headers: { "cv-api-key": "YOUR_API_KEY", "Content-Type": "application/json" },
body: JSON.stringify({
status: "ABANDONED",
idempotencyKey: patient.email,
user: {
email: patient.email,
firstName: patient.firstName,
lastName: patient.lastName,
},
payment: {
amount: 100,
providerReference: { type: "PAYMENT_INTENT", id: paymentIntentId },
},
}),
}
);
const { caseId } = await caseResponse.json();
// Step 3: Redirect patient to BNPL provider
await stripe.confirmPayment({
clientSecret: paymentIntentSecret,
confirmParams: { return_url: "https://yoursite.com/payment/complete" },
});
Retry After Abandonment or Failure
- JavaScript
// 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
| Version | Date | Changes |
|---|---|---|
| 1.1 | 2026-05-29 | Added Payment Failure Handling section — payment_intent.payment_failed now archives the ABANDONED case; new idempotency key required for retry after failure |
| 1.0 | 2026-05-20 | Initial BNPL Integration Guide |