Intake Gateway API
Patient intake submission, plan pricing, Stripe payment, email verification, and Canvas patient creation.
Overview
The Intake Gateway is the public-facing API that powers the React intake forms. It handles the full intake lifecycle: fetching available plans with signed pricing tokens, verifying the patient's email address via OTP, collecting payment information through Stripe, validating discount coupons, and creating patients in Canvas EMR upon final submission.
All endpoints are called directly from the browser -- there is no server-to-server HMAC authentication. The gateway relies on plan token signatures for price integrity and Zod schema validation for input safety.
Base URL
https://intake-api.yourera.com
How It Works
- Frontend fetches plans from
GET /plans, each plan includes an HMAC-signed token encoding the price - Patient completes the intake form, verifies their email via OTP (
/verify-email/send+/verify-email/check) - Frontend creates a Stripe SetupIntent via
POST /payment-intentand collects card details client-side - Optionally validates a discount code via
POST /validate-coupon - Frontend submits the full intake via
POST /submitwith the signed plan token - Gateway validates the token signature, checks state availability and age, creates a Canvas patient, and saves the submission
- Frontend calls
POST /save-payment-infoto attach the payment method to the submission
CORS & Authentication
All Intake Gateway endpoints are public. They are called directly from React intake forms running in the browser. There is no HMAC signature requirement or API key header.
X-API-Key,
X-Timestamp, or X-Signature headers. Price integrity is enforced
through plan token signatures instead.
CORS is configured to allow requests from the intake form origins. All endpoints accept
Content-Type: application/json.
Get Plans
Returns all active intake plans with HMAC-signed pricing tokens. The frontend displays these plans and includes the signed token when submitting, preventing client-side price manipulation.
Request
No request body or query parameters required.
GET /plans
Host: intake-api.yourera.com
Response (200)
{
"plans": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"intakeType": "glp-1",
"plan": "monthly",
"displayName": "GLP-1 Monthly Plan",
"amountCents": 29900,
"interval": "month",
"token": "eyJpbnRha2VUeXBlIjoiZ2xwLTEi...<base64url>.<hex-hmac>"
},
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"intakeType": "nad",
"plan": "monthly",
"displayName": "NAD+ Wellness Protocol",
"amountCents": 17000,
"interval": "month",
"token": "eyJpbnRha2VUeXBlIjoibmFkIi...<base64url>.<hex-hmac>"
}
]
}
Response Fields
| Field | Type | Description |
|---|---|---|
id |
uuid |
Plan database ID |
intakeType |
string |
Intake category: "glp-1", "nad", "micro" |
plan |
string |
Plan identifier (e.g. "monthly") |
displayName |
string |
Human-readable plan name for the UI |
amountCents |
integer |
Price in cents (e.g. 29900 = $299.00) |
interval |
string |
Billing interval: "month" |
token |
string |
HMAC-signed plan token. Must be sent back at submission. See Plan Token Security. |
Error Responses
| Status | Body | Description |
|---|---|---|
| 500 | { "error": "Failed to load plans" } |
Database error loading plans |
Submit Intake
Submit a completed intake form. Validates the request body against the Zod schema,
checks state availability, verifies the patient is 18+, validates the plan token signature,
creates or finds an existing Canvas patient, and saves the submission to the database.
If Canvas patient creation fails, the submission is saved with pending_canvas
status and a retry is queued.
Request Body
| Field | Type | Description | |
|---|---|---|---|
firstName |
string |
required | Patient's first name (min 1 character) |
lastName |
string |
required | Patient's last name (min 1 character) |
dob |
string |
required | Date of birth in MM-DD-YYYY format. Patient must be 18+. |
gender |
string |
optional | Patient gender: "male", "female", "other" |
email |
string |
required | Valid email address. Used for Canvas patient lookup (idempotent creation). |
phone |
string |
required | Phone number (min 10 characters). Formatting is stripped before Canvas creation. |
state |
string |
required | Two-letter state code. Must not be in the excluded states list. |
address |
object |
required | Patient address. See address object. |
intakeType |
string |
required | Intake type: "glp-1", "nad", "micro" |
planToken |
string |
required | HMAC-signed plan token from GET /plans. Verified server-side. Expires after 15 minutes. |
couponCode |
string |
optional | Stripe coupon code for a discount |
address Object
| Field | Type | Description | |
|---|---|---|---|
line1 |
string |
required | Street address line 1 |
line2 |
string |
optional | Street address line 2 (apt, suite, etc.) |
city |
string |
required | City |
state |
string |
required | Two-letter state code |
zip |
string |
required | ZIP code (5-10 characters) |
Example Request
{
"firstName": "Jane",
"lastName": "Smith",
"dob": "03-15-1990",
"gender": "female",
"email": "jane.smith@example.com",
"phone": "(555) 123-4567",
"state": "FL",
"address": {
"line1": "123 Main St",
"line2": "Apt 4B",
"city": "Miami",
"state": "FL",
"zip": "33101"
},
"intakeType": "glp-1",
"planToken": "eyJpbnRha2VUeXBlIjoiZ2xwLTEi...<token>",
"couponCode": "WELCOME50"
}
Success Response (200)
{
"success": true,
"submissionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
Error Responses
| Status | Body | Description |
|---|---|---|
| 400 | { "error": "Validation failed", "details": { "fieldErrors": {...}, "formErrors": [] } } |
Zod schema validation failed. details contains the flattened Zod error. |
| 400 | { "error": "Service is not available in your state" } |
Patient state is in the excluded states list. |
| 400 | { "error": "You must be at least 18 years old" } |
DOB indicates the patient is under 18. |
| 400 | { "error": "Invalid plan token" } |
Plan token HMAC signature does not match. |
| 400 | { "error": "Plan token expired -- please refresh" } |
Plan token was issued more than 15 minutes ago. |
| 500 | { "error": "Failed to save submission" } |
Database error saving the submission. |
Submission Processing Steps
- Validate body -- Parse against
intakeSubmissionSchema(Zod) - State check -- Reject if state is in
EXCLUDED_STATES - Age check -- Verify DOB indicates 18+ using
isOver18() - Verify plan token -- HMAC-SHA256 signature verification + 15-minute expiry check
- Canvas patient -- Search by email (idempotent), create if not found
- Save submission -- Insert into
intake_submissionstable - Queue retry -- If Canvas failed, insert into
intake_retry_queue
Create Payment Intent
Create a Stripe SetupIntent for saving the patient's payment method.
This is not a charge -- it saves the card for later off-session billing when the
prescription is approved. The frontend uses the returned clientSecret with
Stripe Elements to collect card details.
Request Body
| Field | Type | Description | |
|---|---|---|---|
customerId |
string |
optional | Stripe customer ID. If provided, the SetupIntent is attached to this customer. |
Example Request
{
"customerId": "cus_ABC123"
}
Or with an empty body (no customer association):
{}
Success Response (200)
{
"clientSecret": "seti_1abc123_secret_xyz789",
"setupIntentId": "seti_1abc123"
}
Response Fields
| Field | Type | Description |
|---|---|---|
clientSecret |
string |
Pass to Stripe.js confirmSetup() on the frontend |
setupIntentId |
string |
Stripe SetupIntent ID for reference |
Error Responses
| Status | Body | Description |
|---|---|---|
| 500 | { "error": "Failed to create payment intent" } |
Stripe API error creating the SetupIntent |
Validate Coupon
Validate a discount code against Stripe coupons. The coupon ID in Stripe must match the code the user enters. Returns the discount amount (converted from cents to dollars) or percentage off.
Request Body
| Field | Type | Description | |
|---|---|---|---|
code |
string |
required | The coupon code to validate. Must match a Stripe coupon ID exactly. |
Example Request
{
"code": "WELCOME50"
}
Success Response -- Valid Coupon (200)
{
"valid": true,
"amountOff": 50,
"percentOff": null
}
Success Response -- Invalid/Expired Coupon (200)
{
"valid": false,
"reason": "Coupon is no longer valid"
}
Success Response -- Not Found (200)
{
"valid": false,
"reason": "Coupon not found"
}
Response Fields
| Field | Type | Description |
|---|---|---|
valid |
boolean |
Whether the coupon is valid and active |
amountOff |
number | null |
Fixed discount in dollars (converted from Stripe's cents). E.g. 50 = $50 off. |
percentOff |
number | null |
Percentage discount. E.g. 20 = 20% off. |
reason |
string |
Only present when valid is false. Explains why the coupon was rejected. |
amount_off in cents. The gateway converts to
dollars before returning the response. A Stripe coupon with amount_off: 5000
returns "amountOff": 50 (i.e. $50.00).
Error Responses
| Status | Body | Description |
|---|---|---|
| 400 | { "error": "Coupon code is required" } |
Missing or non-string code in request body |
| 500 | { "error": "Failed to validate coupon" } |
Unexpected Stripe API error |
Send Email Verification
Generate a cryptographically random 6-digit OTP and send it to the provided email address. Calling this endpoint again for the same email replaces the previous code. Codes expire after 10 minutes.
Request Body
| Field | Type | Description | |
|---|---|---|---|
email |
string |
required | The email address to send the verification code to |
Example Request
{
"email": "jane.smith@example.com"
}
Success Response (200)
{
"success": true,
"message": "Verification code sent"
}
Error Responses
| Status | Body | Description |
|---|---|---|
| 400 | { "error": "Email is required" } |
Missing or non-string email in request body |
Check Email Verification
Verify a 6-digit OTP code against the one sent to the email. Each code expires after 10 minutes, is single-use (cannot be reused after successful verification), and is rate-limited to 5 wrong attempts per code.
Request Body
| Field | Type | Description | |
|---|---|---|---|
email |
string |
required | The email address the code was sent to |
code |
string |
required | The 6-digit OTP code to verify |
Example Request
{
"email": "jane.smith@example.com",
"code": "482910"
}
Success Response (200)
{
"verified": true
}
Failure Response (200)
{
"verified": false,
"error": "invalid_code"
}
Verification Error Codes
| Error Code | Description |
|---|---|
no_code_sent |
No verification code exists for this email. Call /verify-email/send first. |
code_expired |
The code was sent more than 10 minutes ago. Request a new one. |
code_already_used |
This code has already been successfully verified. Codes are single-use. |
rate_limited |
5 wrong attempts have been made. Request a new code. |
invalid_code |
The code does not match. The attempt counter is incremented. |
Error Responses
| Status | Body | Description |
|---|---|---|
| 400 | { "error": "Email and code are required" } |
Missing email or code in request body |
Save Payment Info
Attach a Stripe payment method to an existing intake submission. Called after
the frontend collects card details via Stripe Elements and receives a
paymentMethodId. The payment method and optional setup intent ID are
merged into the submission's data JSONB column.
Request Body
| Field | Type | Description | |
|---|---|---|---|
submissionId |
uuid |
required | The submissionId returned from POST /submit |
paymentMethodId |
string |
required | Stripe payment method ID (e.g. "pm_1abc123") |
setupIntentId |
string |
optional | Stripe SetupIntent ID for reference |
Example Request
{
"submissionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"paymentMethodId": "pm_1abc123",
"setupIntentId": "seti_1abc123"
}
Success Response (200)
{
"success": true
}
Error Responses
| Status | Body | Description |
|---|---|---|
| 400 | { "error": "submissionId and paymentMethodId are required" } |
Missing required fields |
| 404 | { "error": "Submission not found" } |
No submission exists with the given ID |
| 500 | { "error": "Failed to save payment info" } |
Database error updating the submission |
Plan Token Security
Plan tokens are the primary mechanism preventing price tampering. Since the intake form runs in the browser and there is no HMAC authentication on API calls, a malicious client could attempt to submit a lower price. Plan tokens make this impossible.
How It Works
- When
GET /plansis called, the server creates a token for each plan by signing a payload containing theintakeType,plan,amountCents, and a timestamp with HMAC-SHA256 using a server-only secret (PLAN_TOKEN_SECRET). - The frontend receives the plans with their tokens and displays pricing to the user.
- At submission, the frontend sends the
planTokenback in the request body. - The server verifies the HMAC signature using
crypto.timingSafeEqual(constant-time comparison, preventing timing attacks) and checks the 15-minute expiry.
Token Format
// Format: base64url(payload).hex(hmac-sha256)
// Payload (JSON, base64url-encoded):
{
"intakeType": "glp-1",
"plan": "monthly",
"amountCents": 29900,
"issuedAt": 1710900000000
}
// Signing:
HMAC-SHA256(PLAN_TOKEN_SECRET, base64url(payload))
Security Properties
| Property | Mechanism |
|---|---|
| Price integrity | amountCents is baked into the signed payload. Changing it invalidates the signature. |
| Replay protection | issuedAt timestamp with 15-minute expiry window (TOKEN_EXPIRY_MS = 900000) |
| Timing attack resistance | Signature comparison uses crypto.timingSafeEqual |
| Secret isolation | PLAN_TOKEN_SECRET is a server-only env var, never exposed to the client |
400 with the message "Plan token expired -- please refresh".
Email Verification Flow
Email verification uses a 6-digit OTP (one-time password) flow. The code is generated using
crypto.randomInt for cryptographic randomness and stored in an in-memory
verification store keyed by email address.
Flow Diagram
// 1. Frontend requests OTP
POST /verify-email/send { email: "jane@example.com" }
// Server generates 6-digit code, sends via email, stores in memory
// 2. User enters OTP from email
POST /verify-email/check { email: "jane@example.com", code: "482910" }
// Server verifies code, returns { verified: true }
Rate Limiting & Expiry Rules
| Rule | Value | Behavior |
|---|---|---|
| Code expiry | 10 minutes | After 10 minutes, the code returns code_expired. User must request a new one. |
| Max wrong attempts | 5 | After 5 incorrect attempts, returns rate_limited. User must request a new code. |
| Single use | Once verified | After successful verification, the code is marked used = true. Returns code_already_used on retry. |
| Re-send behavior | Replaces previous | Calling /send again for the same email replaces the old code and resets the attempt counter. |
VerificationStore type). The
database table intake_verification_codes exists in the schema for future
migration to persistent storage.
Canvas Patient Creation
When an intake is submitted, the gateway creates a patient in Canvas EMR using the FHIR Patient API. The creation is idempotent -- it first searches by email to avoid duplicates.
Idempotent Creation
- Search: Query Canvas for an existing patient with the submitted email address
- If found: Use the existing patient's
id-- no new patient created - If not found: Build a FHIR Patient resource and create it via the Canvas API
FHIR Patient Mapping
Intake form data is mapped to a FHIR Patient resource:
| Intake Field | FHIR Path | Transformation |
|---|---|---|
firstName |
name[0].given[0] |
Direct mapping |
lastName |
name[0].family |
Direct mapping |
dob |
birthDate |
MM-DD-YYYY converted to YYYY-MM-DD (FHIR format) |
gender |
gender |
"male", "female", "other" pass through; missing or unrecognized maps to "unknown" |
phone |
telecom[0] |
Non-digit characters stripped. System: phone, use: mobile |
email |
telecom[1] |
System: email |
address.line1 |
address[0].line[0] |
Direct mapping |
address.line2 |
address[0].line[1] |
Only included if present |
address.city |
address[0].city |
Direct mapping |
address.state |
address[0].state |
Direct mapping |
address.zip |
address[0].postalCode |
Direct mapping |
Example FHIR Patient Resource
{
"resourceType": "Patient",
"name": [{
"given": ["Jane"],
"family": "Smith",
"use": "official"
}],
"birthDate": "1990-03-15",
"gender": "female",
"telecom": [
{ "system": "phone", "value": "5551234567", "use": "mobile" },
{ "system": "email", "value": "jane.smith@example.com" }
],
"address": [{
"use": "home",
"line": ["123 Main St", "Apt 4B"],
"city": "Miami",
"state": "FL",
"postalCode": "33101"
}]
}
Retry Queue
If Canvas patient creation fails (network error, API timeout, etc.), the submission is
still saved with status pending_canvas and a record is
inserted into the intake_retry_queue table.
| Canvas Outcome | Submission Status | Retry Queue |
|---|---|---|
| Patient created (or found) | submitted |
No entry |
| Creation failed | pending_canvas |
Entry with action: "create_patient", status: "pending" |
Canvas Retry Worker
The Canvas retry worker is fully functional and runs automatically on a 60-second interval
after the intake gateway starts. It picks up all pending entries in the
intake_retry_queue table and attempts to create the patient in Canvas.
Retry Policy
- Max attempts: 3 -- after 3 failed attempts, the entry is marked as
failed(manual intervention needed) - Interval: Worker polls every 60 seconds
- Idempotent: Each retry checks for an existing patient by email before creating, preventing duplicates
Retry Flow
- Fetch pending entries: Query
intake_retry_queuefor rows withstatus = 'pending' - Look up submission: Retrieve the full submission data using
submissionId - Build FHIR Patient: Map the submission data to a FHIR Patient resource (same mapping as initial creation)
- Check for existing patient: Search Canvas by email to avoid duplicates
- Create patient: If no existing patient found, create via Canvas FHIR API
- On success: Update the submission with
canvasPatientId, set submission status tosubmitted, mark retry entry ascompleted - On failure: Increment
attempts, record the error inlastError. If attempts reach 3, mark asfailed; otherwise leave aspendingfor next cycle
failed (3 attempts exhausted) will not be retried automatically.
An operator must investigate the failure reason in last_error, resolve the underlying
issue, and either reset the entry to pending or manually create the patient in Canvas.
Stripe Integration
The Intake Gateway uses Stripe for payment collection and discount validation. It follows the SetupIntent + off-session PaymentIntent pattern: the patient's card is saved during intake, and charged later when a prescription is approved by a clinician.
Payment Flow
- SetupIntent: Frontend calls
POST /payment-intentto get aclientSecret - Collect card: Stripe Elements uses the
clientSecretto securely collect card details client-side - Save method: After Stripe confirms the setup, frontend calls
POST /save-payment-infowith thepaymentMethodId - Charge later: When the prescription is approved, the orchestrator calls
chargePaymentHold(canvasPatientId)to create an off-session PaymentIntent and charge the saved card
Coupon Validation
Discount codes are validated against Stripe's Coupon API. The coupon ID in Stripe Dashboard must exactly match the code the user enters. There are two discount types:
| Stripe Field | Response Field | Example |
|---|---|---|
amount_off (cents) |
amountOff (dollars) |
Stripe: 5000 (cents) -> Response: 50 ($50.00) |
percent_off |
percentOff |
Stripe: 20 -> Response: 20 (20% off) |
Environment Variables
| Variable | Description |
|---|---|
STRIPE_SECRET_KEY |
Stripe secret key for server-side API calls |
PLAN_TOKEN_SECRET |
HMAC secret for signing/verifying plan tokens |
State Restrictions
YourEra does not currently service all US states. The gateway enforces state restrictions
at submission time -- intakes from excluded states are rejected with a 400 error.
Excluded States
{ "error": "Service is not available in your state" }
| Code | State |
|---|---|
MN | Minnesota |
CA | California |
TX | Texas |
AR | Arkansas |
OK | Oklahoma |
The excluded list is defined as EXCLUDED_STATES = ['MN', 'CA', 'TX', 'AR', 'OK']
in validation.ts. The check uses a Set lookup for O(1) performance.
Age Verification
All patients must be at least 18 years old. The isOver18()
function parses the DOB string (MM-DD-YYYY), computes the exact age accounting
for month and day boundaries, and rejects minors with a 400 error.
Database Schema
The Intake Gateway uses four tables, defined with Drizzle ORM in schema.ts.
intake_plans
Active plan definitions. Each plan maps to an intake type, price, and billing interval.
| Column | Type | Description |
|---|---|---|
id | uuid (PK) | Auto-generated |
intake_type | varchar(50) | e.g. "glp-1", "nad" |
plan | varchar(50) | e.g. "monthly" |
display_name | varchar(255) | Human-readable name |
amount_cents | integer | Price in cents |
interval | varchar(20) | Default: "month" |
is_active | boolean | Default: true. Only active plans are returned. |
created_at | timestamptz | Auto-set |
intake_submissions
Every intake submission is stored here, regardless of Canvas creation outcome.
| Column | Type | Description |
|---|---|---|
id | uuid (PK) | Auto-generated. Returned as submissionId. |
first_name | varchar(100) | Patient first name |
last_name | varchar(100) | Patient last name |
email | varchar(255) | Patient email |
phone | varchar(30) | Patient phone (nullable) |
state | varchar(2) | Two-letter state code (nullable) |
intake_type | varchar(50) | Intake category |
canvas_patient_id | varchar(100) | Canvas FHIR patient ID (null if creation failed) |
status | varchar(30) | "submitted" or "pending_canvas" |
data | jsonb | Full request body + paymentMethodId/setupIntentId after payment step |
created_at | timestamptz | Auto-set |
intake_retry_queue
Queued retries for failed Canvas patient creations.
| Column | Type | Description |
|---|---|---|
id | uuid (PK) | Auto-generated |
submission_id | uuid (FK) | References intake_submissions.id |
action | varchar(50) | Always "create_patient" |
attempts | integer | Number of retry attempts so far |
last_error | text | Error message from last attempt (nullable) |
status | varchar(20) | "pending", "completed", or "failed" |
created_at | timestamptz | Auto-set |
next_retry_at | timestamptz | When to attempt next retry (nullable) |
intake_verification_codes
Persistent storage for email OTP codes (schema defined for future use; current implementation uses in-memory store).
| Column | Type | Description |
|---|---|---|
id | uuid (PK) | Auto-generated |
email | varchar(255) | Email the code was sent to |
code | varchar(6) | 6-digit OTP code |
expires_at | timestamptz | When the code expires |
used_at | timestamptz | When the code was successfully verified (nullable) |
attempts | integer | Number of wrong verification attempts |
created_at | timestamptz | Auto-set |
intake_api_keys
API keys for future authenticated access (not currently used by the public intake routes).
| Column | Type | Description |
|---|---|---|
id | uuid (PK) | Auto-generated |
name | varchar(100) | Descriptive name (nullable) |
api_key | varchar(100) | Public key identifier (unique) |
api_secret | varchar(100) | Secret for HMAC signing |
is_active | boolean | Default: true |
created_at | timestamptz | Auto-set |