GEO-Butler Backend Architecture: A Deep Dive into Serverless Search Analytics

Gabriel Penman • January 5, 2026

Introduction

GEO-Butler is a serverless SEO analytics platform that tracks website visibility across AI-powered search engines like ChatGPT, Gemini, and Perplexity. This post explores the backend architecture that powers real-time search operations, subscription billing, and AI-driven insights.

Tech Stack:


System Architecture Overview

The platform is built entirely on serverless infrastructure, with Firebase Cloud Functions handling all API logic. Here's how the pieces fit together:

graph TB subgraph "Client Layer" WEB[Next.js Web App
React 18 + TypeScript] end subgraph "CDN & Hosting" HOSTING[Firebase Hosting
Static Assets + SSR] end subgraph "Authentication" AUTH[Firebase Auth
Google OAuth] end subgraph "API Gateway" CF[Cloud Functions
Python 3.12
90+ Endpoints] end subgraph "External APIs" GOOGLE[Google Search
DataForSEO API] GPT[OpenAI GPT
GPT-5-nano] PERP[Perplexity AI] GEMINI[Google Gemini
2.5 Flash] STRIPE[Stripe
Payment Processing] end subgraph "Database" FS[(Firestore
20 Collections)] end subgraph "Storage" R2[Cloudflare R2
Schema Storage] end subgraph "Messaging" PUBSUB[Cloud Pub/Sub
Async Processing] end subgraph "Email" EMAIL[SendGrid/Mailgun
Email Delivery] end WEB -->|HTTPS| HOSTING WEB -->|OAuth| AUTH WEB -->|API Calls| CF CF -->|Verify Token| AUTH CF -->|Read/Write| FS CF -->|Search| GOOGLE CF -->|AI Search| GPT CF -->|AI Search| PERP CF -->|Schema Gen| GEMINI CF -->|Payments| STRIPE CF -->|Upload Schema| R2 CF -->|Publish Tasks| PUBSUB CF -->|Send Email| EMAIL PUBSUB -->|Trigger| CF STRIPE -->|Webhooks| CF style WEB fill:#4A90E2 style CF fill:#E85D75 style FS fill:#F39C12 style AUTH fill:#27AE60 style STRIPE fill:#635BFF

Key Architectural Decisions

  1. Serverless-First: All backend logic runs in Cloud Functions that scale to zero when idle
  2. Event-Driven: Pub/Sub decouples long-running operations from API responses
  3. Multi-Engine: Abstract search interface supports 5 different search engines
  4. Atomic Credits: Credit system uses atomic operations (not transactions) to prevent contention
  5. Firestore-Native: NoSQL structure optimized for real-time updates and concurrent writes

Backend Service Breakdown

Our Python backend is organized into 12 core modules powering 90+ API endpoints:

graph TB subgraph "API Layer - Cloud Functions (Python)" direction TB subgraph "Auth & User Management" AUTH_EP[settings
get_onboarding_stage
increment_onboarding_stage
subscription_status] end subgraph "Billing & Credits" BILLING_EP[get_wallet
update_tokens
adjust_customer_credits] end subgraph "Stripe Integration" STRIPE_EP[create_checkout_session
session_status
stripe_webhook] end subgraph "Data Management" SAVES_EP[new_save
get_saves
update_save
delete_save] TEMPLATES_EP[create_template
get_templates
update_template
delete_template] end subgraph "Search Operations" SEARCH_EP[search
search_template
process_single_search
ranked_keywords] end subgraph "Reports" REPORTS_EP[get_reports
get_report_status
get_report_summary
generate_sentiment_analysis] end subgraph "AI Features" AI_EP[generate_weekly_check
summarise_for_email
site_chatbot
generate_onboarding_keywords
parse_business_info] end subgraph "Schema Generation" SCHEMA_EP[generate_schema
generate_schema_async
get_schemas
upload_schema_to_r2
verify_domain] end subgraph "Email Operations" EMAIL_EP[send_email
send_template_email
process_email_queue
contact_form] end subgraph "Admin & Monitoring" ADMIN_EP[get_customers
get_errors
report_frontend_error
get_tickets] end end subgraph "Function Modules" direction LR BILLING_MOD[billing_functions.py
366 lines
Atomic credit operations] SEARCH_MOD[search_functions.py
~2500 lines
Multi-engine search] REPORT_MOD[report_functions_v2.py
~800 lines
Report management] KEYWORD_MOD[keyword_functions.py
431 lines
Keyword rankings] SCHEMA_MOD[schema_functions.py
518 lines
Schema generation] EMAIL_MOD[email_functions.py
917 lines
Email delivery] SCREENSHOT_MOD[screenshot_functions.py
302 lines
Playwright screenshots] ERROR_MOD[error_logging.py
238 lines
Error tracking] AI_REG[ai_function_registry.py
382 lines
AI function calling] end AUTH_EP --> BILLING_MOD BILLING_EP --> BILLING_MOD STRIPE_EP --> BILLING_MOD SEARCH_EP --> SEARCH_MOD SEARCH_MOD --> BILLING_MOD REPORTS_EP --> REPORT_MOD AI_EP --> BILLING_MOD SCHEMA_EP --> SCHEMA_MOD EMAIL_EP --> EMAIL_MOD style AUTH_EP fill:#27AE60 style BILLING_EP fill:#F39C12 style STRIPE_EP fill:#635BFF style SEARCH_EP fill:#E74C3C style AI_EP fill:#9B59B6 style SCHEMA_EP fill:#3498DB

