Skip to content

Forms & API

Form Endpoints

Estimate Form

Page: GET /free-estimate

API: POST /api/estimate

Creates a crm.lead in Odoo.

Request Body

{
  "name": "John Smith",
  "email": "john@example.com",
  "phone": "(555) 123-4567",
  "address": "123 Main St",
  "city": "Dallas",
  "zip": "75201",
  "service_type": "blinds",
  "window_count": "4-6",
  "notes": "Looking for motorized blinds",
  "recaptcha_token": "..."
}

Field Mapping to Odoo

Form Field Odoo Field (crm.lead)
name contact_name
email email_from
phone phone
address street
city city
zip zip
service_type description (included)
window_count description (included)
notes description (included)
- name = "Free Estimate - {name}"
- type = "opportunity"
- stage_id = First CRM stage
- source_id = From config (optional)

Opportunity vs Lead

The estimate form creates records with type='opportunity' so they appear in the CRM Pipeline view, not the Leads view. The stage_id is automatically set to the first stage in the pipeline (lowest sequence number).

Response

// Success
{
  "success": true,
  "message": "Thank you! We'll contact you within 24 hours.",
  "lead_id": 123
}

// Error
{
  "success": false,
  "error": "reCAPTCHA verification failed"
}

Helpdesk Form

Page: GET /helpdesk

API: POST /api/helpdesk

Creates a helpdesk.ticket in Odoo with linked res.partner (customer contact).

Form Fields

Field Required Type Description
name Yes String Customer name
email Yes Email Customer email
phone Yes Phone Customer phone
subject Yes String Ticket subject
ticket_type_id No Select Issue type dropdown
description Yes Textarea Issue description
street Yes String Street address
street2 No String Apt/Suite/Unit
city Yes String City
state_id Yes Select US State dropdown
zip Yes String ZIP code
installation_year No Select Year of installation (optional)
installation_month No Select Month of installation (optional)
trip_charge_acknowledged Yes Checkbox Trip charge acknowledgment
attachments No Files Multiple file uploads

Trip Charge Acknowledgment

The trip_charge_acknowledged checkbox is required. Users must acknowledge the trip charge policy before submitting a support ticket.

Request Body (JSON)

{
  "name": "John Smith",
  "email": "john@example.com",
  "phone": "(512) 555-1234",
  "subject": "Blinds not working",
  "ticket_type_id": "1",
  "description": "My motorized blinds stopped responding to the remote.",
  "street": "123 Main St",
  "street2": "Apt 4B",
  "city": "Austin",
  "state_id": "TX",
  "zip": "78701",
  "recaptcha_token": "..."
}

Request Body (FormData with Attachments)

const formData = new FormData();
formData.append('data', JSON.stringify({
    name: 'John Smith',
    email: 'john@example.com',
    phone: '(512) 555-1234',
    subject: 'Blinds not working',
    description: 'My motorized blinds stopped responding...',
    street: '123 Main St',
    city: 'Austin',
    state_id: 'TX',
    zip: '78701',
    recaptcha_token: '...'
}));
formData.append('attachments', file1);
formData.append('attachments', file2);

fetch('/api/helpdesk', {
    method: 'POST',
    body: formData
});

API Processing Flow

  1. Validate reCAPTCHA - Verify token with Google
  2. Validate required fields - All required fields must be present
  3. Look up state ID - Convert state code (e.g., "TX") to Odoo state ID (e.g., 52)
  4. Find/Create Partner - Search for existing partner by email or create new one
  5. Update Partner Address - Add address fields to partner record
  6. Create Ticket - Create helpdesk.ticket linked to partner
  7. Upload Attachments - Create ir.attachment records linked to ticket

Field Mapping to Odoo

Form Field Odoo Model Odoo Field
name res.partner name
email res.partner email
phone res.partner phone
street res.partner street
street2 res.partner street2
city res.partner city
state_id res.partner state_id (looked up from code)
zip res.partner zip
subject helpdesk.ticket name
description helpdesk.ticket description
ticket_type_id helpdesk.ticket ticket_type_id
- helpdesk.ticket partner_id (linked)
- helpdesk.ticket partner_name, partner_email, partner_phone
- helpdesk.ticket team_id (from config)

Response

// Success
{
  "success": true,
  "message": "Your ticket #21 has been submitted. We will contact you soon.",
  "ticket_id": 21
}

// Validation Error
{
  "success": false,
  "error": "Street Address is required"
}

// reCAPTCHA Error
{
  "success": false,
  "error": "reCAPTCHA verification failed"
}

reCAPTCHA Integration

How It Works

  1. reCAPTCHA v3 runs in the background (invisible to users)
  2. On form submit, JavaScript requests a token from Google
  3. Token is sent with form data to the API
  4. Server verifies token with Google's API
  5. Google returns a score (0.0 - 1.0)
  6. Scores below 0.5 are rejected as potential bots

Client-Side (Alpine.js)

