# Webhooks

Juniper sends real-time notifications to your server when order status changes. Configure your webhook URL in [Partner Settings](/apis/#tag/Partner-Settings) to receive these updates automatically.

## Configuration

Set your webhook endpoint using the Partner Settings API:


```bash
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:


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


```json
{
  "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

| Header | Description |
|  --- | --- |
| `X-Juniper-Signature` | Signature in format `t={timestamp},v1={signature}` |
| `X-Juniper-Webhook-Id` | Unique 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


```python
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


```javascript
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:


```bash
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:


```json
{
  "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

| Field | Type | Description |
|  --- | --- | --- |
| `order.orderId` | string | Unique order identifier |
| `order.status` | string | Current order status (see [Status Events](#status-events)) |
| `order.fulfillmentInfo` | object | Shipping details (present when shipped) |
| `order.fulfillmentInfo.carrier` | string | Shipping carrier name (e.g., "UPS", "USPS", "FedEx") |
| `order.fulfillmentInfo.trackingNumber` | string | Carrier tracking number |
| `order.fulfillmentInfo.trackingStatus` | string | Current tracking status |
| `order.fulfillmentInfo.trackingUrl` | string | URL to track the shipment |
| `order.fulfillmentInfo.checkpoints` | array | Tracking checkpoint history |
| `order.createdAt` | string | Order creation timestamp (ISO 8601) |
| `order.updatedAt` | string | Order last update timestamp (ISO 8601) |


### Checkpoint Fields

| Field | Type | Description |
|  --- | --- | --- |
| `checkpointTime` | string | Timestamp of the checkpoint event (ISO 8601) |
| `location` | string | Location of the shipment at this checkpoint |
| `message` | string | Status message from the carrier |
| `tag` | string | Status tag (e.g., "InTransit", "OutForDelivery", "Delivered") |


## Status Events

Webhooks are triggered for the following order status changes:

| Status | Description |
|  --- | --- |
| `NEW` | Order received and validated |
| `PROCESSING` | Order is being prepared |
| `DISPENSING` | Order is being dispensed/filled |
| `FILLED` | Order has been filled |
| `FULFILLED` | Order fulfillment complete |
| `AWAITING_SHIPMENT` | Order is packaged and ready to ship |
| `SHIPPED` | Order has shipped (includes `fulfillmentInfo` with tracking) |
| `CANCELLED` | Order 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](/apis/#operation/getOrder) endpoint.