Sandbox

Simulate webhook events

Sandbox /simulate — partner integration guide

This guide walks through testing your webhook integration with Wyndly using the sandbox /simulate endpoint. It assumes you've already configured a webhook URL on the partner dashboard and you have a sandbox API key.

Audience: developers integrating with the Wyndly partner API for the first time. Sandbox base URL: https://sandbox-api.wyndly.com


Table of contents

  1. What does /simulate do?
  2. Before you start
  3. Quick start: fire the full lifecycle
  4. Targeting a single event
  5. Scenarios
  6. End-to-end testing with persist=true
  7. Request body reference
  8. Response shape
  9. Error codes
  10. Common recipes
  11. Python example
  12. Troubleshooting

What does /simulate do?

POST /api/v1/sandbox/webhooks/simulate fires one or more partner webhook events at your configured webhook URL(s), with realistic payloads. It lets you:

  • Verify your webhook receiver handles each event type without waiting for real patient activity.
  • Test HMAC signature verification on the receiving side.
  • Drive scenario-based payload values (e.g., "prescription expired", "1 refill left") to exercise different code paths in your integration.
  • Optionally seed real patient + prescription rows in the sandbox database so the IDs returned by /simulate can be passed straight into other endpoints like /shipments/request.

The endpoint is sandbox-only. Calling it against https://api.wyndly.com (production) returns 403 SANDBOX_ONLY.


Before you start

You need:

  1. A sandbox API key. Use this in the X-Api-Key header.
  2. At least one active webhook URL configured on your partner record. /simulate returns 404 NO_WEBHOOK_CONFIGURED if there is none.
  3. A webhook receiver that responds with 2xx. A test service like webhook.site works well for ad-hoc testing.

To configure a webhook URL in sandbox:

curl -X POST "https://sandbox-api.wyndly.com/api/v1/webhooks" \
  -H "X-Api-Key: $WYNDLY_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_url": "https://webhook.site/your-unique-token",
    "webhook_secret": "any-string-you-pick"
  }'

The webhook_secret is used to HMAC-sign every webhook delivery so your receiver can verify authenticity. You'll see the signature in the X-Webhook-Signature header.


Quick start: fire the full lifecycle

The simplest possible call — no body, no flags — fires the full happy-path lifecycle of four events in order:

curl -X POST "https://sandbox-api.wyndly.com/api/v1/sandbox/webhooks/simulate" \
  -H "X-Api-Key: $WYNDLY_SANDBOX_API_KEY"

The events fire in this order:

  1. recommendation_ready — clinician approved a treatment plan; partner can present it to the patient.
  2. ready_for_shipment — prescription approved; partner can request a shipment.
  3. shipment_shipped — kit dispatched; carries the tracking number + carrier so you can notify the member and stamp tracking.
  4. shipment_delivered — carrier confirmed the member received their medication.

A successful response looks like:

{
  "success": true,
  "webhook_urls": ["https://webhook.site/your-unique-token"],
  "results": [
    { "event_type": "recommendation_ready", "success": true, "status_code": 200, "payload": {...}, "..." },
    { "event_type": "ready_for_shipment",   "success": true, "status_code": 200, "payload": {...}, "..." },
    { "event_type": "shipment_shipped",     "success": true, "status_code": 200, "payload": {...}, "..." },
    { "event_type": "shipment_delivered",   "success": true, "status_code": 200, "payload": {...}, "..." }
  ],
  "context": {
    "event_types_fired": ["recommendation_ready", "ready_for_shipment", "shipment_shipped", "shipment_delivered"],
    "scenario": "rx_valid",
    "patient_id": 4242,
    "prescription_id": 9876,
    "shipment_id": "sandbox-ord-a1b2c3d4e5f6",
    "diagnostic_request_id": "test-9c8f6a18-...",
    "persisted": false
  },
  "message": "Delivered 4 webhook event(s) to 1 endpoint(s)"
}

The results array tells you each delivery's outcome (status code, success flag). The context object surfaces the IDs threaded through every fired event — useful for chaining downstream calls (more on that below).


Targeting a single event

When you're iterating on one specific event handler, fire just that event by setting event_type:

curl -X POST "https://sandbox-api.wyndly.com/api/v1/sandbox/webhooks/simulate" \
  -H "X-Api-Key: $WYNDLY_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "shipment_cancelled"}'

