Skip to content

Protocols & Amendments

A protocol is a customer's supplement regimen: the customer reference plus the product line items a practitioner has assigned them. It is the source of truth for what a customer takes — nothing more. Shipping method and billing cadence (daysSupply) live on the subscription and are chosen at checkout; final pricing is computed when each order is built.

A customer may hold several active protocols at once; there is no per-customer uniqueness rule. Each protocol carries at most one live subscription, so a customer with two subscriptions has two protocols. While a protocol has no live subscription you can edit it directly; once a subscription is live, regimen changes go through amendments — a reviewable change set with a customer-consent gate. Scheduled changes let you pre-author regimen edits that fire automatically on a future order ("smart protocols").

All endpoints in this guide require a v2 API key (see the Quickstart). Reads need the order/v2.read scope; writes (POST/PUT/DELETE) additionally need order/v2.write. v1 keys cannot call any /v2/* route.

Create a protocol

Create a protocol for an existing customer (see Customers) with POST /v2/protocols:

curl -X POST https://api.fulfillment.sandbox.juniperhealth.com/v2/protocols \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "2OKpLhTlpCrQHabz5X9qRbH4abc",
    "lineItems": [
      {
        "productId": 12345,
        "quantity": 2,
        "administration": { "morning": 1, "afternoon": 0, "evening": 1, "sleep": 0 },
        "takeWith": "food",
        "note": "Take with breakfast and dinner"
      }
    ]
  }'

For pack (non-external) products, administration is required and its values must sum to quantity. External products (powders, liquids) skip administration. Response 201:

{
  "id": "2vSGym0bH8qVEwCIGlyFoRgJq1A",
  "customer": {
    "id": "2OKpLhTlpCrQHabz5X9qRbH4abc",
    "sourceId": "cust-914",
    "firstName": "Jane",
    "lastName": "Smith",
    "email": "jane@example.com",
    "phone": "+15555550123"
  },
  "lineItems": [
    {
      "productId": 12345,
      "quantity": 2,
      "name": "Elite EPA",
      "image": null,
      "pricing": { "retailPrice": "0.50", "partnerPrice": "0.30", "customerPrice": "0.39", "customerDiscount": 22 },
      "administration": { "morning": 1, "afternoon": 0, "evening": 1, "sleep": 0 },
      "packDescription": "Whole Body Wellness",
      "external": false,
      "note": "Take with breakfast and dinner",
      "takeWith": "food",
      "templateId": null,
      "templateSource": null
    }
  ],
  "status": "ACTIVE",
  "orderType": null,
  "hasExternal": false,
  "createdAt": "2026-07-25T14:03:11.201148Z",
  "updatedAt": "2026-07-25T14:03:11.201148Z"
}

Money fields are decimal strings. Per-line pricing is snapshotted at creation from the current catalog price, your partner discount, and the customer's effective discount. You can also seed line items from a reusable template — see Templates & Library.

Every 400 from this endpoint carries {"message", "code"} and, where the refusal names specifics, a details object. Branch on code, never on message — messages are reworded without notice:

code Meaning details
PRODUCTS_NOT_FOUND A productId does not exist {"missingProductIds": [1001]}
PRODUCTS_NOT_AVAILABLE The products exist but are not orderable {"productsNotAvailable": [1001]}
PRODUCT_LISTED_MORE_THAN_ONCE lineItems names the same product twice {"productId": 1001}
CUSTOMER_NOT_FOUND No such customer for your account —
IDEMPOTENCY_KEY_INVALID Idempotency-Key was empty or over 255 characters —
{
  "message": "One or more requested products were not found",
  "code": "PRODUCTS_NOT_FOUND",
  "details": { "missingProductIds": [1001] }
}

details is omitted entirely when a refusal has no specifics — it is never null.

  • GET /v2/protocols — paginated list (limit 1–100, opaque cursor, direction=next|previous).
  • GET /v2/protocols?customerId=cus_123 — exact match on the customer id, served from the primary store (not the search index), so it is complete and immediately consistent. It pages like the plain list (direction does not apply) — a filtered page can come back short while a cursor is still returned, so keep paging until cursor is null — and an unknown customer — deleted, or another partner's — returns an empty page rather than a 404. This is the reliable answer to "which protocols belong to this customer".
  • GET /v2/protocols?search=jane — fuzzy search over customer name, protocol ID, line-item product names, and practitioner pack labels; pack-label-only matches rank below any product-name substring match. Search ignores direction, uses its own cursor format (never mix cursors between modes — always echo them verbatim), and is served from an asynchronously updated index, so a protocol created seconds ago may not be findable by search yet (the plain list sees it immediately).
  • customerId and search cannot be combined: sending both returns 400 with {"message": "customerId and search cannot be combined", "code": "FILTER_CONFLICT"}.
  • GET /v2/protocols/{protocolId} — single protocol. Another partner's protocol is indistinguishable from a missing one (404).

Direct edits — until a subscription is live

While the protocol has no live subscription, PUT /v2/protocols/{protocolId} replaces the line items wholesale. Only lineItems can be updated; omitting it is a no-op that returns the current protocol. Replaced lines are re-priced with current catalog prices and discounts. Emits the protocol.updated webhook. Its 400s use the same codes and details as create (PRODUCTS_NOT_FOUND, PRODUCTS_NOT_AVAILABLE, PRODUCT_LISTED_MORE_THAN_ONCE).

Once the protocol is linked to a live subscription, PUT returns 409 with code: PROTOCOL_HAS_ACTIVE_SUBSCRIPTION:

{
  "message": "This protocol is linked to a subscription in ACTIVE status. Changes must be submitted as a change request to preserve clinical and billing continuity.",
  "code": "PROTOCOL_HAS_ACTIVE_SUBSCRIPTION"
}

From that point on, use the amendment flow below. This keeps the customer in the loop (consent) and keeps future renewal orders consistent with what they agreed to pay.

Lifecycle: deactivate, reactivate, archive

A protocol is ACTIVE, INACTIVE, or ARCHIVED.

  • POST /v2/protocols/{protocolId}/deactivate — ACTIVE → INACTIVE. Cancels every non-cancelled subscription on the protocol (the customer is not charged again), each emitting subscription.cancelled with reason: protocol_deactivated; cancels pending/viewed checkout sessions, and cancels draft/pending amendments and open scheduled steps. Orders already placed are untouched. The response carries a sweep object with the counts. The customer gets one consolidated email (gated by sendSubscriptionCancelledEmails). 409 PROTOCOL_NOT_ACTIVE unless ACTIVE.
  • POST /v2/protocols/{protocolId}/reactivate — INACTIVE → ACTIVE, status only. Nothing is resumed: create a new checkout session and the customer's purchase creates the new subscription. 409 PROTOCOL_NOT_INACTIVE unless INACTIVE.
  • DELETE /v2/protocols/{protocolId} — archives an INACTIVE protocol. Archived is terminal and read-only. 409 PROTOCOL_NOT_INACTIVE unless INACTIVE (deactivate first).
  • Left untouched, an INACTIVE protocol is archived automatically after 60 days; archivesAt on the protocol says when. Any edit restarts the clock.

Checkout sessions can only be created for an ACTIVE protocol — both checkout endpoints return 409 PROTOCOL_NOT_ACTIVE otherwise, the same status and code the lifecycle routes use for that state conflict. PUT returns 409 PROTOCOL_ARCHIVED on an ARCHIVED protocol.

Checkout from a protocol

Two endpoints turn a protocol into a hosted checkout session:

  • POST /v2/protocols/{protocolId}/checkout/subscription — completing payment creates a subscription linked to the protocol (plus its first order), with the card saved for renewals.
  • POST /v2/protocols/{protocolId}/checkout/order — completing payment creates a single order; no recurring billing is set up.

Unlike generic checkout sessions, you send no customer or product fields: line items come from the protocol's regimen, and the customer's name, email, phone, and stored shipping address are auto-filled from the protocol's customer record (the customer must exist and have a notification email). The body is required — send {} for all defaults:

curl -X POST https://api.fulfillment.sandbox.juniperhealth.com/v2/protocols/2vSGym0bH8qVEwCIGlyFoRgJq1A/checkout/subscription \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "daysSupply": 30, "shippingMethod": "STANDARD" }'

daysSupply (1–365, default 30) and shippingMethod (matched case-insensitively against your configured shipping-method names; canonically STANDARD, EXPEDITED, OVERNIGHT) are supplied here because they are not stored on the protocol — they become the subscription's cadence and shipping. You can also pass successUrl, cancelUrl, and free-form metadata (stored, not echoed back).

The 201 response is a standard checkout session (note the ID key is sessionId): send the customer to checkoutUrl, which is valid for 24 hours (expiresAt). The quoted totalAmount is the amount that will actually be charged. Each protocol has one active session at a time — creating a new session automatically cancels the protocol's previous pending session, so retries are safe. Completion is asynchronous: listen for the order.created webhook (and subscriptionId on the session) rather than the API response. 400 errors at creation: INVALID_CHECKOUT_DATA (out-of-stock or unavailable products), CUSTOMER_NOT_FOUND / CUSTOMER_EMAIL_REQUIRED (the protocol's customer is gone or has no notification email), INVALID_SHIPPING_METHOD (details.availableShippingMethods lists yours, as on POST /v2/orders), and, on the subscription endpoint, STORE_BASE_NOT_CONFIGURED (set a business name or link a store domain first).

Amendments

An amendment is a batched, reviewable set of regimen edits on a protocol. The lifecycle:

author (POST /amendments)                  -> draft
send   (POST /{amendmentId}/send)          -> pending   (live subscription, no attestation)
                                           -> approved  (no live subscription, or attested offline)
approve (customer link or partner API)     -> approved  (changes applied to the protocol)
reject  (customer link or partner API)     -> rejected  (protocol untouched)
cancel  (DELETE, draft/pending only)       -> cancelled (protocol untouched)
45 days pending with no action             -> cancelled (automatic)

The change format

Each change is { "action": ..., "targetItemId": ..., "payload": ... }. action is add, remove, or update. targetItemId (top-level, camelCase) identifies the regimen line being changed — it is stable across attribute edits and required for remove/update; omit it for add.

The payload object is camelCase like the rest of the API. Unknown keys are rejected with 400; the older snake_case spellings are still accepted so amendments authored before the rename still apply. Allowed keys per action:

Action Allowed payload keys Notes
add productId (required), quantity, schedule, takeWith, note schedule is {morning, afternoon, evening, sleep}; quantity defaults to 1
update productId, quantity, schedule, takeWith, note Absolute target state. A different productId swaps the line to the new product (an unspecified quantity and schedule carry over, but note and takeWith do not: send them to set guidance on the new product; the new line gets a new ID). An update with no editable field is a 400.
remove reason Payload optional; reason is recorded on the removed line

Preview the cost impact

POST /v2/protocols/{protocolId}/amendments/cost-preview dry-runs a change set without persisting anything:

{
  "changes": [
    { "action": "add", "payload": { "productId": 204, "quantity": 1, "schedule": { "morning": 1 }, "takeWith": "food" } },
    { "action": "remove", "targetItemId": "2w9XkTqzS0mVnAbCdEfGh", "payload": { "reason": "superseded" } }
  ]
}
{
  "currentTotal": "120.00",
  "proposedTotal": "150.00",
  "priceChange": "30.00",
  "commissionCurrent": "30.00",
  "commissionProposed": "38.00",
  "commissionChange": "8.00",
  "proposedLines": [
    { "productId": 101, "quantity": 2, "lineSubtotal": "90.00", "lineCommission": "22.00" },
    { "productId": 204, "quantity": 1, "lineSubtotal": "60.00", "lineCommission": "16.00" }
  ]
}

The numbers cover the regimen subtotal and your commission only. Pack lines are priced over the live subscription's actual daysSupply (falling back to 30) and external products are counted once per cycle — but promo codes, fees, shipping, and tax are order-time concerns and are excluded. Treat the preview as the price delta the customer will see on renewals, not an invoice total. An empty changes list is a valid no-op preview.

Author, then send

POST /v2/protocols/{protocolId}/amendments with {"changes": [...]} (1–99 changes) creates a draft (201). Nothing touches the protocol, no webhook fires, and payloads are not yet deep-validated — mistakes surface at send time, not authoring. One guard applies at authoring: if a remove/update targets a line that another pending amendment already targets, you get 409 AMENDMENT_LINE_CONFLICT (drafts don't lock lines; add never conflicts).

POST /v2/protocols/{protocolId}/amendments/{amendmentId}/send makes the draft actionable. Every change is first dry-run against the live protocol — an invalid payload fails the whole send with 400 INVALID_AMENDMENT_TRANSITION before any state changes, so a pending amendment can never be stranded un-approvable. Then the consent gate decides the outcome, and you must branch on the returned status:

  • pending — the protocol has a live subscription and you did not attest offline approval. The amendment is held for customer consent: a single-use magic-link review page is minted and, if approval emails are enabled in your partner settings, a branded email is sent to the customer (best effort). The response now carries authoredAgainstRevision, the protocol revision the amendment was validated against.
  • approved — there is no live subscription, or you sent {"attestedOffline": true}. The changes are applied to the protocol immediately, inside the same request, with approvedBy: "partner" set.

Offline attestation: if the customer already approved the change during an appointment or call, send {"attestedOffline": true} (optionally with attestedBy; it defaults to your API client ID). This bypasses the consent hold, and the attestation is recorded on the amendment (offlineApprovalAttested, attestedBy, attestedAt) for audit.

The magic-link token is deliberately absent from every amendment read. To deliver the review link through your own channel (SMS, portal message) instead of — or in addition to — the platform email, call:

POST /v2/protocols/{protocolId}/amendments/{amendmentId}/approval-link (no body) →

{ "approvalUrl": "https://store.examplepractice.com/account/changes/review?token=2zHlPqRsTuVwXyZaBcDeF" }

The URL is hosted on your storefront domain (custom domain when active and linked, platform domain otherwise). Each call issues an additional link; links already delivered, including the emailed one, keep working, and all of them stop working as soon as the amendment is approved, rejected or cancelled. The endpoint returns 409 AMENDMENT_NOT_PENDING unless the amendment is pending. If the protocol changes after the amendment is sent, approving it returns 409 AMENDMENT_REVISION_CONFLICT and the amendment is cancelled with cancellationReason: "superseded"; author and send it again. On the review page the customer approves or rejects; their decision records approvedBy/rejectedBy as "customer".

Approve, reject, cancel — and revision conflicts

You can also resolve a pending amendment on the customer's behalf from your side:

  • POST .../amendments/{amendmentId}/approve — applies all changes to the protocol atomically (approvedBy: "partner"). Nothing is billed at approval time; future renewal orders are built and priced from the updated regimen.
  • POST .../amendments/{amendmentId}/reject — records the rejection; the protocol is untouched.
  • DELETE /v2/protocols/{protocolId}/amendments/{amendmentId} — cancels a draft or pending amendment. Resolving an already-resolved amendment returns 400 INVALID_AMENDMENT_TRANSITION, naming its actual state in the message.

A pending amendment with no action for 45 days is automatically cancelled (the customer gets a reminder email after 7 days). No webhook fires for the auto-cancel, so poll amendments you care about.

Revision conflicts: each applied change bumps the protocol's revision. If the protocol has moved past the amendment's authoredAgainstRevision when you approve (for example, another amendment was approved first, or a scheduled change fired), approval refuses with 409 AMENDMENT_REVISION_CONFLICT and the protocol is untouched. Recover by re-reading the protocol, cancelling the stale amendment, and authoring a fresh one against the current regimen.

Scheduled changes (smart protocols)

A scheduled change is a single pre-authored regimen edit that fires automatically when a future renewal order is built — e.g. "titrate up on the 3rd order."

POST /v2/protocols/{protocolId}/scheduled-changes:

{
  "trigger": { "type": "order_count", "value": 3 },
  "action": "update",
  "targetItemId": "2w9XkTqzS0mVnAbCdEfGh",
  "payload": { "quantity": 2 }
}
  • Triggers: order_count (fire when the Nth order is built) or interval (fire N days after the subscription starts). interval triggers require an endCondition with at least one bound (endDate and/or maxOccurrences).
  • Authoring validation: the change is dry-run against the current regimen at authoring time — a typo'd targetItemId or malformed payload is a 400 INVALID_SCHEDULED_CHANGE now, never a silent skip months later. Payload keys follow the same contract as amendments, with one restriction: an update payload here may not contain productId — author product swaps as separate remove + add steps.
  • Consent is derived server-side (any origin you send is ignored):
  • No live subscription yet (pre-purchase authoring): the 201 has origin: "original_program" and status: "scheduled". The customer consents to the whole program at purchase; the step is fire-eligible.
  • Live subscription (post-purchase edit): the 201 has origin: "amendment" and status: "awaiting_consent", and a pending consent amendment is raised automatically on the protocol (its authoredBy is "scheduled_change"; the protocol_change_request.created webhook fires). The step becomes scheduled only when that amendment is approved — by the customer, or by you via the approve endpoint. No consent email is sent from this path: fetch the amendment's approval link and surface it to the customer yourself.
  • Fire-once: when a renewal order is built, due scheduled steps are folded into the regimen first, then the order is built from the updated regimen. A step fires at most once and ends applied (with appliedAt and appliedOnOrderId) or skipped (with skippedReason — e.g. the target line was removed in the meantime, or the add would duplicate an existing line). Skips are per-step and final; the order still ships.
  • Cancellation: DELETE /v2/protocols/{protocolId}/scheduled-changes/{scheduledChangeId} cancels a still-scheduled step and returns it with status: "cancelled". Any other state — applied, skipped, cancelled, and also awaiting_consent — returns 409 SCHEDULED_CHANGE_NOT_CANCELLABLE. An awaiting_consent step cannot be withdrawn via the API: approving its consent amendment promotes it to scheduled (cancellable), while rejecting the amendment leaves the step parked in awaiting_consent permanently — inert (it will never fire) but visible in lists.

Read endpoints: GET .../scheduled-changes (unpaginated, optional lowercase status filter) and GET .../scheduled-changes/{scheduledChangeId}. Unlike amendment lists, scheduled-change reads are strongly consistent with the protocol.

Reference

Amendment statuses (lowercase on the wire):

Status Meaning
draft Authored, not sent; not validated in depth; never ages out
pending Sent and held for customer consent; 45-day clock running
approved Changes applied to the protocol
rejected Declined; protocol untouched
cancelled Withdrawn (by you) or auto-cancelled after 45 days pending
expired Reserved value — not currently produced; lapsed amendments become cancelled

Scheduled-change statuses: awaiting_consent → scheduled → applied / skipped; cancelled via DELETE. origin is original_program or amendment.

Amendment and scheduled-change error codes not covered above — every body is {"message", "code", "details"?}:

Status code When
400 AMENDMENT_PRODUCT_UNAVAILABLE A change adds or swaps to a missing, unavailable or disabled product (author, send, approve)
400 INVALID_AMENDMENT The cost preview could not apply a change (unknown target line, unknown payload keys)
400 INVALID_STATUS_FILTER ?status= on an amendment or scheduled-change list is not a valid status
404 PRODUCTS_NOT_FOUND The cost preview references a product not in the catalog; details.missingProductIds lists them
404 AMENDMENT_NOT_FOUND / SCHEDULED_CHANGE_NOT_FOUND No such amendment or scheduled change on this protocol
409 PRODUCT_ALREADY_IN_PROTOCOL An add or swap would put a product on the protocol that is already active on it (author, send, approve, schedule)
409 LEGACY_PROTOCOL_NOT_AMENDABLE The protocol predates regimen-based protocols, so it takes no amendments or scheduled changes
409 STORE_BASE_NOT_CONFIGURED Approval link requested but your account has no customer-facing store base yet

Webhook events (see Webhooks for delivery and signatures):

Event When Payload
protocol.created Protocol created The protocol (camelCase, as returned by the API)
protocol.updated Direct PUT edit The updated protocol (camelCase)
protocol.deactivated Protocol deactivated The protocol (camelCase) plus sweep counts
protocol.reactivated Protocol reactivated The protocol (camelCase)
protocol.archived Protocol archived (manually or by the daily job) The protocol (camelCase)
protocol_change_request.created Amendment sent (including auto-approving sends and auto-raised consent amendments) Amendment
protocol_change_request.approved Amendment approved (customer, partner, or auto-approve) Amendment
protocol_change_request.rejected Amendment rejected Amendment
protocol_change_request.cancelled Amendment cancelled via DELETE (not by the 45-day auto-cancel) Amendment

Amendments reuse the legacy protocol_change_request.* event names for wire continuity. The payload is camelCase like everything else, carrying both changeRequestId (legacy alias) and amendmentId (same value):

{
  "changeRequestId": "2zH8mQwErTyUiOpAsDfGj",
  "amendmentId": "2zH8mQwErTyUiOpAsDfGj",
  "protocolId": "2vTgd30JFgnwLrgb383PcV9Rken",
  "subscriptionId": null,
  "status": "pending",
  "changes": [{ "action": "update", "targetItemId": "2w9XkTqzS0mVnAbCdEfGh", "payload": { "quantity": 3 } }],
  "authoredBy": "3fkq2j8xxxxxxxxxxclient",
  "createdAt": "2026-07-25T16:04:12.123456+00:00",
  "approvedBy": null, "approvedAt": null,
  "rejectedBy": null, "rejectedAt": null
}

Good to know

  • POST /v2/protocols accepts an Idempotency-Key header. Send an opaque value of 1-255 characters and a retry of the same request returns the protocol the first call created — same body, 201, plus Idempotent-Replayed: true — instead of a second active protocol. Keys are scoped to your partner account and to this endpoint, and are remembered for 24 hours. Reuse of a key with a different body is a 409 IDEMPOTENCY_KEY_REUSED; retrying while the first call is still running is a 409 IDEMPOTENCY_KEY_IN_FLIGHT (wait a second and retry). A key is released if the call failed, so the retry that follows a timeout can succeed. Without the header, posting the same protocol twice still creates two protocols. Amendments take no key yet: posting the same amendment body twice creates two drafts. Checkout creation is naturally retry-safe (latest session wins).
  • Amendment lists are unpaginated and eventually consistent — a just-created amendment may briefly be missing from GET .../amendments. Fetch by ID for read-your-write.
  • Uniform 404s. A missing protocol, another partner's protocol, or an amendment that belongs to a different protocol than the path all return the same 404 — existence is never disclosed across tenants.
  • Correlating consent amendments: neither the scheduled change nor its consent amendment currently exposes a link to the other. Identify consent amendments by authoredBy: "scheduled_change" on the protocol's amendment list and match on the change contents.
  • A fired scheduled change can invalidate a pending amendment. The fold bumps the protocol revision, so a later approve of an amendment sent before the firing returns the revision-conflict 409. This is by design — re-review against the changed regimen.
  • Amendment approval is not a billing event. Nothing is charged when changes apply; the next renewal order is priced from the updated regimen. Use cost-preview to communicate the price delta ahead of consent.
  • Casing recap: camelCase everywhere — REST requests and responses, the payload object, and webhook payloads alike. Money is always a decimal string.