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

  1. Frontend fetches plans from GET /plans, each plan includes an HMAC-signed token encoding the price
  2. Patient completes the intake form, verifies their email via OTP (/verify-email/send + /verify-email/check)
  3. Frontend creates a Stripe SetupIntent via POST /payment-intent and collects card details client-side
  4. Optionally validates a discount code via POST /validate-coupon
  5. Frontend submits the full intake via POST /submit with the signed plan token
  6. Gateway validates the token signature, checks state availability and age, creates a Canvas patient, and saves the submission
  7. Frontend calls POST /save-payment-info to 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.

No HMAC Authentication Unlike the Pharmacy Router, the Intake Gateway does not require 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

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

FieldTypeDescription
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

StatusBodyDescription
500 { "error": "Failed to load plans" } Database error loading plans

Submit Intake

POST /submit

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

FieldTypeDescription
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

FieldTypeDescription
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

StatusBodyDescription
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

  1. Validate body -- Parse against intakeSubmissionSchema (Zod)
  2. State check -- Reject if state is in EXCLUDED_STATES
  3. Age check -- Verify DOB indicates 18+ using isOver18()
  4. Verify plan token -- HMAC-SHA256 signature verification + 15-minute expiry check
  5. Canvas patient -- Search by email (idempotent), create if not found
  6. Save submission -- Insert into intake_submissions table
  7. Queue retry -- If Canvas failed, insert into intake_retry_queue

Create Payment Intent

POST /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

FieldTypeDescription
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

FieldTypeDescription
clientSecret string Pass to Stripe.js confirmSetup() on the frontend
setupIntentId string Stripe SetupIntent ID for reference

Error Responses

StatusBodyDescription
500 { "error": "Failed to create payment intent" } Stripe API error creating the SetupIntent

Validate Coupon

POST /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

FieldTypeDescription
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

FieldTypeDescription
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.
Cents to Dollars Stripe stores 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

StatusBodyDescription
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

POST /verify-email/send

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

FieldTypeDescription
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

StatusBodyDescription
400 { "error": "Email is required" } Missing or non-string email in request body

Check Email Verification

POST /verify-email/check

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

FieldTypeDescription
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 CodeDescription
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

StatusBodyDescription
400 { "error": "Email and code are required" } Missing email or code in request body

Save Payment Info

POST /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

FieldTypeDescription
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

StatusBodyDescription
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

  1. When GET /plans is called, the server creates a token for each plan by signing a payload containing the intakeType, plan, amountCents, and a timestamp with HMAC-SHA256 using a server-only secret (PLAN_TOKEN_SECRET).
  2. The frontend receives the plans with their tokens and displays pricing to the user.
  3. At submission, the frontend sends the planToken back in the request body.
  4. 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

PropertyMechanism
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
Token Expiry Plan tokens expire after 15 minutes. If a patient takes longer than 15 minutes to complete the form, the frontend should re-fetch plans before submitting. Expired tokens return a 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

RuleValueBehavior
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.
In-Memory Store The verification store is currently in-memory (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

  1. Search: Query Canvas for an existing patient with the submitted email address
  2. If found: Use the existing patient's id -- no new patient created
  3. 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 FieldFHIR PathTransformation
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 OutcomeSubmission StatusRetry 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

Retry Flow

  1. Fetch pending entries: Query intake_retry_queue for rows with status = 'pending'
  2. Look up submission: Retrieve the full submission data using submissionId
  3. Build FHIR Patient: Map the submission data to a FHIR Patient resource (same mapping as initial creation)
  4. Check for existing patient: Search Canvas by email to avoid duplicates
  5. Create patient: If no existing patient found, create via Canvas FHIR API
  6. On success: Update the submission with canvasPatientId, set submission status to submitted, mark retry entry as completed
  7. On failure: Increment attempts, record the error in lastError. If attempts reach 3, mark as failed; otherwise leave as pending for next cycle
Manual Intervention Required for Failed Entries Entries marked as 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

  1. SetupIntent: Frontend calls POST /payment-intent to get a clientSecret
  2. Collect card: Stripe Elements uses the clientSecret to securely collect card details client-side
  3. Save method: After Stripe confirms the setup, frontend calls POST /save-payment-info with the paymentMethodId
  4. Charge later: When the prescription is approved, the orchestrator calls chargePaymentHold(canvasPatientId) to create an off-session PaymentIntent and charge the saved card
SetupIntent, Not PaymentIntent The intake creates a SetupIntent (save card) rather than a PaymentIntent (immediate charge) or authorization hold. The patient is not charged until their prescription is clinically approved. This approach avoids holds expiring before clinical review completes.

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 FieldResponse FieldExample
amount_off (cents) amountOff (dollars) Stripe: 5000 (cents) -> Response: 50 ($50.00)
percent_off percentOff Stripe: 20 -> Response: 20 (20% off)

Environment Variables

VariableDescription
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

Service Unavailable The following states are blocked at the intake level. Submissions with these state codes receive: { "error": "Service is not available in your state" }
CodeState
MNMinnesota
CACalifornia
TXTexas
ARArkansas
OKOklahoma

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.

ColumnTypeDescription
iduuid (PK)Auto-generated
intake_typevarchar(50)e.g. "glp-1", "nad"
planvarchar(50)e.g. "monthly"
display_namevarchar(255)Human-readable name
amount_centsintegerPrice in cents
intervalvarchar(20)Default: "month"
is_activebooleanDefault: true. Only active plans are returned.
created_attimestamptzAuto-set

intake_submissions

Every intake submission is stored here, regardless of Canvas creation outcome.

ColumnTypeDescription
iduuid (PK)Auto-generated. Returned as submissionId.
first_namevarchar(100)Patient first name
last_namevarchar(100)Patient last name
emailvarchar(255)Patient email
phonevarchar(30)Patient phone (nullable)
statevarchar(2)Two-letter state code (nullable)
intake_typevarchar(50)Intake category
canvas_patient_idvarchar(100)Canvas FHIR patient ID (null if creation failed)
statusvarchar(30)"submitted" or "pending_canvas"
datajsonbFull request body + paymentMethodId/setupIntentId after payment step
created_attimestamptzAuto-set

intake_retry_queue

Queued retries for failed Canvas patient creations.

ColumnTypeDescription
iduuid (PK)Auto-generated
submission_iduuid (FK)References intake_submissions.id
actionvarchar(50)Always "create_patient"
attemptsintegerNumber of retry attempts so far
last_errortextError message from last attempt (nullable)
statusvarchar(20)"pending", "completed", or "failed"
created_attimestamptzAuto-set
next_retry_attimestamptzWhen 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).

ColumnTypeDescription
iduuid (PK)Auto-generated
emailvarchar(255)Email the code was sent to
codevarchar(6)6-digit OTP code
expires_attimestamptzWhen the code expires
used_attimestamptzWhen the code was successfully verified (nullable)
attemptsintegerNumber of wrong verification attempts
created_attimestamptzAuto-set

intake_api_keys

API keys for future authenticated access (not currently used by the public intake routes).

ColumnTypeDescription
iduuid (PK)Auto-generated
namevarchar(100)Descriptive name (nullable)
api_keyvarchar(100)Public key identifier (unique)
api_secretvarchar(100)Secret for HMAC signing
is_activebooleanDefault: true
created_attimestamptzAuto-set