Valid event_type values:

EventWhat it represents
recommendation_readyTreatment plan approved by clinical team.
treatment_declinedDiagnostic completed but no treatment is recommended. Fires instead of recommendation_ready in production. reason is no_positive_results_and_no_reported_symptoms (nothing recommended and no reported triggers/seasons) or no_positive_lab_results (a partner requiring a validated lab result had a negative lab panel, even if symptoms were reported).
ready_for_shipmentPrescription approved — partner can call /shipments/request.
shipment_shippedKit dispatched / in transit (status: in_transit). Carries tracking number + carrier.
shipment_deliveredCarrier confirmed delivery (terminal status: delivered). Formerly named shipment_shipped.
shipment_cancelledShipment did not complete (terminal status: cancelled — returned/failed/expired).
rx_renewal_neededPrescription is exhausted or expiring soon. Partner should re-engage the clinician.

Companion rx_renewal_needed events

When you target a terminal shipment event (shipment_delivered or shipment_cancelled) and the scenario indicates renewal is needed, /simulate automatically fires a companion rx_renewal_needed event right after the terminal. This mirrors what production does — your receiver should be prepared to handle the pair. The dispatch event (shipment_shipped) never carries a renewal companion — it's tracking-only.

Example:

curl -X POST "https://sandbox-api.wyndly.com/api/v1/sandbox/webhooks/simulate" \
  -H "X-Api-Key: $WYNDLY_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "shipment_delivered", "scenario": "needs_renewal"}'

You'll see two results in the response: shipment_delivered followed by rx_renewal_needed, both carrying the same patient_id.


Scenarios

Scenarios control the prescription-state values inside the fired payloads (and the database row state if you use persist=true — see below).

Scenariorefills_remainingdays_until_expiryWhat it tests
rx_valid4~365Happy path. Fresh prescription, plenty of refills. Default if you omit scenario.
refills_low1~335Patient has one refill left, no renewal needed yet. Tests "low refill" UX without renewal flow.
needs_renewal0~265Refills exhausted. Triggers needs_renewal=true in the payload and a companion rx_renewal_needed event.
rx_expired0< 0Prescription past its 366-day window. rx_valid=false and /shipments/request returns rx_expired.

Use the scenario field whenever you want to test how your code reacts to different prescription states. For example, your "needs renewal" UI should appear when the payload's rx_renewal_needed field is true:

# Trigger the renewal banner
curl -X POST "https://sandbox-api.wyndly.com/api/v1/sandbox/webhooks/simulate" \
  -H "X-Api-Key: $WYNDLY_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "shipment_delivered", "scenario": "needs_renewal"}'

End-to-end testing with persist=true

By default /simulate is a "pure" webhook firehose — the IDs in the payloads are synthetic and only useful for testing your receiver. If you want to chain calls (e.g., call /shipments/request with a patient_id from a ready_for_shipment payload), set persist: true and /simulate will create real rows in the sandbox database so the IDs work end-to-end.

The flow

┌──────────────────────────────┐
│  POST /simulate              │
│  { event_type: ready_for…,   │ ──── creates sandbox rows
│    scenario: rx_valid,       │       (patients, prescriptions,
│    persist: true }           │        backfill drops_orders)
└──────────────┬───────────────┘
               │
               │ returns context.patient_id, context.prescription_id
               ▼
┌──────────────────────────────┐
│  POST /shipments/request     │
│  { patient_id, prescription_id }
└──────────────┬───────────────┘
               │
               │ returns order_id (the real shipment_id)
               ▼
┌──────────────────────────────┐
│  POST /simulate              │
│  { event_type: shipment_…,   │
│    patient_id,               │
│    prescription_id,          │
│    shipment_id (the real one)│
└──────────────────────────────┘

Step 1 — seed the patient and prescription

curl -X POST "https://sandbox-api.wyndly.com/api/v1/sandbox/webhooks/simulate" \
  -H "X-Api-Key: $WYNDLY_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "ready_for_shipment",
    "scenario": "rx_valid",
    "persist": true
  }'

Grab context.patient_id and context.prescription_id from the response. Those rows now exist in sandbox.patients and sandbox.prescriptions.

Step 2 — request a real shipment

