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 | 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¶
- Validate reCAPTCHA - Verify token with Google
- Validate required fields - All required fields must be present
- Look up state ID - Convert state code (e.g., "TX") to Odoo state ID (e.g., 52)
- Find/Create Partner - Search for existing partner by email or create new one
- Update Partner Address - Add address fields to partner record
- Create Ticket - Create
helpdesk.ticketlinked to partner - Upload Attachments - Create
ir.attachmentrecords 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¶
- reCAPTCHA v3 runs in the background (invisible to users)
- On form submit, JavaScript requests a token from Google
- Token is sent with form data to the API
- Server verifies token with Google's API
- Google returns a score (0.0 - 1.0)
- 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:
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'},
# ...
]