Module Responsibilities


Data Flow #1: Single Search Operation

Let's trace a single search request from user input to final results:

sequenceDiagram autonumber participant User participant Frontend participant AuthProvider participant SearchEndpoint participant BillingFunctions participant SearchFunctions participant ExternalAPI participant Firestore participant Frontend as FrontendResult User->>Frontend: Enter search query Frontend->>AuthProvider: Get ID token AuthProvider-->>Frontend: Return token Frontend->>SearchEndpoint: POST /search
(query, engine, user_id, token) SearchEndpoint->>BillingFunctions: Check credit balance BillingFunctions->>Firestore: Query wallets collection Firestore-->>BillingFunctions: current_balance: 100 alt Sufficient Credits BillingFunctions-->>SearchEndpoint: Balance OK (100 >= 6) SearchEndpoint->>SearchFunctions: Execute search SearchFunctions->>ExternalAPI: API call (GPT/Google/etc) ExternalAPI-->>SearchFunctions: Raw results SearchFunctions->>SearchFunctions: Parse & normalize results SearchFunctions->>SearchFunctions: Calculate domain position SearchFunctions->>Firestore: Store in queries collection SearchFunctions-->>SearchEndpoint: Query ID + results SearchEndpoint->>BillingFunctions: Deduct 6 credits BillingFunctions->>Firestore: Atomic update wallet (100-6=94) BillingFunctions->>Firestore: Log to token_transactions BillingFunctions-->>SearchEndpoint: Credits deducted SearchEndpoint-->>Frontend: 200 OK + results Frontend-->>User: Display results else Insufficient Credits BillingFunctions-->>SearchEndpoint: Insufficient credits SearchEndpoint-->>Frontend: 402 Payment Required Frontend-->>User: Show upgrade modal end

Key Implementation Details

Credit Check Before Execution:

# Check balance BEFORE running expensive operation
balance = get_wallet_balance(user_id)
if balance < SEARCH_WEIGHTS[search_engine]:
    return Response("Insufficient credits", status=402)

# Execute search
results = execute_search(query, engine)

# Deduct credits AFTER successful execution
deduct_tokens_for_search(user_id, query_id, search_engine)

Atomic Wallet Updates:

def update_token_balance_atomic(user_id, amount, operation_type):
    """
    Atomic operation (NOT transaction) to prevent contention.
    Allows concurrent credit operations with retry logic.
    """
    max_retries = 5
    for attempt in range(max_retries):
        try:
            wallet_doc = get_wallet(user_id)
            new_balance = wallet_doc['current_balance'] + amount

            # Allow small negative balance (up to -100)
            if new_balance < -100:
                return False

            # Atomic update (no transaction lock)
            wallet_doc.reference.update({
                'current_balance': new_balance,
                'last_updated': SERVER_TIMESTAMP
            })

            # Log transaction
            log_transaction(user_id, amount, operation_type, new_balance)
            return True
        except Exception:
            # Exponential backoff with jitter
            time.sleep(0.1 * (2 ** attempt) + random.uniform(0, 0.1))

    return False