curl -X POST "https://sandbox-api.wyndly.com/api/v1/shipments/request" \
  -H "X-Api-Key: $WYNDLY_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "patient_id": <from step 1>,
    "prescription_id": <from step 1>
  }'

This goes through the real refill logic. Returns order_id — that's the real shipment_id for the next step.

Step 3 — fire the terminal event with the real shipment id

curl -X POST "https://sandbox-api.wyndly.com/api/v1/sandbox/webhooks/simulate" \
  -H "X-Api-Key: $WYNDLY_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "shipment_delivered",
    "patient_id": <from step 1>,
    "prescription_id": <from step 1>,
    "shipment_id": <order_id from step 2>
  }'

Now your shipment_delivered handler sees a shipment_id that exists in the sandbox database — useful if your integration looks up shipments by ID after receiving the webhook. (The dispatch shipment_shipped event works the same way; swap the event_type.)

What persist mode actually writes

When persist=true:

  • sandbox.patients row created (auto-assigned id; bound to your partner_id from the API key).
  • sandbox.prescriptions row created with date_approved matching the scenario.
  • sandbox.drops_orders backfill rows added when the scenario implies prior refills used (3 rows for refills_low, 4 for needs_renewal/rx_expired, 0 for rx_valid). These are needed because refill counts are derived from COUNT(drops_orders), not a column on prescriptions.

The endpoint never writes the terminal shipment row itself — that's what /shipments/request is for in step 2.

What persist mode does NOT support

  • Choosing the exact id for a brand-new patient/prescription. The columns are GENERATED ALWAYS AS IDENTITY. If you pass patient_id: 9999 and that row doesn't exist, /simulate creates a row with a database-assigned id (not 9999) and returns the new id in context. If 9999 exists and belongs to your partner, it's reused as-is.
  • Mutating existing rows to match a scenario. If you pass a prescription_id that exists but whose state doesn't match the requested scenario, you get 409 SCENARIO_MISMATCH.
  • Using persist=true with event_type: "treatment_declined". A declined treatment has no prescription or shipment flow to seed, so this returns 400 PERSIST_UNSUPPORTED_FOR_EVENT.
  • Running outside the sandbox stage. persist=true requires stage=sandbox (the only stage with the right search_path). Calling it elsewhere returns 400 PERSIST_REQUIRES_SANDBOX_STAGE.

Request body reference

All fields are optional. The endpoint also accepts an empty body or no body at all.

FieldTypeDefaultDescription
event_typestring— (fires full lifecycle)One of recommendation_ready, treatment_declined, ready_for_shipment, shipment_shipped, shipment_delivered, shipment_cancelled, rx_renewal_needed. Omit to fire the full lifecycle recommendation_ready → ready_for_shipment → shipment_shipped → shipment_delivered.
scenariostringrx_validOne of rx_valid, refills_low, needs_renewal, rx_expired. Drives payload values and (when persist=true) DB row state.
patient_idintegerauto-generated syntheticUse a specific patient_id. With persist=true, this must belong to your partner if it exists; otherwise a new row is created with an auto-assigned id.
prescription_idintegerauto-generated syntheticUse a specific prescription_id. Same ownership/identity rules as patient_id.
shipment_idstringauto-generated syntheticUse a specific shipment_id (e.g., one you got back from /shipments/request).
diagnostic_request_idstringauto-generated syntheticUse a specific diagnostic_request_id.
persistbooleanfalseWhen true, write rows into the sandbox schema so the returned IDs work against other endpoints. Sandbox stage only.

Response shape

{
  "success": true,
  "webhook_urls": ["https://..."],
  "results": [
    {
      "webhook_id": 1,
      "webhook_url": "https://...",
      "event_type": "ready_for_shipment",
      "success": true,
      "status_code": 200,
      "message": "Delivered successfully to https://... (status 200)",
      "payload": { /* the exact payload that was POSTed to your URL */ }
    }
  ],
  "context": {
    "event_types_fired": ["ready_for_shipment"],
    "scenario": "rx_valid",
    "patient_id": 4242,
    "prescription_id": 9876,
    "shipment_id": "sandbox-ord-...",
    "diagnostic_request_id": "test-...",
    "persisted": false
  },
  "message": "Delivered 1 webhook event(s) to 1 endpoint(s)"
}
FieldDescription
successtrue only if all deliveries succeeded (every result has success=true).
webhook_urlsList of the active webhook URLs this call sent to.
resultsOne entry per (webhook URL, event) pair. Each entry shows the exact payload, delivery status, and the webhook receiver's HTTP response code.
contextThe IDs and scenario used. Copy patient_id/prescription_id/shipment_id from here into follow-up calls.
messageHuman-readable summary.

