Customers¶
A customer is the person you place orders, run subscriptions, and build protocols for. In v2, customers are referenced resources: you create the customer once, then pass its id wherever a customer is needed — Subscriptions, Protocols, and Checkout all take a customerId rather than an embedded customer object. (Orders are the exception: they accept either a customerId reference or an embedded snapshot, and create the Customer record for you when the snapshot's email is new — see how an order finds its Customer record.) For subscriptions, protocols and checkout, create the customer first; everything else hangs off it.
Every customer belongs to your partner account. You can never see (or collide with) another partner's customers — their records behave exactly like records that don't exist and return 404.
All customer endpoints require an OAuth2 access token from your v2 API key. Reads need the order/v2.read scope; writes need order/v2.read and order/v2.write.
| Operation | Purpose | Scopes |
|---|---|---|
POST /v2/customers |
Create a customer | order/v2.read + order/v2.write |
GET /v2/customers |
List or search customers | order/v2.read |
GET /v2/customers/{customerId} |
Fetch one customer | order/v2.read |
PUT /v2/customers/{customerId} |
Update (merge semantics) | order/v2.read + order/v2.write |
DELETE /v2/customers/{customerId} |
Soft delete + cascade | order/v2.read + order/v2.write |
POST /v2/customers/{customerId}/billing-portal |
Create a billing portal session | order/v2.read + order/v2.write |
POST /v2/customers/{customerId}/setup-intent |
Start collecting a replacement card on your own page (partner-hosted checkout partners only) | order/v2.read + order/v2.write |
POST /v2/customers/{customerId}/payment-method |
Make the card a confirmed SetupIntent collected the renewal default (partner-hosted checkout partners only) | order/v2.read + order/v2.write |
POST /v2/customers/{customerId}/logins |
Record that the customer signed in to your own app, for their activity timeline | order/v2.read + order/v2.write |
Creating a customer¶
POST /v2/customers requires firstName, lastName, and email. Everything else is optional. Set sourceId to the customer's identifier in your system — it becomes your handle for deduplication and reconciliation.
curl -X POST https://api.fulfillment.juniperhealth.com/v2/customers \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sourceId": "crm-88231",
"firstName": "Jane",
"lastName": "Miller",
"email": "jane.miller@example.com",
"phone": "3125550143",
"birthYear": 1984,
"shippingAddress": {
"line1": "437 Lytton Ave",
"line2": "Suite 200",
"city": "Palo Alto",
"state": "CA",
"zipCode": "94301"
},
"discountOverride": 15
}'
Response — 201 Created:
{
"id": "2vTgd30JFgnwLrgb383PcV9Rken",
"partnerId": "2uPqx91HFgnwLrgb383PcV9Rabc",
"sourceId": "crm-88231",
"firstName": "Jane",
"lastName": "Miller",
"fullName": "Jane Miller",
"email": "jane.miller@example.com",
"phone": "3125550143",
"shippingAddress": {
"line1": "437 Lytton Ave",
"line2": "Suite 200",
"city": "Palo Alto",
"state": "CA",
"zipCode": "94301"
},
"billingAddress": null,
"discountOverride": 15,
"partnerConsent": false,
"payer": "customer",
"createdAt": "2026-07-25T14:03:21.512000Z",
"updatedAt": "2026-07-25T14:03:21.512000Z"
}
The id is generated by DailyStak and is what you pass to every other endpoint — it is not your sourceId. A successful create also emits the customer.created webhook event (see Webhooks emitted).
A few normalizations happen on create, so what you read back may not be byte-for-byte what you sent:
- Email is validated as a real email address and stored lowercased.
- Phone is normalized to 10 bare digits: formatting characters are stripped, and a leading
1on an 11-digit number is removed."(312) 555-0143"is stored and returned as"3125550143". Anything that doesn't reduce to a valid 10-digit US number is rejected with 422. - Addresses are US-only:
statemust be one of the 50 state codes plusDCandPR(territories likeGU/VIand military codes are not accepted), andzipCodemust be exactly 5 digits. birthYear(1900–2100) is write-only: it is stored and used for age-based checks, but never returned in any response.
Uniqueness and idempotency¶
Within your partner account, sourceId and email are each unique among non-deleted customers. A duplicate returns 409 Conflict:
{
"message": "Customer with this source_id already exists",
"code": "CUSTOMER_EXISTS",
"details": {
"existingCustomerId": "2vTgd30JFgnwLrgb383PcV9Rken",
"sourceId": "crm-88231"
}
}
Use sourceId as your idempotency key: on a retry after a timeout, a 409 tells you the customer already exists. The 409 returns details.existingCustomerId — the id of the customer that already holds that sourceId — so a retry that conflicts resolves in one round trip, with no lookup. If you need to fetch the whole record, GET /v2/customers/{existingCustomerId}, or use the exact ?sourceId= filter.
A duplicate email returns code: CUSTOMER_EMAIL_EXISTS with the same details.existingCustomerId, and no sourceId:
{
"message": "A customer with this email already exists",
"code": "CUSTOMER_EMAIL_EXISTS",
"details": { "existingCustomerId": "2vTgd30JFgnwLrgb383PcV9Rken" }
}
Two caveats:
- Uniqueness is eventually consistent. Two near-simultaneous creates with the same
sourceIdoremailcan both succeed. Serialize creates per customer on your side; treat the 409 as a best-effort guard, not a transactional lock. - After a customer is deleted, their
sourceIdandemailbecome reusable for a new customer (see Deleting a customer).
Updating a customer¶
PUT /v2/customers/{customerId} uses merge semantics despite the PUT verb: only the fields you include change; everything you omit is left alone. Null handling differs by field, so read this table carefully:
| Field | Sending null |
Notes |
|---|---|---|
firstName, lastName, email, phone |
Same as omitting — no change | These fields cannot be cleared. Email uniqueness is re-checked (409 on conflict). |
shippingAddress, billingAddress |
Same as omitting — no change | When supplied, the address is replaced wholesale — send the complete object with all required subfields (a partial address is a 422). Addresses cannot be cleared once set. |
discountOverride |
Explicit null clears it — the customer reverts to your partner default discount | Non-null values are re-checked against your partner discount ceiling. |
birthYear |
Explicit null clears it | 1900–2100 when non-null. |
partnerConsent |
Explicit null is rejected (422) |
Strict boolean — send true or false only. |
A body with no applicable fields (for example {}) is rejected with 400 and code: INVALID_CUSTOMER_DATA.
curl -X PUT https://api.fulfillment.juniperhealth.com/v2/customers/2vTgd30JFgnwLrgb383PcV9Rken \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phone": "3125550199",
"discountOverride": null,
"billingAddress": {
"line1": "1 Main St",
"city": "Chicago",
"state": "IL",
"zipCode": "60601"
}
}'
The 200 response is the full updated customer with a bumped updatedAt. A successful update emits the customer.updated webhook event.
If the customer already has a payment profile with the payment processor (created via checkout or the billing portal), updating billingAddress also syncs the new address to that profile, best-effort — a sync failure never fails your API call.
Phone is validated and normalized on update, exactly like create: formatted input ("(312) 555-0143") is accepted and stored as 10 bare digits, and anything that doesn't reduce to a valid 10-digit US number is rejected with 422. Email, however, is stored as sent — send it already lowercased, exactly as the create path would have stored it.
Listing and searching¶
GET /v2/customers returns non-deleted customers in roughly creation order, cursor-paginated:
curl "https://api.fulfillment.juniperhealth.com/v2/customers?limit=50" \
-H "Authorization: Bearer $ACCESS_TOKEN"
{
"customers": [ { "...": "Customer objects as above" } ],
"cursor": "eyJwayI6ICJQQVJUTkVSIzEyMyIsICJzayI6ICJDVVNUT01FUiN5In0=",
"hasMore": true
}
Pagination rules:
limitis 1–100, default 50. Pass the previous response'scursorto get the next page; treat it as opaque.- A page can contain fewer than
limititems — even zero — whilehasMoreis stilltrue. Keep paging untilcursoris null /hasMoreis false; never treat a short or empty page as the end. - An invalid or expired cursor doesn't error — the listing silently restarts from the first page. If you see records repeat, your cursor was rejected.
Exact sourceId lookup¶
?sourceId= is an exact, index-backed lookup — not a search. It returns a normal list body containing the one non-deleted customer with that sourceId, or an empty list, always with cursor: null and hasMore: false. This is the reliable answer to "does a customer with this sourceId exist?".
curl "https://api.fulfillment.juniperhealth.com/v2/customers?sourceId=crm-88231" \
-H "Authorization: Bearer $ACCESS_TOKEN"
- Matching is exact and case-sensitive; there is no partial matching.
- Soft-deleted customers never match, so a recycled
sourceIdreturns only the live holder. - It is read-after-write eventually consistent: the filter reads the same index the create's duplicate check reads, so a lookup issued immediately after a create can miss. If you are reconciling right after creating, keep the
idthe create returned rather than looking it up. sourceIdandsearchcannot be combined: sending both returns400with{"message": "sourceId and search cannot be combined", "code": "FILTER_CONFLICT"}.limitandcursorare ignored — the result is at most one customer.
Search is best-effort¶
Adding ?search= switches to contains-matching across first name, last name, email, phone (as stored — 10 digits), sourceId, and the customer id. Email matching is case-insensitive; name matching is case-insensitive on a best-effort basis (common capitalizations only); phone, sourceId, and id matching is exact-case.
curl "https://api.fulfillment.juniperhealth.com/v2/customers?search=miller&limit=10" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Search realism, in one paragraph: search scans a bounded window of your customer base and returns up to limit matches, with cursor pagination disabled (cursor is always null, hasMore always false). For partners with large customer lists, matches outside the scanned window can be missed. Treat search as a convenience for support tooling and dedup lookups — not as an exhaustive query. If you need a guaranteed answer ("does a customer with this sourceId exist?"), use the exact ?sourceId= filter above rather than ?search=.
Customer profile (one read for the account page)¶
GET /v2/customers/{customerId}/profile returns the customer together with
every list an account page renders — their protocols, their subscriptions and
their most recent orders — in a single call:
curl "https://api.fulfillment.juniperhealth.com/v2/customers/2vTgd30JFgnwLrgb383PcV9Rken/profile" \
-H "Authorization: Bearer $ACCESS_TOKEN"
{
"customer": { "...": "the same object GET /v2/customers/{customerId} returns" },
"protocols": [ { "...": "Protocol objects, newest first" } ],
"subscriptions": [ { "...": "Subscription objects, newest first" } ],
"recentOrders": [ { "...": "Order objects, newest first" } ],
"recentOrdersTruncated": false
}
It is a composite of calls you can already make — GET /v2/customers/{id},
GET /v2/protocols?customerId=, GET /v2/subscriptions?customerId= and
GET /v2/orders — and each entry is byte-for-byte the object the standalone
endpoint returns, so you can hold one model per resource.
What to expect:
protocolsandsubscriptionsare complete, in every status (including inactive, archived and cancelled), newest first. They are not paged: the endpoint reads each source to exhaustion, so there is no cursor to follow.recentOrdersholds the 20 most recent orders, newest first, in every status. A customer with more than 20 setsrecentOrdersTruncatedtotrue; that flag is the only signal, and when it istrueyou read the rest fromGET /v2/orders. When it isfalsethe list is the customer's whole order history.- Each order carries its
subscriptionId(null for one-time and API orders), so the page can group orders under the subscription that generated them without a second lookup. - Protocol line items come back with
imagenull here. The images are the one thing the composite does not carry; readGET /v2/protocols/{protocolId}if you render them. - Unknown, deleted and other partners' customers are all
404, exactly asGET /v2/customers/{customerId}answers them. - The endpoint requires no new scope:
order/v2.read, the same scope the rest of/v2/customersrequires, reaches it.
Deleting a customer (soft delete + cascade)¶
DELETE /v2/customers/{customerId} returns 204 No Content and does four things, in order:
- Cancels the customer's subscriptions that are in an active, paused, pending, awaiting-confirmation, or past-due state — all of them, paged, however many the customer holds. Each emits
subscription.cancelledwithreason: customer_deletedandinitiatedBy: PLATFORM. - Cancels the customer's orders that are still in status
NEW(not yet processing). Each cancelled order emits anorder.cancelledwebhook event, delivered asynchronously. Orders already in fulfillment (PROCESSING,FULFILLED,SHIPPED, …) are left untouched and continue to completion. - Soft-deletes the customer. The record disappears from
GET, list, and search (all return 404), and itssourceIdandemailimmediately become reusable for a new customer. - Emits the
customer.deletedwebhook event, carrying the customer's pre-delete state.
Things to know before you wire this up:
- This is a soft delete with data retention, not erasure. Do not present it to end users as a "right to be forgotten" deletion; contact support for data-removal requests.
- The customer's payment-processor artifacts (payment profile, saved cards) are not touched. If the customer has active billing, prefer cancelling their subscriptions explicitly through the Subscriptions API — which handles the billing side — before deleting the customer.
- A caregiver who still manages customers cannot be deleted. The delete returns
409 CUSTOMER_HAS_DEPENDENTSand changes nothing: no subscriptions or orders are cancelled. List the dependents withGET /v2/customers/{customerId}/managed, then delete each one or move it to another caregiver withPUT(managedBy) before deleting the caregiver. - Deleting is not repeatable: a second
DELETEfor the same customer returns 404, as does deleting a customer that never existed. Treat 404 on a delete retry as success. - The 204 response has no meaningful body; do not attempt to parse one.
Billing portal sessions¶
POST /v2/customers/{customerId}/billing-portal creates a short-lived Stripe Billing Portal session where the end customer can manage their saved payment methods and view their billing history. returnUrl (required) is where Stripe sends the customer when they exit the portal.
curl -X POST https://api.fulfillment.juniperhealth.com/v2/customers/2vTgd30JFgnwLrgb383PcV9Rken/billing-portal \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "returnUrl": "https://partner.example.com/account/billing" }'
Response — 200 OK:
{ "url": "https://billing.stripe.com/p/session/live_YWNjdF8x..._secret_..." }
Usage pattern: create the session on demand, immediately before redirecting the customer to the returned url. Sessions are single-use and expire quickly — never cache or email the URL, and never reuse one across customers or visits. Each call creates a fresh session.
If the customer has no payment profile yet, one is created automatically on first use (this does not emit a customer.updated event). A 5xx from this endpoint is transient — retry, and contact support if it persists.
Partners on partner-hosted (Elements) checkout can instead collect a replacement card on their own page: POST /v2/customers/{customerId}/setup-intent returns a Stripe SetupIntent clientSecret for a Payment Element in setup mode, and POST /v2/customers/{customerId}/payment-method with the confirmed setupIntentId makes that card the renewal default. Both are gated like uiMode: elements checkout (403 ELEMENTS_CHECKOUT_DISABLED otherwise). See Checkout → Replacing a saved card on your site.
Recording a sign-in to your app¶
POST /v2/customers/{customerId}/logins tells DailyStak that the customer signed in to your own app, so the sign-in appears on their activity timeline beside sign-ins to a DailyStak-hosted store.
curl -X POST https://api.fulfillment.juniperhealth.com/v2/customers/2vTgd30JFgnwLrgb383PcV9Rken/logins \
-H "Authorization: Bearer $ACCESS_TOKEN"
There is no request body. DailyStak writes the entry itself and timestamps it when it records it, normally within seconds of your call, so you send no timestamp and nothing else about the sign-in. The response is 202 Accepted with an empty JSON object once the entry is queued; if it cannot be queued you get a 5xx, never a 202. Recording is at-least-once: a retry after a timeout or a 5xx can record the sign-in twice, and on rare occasions so can a single call. A customer that is not yours is a 404 with CUSTOMER_NOT_FOUND. It emits no webhook.
discountOverride and pricing¶
discountOverride is a per-customer discount percentage off MSRP (0–100). It controls the customer price DailyStak computes wherever this customer is priced — order quotes, checkout, and subscription renewals:
discountOverride: null(the default) — the customer gets your partner-level default customer discount.discountOverride: 15— this customer gets exactly 15% off MSRP instead, whether that is more or less than your default.- Setting it back to
nullviaPUTreverts the customer to your partner default.
The override can never exceed your partner account's own discount percentage. Exceeding it fails with a 400; details.maxDiscountOverride is the highest value accepted:
{
"message": "discountOverride (35%) cannot exceed partner discount (30%)",
"code": "DISCOUNT_OVERRIDE_TOO_HIGH",
"details": { "maxDiscountOverride": 30 }
}
Changing discountOverride affects pricing computed after the change; already-quoted or already-charged amounts are not retroactively adjusted. See Orders and Checkout for exactly when a price is locked in.
Webhooks emitted¶
Customer endpoints emit three webhook event types; see Webhooks for the delivery envelope, signature verification, and retries.
| Event | When | Notes |
|---|---|---|
customer.created |
Successful POST /v2/customers |
|
customer.updated |
Successful PUT /v2/customers/{customerId} |
Not emitted for the internal payment-profile link created by the billing portal. |
customer.deleted |
Successful DELETE /v2/customers/{customerId} |
Payload is the customer's pre-delete state. |
order.cancelled |
Indirectly, per NEW order cancelled by a delete cascade |
Delivered asynchronously, shortly after the delete. |
The data payload for all three customer.* events is a compact subset of the customer — addresses, discount, and consent fields are not included:
{
"customerId": "2vTgd30JFgnwLrgb383PcV9Rken",
"sourceId": "crm-88231",
"firstName": "Jane",
"lastName": "Miller",
"email": "jane.miller@example.com",
"phone": "3125550143",
"createdAt": "2026-07-25T14:03:21.512000+00:00",
"updatedAt": "2026-07-25T14:03:21.512000+00:00"
}
Error reference¶
Every body is {"message", "code", "details"?} — branch on code. The gateway's own 401/403 carry message only.
| Status | code |
Meaning |
|---|---|---|
| 400 | DISCOUNT_OVERRIDE_TOO_HIGH |
Override above your partner ceiling. details.maxDiscountOverride is the highest value accepted. |
| 400 | INVALID_CUSTOMER_DATA |
PUT body contained nothing applicable, or managedBy names a customer that doesn't exist, is empty, or is itself managed. |
| 400 | FILTER_CONFLICT |
List called with both sourceId and search. |
| 401 | — (gateway) or UNAUTHORIZED |
Missing or unparseable token, or an unknown client. |
| 403 | — (gateway) | Token expired, missing a required scope, or partner suspended. |
| 404 | CUSTOMER_NOT_FOUND |
Unknown ID, deleted customer, or a customer that isn't yours. |
| 409 | CUSTOMER_EXISTS |
Duplicate sourceId on create. details.existingCustomerId names the customer that holds it; details.sourceId repeats the value. |
| 409 | CUSTOMER_EMAIL_EXISTS |
Duplicate email on create or update. details.existingCustomerId names the current owner of the email. |
| 409 | CUSTOMER_HAS_DEPENDENTS |
DELETE on a caregiver who still manages other customers. Nothing was changed; delete or reassign the dependents first. |
| 422 | VALIDATION_FAILED |
Request shape invalid — missing required field, bad email format, bad state/zipCode, birthYear out of range, unparseable phone. details.errors lists each failure by type. |
| 429 | — | Rate limited; back off and retry. |
Good to know¶
partnerConsentrecords whether the customer has consented to you managing their account (subscriptions, changes) on their behalf. It is read-onlyfalseat create time; set it viaPUTonce you have consent. Some management operations on other resources require it.payeris read-only here.customer(the default) means the customer pays with their own card at checkout.practicemeans the practice covers the cost: its staff place the order in the office against the practice's saved card at wholesale, and the customer never signs in to a store or receives email from DailyStak. Only the practice can set it, in the portal; the field is ignored onPOSTandPUT. Expect409 CUSTOMER_PRACTICE_PAIDif you open a checkout session for such a customer.birthYearis write-only. You can set and clear it, but no response ever echoes it. Keep your own copy if you need to display it.- Phone and email round-tripping: create normalizes phone to 10 digits and lowercases email; store what the API returns, not what you sent.
- Timestamps are ISO 8601 UTC everywhere, but the suffix differs by channel: API responses use
Z(...512000Z), webhook payloads use+00:00. Use a real datetime parser rather than string comparison. - 404 means "not yours" too. A valid-looking ID that belongs to another partner returns the same 404 as a nonexistent one — don't build logic that distinguishes them.
- Deleted-customer identifiers recycle. After a delete, the same
sourceIdcan legitimately appear on a brand-new customer with a differentid. Key your internal records to the DailyStakid, not tosourceIdalone. - Keeping your own id mapping is still good practice — but it is no longer the only way back. If you lose the link between your records and a customer's subscriptions or protocols,
GET /v2/subscriptions?customerId=andGET /v2/protocols?customerId=(Subscriptions, Protocols) recover it exactly, from the primary store. Use?sourceId=above to get from your own id to the DailyStak customeridfirst. - Customer creation never touches payment. No payment profile exists until the customer first goes through Checkout or a billing portal session, so you can create customers freely for catalog and protocol work.