Spaces:
Sleeping
Download API_SPECIFICATION.md from alihamzach/skinmodel: direct link, hf CLI and curl.
- Browser
- Download file 47.7 kB
-
https://huggingface.co/spaces/alihamzach/skinmodel/resolve/main/API_SPECIFICATION.md
- Command line
-
hf download hf://spaces/alihamzach/skinmodel/API_SPECIFICATION.md
-
curl -L -o API_SPECIFICATION.md https://huggingface.co/spaces/alihamzach/skinmodel/resolve/main/API_SPECIFICATION.md
Skin Disease Detection API β Full Specification
Base URL:
https://alihamzach-skinmodel.hf.space
API Prefix:/v1
Model: EfficientNetB0 (224Γ224, 22 disease classes)
Auth: JWT (access + refresh tokens)
Rate Limiting: IP-based + user-based tiered quotas
Database: PostgreSQL (Neon) via SQLAlchemy async + asyncpg
Table of Contents
- Authentication & User Management
- Image Scanning & Detection
- Recommendations & Personalization
- Monetization & Subscriptions
- Database Schema
- Rate Limiting Logic
- Error Handling
- Middleware & Configuration
1. Authentication & User Management
1.1 Signup
Creates a new user account with email + password.
POST /auth/signup
Request Body:
{
"email": "user@example.com",
"password": "Str0ng!Pass123",
"full_name": "Jane Doe",
"medical_history": {
"allergies": ["penicillin", "sulfa"],
"chronic_conditions": ["eczema"],
"current_medications": ["cetirizine"]
}
}
Validation Rules:
email: valid email format, max 255 chars, unique in systempassword: min 8 chars, at least 1 uppercase, 1 lowercase, 1 digit, 1 special charfull_name: 1β100 chars, letters/spaces/hyphens onlymedical_history: optional JSON object
Response 201 Created:
{
"id": "uuid",
"email": "user@example.com",
"full_name": "Jane Doe",
"is_verified": false,
"created_at": "2026-05-17T10:30:00Z"
}
Error 409 Conflict:
{
"detail": "An account with this email already exists."
}
Error 422 Unprocessable Entity:
{
"detail": [
{ "loc": ["body", "password"], "msg": "Password must contain at least one uppercase letter" }
]
}
1.2 Login
Authenticates user and returns JWT tokens.
POST /auth/login
Request Body:
{
"email": "user@example.com",
"password": "Str0ng!Pass123"
}
Response 200 OK:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 3600,
"user": {
"id": "uuid",
"email": "user@example.com",
"full_name": "Jane Doe",
"subscription_plan": "free",
"scans_remaining_today": 5
}
}
Error 401 Unauthorized:
{
"detail": "Invalid email or password."
}
Token Specs:
| Token | Lifetime | Payload |
|---|---|---|
| Access | 1 hour | sub (user_id), exp, iat, type: "access" |
| Refresh | 30 days | sub (user_id), exp, iat, type: "refresh" |
1.3 Social Login (OAuth 2.0)
Initiates or completes social login via Google or Facebook.
POST /auth/social/{provider}
Path Parameters:
provider:"google"|"facebook"
Request Body:
{
"access_token": "ya29.a0AfH6SMC..."
}
Server-Side Flow:
- Receive OAuth access token from client
- Verify token with Google/Facebook tokeninfo endpoint
- Extract email, name, profile photo from provider
- If email exists β link social account + login
- If email new β create user with
auth_provider= provider +social_id= provider user ID
Response 200 OK (same shape as login response)
Error 401 Unauthorized:
{
"detail": "Invalid or expired social token."
}
1.4 Forgot Password
Initiates password reset email flow.
POST /auth/forgot-password
Request Body:
{
"email": "user@example.com"
}
Flow:
- Validate email exists (silent fail if not β security best practice)
- Generate cryptographically secure 6-digit code (or UUIDv4 token)
- Store hashed token in
password_reset_tokenstable with 15-min expiry - Send email with reset link/code
Response 200 OK (always):
{
"message": "If an account with that email exists, a password reset link has been sent."
}
1.5 Reset Password
Completes password reset using token from email.
POST /auth/reset-password
Request Body:
{
"token": "a1b2c3d4-e5f6-...",
"new_password": "NewStr0ng!Pass456"
}
Validation:
- Token must exist in DB, not expired, not used
- Token is marked
is_used = trueafter successful reset (one-time use) - Password follows same rules as signup
Response 200 OK:
{
"message": "Password has been reset successfully."
}
Error 400 Bad Request:
{
"detail": "Invalid or expired reset token."
}
1.6 Change Password (Authenticated)
PUT /auth/change-password
Headers: Authorization: Bearer <access_token>
Request Body:
{
"current_password": "OldPass123!",
"new_password": "NewStr0ng!Pass456"
}
Response 200 OK:
{
"message": "Password changed successfully."
}
Error 401 Unauthorized:
{
"detail": "Current password is incorrect."
}
1.7 Refresh Token
POST /auth/refresh
Request Body:
{
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}
Response 200 OK:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"expires_in": 3600
}
1.8 Update Profile (Authenticated)
PUT /auth/profile
Headers: Authorization: Bearer <access_token>
Request Body:
{
"full_name": "Jane Smith",
"profile_photo": "data:image/png;base64,iVBOR...",
"medical_history": {
"allergies": ["penicillin"],
"chronic_conditions": ["eczema", "psoriasis"],
"current_medications": ["diphenhydramine"],
"skin_type": "oily",
"known_sensitivities": ["fragrance", "parabens"]
}
}
Response 200 OK:
{
"id": "uuid",
"email": "user@example.com",
"full_name": "Jane Smith",
"profile_photo_url": "https://cdn.skindetect.com/users/uuid/photo.jpg",
"medical_history": { ... },
"updated_at": "2026-05-17T11:00:00Z"
}
2. Image Scanning & Detection
2.1 Scan Image
Upload a skin image for AI-based disease detection.
POST /scans
Unauthenticated users: IP-based rate limit of 3 total scans per IP.
Authenticated users: Daily limit based on subscription plan (default: 5/day for free tier).
Headers: Authorization: Bearer <access_token> (optional for unauthenticated)
Request Body (multipart/form-data):
| Field | Type | Required | Description |
|---|---|---|---|
image |
file | yes | JPEG or PNG, max 10MB, min 224Γ224px |
location |
string | no | Lat,lng for geo-recommendations (e.g. "40.7128,-74.0060") |
Processing Pipeline:
- Validate file type and size
- Check rate limits (IP for unauth, user quota for auth)
- Upload image to CDN (S3/GCS) with UUID filename
- Preprocess: resize to 224Γ224, normalize to [0,1]
- Run model inference β softmax probabilities over 22 classes
- Apply confidence threshold (default: 15%) to filter low-confidence results
- Log scan record in database
- Increment quota counters
- Return results
Response 200 OK:
{
"items": [
{
"scan_id": "uuid",
"title": "skin_photo.jpg",
"image_url": "https://cdn.skindetect.com/scans/uuid/thumb.jpg",
"primary_detection": {
"disease": "Eczema",
"confidence": 87.32,
"urgency_level": "consult_recommended"
},
"all_results": [
{ "disease": "Eczema", "confidence": 87.32, "severity": "moderate" },
{ "disease": "Psoriasis", "confidence": 8.15 },
{ "disease": "Acne", "confidence": 2.41 }
],
"created_at": "2026-05-17T10:30:00Z"
}
],
"total": 42,
"page": 1,
"page_size": 20,
"total_pages": 3
}
Error 401 Unauthorized:
{
"detail": "Authentication required to view scan history."
}
2.3 Get Single Scan Detail (Authenticated)
GET /scans/{scan_id}
Headers: Authorization: Bearer <access_token>
Response 200 OK: Full scan result (same shape as scan response)
Error 404 Not Found:
{
"detail": "Scan not found."
}
2.4 Rename Scan (Authenticated)
Updates the title of an existing scan.
PATCH /v1/scans/{scan_id}
Headers: Authorization: Bearer <access_token>
Request Body:
{
"title": "My updated scan name"
}
Response 200 OK:
{
"scan_id": "uuid",
"title": "My updated scan name"
}
Error 404 Not Found:
{
"detail": "Scan not found."
}
2.5 Delete Scan (Authenticated)
Permanently deletes a scan record.
DELETE /v1/scans/{scan_id}
Headers: Authorization: Bearer <access_token>
Response 204 No Content (no body)
Error 404 Not Found:
{
"detail": "Scan not found."
}
3. Recommendations & Personalization
3.1 Dermatologist Recommendations
Returns a list of dermatologists ranked by proximity to the user's IP location.
GET /recommendations/dermatologists
Headers: Authorization: Bearer <access_token> (optional β wider results if authenticated)
Query Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
lat |
float | no | IP geo-lookup | Latitude |
lng |
float | no | IP geo-lookup | Longitude |
radius_km |
int | no | 50 | Search radius |
specialization |
string | no | β | Filter by specialty (e.g. "pediatric_dermatology") |
page |
int | no | 1 | β |
page_size |
int | no | 20 | β |
Response 200 OK:
{
"items": [
{
"id": "uuid",
"name": "Dr. Sarah Johnson",
"specialization": ["general_dermatology", "pediatric_dermatology"],
"address": "123 Medical Plaza, Suite 200, New York, NY 10001",
"phone": "+1-212-555-0198",
"email": "dr.johnson@dermclinic.com",
"website": "https://dermclinic.com/dr-johnson",
"rating": 4.8,
"review_count": 124,
"distance_km": 2.3,
"coordinates": { "lat": 40.7580, "lng": -73.9855 },
"available_slots": ["2026-05-18T09:00", "2026-05-18T10:30"],
"insurance_accepted": ["Aetna", "Cigna", "UnitedHealthcare"]
}
],
"user_location": { "lat": 40.7484, "lng": -73.9967, "source": "ip" },
"total": 12,
"page": 1,
"page_size": 20
}
Location Resolution Priority:
- Explicit
lat/lngquery params (highest priority) - User's saved location from profile (if authenticated)
- IP geolocation lookup (MaxMind GeoIP or similar)
3.2 Skin Care Product Recommendations
Returns product/medicine recommendations. May include affiliate links.
GET /recommendations/products
Headers: Authorization: Bearer <access_token> (optional)
Query Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
condition |
string | no | β | Target condition (e.g. "Eczema") |
product_type |
string | no | β | medicine | moisturizer | cleanser | sunscreen | treatment |
lat |
float | no | IP geo | For pharmacy availability |
lng |
float | no | IP geo | β |
page |
int | no | 1 | β |
page_size |
int | no | 20 | β |
Response 200 OK:
{
"items": [
{
"id": "uuid",
"name": "Cetaphil Moisturizing Cream",
"brand": "Cetaphil",
"category": "moisturizer",
"type": "over_the_counter",
"description": "Non-greasy, fragrance-free moisturizer for sensitive skin.",
"key_ingredients": ["glycerin", "petrolatum", "dimethicone"],
"recommended_for": ["Eczema", "Psoriasis", "dry_skin"],
"average_rating": 4.6,
"price": {
"amount": 14.99,
"currency": "USD",
"retailer": "Walmart"
},
"affiliate_link": "https://www.amazon.com/dp/B001B4...?tag=skindetect-20",
"image_url": "https://cdn.skindetect.com/products/uuid/image.jpg",
"medical_rating": 4.5,
"side_effects": ["rare: mild irritation"],
"prescription_required": false
}
],
"total": 8,
"page": 1,
"page_size": 20
}
Affiliate Link Strategy:
- Append tracking tag
?tag=skindetect-{source}-{user_id_hash}to all product links - Link to Amazon Pharmacy, Walmart, CVS, Walgreens affiliate programs
- Track clicks via redirect service (
/r/{product_id}) for analytics
3.3 Clinic Recommendations
Returns nearby skin clinics/hospitals.
GET /recommendations/clinics
Headers: Authorization: Bearer <access_token> (optional)
Query Parameters: (same location params as dermatologists)
Response 200 OK:
{
"items": [
{
"id": "uuid",
"name": "Manhattan Dermatology Center",
"type": "clinic",
"address": "500 Fifth Avenue, Floor 15, New York, NY 10110",
"phone": "+1-212-555-0246",
"distance_km": 0.8,
"rating": 4.5,
"review_count": 89,
"services": ["general_dermatology", "mohs_surgery", "cosmetic"],
"open_hours": {
"monday": "08:00-18:00",
"tuesday": "08:00-18:00",
"wednesday": "08:00-20:00",
"thursday": "08:00-18:00",
"friday": "08:00-16:00",
"saturday": "09:00-13:00",
"sunday": "closed"
},
"accepts_insurance": true,
"walkins_accepted": false,
"coordinates": { "lat": 40.7527, "lng": -73.9822 }
}
],
"total": 5,
"page": 1,
"page_size": 20
}
4. Monetization & Subscriptions
4.1 List Subscription Plans
GET /subscriptions/plans
Response 200 OK:
{
"plans": [
{
"id": "plan_free",
"name": "Free",
"price_cents": 0,
"currency": "USD",
"billing_interval": "month",
"features": {
"daily_scans": 5,
"scan_history_days": 30,
"detailed_results": true,
"product_recommendations": true,
"dermatologist_referrals": false,
"priority_support": false,
"data_export": false
}
},
{
"id": "plan_basic",
"name": "Basic",
"price_cents": 499,
"currency": "USD",
"billing_interval": "month",
"features": {
"daily_scans": 20,
"scan_history_days": 365,
"detailed_results": true,
"product_recommendations": true,
"dermatologist_referrals": true,
"priority_support": false,
"data_export": false
},
"stripe_price_id": "price_1ABC2Def3Ghi4Jkl"
},
{
"id": "plan_premium",
"name": "Premium",
"price_cents": 999,
"currency": "USD",
"billing_interval": "month",
"features": {
"daily_scans": 100,
"scan_history_days": null,
"detailed_results": true,
"product_recommendations": true,
"dermatologist_referrals": true,
"priority_support": true,
"data_export": true
},
"stripe_price_id": "price_5Mno6Pqr7Stu8Vwx"
}
]
}
4.2 Create Checkout Session
Initiates Stripe Checkout Session for subscription purchase.
POST /subscriptions/create-checkout-session
Headers: Authorization: Bearer <access_token>
Request Body:
{
"plan_id": "plan_basic",
"success_url": "https://skindetect.com/subscriptions/success?session_id={CHECKOUT_SESSION_ID}",
"cancel_url": "https://skindetect.com/subscriptions/cancel"
}
Response 200 OK:
{
"checkout_url": "https://checkout.stripe.com/c/pay/cs_test_..."
}
Error 400 Bad Request:
{
"detail": "Invalid plan ID."
}
4.3 Stripe Webhook
Receives Stripe webhook events for subscription lifecycle management.
POST /subscriptions/webhook
Headers: Stripe-Signature: <webhook_signature>
No auth (verified via Stripe signature).
Events Handled:
| Event | Action |
|---|---|
checkout.session.completed |
Activate subscription, set end_date |
invoice.paid |
Renew subscription period |
invoice.payment_failed |
Send payment failure email, mark subscription past_due |
customer.subscription.updated |
Sync plan changes |
customer.subscription.deleted |
Cancel subscription, revert to free plan |
Response 200 OK:
{
"received": true
}
4.4 Get My Subscription
Returns the authenticated user's current subscription details.
GET /subscriptions/my
Headers: Authorization: Bearer <access_token>
Response 200 OK:
{
"user_id": "uuid",
"plan": {
"id": "plan_basic",
"name": "Basic",
"daily_scans": 20,
"features": { ... }
},
"status": "active",
"current_period_start": "2026-05-01T00:00:00Z",
"current_period_end": "2026-06-01T00:00:00Z",
"cancel_at_period_end": false,
"scans_used_today": 3,
"scans_remaining_today": 17,
"stripe_subscription_id": "sub_1ABC2Def3Ghi4Jkl"
}
4.5 Cancel Subscription
POST /subscriptions/cancel
Headers: Authorization: Bearer <access_token>
Response 200 OK:
{
"message": "Subscription will be cancelled at the end of the current billing period.",
"cancel_at_period_end": true,
"current_period_end": "2026-06-01T00:00:00Z"
}
5. Database Schema
5.1 Entity-Relationship Diagram (Textual)
users ββ1:1ββ user_subscriptions ββN:1ββ subscription_plans
users ββ1:Nββ scan_records
users ββ1:Nββ scan_daily_usage
users ββ1:Nββ password_reset_tokens
users ββ1:Nββ payment_transactions
scan_records ββN:1ββ users (nullable)
ip_scan_tracking (standalone, keyed by IP)
5.2 Table Definitions
-- ============================================================
-- USERS
-- ============================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255),
full_name VARCHAR(100) NOT NULL,
profile_photo_url TEXT,
medical_history JSONB DEFAULT '{}',
auth_provider VARCHAR(20) DEFAULT 'email'
CHECK (auth_provider IN ('email', 'google', 'facebook')),
social_id VARCHAR(255),
is_verified BOOLEAN DEFAULT false,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_social ON users(auth_provider, social_id);
-- ============================================================
-- SUBSCRIPTION PLANS (seed data)
-- ============================================================
CREATE TABLE subscription_plans (
id VARCHAR(50) PRIMARY KEY, -- 'plan_free', 'plan_basic', 'plan_premium'
name VARCHAR(100) NOT NULL,
price_cents INTEGER NOT NULL,
currency VARCHAR(3) DEFAULT 'USD',
billing_interval VARCHAR(20) DEFAULT 'month',
daily_scan_limit INTEGER NOT NULL,
features JSONB NOT NULL DEFAULT '{}',
stripe_price_id VARCHAR(255),
is_active BOOLEAN DEFAULT true,
sort_order INTEGER DEFAULT 0
);
-- Seed data
INSERT INTO subscription_plans (id, name, price_cents, daily_scan_limit, features, sort_order) VALUES
('plan_free', 'Free', 0, 5, '{"scan_history_days": 30, "detailed_results": true, "product_recommendations": true, "dermatologist_referrals": false, "priority_support": false, "data_export": false}', 1),
('plan_basic', 'Basic', 499, 20, '{"scan_history_days": 365, "detailed_results": true, "product_recommendations": true, "dermatologist_referrals": true, "priority_support": false, "data_export": false}', 2),
('plan_premium', 'Premium', 999, 100,'{"scan_history_days": null, "detailed_results": true, "product_recommendations": true, "dermatologist_referrals": true, "priority_support": true, "data_export": true}', 3);
-- ============================================================
-- USER SUBSCRIPTIONS
-- ============================================================
CREATE TABLE user_subscriptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id VARCHAR(50) NOT NULL REFERENCES subscription_plans(id),
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'past_due', 'canceled', 'expired')),
current_period_start TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
current_period_end TIMESTAMP WITH TIME ZONE,
cancel_at_period_end BOOLEAN DEFAULT false,
stripe_subscription_id VARCHAR(255),
stripe_customer_id VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id) -- one active subscription per user
);
CREATE INDEX idx_user_sub_status ON user_subscriptions(user_id, status);
-- ============================================================
-- SCAN RECORDS
-- ============================================================
CREATE TABLE scan_records (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
ip_address VARCHAR(45) NOT NULL,
image_url TEXT NOT NULL,
thumbnail_url TEXT,
original_filename VARCHAR(255),
image_size_bytes INTEGER,
processing_time_ms INTEGER,
results JSONB NOT NULL,
primary_detection JSONB,
detected_conditions TEXT[],
model_version VARCHAR(50),
is_flagged BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_scan_user ON scan_records(user_id, created_at DESC);
CREATE INDEX idx_scan_ip ON scan_records(ip_address, created_at DESC);
CREATE INDEX idx_scan_conditions ON scan_records USING GIN(detected_conditions);
-- ============================================================
-- SCAN DAILY USAGE (per-user quota tracking)
-- ============================================================
CREATE TABLE scan_daily_usage (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scan_date DATE NOT NULL DEFAULT CURRENT_DATE,
scan_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, scan_date)
);
-- ============================================================
-- IP SCAN TRACKING (anonymous rate limiting)
-- ============================================================
CREATE TABLE ip_scan_tracking (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ip_address VARCHAR(45) UNIQUE NOT NULL,
scan_count INTEGER NOT NULL DEFAULT 0,
first_scan_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_scan_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- ============================================================
-- PASSWORD RESET TOKENS
-- ============================================================
CREATE TABLE password_reset_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
is_used BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_reset_token ON password_reset_tokens(user_id, is_used);
-- ============================================================
-- PAYMENT TRANSACTIONS
-- ============================================================
CREATE TABLE payment_transactions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
amount_cents INTEGER NOT NULL,
currency VARCHAR(3) DEFAULT 'USD',
payment_provider VARCHAR(50) DEFAULT 'stripe',
payment_provider_id VARCHAR(255),
subscription_id UUID REFERENCES user_subscriptions(id),
status VARCHAR(20) NOT NULL
CHECK (status IN ('pending', 'completed', 'failed', 'refunded')),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_payment_user ON payment_transactions(user_id, created_at DESC);
-- ============================================================
-- DOCTORS / CLINICS / PRODUCTS (for recommendations)
-- ============================================================
CREATE TABLE doctors (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(200) NOT NULL,
specializations TEXT[],
address TEXT,
phone VARCHAR(50),
email VARCHAR(255),
website TEXT,
rating DECIMAL(2,1) DEFAULT 0.0,
review_count INTEGER DEFAULT 0,
coordinates POINT,
insurance_accepted TEXT[],
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_doctor_location ON doctors USING GIST(coordinates);
CREATE TABLE clinics (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(200) NOT NULL,
type VARCHAR(50) DEFAULT 'clinic',
address TEXT,
phone VARCHAR(50),
rating DECIMAL(2,1) DEFAULT 0.0,
review_count INTEGER DEFAULT 0,
services TEXT[],
open_hours JSONB,
accepts_insurance BOOLEAN,
walkins_accepted BOOLEAN,
coordinates POINT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_clinic_location ON clinics USING GIST(coordinates);
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(200) NOT NULL,
brand VARCHAR(100),
category VARCHAR(50),
type VARCHAR(50) CHECK (type IN ('over_the_counter', 'prescription')),
description TEXT,
key_ingredients TEXT[],
recommended_for TEXT[],
average_rating DECIMAL(2,1),
price_amount DECIMAL(10,2),
price_currency VARCHAR(3) DEFAULT 'USD',
retailer VARCHAR(100),
affiliate_link TEXT,
image_url TEXT,
prescription_required BOOLEAN DEFAULT false,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_product_condition ON products USING GIN(recommended_for);
6. Rate Limiting Logic
6.1 Architecture Overview
βββββββββββββββββββ
β Client Request β
ββββββββββ¬βββββββββ
β
ββββββββββΌβββββββββ
β Rate Limit β
β Middleware β
ββββββββββ¬βββββββββ
β
ββββββββββββββββΌβββββββββββββββ
β β
βββββββββββΌββββββββββ ββββββββββββΌβββββββββββ
β Has valid JWT? β β No JWT / Invalid β
β => User-based β β => IP-based β
β rate limiting β β rate limiting β
βββββββββββ¬βββββββββββ ββββββββββββ¬βββββββββββ
β β
βββββββββββΌββββββββββ ββββββββββββΌβββββββββββ
β Check scan_daily β β Check ip_scan_track β
β _usage table β β _ing table β
β WHERE user_id=X β β WHERE ip=X β
β AND date=TODAY β β β
βββββββββββ¬βββββββββββ ββββββββββββ¬βββββββββββ
β β
β ββββββββββββββββββββββββ
β β
βββββΌβββββββΌββββ
β Under limit? ββββNoβββ 429 Too Many Requests
βββββ¬βββββββ¬ββββ
βYes βYes
β β
βββββββββββΌβ ββββΌβββββββββ
β Incrementβ β Proceed β
β counter β β to scan β
ββββββββββββ βββββββββββββ
6.2 Rate Limit Rules
| Scope | Limit | Window | Storage | Reset |
|---|---|---|---|---|
| Anonymous (per IP) | 3 scans total | Lifetime (per IP) | ip_scan_tracking table |
Never (manual reset) |
| Free tier (per user) | 5 scans | Daily (UTC) | scan_daily_usage table |
Midnight UTC |
| Basic plan (per user) | 20 scans | Daily (UTC) | scan_daily_usage table |
Midnight UTC |
| Premium plan (per user) | 100 scans | Daily (UTC) | scan_daily_usage table |
Midnight UTC |
6.3 Quota Check Algorithm (Pseudocode)
async def check_scan_quota(request: Request, user: User | None) -> QuotaResult:
if user:
# ββ Authenticated user ββββββββββββββββββββββββββββββ
plan = await get_user_plan(user.id) # default: free plan
daily_limit = plan.daily_scan_limit
today = date.today()
usage = await ScanDailyUsage.objects.get_or_create(
user_id=user.id, scan_date=today,
defaults={"scan_count": 0}
)
if usage.scan_count >= daily_limit:
raise HTTPException(
status_code=429,
detail={
"message": "Daily scan limit reached.",
"limit": daily_limit,
"plan": plan.name,
"reset_at": f"{today + timedelta(days=1)}T00:00:00Z",
"upgrade_url": "/subscriptions/plans"
}
)
usage.scan_count += 1
await usage.save()
return QuotaResult(remaining=daily_limit - usage.scan_count)
else:
# ββ Anonymous user βββββββββββββββββββββββββββββββββββ
ip = get_client_ip(request)
ANON_LIMIT = 3
tracking = await ip_scan_tracking.objects.get_or_create(
ip_address=ip,
defaults={"scan_count": 0, "first_scan_at": now()}
)
if tracking.scan_count >= ANON_LIMIT:
raise HTTPException(
status_code=429,
detail={
"message": "Anonymous scan limit reached (3 scans). Sign up to continue.",
"limit": ANON_LIMIT,
"reset_at": None # never resets for anonymous
}
)
tracking.scan_count += 1
tracking.last_scan_at = now()
await tracking.save()
return QuotaResult(remaining=ANON_LIMIT - tracking.scan_count)
6.4 IP Address Detection
def get_client_ip(request: Request) -> str:
# Priority: X-Forwarded-For > X-Real-IP > direct remote
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
return request.client.host
6.5 IP Geolocation (for Recommendations)
# Use MaxMind GeoLite2 database
from geoip2.database import Reader
geo_reader = Reader("data/GeoLite2-City.mmdb")
async def get_location_from_ip(ip: str) -> GeoLocation:
if ip in ("127.0.0.1", "::1", "localhost"):
return GeoLocation(lat=40.7128, lng=-74.0060) # default fallback
response = geo_reader.city(ip)
return GeoLocation(
lat=response.location.latitude,
lng=response.location.longitude,
city=response.city.name,
country=response.country.name
)
7. Error Handling
7.1 Global Error Schema
All error responses follow this structure:
{
"detail": "Human-readable error message.",
"code": "ERROR_CODE",
"timestamp": "2026-05-17T10:30:00Z",
"path": "/v1/scans",
"extra": {}
}
7.2 Standard Error Codes
| HTTP Code | Code String | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid input (Pydantic validation) |
| 400 | INVALID_IMAGE |
Bad image format/dimensions/size |
| 401 | UNAUTHORIZED |
Missing/invalid/expired JWT |
| 403 | FORBIDDEN |
Valid JWT but insufficient permissions |
| 404 | NOT_FOUND |
Resource doesn't exist |
| 409 | CONFLICT |
Duplicate email, etc. |
| 422 | UNPROCESSABLE_ENTITY |
Request body fails schema validation |
| 429 | RATE_LIMIT_EXCEEDED |
Quota exceeded |
| 500 | INTERNAL_ERROR |
Unexpected server error |
| 502 | AI_MODEL_ERROR |
Model inference failure |
| 503 | SERVICE_UNAVAILABLE |
Model not loaded / maintenance |
7.3 FastAPI Exception Handler Setup
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
return JSONResponse(
status_code=exc.status_code,
content={
"detail": exc.detail,
"code": ERROR_CODE_MAP.get(exc.status_code, "UNKNOWN"),
"timestamp": datetime.utcnow().isoformat() + "Z",
"path": request.url.path,
}
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
return JSONResponse(
status_code=422,
content={
"detail": exc.errors(),
"code": "VALIDATION_ERROR",
"timestamp": datetime.utcnow().isoformat() + "Z",
"path": request.url.path,
}
)
8. Middleware & Configuration
8.1 CORS Configuration
origins = [
"https://skindetect.com",
"https://app.skindetect.com",
"https://admin.skindetect.com",
"http://localhost:3000", # dev
"http://localhost:5173", # vite dev
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
expose_headers=["X-RateLimit-Remaining", "X-RateLimit-Reset"],
max_age=600,
)
8.2 Audit Logging Middleware
@app.middleware("http")
async def audit_log_middleware(request: Request, call_next):
start_time = time.perf_counter()
request_id = str(uuid4())
request.state.request_id = request_id
response = await call_next(request)
elapsed = (time.perf_counter() - start_time) * 1000
logger.info(
"audit_log",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"elapsed_ms": round(elapsed, 2),
"ip": get_client_ip(request),
"user_agent": request.headers.get("user-agent"),
"user_id": getattr(request.state, "user_id", None),
}
)
return response
8.3 Security Headers Middleware
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Cache-Control"] = "no-store"
return response
8.4 Rate Limit Response Headers
Every scan response includes these headers:
X-RateLimit-Limit: 5 # Total daily limit
X-RateLimit-Remaining: 3 # Remaining scans today
X-RateLimit-Reset: 1715904000 # Unix timestamp of reset (midnight UTC)
9. Complete API Route Table
| Method | Route | Auth | Rate Limited | Description |
|---|---|---|---|---|
| POST | /auth/signup |
No | No | Create account |
| POST | /auth/login |
No | 10/min per IP | Login |
| POST | /auth/social/{provider} |
No | 10/min per IP | Social login |
| POST | /auth/forgot-password |
No | 3/hr per email | Request reset |
| POST | /auth/reset-password |
No | 5/hr per IP | Complete reset |
| PUT | /auth/change-password |
Yes | No | Change password |
| POST | /auth/refresh |
No | No | Refresh JWT |
| PUT | /auth/profile |
Yes | No | Update profile |
| POST | /scans |
Optional | Yes (IP/user) | Scan image |
| GET | /scans |
Yes | 60/min | Scan history |
| GET | /scans/{id} |
Yes | 60/min | Scan detail |
| PATCH | /scans/{id} |
Yes | 60/min | Rename scan |
| DELETE | /scans/{id} |
Yes | 60/min | Delete scan |
| GET | /recommendations/dermatologists |
Optional | 30/min | Find doctors |
| GET | /recommendations/products |
Optional | 30/min | Find products |
| GET | /recommendations/clinics |
Optional | 30/min | Find clinics |
| GET | /subscriptions/plans |
No | No | List plans |
| POST | /subscriptions/create-checkout-session |
Yes | 5/min | Start checkout |
| POST | /subscriptions/webhook |
No (sig) | No | Stripe webhook |
| GET | /subscriptions/my |
Yes | 30/min | My subscription |
| POST | /subscriptions/cancel |
Yes | 5/min | Cancel subscription |
| GET | /health |
No | No | Health check |
10. Environment Variables
# ββ App ββββββββββββββββββββββββββββββββββββββββββββββ
APP_ENV=production
APP_SECRET_KEY=<random-64-char-hex>
APP_DEBUG=false
API_PREFIX=/v1
# ββ Database ββββββββββββββββββββββββββββββββββββββββββ
DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/skindetect
# ββ JWT ββββββββββββββββββββββββββββββββββββββββββββββ
JWT_ACCESS_SECRET=<random-64-char-hex>
JWT_REFRESH_SECRET=<different-random-64-char-hex>
JWT_ACCESS_EXPIRE_MINUTES=60
JWT_REFRESH_EXPIRE_DAYS=30
JWT_ALGORITHM=HS256
# ββ AI Model βββββββββββββββββββββββββββββββββββββββββ
MODEL_PATH=best_skin_model_3.keras
CLASS_NAMES_PATH=class_names.json
MODEL_INPUT_SIZE=224
CONFIDENCE_THRESHOLD=15.0
# ββ Storage (CDN) ββββββββββββββββββββββββββββββββββββ
CDN_PROVIDER=s3
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_S3_BUCKET=skindetect-scans
AWS_REGION=us-east-1
CDN_BASE_URL=https://cdn.skindetect.com
# ββ OAuth ββββββββββββββββββββββββββββββββββββββββββββ
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
FACEBOOK_APP_ID=...
FACEBOOK_APP_SECRET=...
# ββ Stripe βββββββββββββββββββββββββββββββββββββββββββ
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_FREE=price_1...
STRIPE_PRICE_BASIC=price_2...
STRIPE_PRICE_PREMIUM=price_3...
# ββ Rate Limiting ββββββββββββββββββββββββββββββββββββ
ANONYMOUS_SCAN_LIMIT=3
FREE_TIER_DAILY_SCANS=5
BASIC_PLAN_DAILY_SCANS=20
PREMIUM_PLAN_DAILY_SCANS=100
# ββ Email ββββββββββββββββββββββββββββββββββββββββββββ
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=...
FROM_EMAIL=noreply@skindetect.com
# ββ GeoIP ββββββββββββββββββββββββββββββββββββββββββββ
GEOIP_DB_PATH=data/GeoLite2-City.mmdb
# ββ Affiliate ββββββββββββββββββββββββββββββββββββββββ
AMAZON_AFFILIATE_TAG=skindetect-20
WALMART_AFFILIATE_TAG=skindetect-20
CVS_AFFILIATE_TAG=skindetect-20
11. Pydantic Model Definitions
# ββ Schemas ββββββββββββββββββββββββββββββββββββββββββββββ
class SignupRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8, max_length=128)
full_name: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-zA-Z\s\-]+$")
medical_history: Optional[dict] = None
class LoginRequest(BaseModel):
email: EmailStr
password: str
class SocialLoginRequest(BaseModel):
access_token: str
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
new_password: str = Field(..., min_length=8, max_length=128)
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(..., min_length=8, max_length=128)
class RefreshTokenRequest(BaseModel):
refresh_token: str
class UpdateProfileRequest(BaseModel):
full_name: Optional[str] = Field(None, min_length=1, max_length=100)
profile_photo: Optional[str] = None # base64
medical_history: Optional[dict] = None
class ScanResponse(BaseModel):
scan_id: str
image_url: str
results: list[DiseaseResult]
primary_detection: PrimaryDetection
processing_time_ms: int
disclaimer: str
quota_remaining: int
class DiseaseResult(BaseModel):
disease: str
scientific_name: Optional[str] = None
confidence: float # 0β100
severity: Optional[str] = None
description: Optional[str] = None
common_symptoms: list[str] = []
urgency_level: str # low | consult_recommended | urgent | emergency
class PrimaryDetection(BaseModel):
disease: str
confidence: float
severity: Optional[str] = None
urgency_level: str
class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int
total_pages: int
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
user: UserSummary
class UserSummary(BaseModel):
id: str
email: str
full_name: str
subscription_plan: str
scans_remaining_today: int
class ErrorResponse(BaseModel):
detail: str
code: str
timestamp: str
path: str
extra: dict = {}
12. File Structure (Recommended)
app/
βββ main.py # FastAPI app creation, middleware, exception handlers
βββ config.py # Settings from env vars (pydantic-settings)
βββ database.py # SQLAlchemy async engine + session
βββ models/ # SQLAlchemy ORM models
β βββ __init__.py
β βββ user.py
β βββ scan.py
β βββ subscription.py
β βββ recommendation.py
βββ schemas/ # Pydantic request/response schemas
β βββ __init__.py
β βββ auth.py
β βββ user.py
β βββ scan.py
β βββ subscription.py
β βββ recommendation.py
βββ api/
β βββ __init__.py
β βββ deps.py # Dependency injection (get_db, get_current_user, etc.)
β βββ v1/
β βββ __init__.py
β βββ auth.py # /auth/* routes
β βββ users.py # /auth/profile route
β βββ scans.py # /scans/* routes
β βββ recommendations.py # /recommendations/* routes
β βββ subscriptions.py # /subscriptions/* routes
β βββ payments.py # payment webhook
βββ core/
β βββ __init__.py
β βββ security.py # JWT encode/decode, password hashing
β βββ rate_limiter.py # Quota check logic
β βββ ip_geo.py # IP geolocation
β βββ logging.py # Logger setup
βββ services/
β βββ __init__.py
β βββ ai_detector.py # Model loading + inference
β βββ payment_gateway.py # Stripe integration
β βββ email_service.py # SendGrid/SMTP
β βββ recommendation_engine.py # Doctor/product/clinic lookup
βββ middleware/
β βββ __init__.py
β βββ rate_limit.py # Rate limit middleware
βββ tests/
βββ __init__.py
βββ conftest.py
βββ test_auth.py
βββ test_scans.py
βββ test_recommendations.py
βββ test_subscriptions.py
13. Security Checklist
- Passwords hashed with bcrypt (β₯12 rounds)
- JWT signed with separate secrets for access/refresh tokens
- Refresh tokens are single-use (rotate on each refresh)
- Rate limiting on auth endpoints (3/hr forgot password, 10/min login)
- CORS restricted to specific origins
- Security headers via middleware
- Stripe webhook verified via signature, not auth token
- Uploaded images scanned for malware (ClamAV)
- Image EXIF data stripped before storage
- Input validation on all endpoints (Pydantic)
- SQL injection prevention (parameterized queries via SQLAlchemy)
- No secrets in code (all via env vars)
- Rate limit headers exposed to clients
- Password reset tokens are one-time-use with short expiry (15 min)
- Silent fail on forgot-password email lookup (don't reveal if email exists)
14. Performance Considerations
| Concern | Strategy |
|---|---|
| Model inference latency | Load model at startup, warm up with dummy input, use async executor |
| Image upload | Upload to CDN (S3/GCS) directly via presigned URL, process async |
| Scan history queries | Composite indexes on (user_id, created_at DESC), GIN on detected_conditions |
| Rate limit counters | Use Redis for production; fallback to DB for simplicity |
| Geo-recommendations | PostGIS for spatial queries, cache results (5-min TTL) |
| Database connections | Async SQLAlchemy with connection pooling (min 5, max 20) |
| CDN | Serve scan thumbnails (256Γ256 WebP) from CDN with 1-year cache |
| Monitoring | Prometheus metrics: scan latency histogram, error rate, quota exhaustion rate |
Document Version: 1.0.0
Last Updated: 2026-05-17
Author: FastAPI Backend Architecture Team