Skip to content
Last updated

Juniper sends real-time notifications to your server when order status changes. Configure your webhook URL in Partner Settings to receive these updates automatically.


Configuration

Set your webhook endpoint using the Partner Settings API:

curl -X PATCH https://api.fulfillment.sandbox.juniperhealth.com/v1/partners/settings \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "webhookUrl": "https://your-server.com/webhooks/juniper"
  }'

When you set a webhook URL for the first time, Juniper automatically generates a webhook secret for signature verification. Retrieve your secret using the dedicated endpoint:

curl -X GET https://api.fulfillment.sandbox.juniperhealth.com/v1/partners/settings/webhook-secret \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
{
  "webhookSecret": "8f4b2a1c9e7d3f5a6b8c0d2e4f6a8b0c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a"
}

The secret is not included in the general /partners/settings response for security reasons.


Signature Verification

All webhooks are signed using HMAC-SHA256. Verify signatures to ensure requests originate from Juniper and haven't been tampered with.

Request Headers

HeaderDescription
X-Juniper-SignatureSignature in format t={timestamp},v1={signature}
X-Juniper-Webhook-IdUnique identifier for this webhook delivery

Verification Steps

  1. Extract the timestamp (t) and signature (v1) from the X-Juniper-Signature header
  2. Construct the signed payload: {timestamp}.{request_body}
  3. Compute HMAC-SHA256 of the signed payload using your webhook secret
  4. Compare your computed signature with the v1 value
  5. Reject if timestamp is older than 5 minutes (prevents replay attacks)

Python Example

import hmac
import hashlib
import time

def verify_webhook_signature(payload: bytes, signature_header: str, secret: str) -> bool:
    """
    Verify a Juniper webhook signature.

    Args:
        payload: Raw request body bytes
        signature_header: Value of X-Juniper-Signature header
        secret: Your webhook secret (64 hex characters)

    Returns:
        True if signature is valid, False otherwise
    """
    # Parse header: t=timestamp,v1=signature
    parts = dict(part.split('=', 1) for part in signature_header.split(','))
    timestamp = int(parts['t'])
    signature = parts['v1']

    # Reject if timestamp is older than 5 minutes
    if abs(time.time() - timestamp) > 300:
        return False

    # Compute expected signature
    signed_payload = f"{timestamp}.{payload.decode()}"
    expected = hmac.new(
        secret.encode(),
        signed_payload.encode(),
        hashlib.sha256
    ).hexdigest()

    # Use constant-time comparison
    return hmac.compare_digest(signature, expected)


# Flask example
@app.route('/webhooks/juniper', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Juniper-Signature')

    if not verify_webhook_signature(request.data, signature, WEBHOOK_SECRET):
        return 'Invalid signature', 401

    # Process the webhook...
    return 'OK', 200

Node.js Example

const crypto = require('crypto');

function verifyWebhookSignature(payload, signatureHeader, secret) {
  // Parse header: t=timestamp,v1=signature
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('='))
  );
  const timestamp = parseInt(parts.t, 10);
  const signature = parts.v1;

  // Reject if timestamp is older than 5 minutes
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
    return false;
  }

  // Compute expected signature
  const signedPayload = `${timestamp}.${payload}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Use constant-time comparison
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// Express example
app.post('/webhooks/juniper', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-juniper-signature'];

  if (!verifyWebhookSignature(req.body.toString(), signature, WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  // Process the webhook...
  res.status(200).send('OK');
});

Rotating Your Secret

If your webhook secret may have been compromised, rotate it immediately:

curl -X POST https://api.fulfillment.sandbox.juniperhealth.com/v1/partners/settings/rotate-webhook-secret \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

The old secret becomes invalid immediately. Update your webhook handler with the new secret from the response.


Webhook Payload

When an order status changes, Juniper sends an HTTP POST request to your webhook URL with the following payload structure:

{
  "order": {
    "orderId": "2vSGym0bH8qVEwCIGlyFoRgJq1A",
    "status": "SHIPPED",
    "fulfillmentInfo": {
      "carrier": "UPS",
      "trackingNumber": "1Z9999999999999999",
      "trackingStatus": "InTransit",
      "trackingUrl": "https://www.ups.com/track?tracknum=1Z9999999999999999",
      "checkpoints": [
        {
          "checkpointTime": "2023-10-02T08:00:00Z",
          "location": "Los Angeles, CA, USA",
          "message": "Shipment picked up",
          "tag": "InTransit"
        },
        {
          "checkpointTime": "2023-10-03T14:00:00Z",
          "location": "Chicago, IL, USA",
          "message": "Package arrived at facility",
          "tag": "InTransit"
        }
      ]
    },
    "createdAt": "2023-10-01T12:00:00Z",
    "updatedAt": "2023-10-05T14:30:00Z"
  }
}

Payload Fields

FieldTypeDescription
order.orderIdstringUnique order identifier
order.statusstringCurrent order status (see Status Events)
order.fulfillmentInfoobjectShipping details (present when shipped)
order.fulfillmentInfo.carrierstringShipping carrier name (e.g., "UPS", "USPS", "FedEx")
order.fulfillmentInfo.trackingNumberstringCarrier tracking number
order.fulfillmentInfo.trackingStatusstringCurrent tracking status
order.fulfillmentInfo.trackingUrlstringURL to track the shipment
order.fulfillmentInfo.checkpointsarrayTracking checkpoint history
order.createdAtstringOrder creation timestamp (ISO 8601)
order.updatedAtstringOrder last update timestamp (ISO 8601)

Checkpoint Fields

FieldTypeDescription
checkpointTimestringTimestamp of the checkpoint event (ISO 8601)
locationstringLocation of the shipment at this checkpoint
messagestringStatus message from the carrier
tagstringStatus tag (e.g., "InTransit", "OutForDelivery", "Delivered")

Status Events

Webhooks are triggered for the following order status changes:

StatusDescription
NEWOrder received and validated
PROCESSINGOrder is being prepared
DISPENSINGOrder is being dispensed/filled
FILLEDOrder has been filled
FULFILLEDOrder fulfillment complete
AWAITING_SHIPMENTOrder is packaged and ready to ship
SHIPPEDOrder has shipped (includes fulfillmentInfo with tracking)
CANCELLEDOrder was cancelled

Best Practices

  1. Verify signatures - Always verify the X-Juniper-Signature header to ensure authenticity
  2. Return 2xx quickly - Respond with a 2xx status code within 30 seconds to acknowledge receipt
  3. Process asynchronously - Queue webhook payloads for background processing
  4. Handle idempotently - The same event may be sent multiple times; use orderId and updatedAt to deduplicate
  5. Check timestamps - Reject webhooks with timestamps older than 5 minutes to prevent replay attacks

Retry Policy

If your endpoint doesn't respond with a 2xx status code, Juniper will retry the webhook:

  • Retry attempts: 3
  • Retry intervals: 1 minute, 5 minutes, 30 minutes
  • Timeout: 30 seconds per attempt

After all retries are exhausted, the webhook is marked as failed. You can retrieve missed updates by polling the Get Order endpoint.