Add Products to Case
/api/v1/cases/:caseId/productsAttaches one or more products to an existing case. Supports an optional top-level payment that covers all products in the request. Each product can also include an optional intake form and subscription configuration.
https://api.care360-next.carevalidate.com/api/v1/cases/:caseId/productshttps://api-staging.care360-next.carevalidate.com/api/v1/cases/:caseId/productsParameters
cv-api-keystringrequiredContent-Typestringrequiredapplication/jsoncaseIdstring (UUID)requiredpaymentobjectoptionalShow 5 child properties
amountnumberrequiredcurrencystringrequiredUSDdescriptionstringoptionalproviderReferenceobjectoptionalShow 2 child properties
typestringrequiredidstringrequiredapplyPromoCodebooleanoptionalproductsarrayrequiredShow 4 child properties
idstring (UUID)requiredvisitTypestringoptionalformobjectoptionalShow 3 child properties
titlestringoptionaldescriptionstringoptionalquestionsarrayoptionalsubscriptionobjectoptionalShow 2 child properties
intervalstringoptionalintervalCountintegeroptionalForm Question Fields
questionstringrequiredanswerstringrequiredtypestringrequiredrequiredbooleanoptionalphibooleanoptionaloptionsarrayoptionalPayment Status Lifecycle
When a PAYMENT_INTENT is still pending at attach time (the BNPL pre-redirect flow), the product and its payment move through the following states as Stripe webhook events arrive. No polling is required — the case product's status/reason and the linked payment's status update automatically.
| Event | Product status | Product reason | Payment status |
|---|---|---|---|
Pending PAYMENT_INTENT attached | CLOSE | Pending payment | UNPAID |
payment_intent.amount_capturable_updated (BNPL hold acquired) | OPEN | null | UNPAID (capture happens later) |
payment_intent.succeeded (charge captured) | OPEN | null | PAID |
payment_intent.payment_failed | CLOSE | Payment failed | ERROR |
If a PaymentIntent fails, only the product(s) and payment tied to it are closed/errored — the case itself remains OPEN. To retry, re-add the same product with a new PAYMENT_INTENT; it starts a fresh pending-payment cycle independent of the failed one.
Promo Code Discount (PAYMENT_INTENT)
Always send amount as the amount you actually built your PaymentIntent for — a Stripe PaymentIntent's amount is fixed when you create it and can't be reduced through this endpoint. applyPromoCode: true never changes amount. When providerReference.type is PAYMENT_INTENT, it instead uses the case's already-attached promo code to work out what discount your amount implies, and records that against the payment for your own reporting (visible via the case's payment history as discountedAmount and the linked promo code) — regardless of whether that PaymentIntent is a BNPL hold, already succeeded, or still pending.
For example, a case has a 50%-off promo code and you've already applied it yourself, charging $100 instead of the $200 list price: create your PaymentIntent for $100, then call this endpoint with amount: 100 and applyPromoCode: true. The stored payment's amount stays 100; discountedAmount is recorded as 100 (the discount your $100 charge implies against the $200 list price).
It cannot be combined with the promoCode field. It's a no-op for SETUP_INTENT or when no providerReference is given — the case's promo code is never looked up there.
A 100%-off (or otherwise ≥100%) percent-discount promo code causes this endpoint to reject the request with a 400 — there's no gross amount a 100%-off discount can be worked back out from a net amount of more than $0. If this happens, cancel the PaymentIntent: the authorization otherwise stays open on the customer's funding source until it expires on its own.
A BNPL hold's case promo code is still applied automatically at capture if you don't use this flag. If you don't pass applyPromoCode, a BNPL hold attached to a case with a promo code gets that discount applied once, automatically, when the hold is captured — expect the captured amount to be lower than the amount you originally sent. Use applyPromoCode: true instead when you've already applied the discount yourself and want that reflected immediately, rather than only after capture.
Examples
- cURL (no payment)
- cURL (single payment, multi-product)
- cURL (BNPL pre-redirect)
- JavaScript
- Python
curl -X POST "https://api.care360-next.carevalidate.com/api/v1/cases/550e8400-e29b-41d4-a716-446655440000/products" \
-H "cv-api-key: <redacted>" \
-H "Content-Type: application/json" \
-d '{
"products": [
{
"id": "8dff2c47-b2ab-42d8-9af0-4c4dbca8b4b6",
"visitType": "SYNC_VIDEO",
"subscription": {
"interval": "month",
"intervalCount": 3
}
},
{
"id": "621527f5-3877-48b3-b27f-df395a5308bb",
"form": {
"title": "Healthcare Intake Form",
"description": "Patient intake form for healthcare services",
"questions": [
{
"question": "How much do you weigh?",
"answer": "138 lbs",
"phi": true,
"type": "TEXT"
},
{
"question": "What are your weight loss goals?",
"answer": "Lose 1-20lbs for good",
"type": "SINGLESELECT",
"required": true,
"options": [
"Lose 1-20lbs for good",
"Lose 21-50lbs for good",
"Lose over 50 for good",
"Maintain my healthy weight"
]
}
]
}
}
]
}'
curl -X POST "https://api.care360-next.carevalidate.com/api/v1/cases/550e8400-e29b-41d4-a716-446655440000/products" \
-H "cv-api-key: <redacted>" \
-H "Content-Type: application/json" \
-d '{
"payment": {
"amount": 79.98,
"currency": "USD",
"description": "Bundle upsell",
"providerReference": {
"type": "PAYMENT_INTENT",
"id": "pi_3Qx1AbGkkQS2eXzh1Kj9mN2c"
}
},
"products": [
{ "id": "8dff2c47-b2ab-42d8-9af0-4c4dbca8b4b6" },
{ "id": "621527f5-3877-48b3-b27f-df395a5308bb" }
]
}'
# Call this BEFORE redirecting the patient to the BNPL provider.
# Pass the pending PaymentIntent — products attach as CLOSE (inactive)
# and are automatically reopened when payment_intent.succeeded fires.
curl -X POST "https://api.care360-next.carevalidate.com/api/v1/cases/550e8400-e29b-41d4-a716-446655440000/products" \
-H "cv-api-key: <redacted>" \
-H "Content-Type: application/json" \
-d '{
"payment": {
"amount": 79.98,
"currency": "USD",
"description": "BNPL upsell",
"providerReference": {
"type": "PAYMENT_INTENT",
"id": "pi_3Qx1AbGkkQS2eXzh1Kj9mN2c"
}
},
"products": [
{ "id": "8dff2c47-b2ab-42d8-9af0-4c4dbca8b4b6" },
{ "id": "621527f5-3877-48b3-b27f-df395a5308bb" }
]
}'
const caseId = "550e8400-e29b-41d4-a716-446655440000";
const response = await fetch(
`https://api.care360-next.carevalidate.com/api/v1/cases/${caseId}/products`,
{
method: "POST",
headers: {
"cv-api-key": "YOUR_SECRET_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
products: [
{
id: "8dff2c47-b2ab-42d8-9af0-4c4dbca8b4b6",
visitType: "SYNC_VIDEO",
subscription: {
interval: "month",
intervalCount: 3,
},
},
{
id: "621527f5-3877-48b3-b27f-df395a5308bb",
form: {
title: "Healthcare Intake Form",
description: "Patient intake form for healthcare services",
questions: [
{
question: "How much do you weigh?",
answer: "138 lbs",
phi: true,
type: "TEXT",
},
{
question: "What are your weight loss goals?",
answer: "Lose 1-20lbs for good",
type: "SINGLESELECT",
required: true,
options: [
"Lose 1-20lbs for good",
"Lose 21-50lbs for good",
"Lose over 50 for good",
"Maintain my healthy weight",
],
},
],
},
},
],
}),
}
);
const data = await response.json();
console.log(data);
import requests
case_id = "550e8400-e29b-41d4-a716-446655440000"
response = requests.post(
f"https://api.care360-next.carevalidate.com/api/v1/cases/{case_id}/products",
headers={
"cv-api-key": "YOUR_SECRET_KEY",
"Content-Type": "application/json",
},
json={
"products": [
{
"id": "8dff2c47-b2ab-42d8-9af0-4c4dbca8b4b6",
"visitType": "SYNC_VIDEO",
"subscription": {
"interval": "month",
"intervalCount": 3,
},
},
{
"id": "621527f5-3877-48b3-b27f-df395a5308bb",
"form": {
"title": "Healthcare Intake Form",
"description": "Patient intake form for healthcare services",
"questions": [
{
"question": "How much do you weigh?",
"answer": "138 lbs",
"phi": True,
"type": "TEXT",
},
{
"question": "What are your weight loss goals?",
"answer": "Lose 1-20lbs for good",
"type": "SINGLESELECT",
"required": True,
"options": [
"Lose 1-20lbs for good",
"Lose 21-50lbs for good",
"Lose over 50 for good",
"Maintain my healthy weight",
],
},
],
},
},
],
},
)
print(response.json())
Responses
▶200SuccessProducts successfully attached to the case.
{
"success": true,
"data": {
"caseId": "550e8400-e29b-41d4-a716-446655440000"
}
}
▶400Bad RequestInvalid request body or parameters. Also returned when a pending or already-charged `PAYMENT_INTENT` is provided without an `amount`.
{
"success": false,
"message": "Invalid request"
}
▶401UnauthorizedMissing or invalid authentication token.
{
"success": false,
"message": "Unauthorized"
}
▶409ConflictCase conflict (e.g., duplicate product attachment).
{
"success": false,
"message": "Case conflict"
}
▶422Validation FailedRequest body failed validation rules.
{
"success": false,
"message": "Validation failed"
}
▶500Internal Server ErrorUnexpected server error.
{
"success": false,
"message": "Internal server error"
}
Try It Out
API Version: v2.2