Why Atomic (Not Transactional)?


Data Flow #2: Template Report Generation (Async)

Templates allow users to run multiple queries across multiple search engines. This generates 10s or 100s of individual searches that must run asynchronously:

sequenceDiagram autonumber participant User participant Frontend participant TemplateEndpoint participant ReportFunctions participant PubSub participant SearchWorker participant SearchFunctions participant BillingFunctions participant Firestore User->>Frontend: Click "Run Template" Frontend->>TemplateEndpoint: POST /search_template
(template_id, user_id, token) TemplateEndpoint->>Firestore: Get template doc Firestore-->>TemplateEndpoint: Template with 10 queries, 3 engines TemplateEndpoint->>ReportFunctions: Create report ReportFunctions->>Firestore: Create report doc (status: in_progress) ReportFunctions->>Firestore: Create 30 query_status docs
(10 queries × 3 engines) ReportFunctions-->>TemplateEndpoint: Report ID created TemplateEndpoint->>PubSub: Publish 30 search tasks TemplateEndpoint-->>Frontend: 202 Accepted + report_id Frontend->>Frontend: Start polling /get_report_status loop For each search task PubSub->>SearchWorker: Trigger process_single_search_pubsub SearchWorker->>Firestore: Update query_status (processing) SearchWorker->>BillingFunctions: Check & deduct credits SearchWorker->>SearchFunctions: Execute search SearchFunctions->>Firestore: Store query result SearchWorker->>Firestore: Update query_status (completed) SearchWorker->>Firestore: Increment report.completed_queries end Frontend->>TemplateEndpoint: Poll GET /get_report_status TemplateEndpoint->>Firestore: Query query_status collection Firestore-->>TemplateEndpoint: 28/30 completed TemplateEndpoint-->>Frontend: Status: in_progress (93%) Frontend->>TemplateEndpoint: Poll GET /get_report_status TemplateEndpoint->>Firestore: Query query_status collection Firestore-->>TemplateEndpoint: 30/30 completed TemplateEndpoint->>Firestore: Update report (status: completed) TemplateEndpoint-->>Frontend: Status: completed Frontend-->>User: Redirect to /report-overview

Architectural Patterns

CQRS (Command Query Responsibility Segregation):

Pub/Sub for Scale:

def search_template(template_id, user_id):
    """
    Publish search tasks to Pub/Sub instead of processing synchronously.
    Prevents function timeout and enables parallel execution.
    """
    template = get_template(template_id)
    report_id = create_report(template_id)

    # Create query_status docs (lightweight, no contention)
    for query in template['queries']:
        for engine in template['search_engines']:
            create_query_status(report_id, query, engine)

    # Publish to Pub/Sub for async processing
    for query in template['queries']:
        for engine in template['search_engines']:
            publish_message('search-tasks', {
                'report_id': report_id,
                'query': query,
                'engine': engine,
                'user_id': user_id
            })

    return {'report_id': report_id, 'status': 'in_progress'}

Why This Works:


Data Flow #3: Subscription & Billing with Stripe

Payment processing is entirely webhook-driven, ensuring reliability even if the user closes their browser:

sequenceDiagram autonumber participant User participant Frontend participant CreateCheckout participant Stripe participant StripeWebhook participant BillingFunctions participant Firestore User->>Frontend: Select "Classic Plan" Frontend->>CreateCheckout: POST /create_checkout_session
(price_index: 2, user_id) CreateCheckout->>Stripe: Create checkout session Stripe-->>CreateCheckout: session + client_secret CreateCheckout-->>Frontend: Return session data Frontend->>Frontend: Mount Stripe embedded checkout Frontend-->>User: Display payment form User->>Stripe: Enter card details & submit Stripe->>Stripe: Process payment Stripe->>StripeWebhook: POST checkout.session.completed StripeWebhook->>Firestore: Create subscription doc Firestore-->>StripeWebhook: Subscription created Stripe->>StripeWebhook: POST customer.subscription.created StripeWebhook->>Firestore: Update subscription IDs Firestore-->>StripeWebhook: Updated Stripe->>StripeWebhook: POST invoice.paid
(billing_reason: subscription_create) StripeWebhook->>Firestore: Get subscription doc Firestore-->>StripeWebhook: credits_per_cycle: 5000 StripeWebhook->>BillingFunctions: Add 5000 credits BillingFunctions->>Firestore: Atomic update wallet (+5000) BillingFunctions->>Firestore: Log to token_transactions BillingFunctions-->>StripeWebhook: Credits added StripeWebhook->>Firestore: Update subscription.last_invoice_paid StripeWebhook-->>Stripe: 200 OK Stripe-->>User: Redirect to success page User->>Frontend: Navigate to /classic-success Frontend->>Frontend: Fetch updated wallet balance Frontend-->>User: Show success + 5000 credits