Important: if results[].success is false for any entry, success at the top level is false too — but the HTTP status is still 200. You'll see the failure reason in message. The endpoint only returns 4xx/5xx for errors in the request itself, not delivery failures at the partner end.


Error codes

HTTPerror_codeWhy
400PERSIST_REQUIRES_SANDBOX_STAGEpersist=true was used outside the sandbox stage.
400PERSIST_UNSUPPORTED_FOR_EVENTpersist=true was used with event_type: "treatment_declined", which has no prescription or shipment flow to seed.
401INVALID_API_KEYAPI key is missing, unknown, or inactive.
403SANDBOX_ONLYCalled outside dev/sandbox. The endpoint is not available in staging or production.
403PATIENT_NOT_OWNEDpersist=true with a patient_id that belongs to a different partner.
403RX_NOT_OWNEDpersist=true with a prescription_id that's not tied to the resolved patient.
404NO_WEBHOOK_CONFIGUREDYour partner record has no active webhook URLs.
409SCENARIO_MISMATCHProvided prescription_id exists but its live state doesn't match the requested scenario. Pass a different id or omit it.
422(Pydantic)Body validation error (e.g., unknown event_type or scenario).
500SANDBOX_NOT_PROVISIONEDThe sandbox schema is missing required tables. Should not happen in normal operation.
500INTERNAL_ERRORUnexpected server error. Check CloudWatch logs or contact Wyndly.

Common recipes

Test your recommendation_ready handler in isolation

curl -X POST "https://sandbox-api.wyndly.com/api/v1/sandbox/webhooks/simulate" \
  -H "X-Api-Key: $WYNDLY_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "recommendation_ready"}'

Test your treatment_declined handler

In production this event fires instead of recommendation_ready when no treatment is recommended — either the patient tested negative and reported no triggers/seasons (reason: no_positive_results_and_no_reported_symptoms), or, for a partner configured to require a validated lab result, the lab panel showed no positive allergen even if symptoms were reported (reason: no_positive_lab_results). There is no treatment plan and no JotForm link.

curl -X POST "https://sandbox-api.wyndly.com/api/v1/sandbox/webhooks/simulate" \
  -H "X-Api-Key: $WYNDLY_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "treatment_declined"}'

Verify your receiver shows "1 refill left" warnings

curl -X POST "https://sandbox-api.wyndly.com/api/v1/sandbox/webhooks/simulate" \
  -H "X-Api-Key: $WYNDLY_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "shipment_delivered", "scenario": "refills_low"}'

Verify your receiver handles the renewal-needed companion event

curl -X POST "https://sandbox-api.wyndly.com/api/v1/sandbox/webhooks/simulate" \
  -H "X-Api-Key: $WYNDLY_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "shipment_delivered", "scenario": "needs_renewal"}'

Your receiver should see two POSTs: a shipment_delivered followed by an rx_renewal_needed.

Verify your /shipments/request retry logic when the prescription is expired

# 1) Seed an expired prescription
curl -X POST "https://sandbox-api.wyndly.com/api/v1/sandbox/webhooks/simulate" \
  -H "X-Api-Key: $WYNDLY_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "ready_for_shipment", "scenario": "rx_expired", "persist": true}'

# Note the patient_id/prescription_id from the response.

# 2) Try to request a shipment — should fail with 409 rx_expired
curl -X POST "https://sandbox-api.wyndly.com/api/v1/shipments/request" \
  -H "X-Api-Key: $WYNDLY_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"patient_id": <from step 1>, "prescription_id": <from step 1>}'

Python example

import os
import requests

BASE_URL = "https://sandbox-api.wyndly.com"
API_KEY = os.environ["WYNDLY_SANDBOX_API_KEY"]

def simulate(**body):
    response = requests.post(
        f"{BASE_URL}/api/v1/sandbox/webhooks/simulate",
        headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
        json=body,
    )
    response.raise_for_status()
    return response.json()


