Prescriber-Partner Integration Guide
📄 This guide is also available as a PDF download.
Version: 0.1 (Draft)
Date: March 2, 2026
Base URL: https://api.stealth.health (production) | https://sandbox.stealth.health (development)
Status: Proposal / Scoping
This guide describes a Prescriber Partner integration where the partner submits patient intake data via API, our licensed physicians review and prescribe, and the partner receives the full prescription outcome. The partner owns the patient relationship, intake experience, and (optionally) fulfillment.
1. Overview​
This integration is designed for partners who already have their own patient intake experience and need access to a licensed physician network for prescription review and signing. Rather than directing patients to our enrollment pages, the partner collects patient information and questionnaire responses in their own application, then submits the data to our API for clinical review.
How it works:
- Partner collects patient demographics and medical intake data through their own UI.
- Partner submits the appointment via API with structured patient and intake data.
- Our physicians review the intake and either approve (sign a prescription) or deny the case.
- Partner receives the outcome via webhook and/or polling — including full prescription details (medications, dosages, prescriber information).
- Partner either routes the prescription to their own pharmacy or requests that we route it to a pharmacy in our network.
The partner has full visibility into the clinical outcome, including prescription details, prescriber information, and denial reasons.
2. How This Differs from Other Integration Tiers​
| Referral Tier | Clinical Tier | Prescriber Tier (this guide) | |
|---|---|---|---|
| Patient intake | Our enrollment pages | Our enrollment pages | Partner's own UI |
| Who submits data | Patient directly | Patient directly | Partner via API |
| Doctor review | Our physicians | Our physicians | Our physicians |
| Prescription visibility | Status only (no PHI) | Full details | Full details + prescriber info |
| Pharmacy routing | We handle | We handle | Partner's choice (self or us) |
| Fulfillment & shipping | We handle | We handle | Partner handles (or us, optionally) |
| Payment model | Patient pays at enrollment | Patient pays at enrollment | Per-review fee billed to partner |
| PHI exposure | None | Full | Full |
| BAA required | Limited (referral source) | Full | Full |
3. HIPAA & Compliance Requirements​
Because this integration involves the partner sending and receiving protected health information (PHI), the following requirements apply:
3.1 Business Associate Agreement (BAA)​
A fully executed BAA is required before API credentials are issued. The BAA covers:
- Partner's obligations for storing, transmitting, and disposing of PHI.
- Breach notification procedures and timelines.
- Audit and inspection rights.
- Minimum necessary standard for data access.
3.2 Technical Requirements​
| Requirement | Details |
|---|---|
| Transport encryption | TLS 1.2+ required for all API traffic |
| At-rest encryption | AES-256 (or equivalent) for any PHI stored on partner systems |
| Access controls | Role-based access; PHI accessible only to authorized personnel |
| Audit logging | All PHI access must be logged with user, timestamp, and action |
| Retention | Audit logs retained for a minimum of 6 years |
| Disposal | Secure deletion of PHI when no longer needed |
3.3 Compliance Review​
Before going live, partners must:
- Complete a security questionnaire.
- Pass a compliance review.
- Agree to annual re-certification.
4. Authentication & Security​
4.1 API Keys​
Each partner receives a pair of credentials:
| Credential | Purpose |
|---|---|
X-Partner-ID | Identifies the partner (safe to log) |
X-Api-Key | Authenticates the request (secret — never log or expose client-side) |
Both headers are required on every request:
POST /partner/appointments HTTP/1.1
Host: api.stealth.health
X-Partner-ID: ptr_acme_health
X-Api-Key: sk_live_7f3a...redacted
Content-Type: application/json
4.2 Key Rotation​
Partners can request key rotation at any time. When a new key is issued, the previous key remains valid for 72 hours to allow migration.
4.3 Webhook Signature Verification​
All webhook payloads include an X-Stealth-Signature header containing an HMAC-SHA256 signature of the request body, signed with the partner's webhook secret (provided during onboarding, separate from the API key).
X-Stealth-Signature: sha256=a1b2c3d4e5f6...
Verification example (Node.js):
const crypto = require("crypto");
function verifySignature(body, signature, secret) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(JSON.stringify(body))
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
4.4 Transport Security​
- All API traffic must use TLS 1.2+.
- IP allowlisting is available on request.
- All requests are logged with partner ID, endpoint, timestamp, and response code.
5. Integration Flow​
5.1 Sequence Diagram​