Webhook Implementation

Signature Verification (Critical):

@https_fn.on_request()
def stripe_webhook(req):
    """Handle all Stripe webhook events with signature verification."""
    payload = req.get_data()
    sig_header = req.headers.get('Stripe-Signature')

    try:
        # Verify webhook signature (prevents spoofing)
        event = stripe.Webhook.construct_event(
            payload, sig_header, STRIPE_WEBHOOK_SECRET
        )
    except ValueError:
        return Response("Invalid payload", status=400)
    except stripe.error.SignatureVerificationError:
        return Response("Invalid signature", status=400)

    # Route event to appropriate handler
    if event['type'] == 'checkout.session.completed':
        handle_checkout_completed(event['data']['object'])
    elif event['type'] == 'invoice.paid':
        handle_invoice_paid(event['data']['object'])
    elif event['type'] == 'customer.subscription.updated':
        handle_subscription_updated(event['data']['object'])

    return Response("Success", status=200)

Idempotent Credit Allocation:

def handle_invoice_paid(invoice):
    """
    Add credits when subscription invoice is paid.
    Handles both initial subscription and monthly renewals.
    """
    subscription_id = invoice['subscription']
    billing_reason = invoice['billing_reason']

    # Get subscription from Firestore
    subscription = get_subscription_by_stripe_id(subscription_id)
    user_id = subscription['user_id']
    credits = subscription['credits_per_cycle']

    # Determine operation type
    if billing_reason == 'subscription_create':
        operation_type = 'subscription_credit'
    elif billing_reason == 'subscription_cycle':
        operation_type = 'renewal_credit'
    else:
        operation_type = 'subscription_credit'

    # Add credits atomically
    success = update_token_balance_atomic(
        user_id=user_id,
        amount=credits,
        operation_type=operation_type,
        description=f"{operation_type}: {credits} credits"
    )

    if success:
        # Update subscription metadata
        update_subscription_payment_details(
            subscription_id,
            invoice['amount_paid'],
            invoice['status']
        )

Credit Cost Structure:

Plan Price Credits/Month Cost per Credit
Free $0 100 (one-time) N/A
Essentials $29 1,000 $0.029
Classic $99 5,000 $0.020
Select $299 50,000 $0.006
Operation Credits $ (Classic)
Google Search 2 $0.04
GPT Search 6 $0.12
Gemini Search 35 $0.70
Schema Generation 14 $0.28

Data Flow #4: AI Schema Generation with Gemini

One of our most complex features is AI-powered schema.org markup generation using multimodal analysis:

sequenceDiagram autonumber participant User participant Frontend participant SchemaEndpoint participant ScreenshotFunctions participant SchemaFunctions participant Gemini participant BillingFunctions participant R2Storage participant Firestore User->>Frontend: Request schema generation Frontend->>SchemaEndpoint: POST /generate_schema
(domain, schema_type, user_id) SchemaEndpoint->>BillingFunctions: Check credits (14 needed) BillingFunctions-->>SchemaEndpoint: Balance OK SchemaEndpoint->>ScreenshotFunctions: Capture website screenshot ScreenshotFunctions->>ScreenshotFunctions: Launch Playwright ScreenshotFunctions->>ScreenshotFunctions: Navigate to domain ScreenshotFunctions->>ScreenshotFunctions: Remove cookie banners ScreenshotFunctions->>ScreenshotFunctions: Capture full-page PNG ScreenshotFunctions-->>SchemaEndpoint: screenshot_base64 + html_content SchemaEndpoint->>SchemaFunctions: Generate schema SchemaFunctions->>Gemini: Multimodal prompt
(screenshot + HTML + instructions) Gemini->>Gemini: Process with gemini-2.5-flash Gemini-->>SchemaFunctions: Generated schema JSON-LD SchemaFunctions->>SchemaFunctions: Validate against vocabulary SchemaFunctions->>SchemaFunctions: Calculate validation score SchemaFunctions->>Firestore: Save to schemas collection Firestore-->>SchemaFunctions: Schema ID SchemaFunctions->>BillingFunctions: Deduct 14 credits BillingFunctions->>Firestore: Update wallet & log transaction BillingFunctions-->>SchemaFunctions: Credits deducted alt User requests R2 upload SchemaFunctions->>R2Storage: Upload schema JSON R2Storage-->>SchemaFunctions: Public URL SchemaFunctions->>Firestore: Update schema with R2 URL end SchemaFunctions-->>SchemaEndpoint: Schema data + validation SchemaEndpoint-->>Frontend: Return schema Frontend-->>User: Display schema + validation results

