External API
Another system can read and write Rx Tracking records through Odoo's external API: XML-RPC at /xmlrpc/2/common and
/xmlrpc/2/object, or JSON-RPC at /jsonrpc. The modules add no API of their own: every record type in the
technical reference is reachable with the standard calls, under the same access rights and record rules as
the screens. This page shows how to connect and gives ten recipes. Every recipe on this page was run, as shown, against a new
database with the six modules of this release.
For the calls themselves (execute_kw, domains, search_read, fields_get, examples in other languages), see Odoo's
External API documentation for 18.0.
Odoo 19 adds a new external API (JSON-2) and deprecates XML-RPC and JSON-RPC, which Odoo plans to remove in Odoo 22. The recipes below are for the 18.0 series; see Upgrading.
Before you start
- Use HTTPS. Every call carries the API key; send it only to an
https://address. - Use a dedicated integration user, never the administrator. Create an internal user for the integration in Settings ‣ Users & Companies ‣ Users and give it an email address: records such as licenses log their changes in the chatter in the user's name, and Odoo refuses the change with "Unable to send message, please configure the sender's email address." when the user has none. On the Access Rights tab, give it only the access it needs: Rx Tracking User to read packages, documents and licenses, import supplier files and answer trace requests; Rx Tracking Manager only if it maintains licenses; Contact Creation (under Extra Rights, shown in developer mode) only if it creates partners. What each Rx Tracking level allows is on Roles and permissions.
- Use an API key instead of the password. Log in as the integration user, select the avatar at the top right, then Preferences, the Account Security tab and New API Key. Odoo asks for the password again (Security Control), then for a description and a duration (New API Key), and shows the key once (API Key Ready): store it in your integration's secret store. The key replaces the password in API calls (not when logging in to the screens) and stops working when its duration ends. A user who isn't an administrator can choose at most 90 days by default: that is the API Keys maximum duration days of the Internal User group, which an administrator can change in developer mode under Settings ‣ Users & Companies ‣ Groups. Plan the key's renewal before it expires.
Connect
All examples use Python's standard library and these placeholders: https://odoo.example.com (your Odoo address), example-db
(the database name), integration@example.com (the integration user's login) and <your-api-key>.
import xmlrpc.client
url = "https://odoo.example.com"
db = "example-db"
username = "integration@example.com"
api_key = "<your-api-key>"
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
print(common.version()["server_version"]) # 18.0, or 18.0+e with Enterprise
uid = common.authenticate(db, username, api_key, {}) # the user's id; False when the login or key is wrong
models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object", allow_none=True)
def call(model, method, *args, **kwargs):
"""Call a public method of a model as the integration user."""
return models.execute_kw(db, uid, api_key, model, method, list(args), kwargs)
The recipes below use call() and build on each other: run them in order in one Python session. allow_none=True lets a method
that returns nothing (such as a button action) answer.
Recipes
1. Read the fields of a record type
fields_get returns every field of a model with its label, type and, for a choice list, its values. The
technical reference lists the same fields with their help texts.
fields = call("dscsa.license", "fields_get", attributes=["string", "type", "required", "selection"])
print(fields["number"]) # {'required': True, 'string': 'Number', 'type': 'char'}
print(fields["license_type"]["selection"]) # [['state', 'State license'], ['fda', 'FDA establishment registration'], ...]
2. Record a trading partner's license and mark it verified
Needs Rx Tracking Manager, and Contact Creation to create the partner. action_mark_verified is the Mark verified button of the license form: it records who verified the
license and when, and needs the Verification Source. A partner is an authorized trading partner once it has the valid licenses
its DSCSA Role needs (What the trading-partner checks look at).
us = call("res.country", "search", [("code", "=", "US")])[0]
ny = call("res.country.state", "search", [("code", "=", "NY"), ("country_id", "=", us)])[0]
partner_id = call("res.partner", "create", [{
"name": "Example Pharmacy LLC", "is_company": True, "dscsa_role": "dispenser",
"country_id": us, "state_id": ny,
}])[0]
license_id = call("dscsa.license", "create", [{
"partner_id": partner_id, "license_type": "state", "number": "EXAMPLE-0001", "jurisdiction_id": ny,
"expiry_date": "2030-12-31", "source": "State board online verification",
}])[0]
call("dscsa.license", "action_mark_verified", [license_id])
print(call("dscsa.license", "read", [license_id], ["state", "verified_on"]))
# [{'id': 2, 'state': 'valid', 'verified_on': '2026-09-29'}]
print(call("res.partner", "read", [partner_id], ["is_authorized_trading_partner"]))
# [{'id': 10, 'is_authorized_trading_partner': True}]
3. Import a supplier's EPCIS file on a receipt
The recipe for an integration that receives suppliers' files by EDI or SFTP. It does what the Import EPCIS button of a receipt
does: the file is checked against the receipt and its units become Expected packages, which the warehouse then scans as usual
(Receive a sealed case with the supplier's EPCIS file). Needs Rx Tracking
User. P00012 is the purchase order the file is for; the file is read from disk.
import base64
po_number = "P00012"
receipt_ids = call("stock.picking", "search", [
("origin", "=", po_number), ("picking_type_code", "=", "incoming"), ("state", "not in", ["done", "cancel"]),
], limit=1)
with open("supplier-epcis.xml", "rb") as handle:
content = base64.b64encode(handle.read()).decode()
wizard_id = call("dscsa.epcis.import.wizard", "create", [{
"picking_id": receipt_ids[0], "file": content, "file_name": "supplier-epcis.xml",
}])[0]
action = call("dscsa.epcis.import.wizard", "action_import", [wizard_id])
imported = call("dscsa.epcis.import", "read", [action["res_id"]],
["name", "state", "announced_count", "warning_text"])[0]
print(imported)
# {'id': 1, 'name': 'supplier-epcis.xml', 'state': 'draft', 'announced_count': 24, 'warning_text': False}
state draft is shown as Awaiting Receipt. The import is refused with an error (see Errors) when the file
can't be read as EPCIS 1.2, names another seller or buyer than the receipt's, lists a product that isn't on the receipt or has no lot
for a unit; smaller problems (a count or purchase order number that differs, a missing expiry date, a Transaction Statement that isn't
affirmed) are imported, with one line per problem in warning_text. What each one means:
Review supplier EPCIS files.
4. Find a package by its serial number
Every serialized unit is a dscsa.package record. Search by serial (several products can use the same serial, so add gtin14
when you know it).
packages = call("dscsa.package", "search_read", [("serial", "=", "100000000001")],
fields=["gtin14", "serial", "lot_id", "expiry_date", "state", "sscc"])
print(packages)
# [{'id': 1, 'gtin14': '00399990101300', 'serial': '100000000001', 'lot_id': [1, 'BPDPZ2509A'],
# 'expiry_date': '2028-06-30', 'state': 'in_stock', 'sscc': '003999900000004175'}]
The states and their stored values are listed on Statuses.
5. Search posted DSCSA documents
A DSCSA document (dscsa.document) is created and posted when a transfer of DSCSA products is done: outbound for a delivery,
inbound for a receipt with a supplier's file. Posted documents never change.
documents = call("dscsa.document", "search_read", [
("state", "=", "posted"), ("direction", "=", "inbound"), ("transaction_date", ">=", "2026-09-01"),
], fields=["name", "partner_id", "transaction_date", "order_ref", "file_ids"], order="transaction_date desc")
print(documents)
# [{'id': 1, 'name': 'DSCSA/IN/2026/00001', 'partner_id': [9, 'Bluepeak Pharmaceuticals Inc.'],
# 'transaction_date': '2026-09-24', 'order_ref': 'P00012', 'file_ids': [510]}]
6. Download a document's files
A posted document keeps the files frozen when it was posted as attachments, with the SHA-256 of each in file_checksums: the T3
report and, when it could be written, the EPCIS file of an outbound document; the supplier's own EPCIS file of an inbound one. Read
them through ir.attachment, and compare the checksums to show the files are the ones stored at posting.
import hashlib
import json
document = call("dscsa.document", "read", [documents[0]["id"]], ["name", "file_ids", "file_checksums"])[0]
checksums = json.loads(document["file_checksums"])
for attachment in call("ir.attachment", "read", document["file_ids"], ["name", "mimetype", "datas"]):
content = base64.b64decode(attachment["datas"])
assert hashlib.sha256(content).hexdigest() == checksums[attachment["name"]]
with open(attachment["name"], "wb") as handle:
handle.write(content)
print(attachment["name"], attachment["mimetype"], len(content)) # supplier-epcis.xml application/xml 13104
7. Answer a trace request
A trace request (dscsa.trace.request) logs a request for records from the FDA, a state or a trading partner. action_search runs
its query and action_respond builds the response zip and closes the request: the Search and Respond buttons of
Trace requests. Both need Rx Tracking User. query_serials takes one unit per line
(a serial, a GS1 DataMatrix string or an SGTIN); query_lots, query_gtins, date_from and date_to narrow the search further.
import io
import zipfile
request_id = call("dscsa.trace.request", "create", [{
"requester_type": "fda", "requester_reference": "FDA-REQ-0001",
"description": "Transaction records of serial 100000000001", "query_serials": "100000000001",
}])[0]
call("dscsa.trace.request", "action_search", [request_id])
call("dscsa.trace.request", "action_respond", [request_id])
answered = call("dscsa.trace.request", "read", [request_id],
["name", "state", "document_count", "package_count", "response_attachment_id", "response_sha256"])[0]
response = call("ir.attachment", "read", [answered["response_attachment_id"][0]], ["name", "datas"])[0]
content = base64.b64decode(response["datas"])
assert hashlib.sha256(content).hexdigest() == answered["response_sha256"]
print(answered["state"], response["name"], sorted(zipfile.ZipFile(io.BytesIO(content)).namelist()))
# responded DSCSA-TR-2026-00001-response.zip
# ['documents/DSCSA-IN-2026-00001/supplier-epcis.xml', 'index.csv', 'packages.csv', 'request.txt']
The zip's content is described in CSV exports.
8. Read over JSON-RPC
The same calls work over JSON-RPC: POST /jsonrpc with the service object and the method execute_kw.
import urllib.request
payload = {"jsonrpc": "2.0", "method": "call", "id": 1, "params": {
"service": "object", "method": "execute_kw",
"args": [db, uid, api_key, "dscsa.license", "search_read", [[("number", "=", "EXAMPLE-0001")]],
{"fields": ["partner_id", "state"]}],
}}
request = urllib.request.Request(f"{url}/jsonrpc", json.dumps(payload).encode(), {"Content-Type": "application/json"})
answer = json.loads(urllib.request.urlopen(request).read())
print(answer["result"]) # [{'id': 2, 'partner_id': [10, 'Example Pharmacy LLC'], 'state': 'valid'}]
An error comes back as answer["error"] instead of result, with the exception's name and message in error["data"].
9. Private methods are refused
Only public methods can be called: Odoo refuses any method whose name starts with an underscore, on every model. The documented override points of Extending the modules are for Python code inside Odoo, not for the API.
try:
call("dscsa.document", "_check_company")
except xmlrpc.client.Fault as error:
print(error.faultCode, error.faultString)
# 4 Private methods (such as 'dscsa.document._check_company') cannot be called remotely.
10. Access rights apply as on the screens
An Rx Tracking User can read licenses but not create them. Here reader@example.com is a second integration user with only
Rx Tracking User, and <reader-api-key> its key.
reader_key = "<reader-api-key>"
reader_uid = common.authenticate(db, "reader@example.com", reader_key, {})
print(models.execute_kw(db, reader_uid, reader_key, "dscsa.license", "search_count", [[]])) # 2
try:
models.execute_kw(db, reader_uid, reader_key, "dscsa.license", "create",
[[{"partner_id": partner_id, "number": "EXAMPLE-0002"}]])
except xmlrpc.client.Fault as error:
print(error.faultCode, error.faultString.splitlines()[0])
# 4 You are not allowed to create 'Trading Partner License' (dscsa.license) records.
Errors
Over XML-RPC an error is an xmlrpc.client.Fault; its faultCode says what kind it is and faultString holds the message as the
screen would show it.
faultCode | Raised for | Examples |
|---|---|---|
2 | a refusal of the modules or of Odoo (a warning or user error) | a DSCSA check that blocks the operation, a supplier file that isn't EPCIS 1.2, a posted document that can't change |
3 | wrong credentials | a wrong, expired or revoked API key |
4 | access rights | a missing Rx Tracking right, a record rule, a private method |
1 | anything else | the message is the server's traceback |
The modules' messages and what to do about each are in the troubleshooting pages, searchable by their text. The checks run on the server for every caller, so a call that the screens would refuse is refused over the API with the same message (What is checked where).
Related
- Architecture: the records behind these calls.
- File formats: the EPCIS, T3 and CSV files the recipes read and write.
- Security: access rights and record rules by group.
- Upgrading: what changes for integrations after the 18.0 series.