5.2 Lifecycle Summary​
- Partner submits appointment →
pending - Doctor begins review →
in_review - Doctor approves →
doctor_reviewed(prescription details available) - Doctor denies →
rejected(denial reason available) - (Optional) Partner requests pharmacy routing →
routed→filled
6. API Reference — Submit Appointment​
6.1 POST /partner/appointments​
Submit a new appointment for physician review.
Request body:
{
"patient": {
"email": "jane.doe@example.com",
"first_name": "Jane",
"last_name": "Doe",
"date_of_birth": "1990-05-15",
"gender": "female",
"phone": "+15551234567",
"address": {
"line1": "123 Main St",
"line2": "Apt 4B",
"city": "Miami",
"state": "FL",
"zip": "33101",
"country": "US"
}
},
"condition": "trt-cream",
"intake_answers": [
{
"question_id": "current_medications",
"question_text": "What medications are you currently taking?",
"answer": "Lisinopril 10mg daily, Vitamin D 2000 IU"
},
{
"question_id": "allergies",
"question_text": "Do you have any known allergies?",
"answer": "No known allergies"
},
{
"question_id": "medical_history",
"question_text": "Relevant medical history",
"answer": "Hypertension, managed with medication. No surgeries."
},
{
"question_id": "current_symptoms",
"question_text": "What symptoms are you experiencing?",
"answer": "Fatigue, low energy, reduced libido, difficulty concentrating"
},
{
"question_id": "prior_treatments",
"question_text": "Have you tried any prior treatments?",
"answer": "No prior testosterone therapy"
},
{
"question_id": "recent_labs",
"question_text": "Recent lab results (if available)",
"answer": "Total T: 280 ng/dL, Free T: 5.2 pg/mL, CBC within normal limits"
}
],
"pharmacy_preference": "partner",
"partner_reference": "ACME-CASE-4821",
"metadata": {
"internal_case_id": "4821",
"provider_name": "Dr. Partner Physician"
}
}
Required fields:
| Field | Type | Description |
|---|---|---|
patient | object | Patient demographics (see schema below) |
patient.email | string | Patient email address |
patient.first_name | string | Patient first name |
patient.last_name | string | Patient last name |
patient.date_of_birth | string | ISO date (YYYY-MM-DD) |
patient.gender | string | male, female, or other |
patient.phone | string | Phone number with country code |
patient.address | object | Full address including state and country |
condition | string | Treatment category (see Products endpoint) |
intake_answers | array | Structured intake responses (see Section 10) |
partner_reference | string | Partner's internal reference for this case |
Optional fields:
| Field | Type | Default | Description |
|---|---|---|---|
pharmacy_preference | string | "partner" | "partner" (you handle Rx routing) or "stealth" (we route to our pharmacy network) |
preferred_pharmacy_id | string | null | Specific pharmacy ID from our network (only when pharmacy_preference = "stealth") |
metadata | object | {} | Arbitrary key-value pairs returned in webhooks and API responses |
Response (201 Created):
{
"appointment_id": "appt_f7c2e1a4b8d3",
"patient_id": "prof_a1b2c3d4e5f6",
"partner_reference": "ACME-CASE-4821",
"status": "pending",
"condition": "trt-cream",
"estimated_review_hours": 24,
"review_fee": {
"amount_cents": 4500,
"currency": "USD",
"status": "charged"
},
"created_at": "2026-03-02T14:30:00Z"
}
Error responses:
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing or invalid required fields |
| 400 | INVALID_INTAKE | Required intake questions missing or malformed |
| 400 | UNSUPPORTED_JURISDICTION | Patient's state/country not supported |
| 402 | PAYMENT_REQUIRED | Review fee charge failed |
| 409 | DUPLICATE_REFERENCE | partner_reference already exists for this partner |
7. API Reference — Appointments​
7.1 GET /partner/appointments​
List all appointments submitted by this partner.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status: pending, in_review, doctor_reviewed, rejected, completed |
condition | string | Filter by treatment category |
patient_email | string | Filter by patient email |
created_after | ISO datetime | Only appointments created after this time |
created_before | ISO datetime | Only appointments created before this time |
limit | integer | Results per page (default: 50, max: 200) |
cursor | string | Pagination cursor from previous response |
Response (200):
{
"appointments": [
{
"appointment_id": "appt_f7c2e1a4b8d3",
"patient_id": "prof_a1b2c3d4e5f6",
"partner_reference": "ACME-CASE-4821",
"condition": "trt-cream",
"status": "doctor_reviewed",
"has_prescription": true,
"pharmacy_preference": "partner",
"created_at": "2026-03-02T14:30:00Z",
"reviewed_at": "2026-03-03T09:15:00Z"
}
],
"pagination": {
"has_more": false,
"next_cursor": null
}
}
7.2 GET /partner/appointments/:appointment_id​
Get full details for a single appointment.
Response (200):
{
"appointment_id": "appt_f7c2e1a4b8d3",
"patient_id": "prof_a1b2c3d4e5f6",
"partner_reference": "ACME-CASE-4821",
"condition": "trt-cream",
"status": "doctor_reviewed",
"patient": {
"first_name": "Jane",
"last_name": "Doe",
"email": "jane.doe@example.com",
"date_of_birth": "1990-05-15",
"gender": "female"
},
"prescription": {
"status": "signed",
"medications": [
{
"name": "Testosterone Cypionate Cream 200mg/mL",
"dosage_instructions": "Apply 1mL topically once daily",
"quantity": 1,
"quantity_unit": "tube",
"refills": 2,
"days_supply": 30
}
],
"prescriber": {
"name": "Dr. Sarah Johnson",
"npi": "1234567890",
"license_state": "FL"
},
"notes": "Patient is a good candidate for TRT based on symptoms and lab values.",
"signed_at": "2026-03-03T09:15:00Z"
},
"pharmacy_routing": null,
"review_fee": {
"amount_cents": 4500,
"currency": "USD",
"status": "charged",
"charged_at": "2026-03-02T14:30:00Z"
},
"denial_details": null,
"metadata": { "internal_case_id": "4821" },
"created_at": "2026-03-02T14:30:00Z",
"updated_at": "2026-03-03T09:15:00Z"
}
When an appointment is denied, the denial_details field is populated:
{
"denial_details": {
"category": "medical_contraindication",
"reason": "Patient's current medications present a contraindication.",
"denied_at": "2026-03-03T09:20:00Z"
}
}
8. API Reference — Prescriptions​
8.1 GET /partner/appointments/:appointment_id/prescription​
Returns the full prescription payload after a physician has reviewed and approved the appointment.
Response (200):
{
"prescription_id": "rx_e4f5a6b7c8d9",
"appointment_id": "appt_f7c2e1a4b8d3",
"status": "signed",
"prescriber": {
"name": "Dr. Sarah Johnson",
"npi": "1234567890",
"license_state": "FL",
"dea_number": "FJ1234567"
},
"patient": {
"first_name": "Jane",
"last_name": "Doe",
"date_of_birth": "1990-05-15"
},
"medications": [
{
"name": "Testosterone Cypionate Cream 200mg/mL",
"ndc": "12345-6789-01",
"dosage_instructions": "Apply 1mL topically once daily",
"quantity": 1,
"quantity_unit": "tube",
"refills": 2,
"days_supply": 30
}
],
"diagnosis_codes": ["E29.1"],
"notes": "Patient is a good candidate for TRT based on symptoms and lab values.",
"signed_at": "2026-03-03T09:15:00Z",
"valid_until": "2027-03-03T09:15:00Z"
}
Error responses:
| Status | Code | When |
|---|---|---|
| 404 | PRESCRIPTION_NOT_AVAILABLE | Appointment has not been reviewed yet, or was denied |
| 404 | APPOINTMENT_NOT_FOUND | Appointment does not exist or does not belong to this partner |
9. API Reference — Pharmacy Routing​
9.1 POST /partner/appointments/:appointment_id/route-pharmacy​
Request that we route a signed prescription to a pharmacy in our network. This is optional — only call this if pharmacy_preference was set to "stealth" at submission time, or if you decide after review that you want us to handle routing.
Request body:
{
"pharmacy_id": "pharm_abc123"
}
The pharmacy_id is optional. If omitted, we route to the default pharmacy in our network for the patient's location.
Response (200):
{
"routing_status": "submitted",
"pharmacy": {
"name": "CDRX Pharmacy",
"npi": "9876543210",
"city": "Tampa",
"state": "FL"
},
"estimated_fill_date": "2026-03-05",
"routed_at": "2026-03-03T10:00:00Z"
}
Error responses:
| Status | Code | When |
|---|---|---|
| 400 | PRESCRIPTION_NOT_SIGNED | Cannot route — appointment has not been approved |
| 400 | ALREADY_ROUTED | Prescription has already been sent to a pharmacy |
| 404 | PHARMACY_NOT_FOUND | Specified pharmacy_id does not exist |
9.2 GET /partner/appointments/:appointment_id/routing-status​
Check the status of a pharmacy routing request.
Response (200):
{
"routing_status": "filled",
"pharmacy": {
"name": "CDRX Pharmacy",
"npi": "9876543210"
},
"routed_at": "2026-03-03T10:00:00Z",
"filled_at": "2026-03-05T14:00:00Z",
"tracking": {
"carrier": "USPS",
"tracking_number": "9400111899223456789012",
"shipped_at": "2026-03-05T16:00:00Z"
}
}
9.3 GET /partner/products​
List available treatment categories that can be submitted for review.
Response (200):
{
"products": [
{
"category_id": "trt-cream",
"name": "TRT Cream",
"description": "Testosterone Replacement Therapy — topical cream",
"supported_countries": ["US"],
"supported_states": ["FL", "TX", "CA", "NY"],
"review_fee_cents": 4500,
"intake_schema_version": "1.0",
"required_intake_questions": [
"current_medications", "allergies", "medical_history",
"current_symptoms", "prior_treatments", "recent_labs"
]
},
{
"category_id": "bpc-157",
"name": "BPC-157 Peptide",
"description": "Body Protection Compound peptide therapy",
"supported_countries": ["US"],
"supported_states": ["FL", "TX", "CA"],
"review_fee_cents": 4500,
"intake_schema_version": "1.0",
"required_intake_questions": [
"current_medications", "allergies", "medical_history",
"current_symptoms", "prior_treatments"
]
}
]
}
10. Structured Intake Schema​
When submitting an appointment, the intake_answers array must include responses to required questions defined for the treatment category. Use the GET /partner/products endpoint to retrieve the list of required_intake_questions for each category.
10.1 Common Questions (required for all conditions)​
| Question ID | Description | Expected Answer Format |
|---|---|---|
current_medications | All medications the patient is currently taking | Free text or structured list |
allergies | Known allergies (medications, foods, environmental) | Free text; "None" if none |
medical_history | Relevant medical history, surgeries, hospitalizations | Free text |
current_symptoms | Presenting symptoms the patient is seeking treatment for | Free text or checklist |
prior_treatments | Previous treatments tried for this condition | Free text; "None" if none |
10.2 Condition-Specific Questions​
TRT (trt-cream):
| Question ID | Description | Expected Answer Format |
|---|---|---|
recent_labs | Recent lab results (Total T, Free T, CBC, metabolic panel) | Free text with values and dates |
testosterone_symptoms | Specific testosterone deficiency symptom checklist | Comma-separated symptoms |
Peptides (bpc-157):
| Question ID | Description | Expected Answer Format |
|---|---|---|
injury_description | Description of injury or condition being treated | Free text |
treatment_goals | What the patient hopes to achieve | Free text |
10.3 Additional Questions​
Partners may include additional questions beyond the required set. These should use question_id values prefixed with custom_ and will be displayed to the reviewing physician under an "Additional Information" section.
{
"question_id": "custom_exercise_routine",
"question_text": "Describe your current exercise routine",
"answer": "Weight training 4x/week, cardio 2x/week"
}
10.4 Schema Versioning​
The intake schema is versioned (e.g. intake_schema_version: "1.0"). When we add or modify required questions, we increment the version and notify partners. Previous versions remain accepted for a deprecation period.
11. Webhook Events​
11.1 Event Types​
| Event | When | Key Payload Fields |
|---|---|---|
appointment.submitted | Appointment created and queued for review | appointment_id, patient_id, status, estimated_review_hours |
appointment.in_review | Physician has opened the case | appointment_id |
appointment.prescription_signed | Physician approved and signed a prescription | appointment_id, prescription (full medication details), prescriber |
appointment.denied | Physician denied the case | appointment_id, denial_category, denial_reason |
prescription.routed | Prescription sent to pharmacy (when we route) | appointment_id, pharmacy_name, pharmacy_npi |
prescription.filled | Pharmacy confirmed fill and shipped (when we route) | appointment_id, tracking |
review.billed | Per-review charge processed | appointment_id, amount_cents, currency, invoice_id |
11.2 Webhook Payload Format​
All webhook payloads follow this structure:
{
"event_id": "evt_a1b2c3d4e5f6",
"event_type": "appointment.prescription_signed",
"appointment_id": "appt_f7c2e1a4b8d3",
"patient_id": "prof_a1b2c3d4e5f6",
"partner_reference": "ACME-CASE-4821",
"data": {
"status": "doctor_reviewed",
"condition": "trt-cream",
"prescription": {
"medications": [
{
"name": "Testosterone Cypionate Cream 200mg/mL",
"dosage_instructions": "Apply 1mL topically once daily",
"quantity": 1,
"quantity_unit": "tube",
"refills": 2,
"days_supply": 30
}
],
"prescriber": {
"name": "Dr. Sarah Johnson",
"npi": "1234567890",
"license_state": "FL"
},
"signed_at": "2026-03-03T09:15:00Z"
},
"occurred_at": "2026-03-03T09:15:00Z"
},
"metadata": { "internal_case_id": "4821" },
"created_at": "2026-03-03T09:15:00Z"
}
11.3 Denial Webhook Payload​
{
"event_id": "evt_d4e5f6a7b8c9",
"event_type": "appointment.denied",
"appointment_id": "appt_f7c2e1a4b8d3",
"patient_id": "prof_a1b2c3d4e5f6",
"partner_reference": "ACME-CASE-4821",
"data": {
"status": "rejected",
"denial_category": "medical_contraindication",
"denial_reason": "Current medications present a contraindication for the requested therapy.",
"occurred_at": "2026-03-03T09:20:00Z"
},
"metadata": { "internal_case_id": "4821" },
"created_at": "2026-03-03T09:20:00Z"
}
11.4 Denial Categories​
| Category | Description |
|---|---|
medical_contraindication | Patient's medical history or current medications prevent prescribing |
incomplete_information | Insufficient information to make a clinical decision |
not_a_candidate | Patient does not meet clinical criteria for the requested therapy |
age_restriction | Patient does not meet age requirements |
jurisdiction_restriction | Cannot prescribe in the patient's state/country |
11.5 Retry Policy​
Failed webhook deliveries are retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
After 6 failed attempts, the event is moved to dead letter. Partners can retrieve missed events via the GET /partner/events endpoint.
12. Per-Review Billing​
Each appointment submission incurs a per-review fee. The fee is charged at the time of submission, regardless of whether the physician approves or denies the case.
12.1 How Billing Works​
- During onboarding, we set up a Stripe billing relationship with a
review_fee_centsamount in your partner configuration. - When you submit an appointment via
POST /partner/appointments, the review fee is charged to your account. - A
review.billedwebhook fires when the charge is processed. - Monthly invoices are available via Stripe.
12.2 Fee Structure​
| Item | Details |
|---|---|
| Review fee | Per-appointment, charged at submission |
| Pharmacy routing | No additional fee (when applicable) |
| Sandbox | No charges — all billing is simulated |
12.3 Billing in Sandbox​
In the sandbox environment, review fees are simulated. The review_fee object in responses will show "status": "simulated" instead of "charged".
13. Data Models​
13.1 Appointment​
{
"appointment_id": "string",
"patient_id": "string",
"partner_reference": "string",
"condition": "string",
"status": "pending | in_review | doctor_reviewed | rejected | completed",
"has_prescription": "boolean",
"pharmacy_preference": "partner | stealth",
"prescription": "Prescription | null",
"pharmacy_routing": "PharmacyRouting | null",
"denial_details": "DenialDetails | null",
"review_fee": "ReviewFee",
"metadata": "object",
"created_at": "ISO datetime",
"updated_at": "ISO datetime",
"reviewed_at": "ISO datetime | null"
}
13.2 Prescription​
{
"prescription_id": "string",
"status": "signed",
"prescriber": {
"name": "string",
"npi": "string",
"license_state": "string",
"dea_number": "string"
},
"medications": [
{
"name": "string",
"ndc": "string | null",
"dosage_instructions": "string",
"quantity": "number",
"quantity_unit": "string",
"refills": "number",
"days_supply": "number"
}
],
"diagnosis_codes": ["string"],
"notes": "string | null",
"signed_at": "ISO datetime",
"valid_until": "ISO datetime"
}
13.3 Denial Details​
{
"category": "string (see denial categories)",
"reason": "string",
"denied_at": "ISO datetime"
}
13.4 Review Fee​
{
"amount_cents": "number",
"currency": "string (USD)",
"status": "charged | simulated | failed",
"charged_at": "ISO datetime | null",
"invoice_id": "string | null"
}
14. Error Handling​
14.1 Error Response Format​
All errors follow a consistent structure:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Human-readable description of what went wrong.",
"details": { "field": "patient.email", "reason": "required" }
}
}
14.2 Error Codes​
| Code | HTTP Status | Description |
|---|---|---|
MISSING_CREDENTIALS | 401 | X-Partner-ID or X-Api-Key header missing |
INVALID_CREDENTIALS | 401 | API key does not match |
PRESCRIBER_ACCESS_REQUIRED | 403 | Endpoint requires prescriber tier |
VALIDATION_ERROR | 400 | Request body fails validation |
INVALID_INTAKE | 400 | Required intake questions missing |
UNSUPPORTED_JURISDICTION | 400 | Patient's state/country not supported for this condition |
DUPLICATE_REFERENCE | 409 | partner_reference already used |
PAYMENT_REQUIRED | 402 | Review fee charge failed |
APPOINTMENT_NOT_FOUND | 404 | Appointment does not exist or belongs to another partner |
PRESCRIPTION_NOT_AVAILABLE | 404 | Appointment not yet reviewed or was denied |
PRESCRIPTION_NOT_SIGNED | 400 | Cannot route — no signed prescription |
ALREADY_ROUTED | 400 | Prescription already sent to pharmacy |
RATE_LIMIT_EXCEEDED | 429 | Too many requests |
INTERNAL_ERROR | 500 | Unexpected server error |
15. Rate Limits & Environments​
15.1 Rate Limits​
| Environment | Limit |
|---|---|
| Production | 300 requests/minute |
| Sandbox | 60 requests/minute |
Rate limit headers are included in every response:
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 298
X-RateLimit-Reset: 1709401200
15.2 Environments​
| Environment | Base URL | Billing | Doctor Review |
|---|---|---|---|
| Sandbox | https://sandbox.stealth.health | Simulated (no real charges) | Simulated (auto-review after ~2 minutes) |
| Production | https://api.stealth.health | Real charges via Stripe | Real physician review (typically < 24 hours) |
In sandbox, appointments are automatically advanced through the review lifecycle to allow end-to-end testing without waiting for a real physician.
Appendix: Appointment Status Lifecycle​