Multimodal AI Implementation

Gemini Vision Prompt:

def generate_schema_with_gemini(domain, schema_type, screenshot_base64, html_content):
    """
    Generate schema.org JSON-LD using Gemini's multimodal capabilities.
    Analyzes both visual appearance and HTML structure.
    """
    model = genai.GenerativeModel('gemini-2.5-flash')

    # Prepare multimodal input
    image_part = {
        'mime_type': 'image/png',
        'data': screenshot_base64
    }

    prompt = f"""
    Analyze this website and generate comprehensive schema.org JSON-LD markup.

    Domain: {domain}
    Requested Schema Type: {schema_type}

    Use the screenshot to understand:
    - Visual branding and design
    - Layout and structure
    - Key content placement

    Use the HTML to extract:
    - Structured data
    - Metadata
    - Content hierarchy

    Generate verbose, detailed schema with all relevant properties.
    Follow schema.org vocabulary strictly.

    HTML Content:
    {html_content[:10000]}  # First 10KB
    """

    # Call Gemini with multimodal input
    response = model.generate_content([prompt, image_part])
    schema_json = extract_json_from_response(response.text)

    # Validate against schema.org vocabulary
    validation_results = validate_schema(schema_json)

    return {
        'schema_json': schema_json,
        'validation_score': validation_results['score'],
        'validation_errors': validation_results['errors']
    }

Schema Validation:

def validate_schema_against_vocabulary(schema_json):
    """
    Validate generated schema against official schema.org vocabulary.
    Loaded from 1.5MB schemaorg-current.jsonld file.
    """
    vocabulary = load_schema_org_vocabulary()  # Cached in memory

    errors = []
    warnings = []
    score = 100

    # Check type exists
    if schema_json['@type'] not in vocabulary['types']:
        errors.append(f"Invalid type: {schema_json['@type']}")
        score -= 50

    # Check properties
    for prop, value in schema_json.items():
        if prop.startswith('@'):
            continue

        if prop not in vocabulary['properties']:
            warnings.append(f"Non-standard property: {prop}")
            score -= 5

    return {
        'score': max(0, score),
        'errors': errors,
        'warnings': warnings
    }

Database Architecture

Our Firestore database uses 20 collections optimized for real-time updates and concurrent access:

