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
- What does /simulate do?
- Before you start
- Quick start: fire the full lifecycle
- Targeting a single event
- Scenarios
- End-to-end testing with persist=true
- Request body reference
- Response shape
- Error codes
- Common recipes
- Python example
- 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:
- A sandbox API key. Use this in the X-Api-Key header.
- At least one active webhook URL configured on your partner record. /simulate returns 404 NO_WEBHOOK_CONFIGURED if there is none.
- 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:
- recommendation_ready — clinician approved a treatment plan; partner can present it to the patient.
- ready_for_shipment — prescription approved; partner can request a shipment.
- shipment_shipped — kit dispatched; carries the tracking number + carrier so you can notify the member and stamp tracking.
- 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:
| Event | What it represents |
|---|---|
| recommendation_ready | Treatment plan approved by clinical team. |
| treatment_declined | Diagnostic 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_shipment | Prescription approved — partner can call /shipments/request. |
| shipment_shipped | Kit dispatched / in transit (status: in_transit). Carries tracking number + carrier. |
| shipment_delivered | Carrier confirmed delivery (terminal status: delivered). Formerly named shipment_shipped. |
| shipment_cancelled | Shipment did not complete (terminal status: cancelled — returned/failed/expired). |
| rx_renewal_needed | Prescription 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).
| Scenario | refills_remaining | days_until_expiry | What it tests |
|---|---|---|---|
| rx_valid | 4 | ~365 | Happy path. Fresh prescription, plenty of refills. Default if you omit scenario. |
| refills_low | 1 | ~335 | Patient has one refill left, no renewal needed yet. Tests "low refill" UX without renewal flow. |
| needs_renewal | 0 | ~265 | Refills exhausted. Triggers needs_renewal=true in the payload and a companion rx_renewal_needed event. |
| rx_expired | 0 | < 0 | Prescription 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.
| Field | Type | Default | Description |
|---|---|---|---|
| event_type | string | — (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. |
| scenario | string | rx_valid | One of rx_valid, refills_low, needs_renewal, rx_expired. Drives payload values and (when persist=true) DB row state. |
| patient_id | integer | auto-generated synthetic | Use 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_id | integer | auto-generated synthetic | Use a specific prescription_id. Same ownership/identity rules as patient_id. |
| shipment_id | string | auto-generated synthetic | Use a specific shipment_id (e.g., one you got back from /shipments/request). |
| diagnostic_request_id | string | auto-generated synthetic | Use a specific diagnostic_request_id. |
| persist | boolean | false | When 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)"
}
| Field | Description |
|---|---|
| success | true only if all deliveries succeeded (every result has success=true). |
| webhook_urls | List of the active webhook URLs this call sent to. |
| results | One entry per (webhook URL, event) pair. Each entry shows the exact payload, delivery status, and the webhook receiver's HTTP response code. |
| context | The IDs and scenario used. Copy patient_id/prescription_id/shipment_id from here into follow-up calls. |
| message | Human-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
| HTTP | error_code | Why |
|---|---|---|
| 400 | PERSIST_REQUIRES_SANDBOX_STAGE | persist=true was used outside the sandbox stage. |
| 400 | PERSIST_UNSUPPORTED_FOR_EVENT | persist=true was used with event_type: "treatment_declined", which has no prescription or shipment flow to seed. |
| 401 | INVALID_API_KEY | API key is missing, unknown, or inactive. |
| 403 | SANDBOX_ONLY | Called outside dev/sandbox. The endpoint is not available in staging or production. |
| 403 | PATIENT_NOT_OWNED | persist=true with a patient_id that belongs to a different partner. |
| 403 | RX_NOT_OWNED | persist=true with a prescription_id that's not tied to the resolved patient. |
| 404 | NO_WEBHOOK_CONFIGURED | Your partner record has no active webhook URLs. |
| 409 | SCENARIO_MISMATCH | Provided 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). |
| 500 | SANDBOX_NOT_PROVISIONED | The sandbox schema is missing required tables. Should not happen in normal operation. |
| 500 | INTERNAL_ERROR | Unexpected 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.
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
API Key for partner identification
Request body
Response
Successful Response
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.