# Quick smoke test: fire the full lifecycle.
result = simulate()
print(f"Fired: {result['context']['event_types_fired']}")
print(f"Patient ID used: {result['context']['patient_id']}")


# Test a single event with a scenario.
result = simulate(event_type="shipment_cancelled", scenario="needs_renewal")
events = [r["event_type"] for r in result["results"]]
print(f"Fired: {events}")  # ['shipment_cancelled', 'rx_renewal_needed']


# End-to-end: persist → /shipments/request → terminal event.
seed = simulate(event_type="ready_for_shipment", scenario="rx_valid", persist=True)
patient_id = seed["context"]["patient_id"]
prescription_id = seed["context"]["prescription_id"]

ship = requests.post(
    f"{BASE_URL}/api/v1/shipments/request",
    headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
    json={"patient_id": patient_id, "prescription_id": prescription_id},
)
ship.raise_for_status()
shipment_id = ship.json()["order_id"]

simulate(
    event_type="shipment_delivered",
    patient_id=patient_id,
    prescription_id=prescription_id,
    shipment_id=shipment_id,
)
print(f"Fired terminal event for real shipment {shipment_id}")

Troubleshooting

My webhook receiver isn't getting any POSTs. Check results[] in the response. If success is false and the error is Request failed: ..., your URL is unreachable. If status_code is 4xx or 5xx, your receiver responded but rejected the POST.

I'm getting 404 NO_WEBHOOK_CONFIGURED. You haven't configured a webhook URL on your partner record. See Before you start.

I'm getting 422 even though my body looks right. Check spelling on event_type and scenario. The valid values are listed in Targeting a single event and Scenarios — anything else is rejected by validation.

persist=true is returning SCENARIO_MISMATCH. You passed a prescription_id that exists but whose live state doesn't match the requested scenario. The endpoint never silently mutates existing data. Either pick a different scenario, pick a different prescription_id, or omit prescription_id entirely so a fresh row is created.

My follow-up call to /shipments/request returns rx_expired even though I passed scenario: rx_valid. Make sure you used persist=true on the /simulate call that returned the IDs. Without persist=true, the IDs are synthetic and don't exist in the database.

I want to clean up my test data. Sandbox is throwaway by convention — there is no cleanup endpoint. Test rows accumulate over time and are wiped periodically by Wyndly. Don't write integration tests that assume a clean schema.

post/api/v1/sandbox/webhooks/simulate

Request

  • Base URL: https://api.wyndly.com (the document declares no server; this is the origin it was published from)
  • URL: https://api.wyndly.com/api/v1/sandbox/webhooks/simulate
  • Auth: none declared. Required headers: x-api-key

Headers

x-api-keystring required

API Key for partner identification

Request body

event_type'recommendation_ready' | 'treatment_declined' | 'ready_for_shipment' | 'shipment_shipped' | 'shipment_delivered' | 'shipment_cancelled' | 'rx_renewal_needed'

Webhook event types for partner notifications

scenario'rx_valid' | 'refills_low' | 'needs_renewal' | 'rx_expired'

Rx-state scenarios for sandbox /simulate.

patient_idinteger nullable

Use this patient_id instead of a generated one.

prescription_idinteger nullable

Use this prescription_id instead of a generated one.

shipment_idstring nullable

Use this shipment_id (drops_orders.id) instead of a generated one.

diagnostic_request_idstring nullable

Use this diagnostic_request_id instead of a generated one.

persistboolean

When true, write sandbox.patients + sandbox.prescriptions (+ backfill sandbox.drops_orders) so the returned IDs work against /shipments/request.

Response

Successful Response

successboolean required

Whether all webhooks were delivered successfully

webhook_urlsstring[] required

Webhook URLs the events were sent to

messagestring required

Overall success or error message

Example response

{
  "results": [
    {
      "payload": {
        "data": {
          "diagnostic_request_id": "test-abc123",
          "jotform_link": "https://form.jotform.com/example?prefilled=test_data",
          "mixes": [
            "Tree Mix",
            "Mold Mix",
            "Cat"
          ],
          "patient_id": 12345
        },
        "event_type": "recommendation_ready",
        "timestamp": "2026-04-29T10:30:00Z"
      }
    }
  ]
}

Changes

No recorded changes to this operation across all 1 revision of this API.