erDiagram USERS ||--o{ USER_SETTINGS : has USERS ||--|| WALLETS : has USERS ||--o{ SUBSCRIPTIONS : has USERS ||--o{ SAVES : owns USERS ||--o{ TEMPLATES : creates USERS ||--o{ QUERIES : executes USERS ||--o{ TOKEN_TRANSACTIONS : has SAVES ||--o{ SITES : analyzes SAVES ||--o{ TEMPLATES : contains SAVES ||--o{ REPORTS : generates TEMPLATES ||--o{ REPORTS : executes TEMPLATES }o--o{ QUERIES : includes REPORTS ||--o{ QUERY_STATUS : tracks REPORTS }o--o{ QUERIES : contains QUERIES }o--|| SITES : mentions WALLETS ||--o{ TOKEN_TRANSACTIONS : logs SUBSCRIPTIONS }o--|| STRIPE_CUSTOMER : linked USERS { string id PK string firebase_uid string email timestamp created_at } WALLETS { string user_id FK int current_balance string status timestamp last_updated } TOKEN_TRANSACTIONS { string id PK string user_id FK string wallet_id FK string type int amount string operation_type timestamp created_at } SUBSCRIPTIONS { string user_id FK string stripe_customer_id string stripe_subscription_id string plan_id int credits_per_cycle string status } REPORTS { string id PK string template_id FK string save_id FK string user_id FK string status int total_queries int completed_queries } QUERY_STATUS { string id PK string report_id FK string query_text string search_engine string status string query_id FK } QUERIES { string id PK string user_id FK string save_id FK string query_text string search_type array formatted_results int credits_charged }

Key Design Decisions

Separate query_status Collection:

User-Scoped user_data Maps:

interface Site {
  domain: string;
  public_data: {
    title: string;
    screenshot_base64: string;
    // Shared across all users
  };
  user_data: {
    [user_id: string]: {
      private_data: {
        competitors: string[];
        custom_notes: string;
        // Private to each user
      }
    }
  }
}

Atomic Wallet Updates:


External API Integration

GEO-Butler integrates with 8 external services:

graph LR subgraph "GEO-Butler Backend" SF[Search Functions] KF[Keyword Functions] SCF[Schema Functions] EF[Email Functions] BF[Billing Functions] end subgraph "Search APIs" DF[DataForSEO
Google Search
Keyword Rankings] OA[OpenAI
GPT-5-nano
AI Search & Summaries] PA[Perplexity AI
AI Search] GM[Google Gemini
2.5 Flash
Schema Generation] end subgraph "Infrastructure APIs" ST[Stripe
Payment Processing
Subscription Management] SG[SendGrid/Mailgun
Email Delivery] R2[Cloudflare R2
Schema Storage] end subgraph "Google Cloud" FA[Firebase Auth
OAuth & JWT] FS[(Firestore
NoSQL Database)] PS[Cloud Pub/Sub
Async Messaging] end SF -->|Search Requests| DF SF -->|AI Search| OA SF -->|AI Search| PA SF -->|AI Search| GM KF -->|Ranking Data| DF SCF -->|Schema Generation| GM SCF -->|Upload Schema| R2 EF -->|Send Email| SG BF -->|Payments| ST BF -->|Webhooks| ST SF -->|Read/Write| FS KF -->|Read/Write| FS SCF -->|Read/Write| FS EF -->|Read/Write| FS BF -->|Read/Write| FS SF -->|Publish Tasks| PS SCF -->|Publish Tasks| PS PS -->|Trigger Functions| SF PS -->|Trigger Functions| SCF SF -->|Verify Tokens| FA KF -->|Verify Tokens| FA SCF -->|Verify Tokens| FA style DF fill:#4285F4 style OA fill:#412991 style PA fill:#20808D style GM fill:#4285F4 style ST fill:#635BFF style SG fill:#1A82E2 style R2 fill:#F38020 style FA fill:#FFCA28 style FS fill:#F57C00 style PS fill:#4285F4

Multi-Engine Search Abstraction

class SearchEngine:
    """Abstract base class for all search engines."""

    @abstractmethod
    def search(self, query: str, location: str, language: str) -> dict:
        pass

    @abstractmethod
    def get_cost(self) -> int:
        pass

class GoogleSearchEngine(SearchEngine):
    """Google Search via DataForSEO API."""
    def search(self, query, location, language):
        response = requests.post(
            'https://api.dataforseo.com/v3/serp/google/organic/live/advanced',
            auth=(DATAFORSEO_LOGIN, DATAFORSEO_PASSWORD),
            json=[{
                'keyword': query,
                'location_code': location,
                'language_code': language
            }]
        )
        return self.parse_results(response.json())

    def get_cost(self):
        return 2  # credits

class GPTSearchEngine(SearchEngine):
    """GPT-powered search (Google + AI analysis)."""
    def search(self, query, location, language):
        # First get Google results
        google_results = GoogleSearchEngine().search(query, location, language)

        # Then analyze with GPT
        analysis = openai.chat.completions.create(
            model='gpt-5-nano',
            messages=[{
                'role': 'system',
                'content': 'Analyze these search results and provide insights.'
            }, {
                'role': 'user',
                'content': f"Query: {query}\n\nResults: {google_results}"
            }]
        )

        return {
            'google_results': google_results,
            'ai_analysis': analysis.choices[0].message.content
        }

    def get_cost(self):
        return 6  # credits (2 for Google + 4 for GPT)

Security Architecture

Four layers of security protect every API call:

graph TB subgraph "Security Layers" direction TB subgraph "1. Authentication" A1[Firebase Auth
Google OAuth] A2[ID Token Generation
JWT with 1hr expiry] A3[Auto Token Refresh] end subgraph "2. Authorization" B1[Token Verification
Every API call] B2[User ID Validation
Token UID = Header user-id] B3[Resource Ownership
Check user_id in docs] B4[Admin Privilege Check
users.is_admin flag] end subgraph "3. API Security" C1[CORS Configuration
Allowed origins only] C2[Stripe Webhook Signature
Verify all webhooks] C3[Rate Limiting
Per user/endpoint] C4[Input Validation
All user inputs] end subgraph "4. Data Security" D1[Firestore Security Rules] D2[Private user_data Maps
Per-user isolation] D3[Public/Private Mode
Magic link tokens] D4[Sensitive Data
Environment variables] end end A1 --> A2 --> A3 A3 --> B1 --> B2 --> B3 --> B4 B4 --> C1 --> C2 --> C3 --> C4 C4 --> D1 --> D2 --> D3 --> D4 style A1 fill:#27AE60 style B2 fill:#E74C3C style C2 fill:#F39C12 style D1 fill:#3498DB

Token Verification Pattern

Every Cloud Function starts with this:

def verify_user(request):
    """Verify Firebase ID token and extract user_id."""
    user_id = request.headers.get('user-id')
    auth_header = request.headers.get('Authorization')

    if not user_id or not auth_header:
        raise ValueError("Missing authentication")

    # Extract token from "Bearer <token>"
    token = auth_header.split('Bearer ')[1]

    try:
        # Verify with Firebase Admin SDK
        decoded_token = firebase_auth.verify_id_token(token)

        # Ensure user_id matches token
        if decoded_token['uid'] != user_id:
            raise ValueError("User ID mismatch")

        return user_id, decoded_token
    except Exception as e:
        raise ValueError(f"Invalid token: {e}")

@https_fn.on_request()
def my_endpoint(req):
    try:
        user_id, token = verify_user(req)

        # Process request for verified user
        data = get_user_data(user_id)
        return Response(json.dumps(data), status=200)

    except ValueError as e:
        return Response(str(e), status=401)

Performance & Scalability

Current Configuration

Cloud Functions:

Firestore:

Scaling Strategies

  1. Horizontal Scaling: Functions auto-scale based on load
  2. Async Processing: Pub/Sub prevents function timeouts
  3. CQRS Pattern: Separate query_status reduces contention
  4. Atomic Operations: No transaction locks = better concurrency
  5. Efficient Indexing: Composite indexes for common queries
  6. Caching: Schema.org vocabulary cached in memory (1.5MB)

Cost Optimization


Lessons Learned

1. Transactions vs. Atomic Operations

Problem: Initial implementation used Firestore transactions for credit updates, causing contention under load (concurrent template reports).

Solution: Switched to atomic operations with exponential backoff. Small negative balance buffer (-100 credits) handles rare race conditions.

Result: 10x improvement in concurrent report execution.

2. Report Document Contention

Problem: Storing all query results in report document caused timeouts when 30+ workers tried to update simultaneously.

Solution: Created separate query_status collection. Report doc only tracks counts, not full results.

Result: Reports with 100+ queries now complete reliably.

3. Function Timeouts on Large Templates

Problem: Processing 50 queries × 3 engines = 150 searches sequentially would timeout at 540 seconds.

Solution: Immediate Pub/Sub publish with 202 Accepted response. Frontend polls status.

Result: Templates of any size complete successfully.

4. Webhook Reliability

Problem: User closing browser during payment caused missed subscription credits.

Solution: 100% webhook-driven billing. Credits added asynchronously after payment confirmation.

Result: Zero missed credit allocations.


Conclusion

Building GEO-Butler taught us that serverless architecture requires different thinking than traditional backends:

The result is a platform that scales effortlessly from zero to thousands of concurrent searches, with sub-second API responses and reliable billing.


Project: GEO-Butler
Stack: Next.js + Firebase + Python
Architecture: Serverless Microservices
Database: Firestore (20 collections)
API Endpoints: 90+
External APIs: 8
Region: europe-west2

Documentation Repository: GitHub


Want to learn more? Check out the full architecture documentation for detailed API references, database schemas, and component architecture.