Skip to main contentCertyo Developer Portal

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

Integration flowbash
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:

Odoo custom module — models/stock_picking.pypython
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.

certyo_odoo_sync.pypython
"""
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]}...")

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:

Odoo Automated Action — Server Action (Python code)python
# 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")
Odoo Automated Actions & network calls
Automated Actions run inside a database transaction. If the Certyo API call is slow or fails, it can block the user. For production, prefer the external middleware approach or use a scheduled action (ir.cron) that processes a queue.

Step 4: Poll for anchoring status

Create a scheduled action (ir.cron) that periodically checks Certyo for anchoring results and updates the Odoo records:

Scheduled action — poll Certyo verificationpython
# 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 FieldCertyo FieldNotes
stock.picking.namerecordIde.g. "WH/OUT/00042"
stock.picking.date_donesourceTimestampISO 8601 format
(config param)tenantIdSet in Odoo System Parameters
"odoo"databaseHardcoded identifier
"stock.picking"collectionOdoo model name
(computed)recordPayloadJSON object with picking + product details
picking.name + versionidempotencyKeyPrevents 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:

Set Certyo credentials in Odoo System Parametersbash
# 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:

Manufacturing order → Certyopython
# 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",
}
Odoo version compatibility
This guide targets Odoo 16+ (Community and Enterprise). For Odoo 15, replace move.quantity with move.quantity_done. For Odoo 14, the XML-RPC and JSON-RPC patterns are identical but API Keys require enabling the auth_api_key module.

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 · v1.0.0

AI Integration Skill

Download a skill file that enables AI agents to generate working Odoo + Certyo integration code for any language or framework.

v1.0.0
What is this?
A markdown file containing Odoo-specific field mappings, authentication setup, code examples, and integration patterns for Certyo. Drop it into your AI agent's context and ask it to generate integration code.

What's inside

  • AuthenticationOdoo API Keys and ir.config_parameter for credential storage
  • Architecturestock.picking state=done → external cron or Automated Action → Certyo
  • Field mappingstock.picking, mrp.production fields to Certyo record schema
  • Code examplesPython XML-RPC, JSON-RPC, Node.js odoo-await, Automated Action server code
  • Verificationir.cron scheduled action for polling anchoring status
  • Version handlingOdoo 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