Odoo Integration
Connect Odoo ERP modules (Inventory, Manufacturing, Sales, Quality) to Certyo using XML-RPC, JSON-RPC, or the newer REST API. Anchor product and lot data on the blockchain every time goods are received, manufactured, or shipped.
Architecture
Odoo Server Action / Automated Action
│
▼
Odoo Webhook (or cron job polling)
│
▼
Your middleware (Python / Node.js)
│ ┌─ Transform Odoo record to Certyo payload
│ └─ POST /api/v1/records (X-API-Key header)
▼
Certyo Pipeline
│ Kafka → Accumulate → Merkle tree → IPFS → Polygon
▼
Webhook callback → Update Odoo custom field
(x_certyo_anchor_status, x_certyo_tx_hash)Prerequisites
- Odoo 16+ (Community or Enterprise) with API access enabled
- An Odoo user with API key or password for XML-RPC / JSON-RPC
- A Certyo API key (
X-API-Key) - Python 3.10+ with xmlrpc.client (stdlib) and requests
Step 1: Add custom fields in Odoo
Create custom fields on the stock.picking (delivery order) or mrp.production (manufacturing order) model to store Certyo anchoring results. Go to Settings → Technical → Fields or use a custom module:
from odoo import fields, models
class StockPicking(models.Model):
_inherit = "stock.picking"
x_certyo_record_hash = fields.Char(
string="Certyo Record Hash",
readonly=True,
copy=False,
)
x_certyo_anchor_status = fields.Selection(
[
("pending", "Pending"),
("anchored", "Anchored"),
("failed", "Failed"),
],
string="Anchor Status",
readonly=True,
copy=False,
)
x_certyo_tx_hash = fields.Char(
string="Polygon Tx Hash",
readonly=True,
copy=False,
)Step 2: Send records to Certyo
When a delivery order is validated (state changes to done), read the picking data via Odoo's API and forward it to Certyo. You can trigger this with an Odoo Automated Action or an external cron job.
"""
Odoo → Certyo integration script.
Polls Odoo for validated delivery orders and ingests them into Certyo.
"""
import xmlrpc.client
import requests
from datetime import datetime, timedelta
# --- Odoo connection ---
ODOO_URL = "https://mycompany.odoo.com"
ODOO_DB = "mycompany-prod"
ODOO_USER = "api-user@mycompany.com"
ODOO_API_KEY = "odoo-api-key-here" # Settings → Users → API Keys
common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common")
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_API_KEY, {})
models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object")
# --- Certyo connection ---
CERTYO_URL = "https://www.certyos.com/api/v1"
CERTYO_API_KEY = "your-certyo-api-key"
CERTYO_TENANT = "mycompany"
def get_recent_pickings():
"""Fetch delivery orders validated in the last hour."""
since = (datetime.utcnow() - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
return models.execute_kw(
ODOO_DB, uid, ODOO_API_KEY,
"stock.picking", "search_read",
[[
("state", "=", "done"),
("picking_type_code", "=", "outgoing"),
("date_done", ">=", since),
("x_certyo_record_hash", "=", False), # not yet ingested
]],
{"fields": [
"name", "partner_id", "origin", "date_done",
"move_ids_without_package", "carrier_tracking_ref",
]},
)
def get_move_lines(picking_id):
"""Fetch product details for a delivery order."""
return models.execute_kw(
ODOO_DB, uid, ODOO_API_KEY,
"stock.move", "search_read",
[[("picking_id", "=", picking_id)]],
{"fields": [
"product_id", "product_uom_qty", "quantity",
"lot_ids", "reference",
]},
)
def ingest_picking(picking):
"""Send a single delivery order to Certyo."""
moves = get_move_lines(picking["id"])
products = []
for move in moves:
products.append({
"productId": move["product_id"][0],
"productName": move["product_id"][1],
"orderedQty": move["product_uom_qty"],
"deliveredQty": move["quantity"],
"lotIds": move.get("lot_ids", []),
})
payload = {
"tenantId": CERTYO_TENANT,
"database": "odoo",
"collection": "stock.picking",
"recordId": picking["name"], # e.g. "WH/OUT/00042"
"recordVersion": "1",
"operationType": "insert",
"recordPayload": {
"pickingName": picking["name"],
"partner": picking["partner_id"][1] if picking["partner_id"] else None,
"sourceDocument": picking.get("origin"),
"dateDone": picking["date_done"],
"trackingRef": picking.get("carrier_tracking_ref"),
"products": products,
},
"sourceTimestamp": picking["date_done"],
"idempotencyKey": f'{picking["name"]}-v1',
}
resp = requests.post(
f"{CERTYO_URL}/records",
json=payload,
headers={
"X-API-Key": CERTYO_API_KEY,
"Content-Type": "application/json",
},
timeout=10,
)
resp.raise_for_status()
return resp.json()
def update_odoo_status(picking_id, certyo_response):
"""Write Certyo hash back to the Odoo record."""
models.execute_kw(
ODOO_DB, uid, ODOO_API_KEY,
"stock.picking", "write",
[[picking_id], {
"x_certyo_record_hash": certyo_response["recordHash"],
"x_certyo_anchor_status": "pending",
}],
)
if __name__ == "__main__":
pickings = get_recent_pickings()
print(f"Found {len(pickings)} new delivery orders")
for picking in pickings:
result = ingest_picking(picking)
update_odoo_status(picking["id"], result)
print(f" ✓ {picking['name']} → {result['recordHash'][:16]}...")"""
Alternative: Use Odoo JSON-RPC instead of XML-RPC.
JSON-RPC is faster for large payloads and avoids XML encoding overhead.
"""
import requests
ODOO_URL = "https://mycompany.odoo.com"
ODOO_DB = "mycompany-prod"
ODOO_USER = "api-user@mycompany.com"
ODOO_API_KEY = "odoo-api-key-here"
def jsonrpc(url, method, params):
"""Make a JSON-RPC 2.0 call to Odoo."""
resp = requests.post(
url,
json={"jsonrpc": "2.0", "method": method, "params": params, "id": 1},
timeout=30,
)
result = resp.json()
if "error" in result:
raise Exception(result["error"]["data"]["message"])
return result["result"]
# Authenticate
uid = jsonrpc(
f"{ODOO_URL}/jsonrpc",
"call",
{
"service": "common",
"method": "authenticate",
"args": [ODOO_DB, ODOO_USER, ODOO_API_KEY, {}],
},
)
# Search for validated pickings
pickings = jsonrpc(
f"{ODOO_URL}/jsonrpc",
"call",
{
"service": "object",
"method": "execute_kw",
"args": [
ODOO_DB, uid, ODOO_API_KEY,
"stock.picking", "search_read",
[[
("state", "=", "done"),
("picking_type_code", "=", "outgoing"),
("x_certyo_record_hash", "=", False),
]],
{"fields": ["name", "partner_id", "origin", "date_done"], "limit": 100},
],
},
)
print(f"Found {len(pickings)} pickings to sync")/**
* Node.js integration: Odoo JSON-RPC → Certyo.
* Uses the odoo-await library for cleaner async/await syntax.
*
* npm install odoo-await node-fetch
*/
const Odoo = require("odoo-await");
const fetch = require("node-fetch");
const CERTYO_URL = "https://www.certyos.com/api/v1";
const CERTYO_API_KEY = "your-certyo-api-key";
const CERTYO_TENANT = "mycompany";
async function main() {
// Connect to Odoo
const odoo = new Odoo({
baseUrl: "https://mycompany.odoo.com",
db: "mycompany-prod",
username: "api-user@mycompany.com",
password: "odoo-api-key-here",
});
await odoo.connect();
// Fetch validated outgoing pickings not yet synced
const pickings = await odoo.searchRead("stock.picking", {
domain: [
["state", "=", "done"],
["picking_type_code", "=", "outgoing"],
["x_certyo_record_hash", "=", false],
],
fields: ["name", "partner_id", "origin", "date_done"],
limit: 100,
});
console.log(`Found ${pickings.length} delivery orders`);
for (const picking of pickings) {
// Ingest into Certyo
const resp = await fetch(`${CERTYO_URL}/records`, {
method: "POST",
headers: {
"X-API-Key": CERTYO_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
tenantId: CERTYO_TENANT,
database: "odoo",
collection: "stock.picking",
recordId: picking.name,
recordVersion: "1",
operationType: "insert",
recordPayload: {
pickingName: picking.name,
partner: picking.partner_id?.[1] ?? null,
sourceDocument: picking.origin,
dateDone: picking.date_done,
},
sourceTimestamp: picking.date_done,
idempotencyKey: `${picking.name}-v1`,
}),
});
const result = await resp.json();
console.log(` ✓ ${picking.name} → ${result.recordHash.slice(0, 16)}...`);
// Write hash back to Odoo
await odoo.update("stock.picking", picking.id, {
x_certyo_record_hash: result.recordHash,
x_certyo_anchor_status: "pending",
});
}
}
main().catch(console.error);Step 3: Automated Action (no middleware)
For simpler setups, use an Odoo Automated Action (Settings → Technical → Automated Actions) to call Certyo directly when a picking is validated. This avoids the need for an external sync script:
# Triggered: When stock.picking is updated and state == 'done'
# Action type: Execute Python Code
import json
import requests
CERTYO_URL = "https://www.certyos.com/api/v1/records"
CERTYO_API_KEY = env["ir.config_parameter"].sudo().get_param("certyo.api_key")
CERTYO_TENANT = env["ir.config_parameter"].sudo().get_param("certyo.tenant_id")
for picking in records.filtered(
lambda p: p.picking_type_code == "outgoing" and not p.x_certyo_record_hash
):
payload = {
"tenantId": CERTYO_TENANT,
"database": "odoo",
"collection": "stock.picking",
"recordId": picking.name,
"recordVersion": "1",
"operationType": "insert",
"recordPayload": {
"pickingName": picking.name,
"partner": picking.partner_id.name if picking.partner_id else None,
"sourceDocument": picking.origin or "",
"dateDone": str(picking.date_done),
"products": [
{
"productId": move.product_id.id,
"productName": move.product_id.name,
"quantity": move.quantity,
}
for move in picking.move_ids
],
},
"sourceTimestamp": str(picking.date_done),
"idempotencyKey": f"{picking.name}-v1",
}
try:
resp = requests.post(
CERTYO_URL,
json=payload,
headers={"X-API-Key": CERTYO_API_KEY, "Content-Type": "application/json"},
timeout=10,
)
if resp.status_code == 202:
data = resp.json()
picking.sudo().write({
"x_certyo_record_hash": data["recordHash"],
"x_certyo_anchor_status": "pending",
})
log(f"Certyo: {picking.name} ingested → {data['recordHash'][:16]}...")
else:
log(f"Certyo error for {picking.name}: {resp.status_code}", level="warning")
except Exception as e:
log(f"Certyo request failed for {picking.name}: {e}", level="error")Step 4: Poll for anchoring status
Create a scheduled action (ir.cron) that periodically checks Certyo for anchoring results and updates the Odoo records:
# ir.cron: Run every 5 minutes
# Model: stock.picking
# Python code:
import requests
CERTYO_URL = "https://www.certyos.com/api/v1"
CERTYO_API_KEY = env["ir.config_parameter"].sudo().get_param("certyo.api_key")
CERTYO_TENANT = env["ir.config_parameter"].sudo().get_param("certyo.tenant_id")
pending = env["stock.picking"].search([
("x_certyo_anchor_status", "=", "pending"),
("x_certyo_record_hash", "!=", False),
], limit=50)
for picking in pending:
try:
resp = requests.post(
f"{CERTYO_URL}/verify/record",
json={
"tenantId": CERTYO_TENANT,
"recordId": picking.name,
},
headers={"X-API-Key": CERTYO_API_KEY, "Content-Type": "application/json"},
timeout=10,
)
if resp.status_code == 200:
data = resp.json()
if data.get("verified"):
picking.sudo().write({
"x_certyo_anchor_status": "anchored",
"x_certyo_tx_hash": data.get("transactionHash", ""),
})
log(f"Certyo: {picking.name} anchored ✓")
except Exception as e:
log(f"Certyo verify failed for {picking.name}: {e}", level="warning")Field mapping
| Odoo Field | Certyo Field | Notes |
|---|---|---|
stock.picking.name | recordId | e.g. "WH/OUT/00042" |
stock.picking.date_done | sourceTimestamp | ISO 8601 format |
(config param) | tenantId | Set in Odoo System Parameters |
"odoo" | database | Hardcoded identifier |
"stock.picking" | collection | Odoo model name |
(computed) | recordPayload | JSON object with picking + product details |
picking.name + version | idempotencyKey | Prevents duplicate ingestion on retry |
Authentication
Odoo side
Odoo supports three authentication methods for API access:
- API Keys (Odoo 14+) — Go to Settings → Users → API Keys. Preferred for server-to-server integrations.
- Password — The user's login password. Works but less secure for automated scripts.
- OAuth 2.0 (Odoo Enterprise) — Available via the auth_oauth module for SSO integrations.
Certyo side
Pass your Certyo API key in the X-API-Key header. Store it as an Odoo System Parameter (ir.config_parameter) so it's not hardcoded:
# Via Odoo shell or Settings → Technical → Parameters → System Parameters
env['ir.config_parameter'].set_param('certyo.api_key', 'your-certyo-api-key')
env['ir.config_parameter'].set_param('certyo.tenant_id', 'mycompany')Manufacturing orders
The same pattern works for manufacturing orders (mrp.production). Anchor the bill of materials and production lot when a manufacturing order is marked as done:
# Same pattern, different model and fields
production = env["mrp.production"].browse(production_id)
payload = {
"tenantId": CERTYO_TENANT,
"database": "odoo",
"collection": "mrp.production",
"recordId": production.name, # e.g. "MO/00015"
"recordVersion": "1",
"operationType": "insert",
"recordPayload": {
"productionName": production.name,
"product": production.product_id.name,
"quantity": production.qty_produced,
"lotNumber": production.lot_producing_id.name if production.lot_producing_id else None,
"billOfMaterials": production.bom_id.display_name,
"components": [
{
"product": move.product_id.name,
"quantity": move.quantity,
"lot": move.lot_ids[0].name if move.lot_ids else None,
}
for move in production.move_raw_ids
],
"dateFinished": str(production.date_finished),
},
"sourceTimestamp": str(production.date_finished),
"idempotencyKey": f"{production.name}-v1",
}Next steps
- For initial historical data migration, use POST /api/v1/records/bulk with batches of up to 1000 records
- Once Certyo adds webhook support, register a callback URL to receive batch.anchored events instead of polling
- Integrate with Odoo Quality (quality.check) to automatically create quality checks with Certyo verification results
- Add a Certyo certificate section to the delivery slip QWeb report template
AI Integration Skill
Download a skill file that enables AI agents to generate working Odoo + Certyo integration code for any language or framework.
What's inside
- Authentication — Odoo API Keys and ir.config_parameter for credential storage
- Architecture — stock.picking state=done → external cron or Automated Action → Certyo
- Field mapping — stock.picking, mrp.production fields to Certyo record schema
- Code examples — Python XML-RPC, JSON-RPC, Node.js odoo-await, Automated Action server code
- Verification — ir.cron scheduled action for polling anchoring status
- Version handling — Odoo 14/15/16/17+ field name differences and compatibility
How to use
Claude Code
Place the file in your project's .claude/commands/ directory, then use it as a slash command:
# Download the skill file
mkdir -p .claude/commands
curl -o .claude/commands/certyo-odoo.md \
https://www.certyos.com/developers/skills/certyo-odoo-skill.md
# Use it in Claude Code
/certyo-odoo "Generate a Python script that syncs Odoo delivery orders to Certyo"Cursor / Copilot / Any AI Agent
Add the file to your project root or attach it to a conversation. The AI agent will use the Odoo-specific patterns, field mappings, and code examples to generate correct integration code.
# Add to your project
curl -o CERTYO_ODOO.md \
https://www.certyos.com/developers/skills/certyo-odoo-skill.md
# Then in your AI agent:
"Using the Certyo Odoo spec in CERTYO_ODOO.md,
generate a python script that syncs odoo delivery orders to certyo"CLAUDE.md Context File
Append the skill file to your project's CLAUDE.md so every Claude conversation has Odoo + Certyo context automatically.
# Append to your project's CLAUDE.md
echo "" >> CLAUDE.md
echo "## Certyo Odoo Integration" >> CLAUDE.md
cat CERTYO_ODOO.md >> CLAUDE.md