// In form x-data
async submitForm() {
    // Get reCAPTCHA token
    let recaptchaToken = '';
    if (typeof grecaptcha !== 'undefined') {
        recaptchaToken = await grecaptcha.execute(
            'SITE_KEY',
            {action: 'helpdesk'}
        );
    }

    // Build FormData for file upload support
    const formData = new FormData();
    formData.append('data', JSON.stringify({
        ...this.form,
        recaptcha_token: recaptchaToken
    }));

    // Add files
    for (const file of this.files) {
        formData.append('attachments', file);
    }

    const response = await fetch('/api/helpdesk', {
        method: 'POST',
        body: formData
    });
}

Server-Side Verification

# app/services/recaptcha.py
def verify_recaptcha(token):
    if not token:
        return False

    secret_key = current_app.config.get('RECAPTCHA_SECRET_KEY')
    if not secret_key:
        return True  # Skip in dev mode

    response = requests.post(
        'https://www.google.com/recaptcha/api/siteverify',
        data={
            'secret': secret_key,
            'response': token
        }
    )
    result = response.json()
    return result.get('success') and result.get('score', 0) >= 0.5

Odoo API Integration

API Client

The landing page uses a custom OdooAPI class to communicate with Odoo's REST API.

# app/services/odoo_api.py
class OdooAPI:
    def __init__(self):
        self.base_url = config.ODOO_BASE_URL  # e.g., 'http://odoo:8069'
        self.api_key = config.ODOO_API_KEY
        self.headers = {'X-API-Key': self.api_key}

    def create(self, model, values):
        """Create a record in Odoo."""
        response = requests.post(
            f"{self.base_url}/restapi/1.0/object/{model}",
            headers=self.headers,
            data={'vals': json.dumps(values)},  # Note: form data, not JSON
            timeout=30
        )
        return response.json()

    def search_read(self, model, domain=None, fields=None, limit=100, order=None):
        """Search and read records from Odoo."""
        params = {'limit': limit}
        if domain:
            params['domain'] = json.dumps(domain)
        if fields:
            params['fields'] = json.dumps(fields)
        if order:
            params['order'] = order  # e.g., 'sequence asc', 'create_date desc'

        response = requests.get(
            f"{self.base_url}/restapi/1.0/object/{model}",
            headers=self.headers,
            params=params,
            timeout=30
        )
        return response.json()

    def find_or_create_partner(self, email, name, phone='', address=None):
        """Find existing partner by email or create new one with address."""
        # Search for existing
        result = self.search_read('res.partner',
            domain=[('email', '=', email)],
            fields=['id', 'name', 'phone', 'street', 'city'],
            limit=1
        )

        if result.get('data'):
            partner_id = result['data'][0]['id']
            # Update missing address fields...
            return {'success': True, 'partner_id': partner_id}

        # Create new partner with address
        partner_data = {'name': name, 'email': email, 'phone': phone}
        if address:
            partner_data.update(address)

        create_result = self.create('res.partner', partner_data)
        return {'success': True, 'partner_id': create_result['id']}

Important: REST API Format

The Odoo REST API expects form data with a vals parameter, NOT JSON body:

# Correct
requests.post(url, headers=headers, data={'vals': json.dumps(values)})

# Incorrect - will cause "type http vs json" error
requests.post(url, headers=headers, json=values)

State Code to ID Lookup

The form uses state codes (e.g., "TX") but Odoo needs state IDs:

def get_state_id(odoo, state_code):
    """Look up Odoo state ID from state code (e.g., 'TX' -> 52)."""
    result = odoo.search_read(
        'res.country.state',
        domain=[('code', '=', state_code), ('country_id.code', '=', 'US')],
        fields=['id'],
        limit=1
    )
    if result.get('data'):
        return result['data'][0]['id']
    return None

Health Check

Endpoint: GET /health

Response:

{
  "status": "healthy"
}

Used by Docker healthcheck and monitoring.


Configuration

Required Environment Variables

# Odoo API
ODOO_BASE_URL=http://odoo:8069
ODOO_API_KEY=your_api_key_here
ODOO_DB=odoo_production

# CRM (Estimate Form)
CRM_SOURCE_ID=1              # Optional: UTM source ID for tracking

# Helpdesk
HELPDESK_TEAM_ID=1

# reCAPTCHA
RECAPTCHA_SITE_KEY=your_site_key
RECAPTCHA_SECRET_KEY=your_secret_key

Ticket Types

The helpdesk form fetches ticket types from Odoo dynamically:

def get_ticket_types():
    """Fetch ticket types from Odoo or return defaults."""
    odoo = OdooAPI()
    result = odoo.search_read(
        'helpdesk.ticket.type',
        domain=[],
        fields=['id', 'name'],
        limit=50
    )
    if result.get('success') and result.get('data'):
        return [{'value': str(t['id']), 'label': t['name']} for t in result['data']]

    # Fallback defaults
    return [
        {'value': '1', 'label': 'General Inquiry'},
        {'value': '2', 'label': 'Technical Support'},
        # ...
    ]