Canonical source: docs/claude/firebase-backend.md. This page is generated by docs/scripts/sync-handbook.mjs. Edit the source file in the repo; changes appear here on the next build.
Firebase Functions (Cloud Backend)
Performance: Always Use Warm Instances
Cloud Functions should use min_instances=1 for user-facing functions to avoid cold start delays (3-10+ seconds).
# GOOD - 1GB memory + warm instance
@https_fn.on_call(
memory=options.MemoryOption.GB_1, # ALWAYS use GB_1, not MB_512
min_instances=1,
cors=CorsOptions(...),
)
# BAD - Will OOM and have cold starts
@https_fn.on_call(memory=options.MemoryOption.MB_512)
Only skip min_instances for rarely used admin/maintenance functions.
Secret Scoping (CRITICAL — #1 cause of silent 500 errors)
Each Firebase Function only receives secrets explicitly listed in its secrets=[] parameter. Module-level SecretParam declarations don't auto-inject.
# Module level — does NOT make the secret available to all functions
STRIPE_SIGNER_SECRET_KEY: SecretParam = SecretParam('STRIPE_SIGNER_SECRET_KEY')
# This function CAN'T access STRIPE_SIGNER_SECRET_KEY
@https_fn.on_call(secrets=[STRIPE_API_KEY])
def my_function(req): ...
# This function CAN access it
@https_fn.on_call(secrets=[STRIPE_API_KEY, STRIPE_SIGNER_SECRET_KEY])
def my_function(req): ...
Before adding code that uses a SecretParam:
- Find the function's decorator
- Check if the secret is in its
secrets=[]list - If not, ADD it — otherwise you get a runtime 500
Dispatcher functions (like payment) route to many handlers. If you add a new handler that uses a secret the dispatcher doesn't declare, ALL calls to that handler will 500.
@init functions run at module load time. If @init accesses a secret, that secret must be in the secrets list of EVERY function in the module.
CORS Origins (REQUIRED)
Always include the FULL CORS origins list:
cors=CorsOptions(cors_origins=[
"http://localhost:3000", # Local dev - NEVER FORGET THIS
"http://localhost:3001", # Alt local dev
"http://localhost:5555", # Emulator
"http://127.0.0.1:5555", # Emulator alt
"https://app.basishealth.io", # Legacy Flutter web
"https://platform.basishealth.io", # Staff platform
"https://basis-hybrid.web.app", # Firebase hosting
])
Function Codebases
| Codebase | Path | Purpose |
|---|---|---|
default | functions/ | Main backend (auth, payments, clinic, AI, etc.) |
labs | functions-labs/ | Lab-specific processing |
transcoder | functions-video/ | Video transcoding |
functions-vertex | functions-vertex/ | Vertex AI/LangChain operations |
Key Callable Functions
| Function | File | Purpose |
|---|---|---|
clinic_service | functions_clinic.py | Main clinic operations dispatcher |
payment | functions_payment.py | Stripe payment operations |
permission | functions_permissions.py | RBAC permission management |
clinical_assistant | functions_ai.py | AI-powered clinical Q&A |
clinical_quick | functions_ai.py | Fast clinical quick answers |
clinical_stream | functions_ai.py | Streaming AI responses |
Key HTTP Functions (Webhooks)
| Function | Purpose |
|---|---|
stripe_webhook / stripe_webhook_test | Stripe payment webhooks |
stripe_general_webhook | General Stripe events |
zoom_oauth_callback | Zoom OAuth flow |
google_oauth_callback | Google Meet OAuth flow |
fullscript_oauth_callback | Fullscript OAuth flow |
fullscript_webhook | Fullscript order events |
junction_webhook | Junction Health lab results |
New Firebase Function Checklist
- Memory: Use
GB_1(REQUIRED) —MB_512causes OOM - CORS: Include full origins list — missing
localhost:3000is #1 CORS error cause - Test from localhost BEFORE marking complete
- Firestore Rules: If accessing new paths, add rules and deploy
- Assets/Images: If referencing images, verify file exists in
public/ - API Endpoints: Verify exact endpoint path in official docs — don't guess
Deployment Commands
NEVER deploy all functions at once — always deploy specific functions only
cd /Users/G/basis/basis-functions
# Deploy specific function(s)
firebase deploy --only functions:clinic_service --force
firebase deploy --only functions:payment --force
# Deploy specific codebase
firebase deploy --only functions:labs
# Deploy hosting
firebase deploy --only hosting:platform
Third-Party Integrations
| Integration | File | Purpose |
|---|---|---|
| Terra API | functions_terra.py | Unified health device data (Fitbit, Apple Health, Garmin, etc.) |
| Stripe | functions_payment.py | Payment processing, subscriptions (see stripe-payments.md) |
| Zoom | functions_zoom.py | Video appointment meetings |
| Google Meet | functions_google_meet.py | Calendar events with Meet links |
| Fullscript | functions_fullscript.py | Supplement ordering and treatment plans |
| Junction Health | functions_junction.py | Lab test ordering and results |
| Mailersend | functions_appointment_emails.py | Transactional emails, branded domains |
| OpenAI / Vertex AI | functions_ai.py | Clinical assistant, copilot, document analysis |
Data Flows
Appointment Booking
Client -> book_appointment (clinic_service)
-> Validate membership limits
-> Check slot availability + booking windows
-> Create scheduled event + transaction (audit)
-> Update usage counters
-> Process payment (if applicable) via Stripe
-> Send confirmation email
-> Return result
Health Data Sync
Wearable Device -> Terra API -> Webhook (Cloud Function)
-> Verify HMAC signature
-> Store raw data in GCS
-> Process in DuckDB
-> Mirror to Firestore healthSummaries
-> Mirror to clinic_users/{uid}/healthSummaries
Document Upload
Staff uploads -> Cloud Storage
-> Process document (Cloud Function)
-> AI text extraction/analysis (Vertex AI)
-> Store metadata in Firestore
-> Create documentLink in clinic_users
-> Return result
Creating Webhook Handlers
Webhooks receive HTTP POST requests from third-party services. They are fundamentally different from on_call functions.
Checklist for a New Webhook
- Use
@https_fn.on_request— neveron_call. Third-party services don't speak Firebase's callable protocol. - Add
POSTmethod guard — return 405 for non-POST requests. - Read raw bytes BEFORE parsing JSON —
request.get_data()thenjson.loads(body). If you callrequest.get_json()first, Flask may drain the stream, making signature verification impossible. - Verify signatures with
hmac.compare_digest()— never==(timing attack). Degrade gracefully during initial setup: accept if secret not yet configured. - No CORS — webhooks are server-to-server. Don't add
cors=parameter. - Register in
main.py— both the minimal-loadFUNCTION_TARGETblock AND the wildcardimport *block. - Use
GB_1memory unless the handler is trivially simple. - Declare secrets explicitly in
secrets=[]. Reference secrets from other modules by string name to avoid duplicateSecretParamerrors. - Always return 200 for acknowledged events, even unrecognized ones — returning 4xx/5xx causes third-party retries.
- Write an audit trail — log events to Firestore for debugging. Use an orphan collection for events that can't be matched to a clinic.
Common Gotchas
| Gotcha | Details |
|---|---|
| Raw bytes before JSON | get_data() MUST come before get_json() when doing HMAC verification. Flask drains the stream on first read. |
Duplicate SecretParam | If a secret is declared in another file, use secrets=['SECRET_NAME'] (string), not a new SecretParam('SECRET_NAME'). |
| firebase.json rewrite optional | Only needed if you want a branded URL (e.g., app.basishealth.io/my_webhook). Direct CF URL works without it. |
| Challenge-first ordering | Some services (Slack) send a URL verification challenge before signing requests. Handle challenge BEFORE signature verification. |
| Retry deduplication | Most services retry on non-2xx. Either process idempotently (keyed on event ID) or reject retries (Slack: X-Slack-Retry-Num header). |
| No user context | Webhooks have no req.auth. Any Firestore operations need system-level access patterns, not permission-checked flows. |
| Event field name variance | Third-party payloads use inconsistent casing (EventType vs event_type vs type). Always check multiple field names. |
| Background threads are risky | Only MailerSend uses threading.Thread for async processing. The function instance may be killed before the thread finishes. Process synchronously unless the sender has a very short timeout. |
Template
WEBHOOK_SECRET = SecretParam('MY_SERVICE_WEBHOOK_SECRET')
@https_fn.on_request(
memory=options.MemoryOption.GB_1,
secrets=[WEBHOOK_SECRET],
)
def my_service_webhook(request) -> https_fn.Response:
if request.method != 'POST':
return https_fn.Response('Method not allowed', status=405)
# 1. Raw bytes first (for signature verification)
raw_body = request.get_data()
# 2. Verify signature
secret = WEBHOOK_SECRET.value
signature = request.headers.get('X-My-Service-Signature', '')
if secret and signature:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return https_fn.Response('Invalid signature', status=401)
# 3. Parse JSON
payload = json.loads(raw_body) if raw_body else {}
# 4. Dispatch on event type
event_type = payload.get('EventType') or payload.get('event_type') or 'unknown'
# 5. Write audit trail
fire.db.collection('myServiceEvents').document().set({
'eventType': event_type,
'payload': payload,
'receivedAt': fire.SERVER_TIMESTAMP,
})
# 6. Process event
# ...
return https_fn.Response(json.dumps({'status': 'ok'}), status=200)
Existing Webhooks Reference
| Handler | File | Sig Verification | Routing |
|---|---|---|---|
fullscript_webhook | functions_fullscript.py | None | firebase.json rewrite |
junction_webhook | functions_junction.py | None | firebase.json rewrite |
sentry_webhook | functions_sentry_webhook.py | HMAC-SHA256 | Direct CF URL |
github_webhook | functions_github_integration.py | HMAC-SHA256 (sha256= prefix) | Direct CF URL |
slack_events | functions_slack_bot.py | HMAC-SHA256 (Slack v0: scheme) | Direct CF URL |
scribe_webhook | functions_scribe.py | None | Direct CF URL |
mailersend_webhook | functions_email_webhooks.py | HMAC-SHA256 (optional) | Direct CF URL |
dosespot_webhook | functions_dosespot.py | HMAC-SHA256 (pending DoseSpot docs) | Direct CF URL |