Skip to content

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 POST with a JSON body (Content-Type: application/json). Endpoint URLs must be https:// 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.shipped can beat an earlier order.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:

  1. Split the header on ,, then each part on =: t is the delivery timestamp (unix seconds), v1 is the signature (hex).
  2. Concatenate the signed payload: the timestamp, a literal ., and the raw request body — "{t}.{rawBody}".
  3. Compute HMAC-SHA256 of that string using your endpoint secret as the key.
  4. Compare the hex digest to v1 using a constant-time comparison.
  5. Reject stale timestamps: if t differs 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

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.webhook event 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-Signature scheme). The secret is generated automatically the first time you set webhookUrl; retrieve it with GET /v2/partners/settings/webhook-secret and rotate it with POST /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 processed evt_… ids and skip duplicates. Retries carry the same id (and a different X-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 GET the 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.updatedAt is 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.
  • created is unix seconds; data datetimes are ISO-8601 with a +00:00 offset. API responses use the Z suffix — 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-secret invalidates 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.created your API-created orders do. Filter on your own correlation ids (e.g. customer sourceId) 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.