
Work with Odoo from your application
A company that runs its business in Odoo can build internal tools on the records Odoo already holds. The application declares the company’s Odoo once, by name, and reaches it with its own integration key, never with one of its users’ accounts.
Read in place: a list or record screen asks Odoo when it opens, so it always shows what Odoo holds. Copy kept current: a scheduled pipeline copies chosen fields into the application, where they sit beside fields only the application owns, and it can read only the records changed since its last pass.
Write back: a resource can create, change and delete Odoo records, and an update is checked against the version the person was looking at, so most edits made over someone else’s newer change are refused instead of silently overwriting it. Actions: an Odoo operation such as posting a draft invoice can be run from the application, under the same roles, company limits and audit as a write.
Per company: when one Odoo database holds several companies, each subsidiary’s people see, change and act on their own company’s records only. Both directions: a pipeline can send records created in the application to Odoo, or keep records edited on both sides in step, with each field owned by one side or shared.
Example — Client accounts and invoices for a services firm
A project tool copies client accounts from Odoo every hour and lets the team add internal notes; Odoo stays the source of each client’s name and email. Finance managers maintain the catalogue of billable services in Odoo from the tool’s own screens, and post draft invoices from it. A manager of one subsidiary does all of this for that subsidiary’s company only.
For engineers
An Odoo deployment is a system of record, not a provider: nothing selects it through the capability plane, and no person’s connected account is spent on it. Wonder Todos declares its Odoo under systemsOfRecord in wildo.saas.config.ts, comments omitted:
systemsOfRecord: {
odoo: {
vendor: 'odoo',
dialect: HttpApiTransportDialect.ODOO_JSONRPC,
partition: true,
personalDataOrigin: SystemOfRecordPersonalDataOrigin.NO_PUBLICLY_ACCESSIBLE_SOURCES,
},
},
The name odoo is yours to choose. It identifies this instance, so a second deployment of the same vendor is a second name. The odoo vendor facet ships in the systems-of-record library, @wildo-ai/systems-of-record, which wildo config sync adds to the backend as soon as a system of record is declared. The facet offers one credential kind, so the declaration does not name it. partition: true turns on per-company confinement, and personalDataOrigin tells the privacy notice that the personal data in this Odoo was collected by the company itself, none of it from public sources. Three Wonder Todos resources bind the instance by name: a copied client list, a read-through service catalogue that writes back, and read-through invoices with a posting action.
Supply the address and key per environment
The facet asks for three address values, baseUrl, database and login, and one secret, apiKey. Addresses go under systemsOfRecord.odoo in the environment’s wildo.infra.<env>.config.ts; the key goes in that environment’s secrets.json. You never write a variable name: they are derived from the instance name as WILDO_SOR_ODOO__BASE_URL, WILDO_SOR_ODOO__DATABASE, WILDO_SOR_ODOO__LOGIN and WILDO_SOR_ODOO__API_KEY.
An environment with no access to the deployment, such as a developer machine, declares odoo: { enabled: false }, as Wonder Todos’ local environment does. The application still boots, and reads of bound resources are refused rather than answered as empty. Leaving the instance out of an environment’s configuration is refused, because it cannot be told apart from a forgotten address. At boot the engine joins the declaration, the vendor facet and the environment, and refuses an incoherent instance; it does not contact Odoo, so an Odoo outage does not stop the application starting.
How the client authenticates
The shared client calls Odoo’s authenticate with the configured database, login and key to obtain a user id, then puts database, user id and key in the arguments of every model call. The user id is cached against the credential it was obtained with, so a rotated key authenticates again without an invalidation step. The configured Odoo user decides what the application can reach: it must have access to the models and methods the resources use.
Copy client accounts
The following is the binding and mapping portion of Wonder Todos’ externalCustomers pipeline, with comments omitted. Its resource schema, operations and registration surround this excerpt. SystemOfRecordSubjectRightsPosture and HttpApiTransportDialect are imported from @wildo-ai/saas-models; the other enums come from @wildo-ai/saas-models/external-data.
externalDataPipeline: {
binding: {
dialect: HttpApiTransportDialect.ODOO_JSONRPC,
system: 'odoo',
entityRef: 'res.partner',
keyFields: [{ localField: 'id', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
subjectRights: { posture: SystemOfRecordSubjectRightsPosture.OWNED_COPY },
purposeRefs: ['task-planning-service'],
},
remoteKeyLocalField: 'odooPartnerRef',
partitionUnitLocalField: 'organizationUnitId',
partitionKeyLocalField: 'odooCompanyRef',
mapping: [
{
kind: ExternalDataMappingEntryKind.CONCATENATION,
localField: 'name',
parts: [
{ kind: ExternalDataConcatenationPartKind.REMOTE_FIELD, remoteField: 'name' },
{ kind: ExternalDataConcatenationPartKind.REMOTE_FIELD, remoteField: 'ref' },
],
separator: ' · ',
},
{ kind: ExternalDataMappingEntryKind.REMOTE_FIELD, remoteField: 'email', localField: 'email' },
],
sourceFilter: { is_company: true },
population: ExternalDataDestinationPopulation.CLOSED,
orphanPolicy: ExternalDataOrphanPolicy.IMPERSONALIZE,
scheduleCron: '0 * * * *',
},
The binding declares no tenancy. Rows read out of a system of record are stamped with the organization that owns the application, so the ordinary per-row tenant check decides who sees them and nothing about your tenants is sent to Odoo. The integer remote key identifies the source record; odooPartnerRef retains it on the local row.
The subject-rights posture follows from what the copied fields can hold. A client’s name and email can be a person’s: a sole trader is recorded as a company in Odoo, and a named contact’s address on a company record is theirs. The is_company filter leaves out the individual contacts filed under each account; it does not make the remaining records a legal person’s. So the destination declares name and email as personal data. The copy also keeps internalNote, which a team member writes and Odoo does not hold, so the binding declares OWNED_COPY: the application keeps this copy beyond its source and erases it itself, while Odoo, the company’s own system, answers requests about the records it came from. Without the note the copy would follow Odoo (HANDLED_AT_SOURCE, the default), leave an erasure to it, and remove what Odoo removes. NO_SUBJECT_DATA would be refused beside fields declared as personal data, and FORWARDED_TO_SOURCE is refused on a pipeline that only reads, since nothing sends a delete through it. purposeRefs names why the copy exists, so the privacy notice can list Odoo as a source of these fields for that purpose.
An erasure through the application does reach the client list. Each client row is its own data subject: it is no one’s login, so erasing it ends no session, and the person is found by what identifies them, usually the email. The todos done for a client point at its row, so an erasure keeps the row and scrubs name, email and internalNote in place and records an erasure suppression so the next pass does not copy that Odoo record back. A client that no longer appears in a complete pass, because it was deleted in Odoo, stopped matching the filter or can no longer be read, has the same three fields scrubbed in place by that pass (IMPERSONALIZE). That scrub writes no suppression, because nobody asked for the record to be forgotten: if Odoo lists it again, it loads as a new row, and the todos keep pointing at the scrubbed one. A row under legal hold is left unchanged. Reassess every one of these choices for a different population.
The two partition fields are written by the engine, never by the mapping. organizationUnitId receives the unit the client’s Odoo company is assigned to, which is what confines a subsidiary’s members to their own clients. odooCompanyRef keeps Odoo’s company_id itself, so a client still says which company it belongs to after that company is reassigned or unassigned. A company whose assignment is withdrawn is not mistaken for clients that disappeared, because each pass still reads its clients and counts them as present without loading them; until the company is assigned again, those clients are read by nobody here, organization-wide members included. Both stay empty on a client that belongs to no company, which every caller may see. The name shown is a declared expression: the client’s name followed by Odoo’s reference (Acme Industries · C-0042), with the separator dropped when there is no reference.
This pipeline runs a full pass every hour. An Odoo pipeline can instead read only the rows changed since its last pass, by write_date (ExternalDataIncrementalExtractionMode.ODOO_WRITE_DATE), with a periodic full pass to notice deletions, and an inbound notification can ask it to re-read particular records by id.
Write through to Odoo
Wonder Todos’ billable-service catalogue has no local table: it is read from Odoo’s product.template on every request, and its writes go straight to Odoo. The binding, comments omitted:
persistenceAdapter: PersistenceAdapter.HTTP_API,
httpApiBinding: {
dialect: HttpApiTransportDialect.ODOO_JSONRPC,
system: 'odoo',
entityRef: 'product.template',
keyFields: [{ localField: 'odooProductId', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
fieldMappings: [
{ localField: 'listPrice', remoteField: 'list_price' },
{ localField: 'unitOfMeasure', remoteField: 'uom_name' },
{ localField: 'lastUpdatedAt', remoteField: 'write_date' },
],
subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
writableVerbs: [HttpApiWriteVerb.CREATE, HttpApiWriteVerb.UPDATE, HttpApiWriteVerb.DELETE],
writeConcurrency: { mode: HttpApiWriteConcurrencyMode.VERSION_CHECKED, versionField: 'lastUpdatedAt' },
},
Each opened verb is one execute_kw call (create, write or unlink) under the instance’s own identity, and each has its operation, gated on ORG_MANAGER. Every write requires an Idempotency-Key: Odoo offers no idempotency of its own, so a write is sent once, an interrupted one answers that its outcome is unknown, and a retry under the same key gives that answer again instead of creating a second product. Under VERSION_CHECKED an update carries back the write_date the caller read, and is refused when Odoo’s is newer. A product is not a person, which is why this binding may declare NO_SUBJECT_DATA; pointing it at people would change that.
Run an Odoo method as a declared action
Posting an invoice is not a row update: Odoo validates it, numbers it and writes its journal entries. Wonder Todos’ customerInvoices binding on account.move declares it as an action, comments omitted:
actions: {
[CUSTOMER_INVOICES_POST_ACTION]: {
target: SystemOfRecordActionTarget.RECORD,
call: {
shape: SystemOfRecordActionCallShape.ODOO_EXECUTE_KW,
method: 'action_post',
inputPlacement: SystemOfRecordOdooActionInputPlacement.NONE,
},
input: z.object({}),
writes: true,
retrySafe: false,
roles: [CORE_ORG_ROLES.ORG_MANAGER],
operation: CustomerInvoices_Operations.POST_INVOICE,
},
},
Its only HTTP door is the resource’s post_invoice operation, PUT …/customer-invoices/{id}/post_invoice, which requires an Idempotency-Key, is offered only on a draft, and hands the request to the system-of-record action executor. The executor checks the role in the owning organization, reads the invoice first so another company’s invoice answers as absent, sends one execute_kw('account.move', 'action_post', [[id]]), and records the outcome. Odoo offers no idempotency for a method call, so retrySafe is false and a lost answer is never re-sent.
Send local changes, or keep both sides in step
Wonder Todos’ Odoo pipelines only read. The same declaration can run the other way: an outbound pipeline creates in Odoo a record first made in the application, keeps the id Odoo answers, and sends later edits as updates. A two-way pipeline loads the fields Odoo owns, sends the fields the application owns, and for a field both sides edit applies its conflict policy or reports the conflict. Because Odoo serves write_date only to the second, two-way agreement also compares a fingerprint of the shared values, so a second edit within the same second is not missed.
Keep compatibility tied to the configured server
This dialect targets the classic JSON-RPC contract implemented by its compiler and client. Protocol-simulator tests exercise authentication, extraction, writes, actions, changing source populations and both sending directions; they do not certify every hosted Odoo version or deployment. Verify the instance’s exposed API and the integration user’s permissions before depending on it for a business workflow.