Webhooks¶
Webhooks are how the platform tells you something happened: an order shipped, a renewal came due, a customer approved a regimen change, a refund settled. Instead of polling, you register an HTTPS endpoint and DailyStak POSTs a signed JSON event to it within seconds of the change. Webhooks are the primary integration signal for everything asynchronous in the v2 API — order fulfillment, checkout completion, the partner-managed renewal loop in Subscriptions, and refund settlement all depend on them.
Webhook endpoints are managed in the partner portal: create endpoints, choose which events each receives, view delivery history, re-send failed deliveries, and send test events. The v2 API itself carries a small legacy configuration surface under /v2/partners/settings — see Managing endpoints.
How delivery works¶
- Transport: HTTPS
POSTwith a JSON body (Content-Type: application/json). Endpoint URLs must behttps://on a publicly resolvable hostname — private, loopback, and IP-literal addresses are rejected when you register the endpoint. - Success = 2xx. Any response with a 2xx status code within 10 seconds counts as delivered. Anything else — timeouts, 4xx, 5xx, connection errors — is a failed attempt.
- Redirects are failures. Deliveries never follow redirects; a 3xx response counts as a failed attempt. Register the final URL.
- Retries: a failed delivery is retried with exponential backoff — the delay roughly doubles per attempt, capped at 15 minutes — for up to 5 attempts total. After the fifth failure the event is dropped for that endpoint. Delivery history (every attempt, with status code and timing) is visible in the portal for 30 days, and failed deliveries can be re-sent manually from there.
- At-least-once, unordered. The same event can be delivered more than once, and events are not guaranteed to arrive in the order they occurred (
order.shippedcan beat an earlierorder.updated). Build for both — see Building a reliable handler. - Best-effort. Webhook delivery never fails the API call or fulfillment step that produced the event. Treat webhooks as a fast signal, not as your system of record — the REST API is always authoritative.
Each request carries two headers of interest:
| Header | Meaning |
|---|---|
X-Juniper-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> — see Verifying signatures |
X-Juniper-Webhook-Id |
Unique id for this delivery attempt (whk_…). A retry of the same event gets a new one |
Because X-Juniper-Webhook-Id changes per attempt, deduplicate on the event id inside the body, not on this header.
The event envelope¶
Every event delivered to a managed endpoint is wrapped in the same envelope:
{
"id": "evt_2zKQx4tR8vWn3kFh9YbT0cGdE5L",
"type": "order.shipped",
"created": 1753452000,
"data": {
"orderId": "2vSGym0bH8qVEwCIGlyFoRgJq1A",
"status": "SHIPPED",
"fulfillmentInfo": {
"carrier": "USPS",
"trackingNumber": "9400111899223100000000",
"trackingUrl": "https://tools.usps.com/go/TrackConfirmAction?tLabels=9400111899223100000000"
},
"createdAt": "2026-07-20T15:04:05+00:00",
"updatedAt": "2026-07-25T13:20:00+00:00",
"checkoutSessionId": null,
"metadata": null
}
}
| Field | Type | Meaning |
|---|---|---|
id |
string | Unique event id (evt_…). Stable across retries — your deduplication key |
type |
string | Event type from the catalog, e.g. order.shipped |
created |
integer | Unix timestamp (seconds) when the event was created |
data |
object | Event payload. Shape varies by type; datetimes inside data are ISO-8601 with a +00:00 offset |
Payloads are deliberately thin — enough to route and correlate, not the full resource. Fetch the current state through the API when you need it (e.g. GET /v2/orders/{orderId} on an order event).
Events produced by the subscription and order table streams carry the same envelope (id, type, created, data) and signature headers as API-triggered events; the evt_ id is assigned once and is stable across retries.
Verifying signatures¶
Every delivery is signed with your endpoint's secret so you can prove it came from DailyStak and was not tampered with. Verify before you parse — compute the signature over the raw request bytes, exactly as received, before JSON-decoding anything.
The X-Juniper-Signature header looks like:
X-Juniper-Signature: t=1753452005,v1=43cebf3abec6a3b49b4745b03fb5279786cdeab4b37377e5afa7990c0b1bb4ae
To verify:
- Split the header on
,, then each part on=:tis the delivery timestamp (unix seconds),v1is the signature (hex). - Concatenate the signed payload: the timestamp, a literal
., and the raw request body —"{t}.{rawBody}". - Compute HMAC-SHA256 of that string using your endpoint secret as the key.
- Compare the hex digest to
v1using a constant-time comparison. - Reject stale timestamps: if
tdiffers from your clock by more than a few minutes (5 minutes is a good tolerance), reject the delivery even if the signature matches — this defeats replay of captured requests. Retries are re-signed with a fresh timestamp, so a legitimate retry never looks stale.
Worked example¶
With endpoint secret
7f3a9c1e5b8d2f4a6c0e8b3d5f7a9c1e2b4d6f8a0c2e4b6d8f0a2c4e6b8d0f2a
timestamp 1753452005, and this exact raw body (one line, no trailing newline):
{"id":"evt_2zKQx4tR8vWn3kFh9YbT0cGdE5L","type":"order.shipped","created":1753452000,"data":{"orderId":"2vSGym0bH8qVEwCIGlyFoRgJq1A","status":"SHIPPED","fulfillmentInfo":{"carrier":"USPS","trackingNumber":"9400111899223100000000","trackingUrl":"https://tools.usps.com/go/TrackConfirmAction?tLabels=9400111899223100000000"},"createdAt":"2026-07-20T15:04:05+00:00","updatedAt":"2026-07-25T13:20:00+00:00"}}
HMAC-SHA256 of "1753452005." + body yields:
43cebf3abec6a3b49b4745b03fb5279786cdeab4b37377e5afa7990c0b1bb4ae
which matches the header's v1. You can reproduce this in any language to test your implementation.
Reference implementations¶
Python:
import hashlib, hmac, time
def verify(signature_header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t, v1 = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
Node.js:
const crypto = require("crypto");
function verify(signatureHeader, rawBody, secret, tolerance = 300) {
const { t, v1 } = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
Make sure your web framework gives you the raw body bytes — many frameworks parse and re-serialize JSON by default, which changes whitespace and key order and breaks the signature.
Managing endpoints¶
Managed endpoints (recommended)¶
Create and manage webhook endpoints in the partner portal. Each managed endpoint has:
- Its own secret, generated at creation and shown once — store it immediately. It is not retrievable later; if a secret is lost or compromised, create a replacement endpoint in the portal and retire the old one. Retries are always signed with the endpoint's current secret.
- An event filter: subscribe to exact types (
order.shipped), a family wildcard (order.*,subscription.*,customer.*,protocol.*,protocol_change_request.*,refund.*,contact.*), or*for everything. - Delivery history with per-attempt status and manual re-send, plus a send test event button that delivers a
test.webhookevent through the full signing pipeline — your handler should return 2xx for it.
You can register multiple endpoints (e.g. one for order events, one for billing events); each event is delivered independently to every endpoint whose filter matches. Disabling an endpoint stops deliveries, including pending retries.
Legacy single URL¶
Older integrations use a single webhookUrl configured via PATCH /v2/partners/settings. This channel behaves differently, and is kept for compatibility:
- It receives order status notifications only, wrapped as
{"order": { ...order payload... }}— without the{id, type, created, data}envelope. - It is signed with a single partner-level secret (same
X-Juniper-Signaturescheme). The secret is generated automatically the first time you setwebhookUrl; retrieve it withGET /v2/partners/settings/webhook-secretand rotate it withPOST /v2/partners/settings/rotate-webhook-secret. Rotation is immediate — the old secret stops validating the moment the call returns. - The partner-level secret does not apply to managed endpoints, and managed-endpoint secrets are not retrievable through the API.
Use managed endpoints for new integrations. They carry the full event catalog, the standard envelope, per-endpoint secrets, event filtering, and portal delivery history; the legacy channel carries none of that.
Event catalog¶
Orders¶
All order events share one thin camelCase payload: {"orderId", "status", "fulfillmentInfo", "createdAt", "updatedAt", "checkoutSessionId", "metadata", "subscriptionId", "protocolId"}. subscriptionId is the subscription whose billing cycle generated the order and protocolId the protocol it was built from (subscriptionId is null for one-time and API-created orders; protocolId is null for API-created orders and orders not placed from a protocol); like the two below they are on every order.* event, so a renewal can be attributed to one of a customer's several subscriptions without fetching the order. checkoutSessionId is the checkout session that created the order (null for orders created any other way) and metadata is the metadata object sent when that session was created (null when none; numbers (including nested ones and those inside arrays) arrive as strings — 3 comes back as "3" — while strings, booleans, null and the object/array structure are preserved). Both fields are present on every order.* event, not only order.created, so any event can be matched back to the checkout that produced the order. Fetch GET /v2/orders/{orderId} for the full order. See Orders and Checkout.
| Event | Fires when |
|---|---|
order.created |
An order is created — by POST /v2/orders, a completed checkout session, or a subscription renewal |
order.updated |
Any other change to the order: progress through PROCESSING/DISPENSING/FILLED, shipping-address updates, tracking updates |
order.cancelled |
The order is cancelled (API cancel, or the cascade from deleting a customer) |
order.shipped |
The carrier accepts the shipment; fulfillmentInfo now carries tracking details |
order.delivered |
Carrier tracking reports delivery |
order.exception |
Fulfillment hits a problem needing attention; orders can recover and continue |
order.fulfilled |
The order passed through FULFILLED en route to SHIPPED, which the packing flow writes as its own step. Orders advanced by the carrier ship-notify skip FULFILLED, so not every shipment emits it — key off order.shipped |
Subscriptions¶
See Subscriptions for the renewal loop these events drive. All subscription payloads are camelCase, like the rest of the API.
| Event | Fires when | Payload |
|---|---|---|
subscription.created |
A subscription is created — POST /v2/subscriptions, the partner portal, or a completed hosted checkout |
The subscription resource, same shape as GET /v2/subscriptions/{subscriptionId}, camelCase, plus the lifecycle fields below. Carries protocolId and customerId; with one protocol per cart, protocolId is your cart correlation key |
subscription.updated |
daysSupply, shippingMethod, endDate or notes changed, or a product was added or removed in the storefront |
The subscription record plus changedFields[] and reason — cadence_changed when daysSupply moved, otherwise fields_changed. Storefront product changes also carry action and productId |
subscription.paused |
The subscription moves to PAUSED |
The subscription record |
subscription.resumed |
A PAUSED subscription returns to ACTIVE |
The subscription record |
subscription.cancelled |
The subscription moves to CANCELLED |
The subscription record plus reason: requested, dunning_exhausted, customer_deleted, protocol_deactivated, checkout_compensation or end_date_reached |
subscription.skipped |
The next order is skipped and the billing date advances one cycle | The subscription record |
subscription.rescheduled |
nextBillingDate is moved |
The subscription record |
subscription.payment_recovered |
A dunning retry finally settles and a PAST_DUE subscription returns to ACTIVE |
The subscription record plus reason: retry_succeeded |
subscription.renewal_pending |
A partner-managed renewal comes due and the subscription enters AWAITING_PARTNER |
subscriptionId, customerId, protocolId, confirmationToken (expires 48 hours after issuance), expiresAt, amountCents (integer cents to collect), lineItems[] (productId, name, quantity, customerPrice — name and customerPrice can be null) |
subscription.renewal_blocked |
A platform-billed renewal came due but can't be charged until its cause is fixed. Nothing was charged, the status is unchanged, and it is retried on later billing runs; the subscription shows renewalBlockedReason until then. Sent once per cause per billing cycle |
subscriptionId, customerId, protocolId, reason (customer_not_found, no_provider_customer, provider_customer_missing, no_payment_method, no_shipping_address, zero_total, protocol_not_found), message, nextBillingDate |
subscription.order_created |
A renewal order is generated. Platform renewals send it too, when the charge settles (confirmedBy: platform, billedNow: true) — as do your POST /v2/subscriptions/{subscriptionId}/confirm-renewal and a portal bill-now (confirmedBy: partner) |
One shape for every path: subscriptionId, customerId, protocolId, orderId, confirmedBy (platform/partner), billedNow, amountCharged (integer cents), nextBillingDate, externalPaymentReference — what a path cannot know is null |
subscription.payment_failed |
Every failed renewal payment attempt — each Juniper-managed decline (the first one included, and the terminal one, which subscription.cancelled with reason: dunning_exhausted follows), or you miss three consecutive partner-managed confirmation windows |
One shape for every cause: the subscription record plus reason (card_declined, authentication_required, partner_confirmation_timeout), attempt, maxAttempts, nextRetryAt (null on the last attempt), failureMessage, changedBy: dunning, initiatedBy: PLATFORM |
Every subscription lifecycle transition emits, whoever caused it. initiatedBy is PARTNER_API | PARTNER_PORTAL | CUSTOMER | PLATFORM; changedBy is the raw actor — a client id as partner:{clientId}, the partner user's email, the customer's id, or a platform name such as billing or dunning. Delivery is at-least-once; dedupe on the event id.
These events are derived from the subscription record itself, so they fire for changes your own API calls made too — subscribe to subscription.* and treat the event, not the response body, as the system of record. Every lifecycle payload carries changedBy, initiatedBy and reason; reason is null where the table above does not name one. There is no subscription.renewed event — a successful renewal is signaled by subscription.order_created (and the order's own order.created).
The four renewal outcomes make one set: subscription.renewal_blocked (could not be attempted), subscription.payment_failed (attempted and declined), subscription.payment_recovered (a later retry settled) and subscription.order_created (settled and produced an order). Subscribe to all four and you never have to poll a subscription to learn a card stopped working.
Customers¶
camelCase payload: {"customerId", "sourceId", "firstName", "lastName", "email", "phone", "createdAt", "updatedAt"}. See Customers.
| Event | Fires when |
|---|---|
customer.created |
POST /v2/customers succeeds |
customer.updated |
PUT /v2/customers/{customerId} succeeds |
customer.deleted |
DELETE /v2/customers/{customerId} succeeds; the payload is the customer's pre-delete state. The delete cascade also emits order.cancelled for each NEW order it cancels, but no subscription events |
Protocols¶
camelCase payloads. See Protocols.
| Event | Fires when |
|---|---|
protocol.created |
A protocol is created |
protocol.updated |
A protocol is updated via PUT /v2/protocols/{protocolId}. Regimen changes applied through the amendment flow signal via protocol_change_request.approved instead |
protocol.deactivated |
A protocol is deactivated (its subscriptions, checkout links and pending changes are cancelled) |
protocol.reactivated |
A protocol is reactivated |
protocol.archived |
A protocol is archived via DELETE /v2/protocols/{protocolId} or by the daily archiver (60 days inactive) |
Amendments¶
Amendment events keep legacy protocol_change_request.* names for wire continuity, but their payloads are camelCase like everything else: changeRequestId and amendmentId (same value under both keys), protocolId, subscriptionId, status, changes[], authoredBy, createdAt, and the approvedBy/approvedAt/rejectedBy/rejectedAt fields. See Protocols.
| Event | Fires when |
|---|---|
protocol_change_request.created |
An amendment is sent for approval (draft → pending) — including sends that auto-approve, and consent amendments raised automatically by scheduled changes |
protocol_change_request.approved |
The amendment is approved — by the customer on the review page, by you via the approve endpoint, or automatically during an attested/no-subscription send |
protocol_change_request.rejected |
The amendment is rejected, by the customer or by you |
protocol_change_request.cancelled |
You cancel a draft or pending amendment via the DELETE endpoint. The 45-day automatic lapse of an unanswered amendment does not emit an event — poll the amendment if you need to observe it |
Authoring a draft emits no event; the lifecycle starts at send.
Refunds¶
camelCase payload: {"refundId", "orderId", "amount", "refundType", "status", "reason"} (amount in integer cents). See Refunds.
| Event | Fires when |
|---|---|
refund.created |
A refund is created against one of your orders; the refund is PROCESSING |
refund.completed |
The refund settles with the payment processor |
refund.failed |
The refund failed: rejected when created, or reported failed later (even days after refund.completed). Adds failureReason to the payload |
Storefront contact¶
| Event | Fires when |
|---|---|
contact.received |
A visitor submits the contact form on your hosted storefront. camelCase payload: {"id", "partnerId", "fields", "page", "receivedAt"} — fields is the submitted form fields as a key–value object |
Building a reliable handler¶
- Respond 2xx fast. You have 10 seconds, and a slow endpoint burns retry attempts. Verify the signature, persist the raw event, return
200, and process asynchronously. Never do your business logic inline before responding. - Deduplicate on the event
id. Delivery is at-least-once: keep a store of processedevt_…ids and skip duplicates. Retries carry the sameid(and a differentX-Juniper-Webhook-Id). - Fetch on event. Payloads are thin and unordered delivery means a payload can describe a state the resource has already moved past. Treat the event as "something changed about resource X", then
GETthe resource for current truth before acting. - Don't depend on ordering. Two events for the same order can arrive out of order. If you track state, ignore events whose
data.updatedAtis older than what you have stored, or simply refetch. - Handle unknown types gracefully. Return 2xx for event types you don't recognize (including
test.webhook); new types may be added over time, and non-2xx responses just trigger useless retries. - Plan for gaps. After 5 failed attempts an event is gone from the queue. If your endpoint was down for an extended period, reconcile by listing recent resources (
GET /v2/orders?limit=..., etc.) rather than assuming webhooks are complete, and use the portal's delivery history to re-send recent failures.
Good to know¶
- Casing is camelCase everywhere, in webhook payloads exactly as in REST responses. Event type names keep their own spelling (
subscription.order_created,protocol_change_request.created) — those are identifiers, not JSON keys. createdis unix seconds;datadatetimes are ISO-8601 with a+00:00offset. API responses use theZsuffix — parse both forms.- Managed endpoint management is portal-only today. Your v2 API key cannot create or edit webhook endpoints; the only webhook operations on the API are the legacy-channel secret endpoints under
/v2/partners/settings. - Legacy secret rotation is immediate.
POST /v2/partners/settings/rotate-webhook-secretinvalidates the old secret the moment the call returns — be ready to deploy the new secret to your verifier at once, or accept either secret briefly during the switch. - Webhooks fire regardless of what triggered the change. An order created from the portal, a checkout page, or a renewal emits the same
order.createdyour API-created orders do. Filter on your own correlation ids (e.g. customersourceId) if you only care about a subset. - Sandbox works the same — register sandbox endpoints in the sandbox portal and test the full signing pipeline there. Flows that depend on real payment settlement (checkout completion,
refund.completed) do not run end-to-end in sandbox, so drive test coverage of those handlers with the portal's test event and recorded payloads.