If you work with vendors or trading partners in Mexico, Central America, or South America, you've likely already received — or are about to receive — EDI 856 files via SFTP. The 856 (Advance Ship Notice / ASN) is the document that confirms exactly what was shipped, when, how many packages, and with what tracking number. Without processing it correctly, Odoo never knows what's inbound.
At Molten Logistics we've processed over 100,000 automated shipments for clients across the region, connecting vendors, 3PLs, and marketplaces with Odoo, Shopify, Amazon, and more. This tutorial shows exactly how we build that connection — from the raw file on the SFTP to the validated receipt in Odoo.
The X12 856 Ship Notice/Manifest (also known as ASN — Advance Ship Notice) is the EDI document your vendor or warehouse sends before the physical goods arrive. It essentially says: "this shipment is on its way — here are the boxes, the products, the carrier, and the tracking number."
In Odoo, the goal is for each inbound 856 to automatically create or validate a stock.picking (warehouse receipt) with its movement lines (stock.move), eliminating manual data entry entirely.
Unlike the 850 (which is flat), the 856 is hierarchical. The HL (Hierarchical Level) loops define the hierarchy shipment → order → item:
| HL Loop | Level | Key Segments | Odoo Equivalent |
|---|---|---|---|
| HL S | Shipment | BSN, DTM, TD1, TD5, REF | stock.picking (cabecera) |
| HL O | Order (Purchase Order) | PRF, REF | purchase.order (referencia) |
| HL P | Pack (Carton / Box) | MAN, PKG, TD1 | stock.quant.package |
| HL I | Item (Product) | LIN, SN1, PID, SLN | stock.move.line |
ISA*00* *00* *ZZ*VENDOR *ZZ*YOURCOMPANY *260510*0800*^*00501*000000042*0*P*: GS*SH*VENDOR*YOURCOMPANY*20260510*0800*42*X*005010 ST*856*0001 ~ ── Shipment header ──────────────────────────────────────────── BSN*00*ASN-20260510-001*20260510*0800*0001 ~ # ASN number + date/time DTM*011*20260510 ~ Ship date DTM*017*20260513 ~ Estimated delivery date ~ ── Carrier and tracking ─────────────────────────────────────── TD5*B*2*ESTAFETA** ~ Carrier: Estafeta REF*CN*9261290100830121234567 ~ Tracking number ~ ── Level Embarque (HL S) ────────────────────────────────────── HL*1**S TD1*CTN*2****G*18.5*KG ~ 2 packages, 18.5 kg total ~ ── Level Orden (HL O, padre=1) ──────────────────────────────── HL*2*1*O PRF*OC-2026-0089**20260501 ~ Reference to original PO ~ ── Level Bulto 1 (HL P, padre=2) ───────────────────────────── HL*3*2*P MAN*GM*1234567890128 ~ SSCC/código de caja ~ ── Level Ítem 1 (HL I, padre=3) ────────────────────────────── HL*4*3*I LIN**VP*SKU-MX-001 ~ VP = vendor part number (your SKU) SN1**24*EA ~ Quantity: 24 units PID*F****Widget Azul 500ml ~ ── Level Ítem 2 (HL I, padre=3) ────────────────────────────── HL*5*3*I LIN**VP*SKU-MX-002 SN1**12*EA PID*F****Widget Rojo Pro ~ ── Totals ───────────────────────────────────────────────────── CTT*5 ~ 5 HL segments total SE*20*0001 GE*1*42 IEA*1*000000042
TD5 with local carrier codes: ESTAFETA, DHL, FEDEX, PAQEX. Make sure to map these codes to Odoo's carrier_id in your config table — never assume they match the North American SCAC codes.
Before writing a single line of code, establish your SFTP folder structure and set the right permissions. A clear convention will save hours of production debugging.
The processing/ folder is critical: by moving the file there before parsing it, you prevent two process instances from reading the same file simultaneously (race condition). Think of it as a file-level database lock.
import paramiko import os # Config — ideally from environment variables or Odoo ir.config.parameter SFTP_CONFIG = { "host": os.getenv("SFTP_HOST", "sftp.vendor.com"), "port": 22, "username": os.getenv("SFTP_USER"), "key_path": os.getenv("SFTP_KEY_PATH", "/etc/molten/keys/vendor_rsa"), "outbound_dir": "/edi/outbound", "processing_dir": "/edi/processing", "processed_dir": "/edi/processed", "error_dir": "/edi/error", } def get_sftp_client(config): ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) key = paramiko.RSAKey.from_private_key_file(config["key_path"]) ssh.connect( hostname=config["host"], port=config["port"], username=config["username"], pkey=key, timeout=30, ) return ssh.open_sftp(), ssh def fetch_pending_856_files(config): """Fetches and locks all pending .edi files from outbound/""" sftp, ssh = get_sftp_client(config) files_fetched = [] try: pending = sftp.listdir(config["outbound_dir"]) for fname in pending: if not fname.endswith(".edi"): continue src = f"{config['outbound_dir']}/{fname}" lock = f"{config['processing_dir']}/{fname}" sftp.rename(src, lock) # rename = atomic lock raw = sftp.file(lock, "r").read().decode("utf-8") files_fetched.append((fname, raw, lock)) finally: sftp.close(); ssh.close() return files_fetched
The biggest challenge with the 856 is the HL hierarchy. You need to build an in-memory tree before mapping to Odoo, because items belong to packages that belong to orders that belong to the shipment.
import re def parse_856(raw_edi: str) -> dict: """ Parsea un X12 856 crudo y retorna estructura: { "asn_number": str, "ship_date": str, "delivery_date": str, "carrier": str, "tracking": str, "po_reference": str, "packages": [{"sscc": str, "items": [{"sku": str, "qty": int, "desc": str}]}] } """ # Detect delimiters from the ISA envelope elem_sep = raw_edi[3] # position 3 of ISA segment_sep = raw_edi[105] # position 105 of ISA segments = [s.strip() for s in raw_edi.split(segment_sep) if s.strip()] result = {"packages": [], "po_reference": "", "carrier": "", "tracking": ""} current_package = None hl_map = {} for seg in segments: els = seg.split(elem_sep) tag = els[0] if tag == "BSN": result["asn_number"] = els[2] # BSN02 result["ship_date"] = els[3] # BSN03 elif tag == "DTM" and els[1] == "017": result["delivery_date"] = els[2] elif tag == "TD5": result["carrier"] = els[3] if len(els) > 3 else "" # TD503 elif tag == "REF" and els[1] == "CN": result["tracking"] = els[2] elif tag == "PRF": result["po_reference"] = els[1] # Original PO number elif tag == "HL": hl_id = els[1] hl_level = els[3] if len(els) > 3 else "" hl_map[hl_id] = hl_level if hl_level == "P": # new package/carton current_package = {"sscc": "", "items": []} result["packages"].append(current_package) elif tag == "MAN" and current_package is not None: current_package["sscc"] = els[2] # Carton/SSCC code elif tag == "LIN": # New item starting — store SKU result["_current_item"] = {"sku": els[3] if len(els) > 3 else "", "qty": 0, "desc": ""} if current_package: current_package["items"].append(result["_current_item"]) elif tag == "SN1" and "_current_item" in result: result["_current_item"]["qty"] = int(els[2]) # SN102 elif tag == "PID" and "_current_item" in result: result["_current_item"]["desc"] = els[5] if len(els) > 5 else "" result.pop("_current_item", None) # clean up temp field return result
| X12 856 Segment | Field / Value | Odoo Model | Odoo Field | Note |
|---|---|---|---|---|
| BSN02 | ASN Number | stock.picking | origin | Prefijo "ASN-" recomendado |
| BSN03 | Ship date | stock.picking | scheduled_date | Format YYYYMMDD → datetime |
| DTM 017 | Estimated delivery date | stock.picking | date_deadline | Available from Odoo 15 |
| TD5-03 | Transportista | stock.picking | carrier_id | Map code → res.partner |
| REF CN | Tracking number | stock.picking | carrier_tracking_ref | — |
| PRF01 | PO number | stock.picking | purchase_id | Look up by name in purchase.order |
| MAN02 (SSCC) | Package/carton code | stock.quant.package | name | Create if not found |
| LIN-03 (VP) | Vendor's SKU | stock.move | product_id | Search product.supplierinfo first |
| SN1-02 | Shipped quantity | stock.move.line | reserved_qty / qty_done | Do not validate yet — only reserve |
LIN VP) to the correct product.product, always search product.supplierinfo first using product_code and partner_id. Never assume the vendor SKU matches your Odoo internal reference — it rarely does, especially with LATAM vendors.
With the 856 parsed, we use Odoo's XML-RPC API to create the stock.picking and its move lines. We do this via external API (not an installed Odoo module) to keep the integration logic version-independent.
import xmlrpc.client from datetime import datetime ODOO_URL = "https://tuempresa.odoo.com" ODOO_DB = "tuempresa" ODOO_USER = "edi@tuempresa.com" ODOO_PASS = "tu_api_key_aqui" # use API Key, not password def odoo_connect(): common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common") uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {}) models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object") return models, uid def resolve_product_from_supplier_sku(models, uid, partner_id, supplier_sku): # Search product.supplierinfo by vendor part code info_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS, "product.supplierinfo", "search", [[("partner_id", "=", partner_id), ("product_code", "=", supplier_sku)]] ) if not info_ids: raise ValueError(f"Vendor SKU not found: {supplier_sku}") info = models.execute_kw(ODOO_DB, uid, ODOO_PASS, "product.supplierinfo", "read", [info_ids[0]], {"fields": ["product_id", "product_tmpl_id"]} ) return info["product_id"][0] # product.product ID def create_picking_from_856(asn_data: dict, partner_id: int): models, uid = odoo_connect() # Look up the referenced PO po_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS, "purchase.order", "search", [[("name", "=", asn_data["po_reference"])]] ) purchase_id = po_ids[0] if po_ids else False # Map date YYYYMMDD → Odoo datetime string ship_dt = datetime.strptime(asn_data["ship_date"], "%Y%m%d").strftime("%Y-%m-%d %H:%M:%S") # Build move lines move_lines = [] for pkg in asn_data["packages"]: for item in pkg["items"]: product_id = resolve_product_from_supplier_sku(models, uid, partner_id, item["sku"]) move_lines.append((0, 0, { "name": item["desc"] or item["sku"], "product_id": product_id, "product_uom_qty": item["qty"], "product_uom": 1, # Units — adjust if using a different UOM "location_id": 8, # Vendor location (adjust ID) "location_dest_id": 12, # WH/Input (adjust ID) })) # Create the picking (receipt) picking_vals = { "picking_type_id": 1, # Receipts — adjust for your warehouse "partner_id": partner_id, "origin": f"EDI-ASN: {asn_data['asn_number']}", "scheduled_date": ship_dt, "carrier_tracking_ref": asn_data["tracking"], "move_ids_without_package": move_lines, "note": f"Automatically imported by Molten Logistics EDI Engine. ASN: {asn_data['asn_number']}", } if purchase_id: picking_vals["purchase_id"] = purchase_id picking_id = models.execute_kw(ODOO_DB, uid, ODOO_PASS, "stock.picking", "create", [picking_vals] ) # Confirm the picking (sets to "Ready" state) models.execute_kw(ODOO_DB, uid, ODOO_PASS, "stock.picking", "action_confirm", [[picking_id]]) return picking_id
picking_type_id, location_id and location_dest_id depend on your specific Odoo instance. Never hardcode them as constants in production — store them in ir.config.parameter or in your integration's config table.
Before any technical setup, coordinate with your vendor's EDI team: credentials, destination folder, transmission frequency, and filename format. In Mexico and LATAM, many vendors use the naming pattern ASN_[date]_[folio].edi — confirm this in their spec.
Request at least 3 real (not fabricated) sample 856 files to validate your parser before connecting to production.
Create the /outbound/, /processing/, /processed/, /error/, and /ack/ directories. Set permissions: your vendor only needs write on /outbound/. Your integration process needs read/write on all.
If using a managed service (AWS Transfer Family, Azure SFTP, etc.), configure CloudWatch or Azure Monitor to alert when there's no activity in /outbound/ for more than 24 hours — this often means the vendor's system failed silently.
Use the parser shown in Section 03. Validate against your sample files and specifically check: automatic delimiter detection, handling of HL loops without a P level (some vendors skip the package level), and character encoding (ISO-8859-1 is common in EDI files from legacy systems in Mexico).
# Many legacy EDI systems in LATAM use ISO-8859-1, not UTF-8 def read_edi_file_safe(raw_bytes: bytes) -> str: for encoding in ["utf-8", "iso-8859-1", "cp1252"]: try: return raw_bytes.decode(encoding) except UnicodeDecodeError: continue raise ValueError("Could not decode EDI file with any known encoding")
Connect all components into an orchestrated process. This is the main loop that Molten Logistics runs as a scheduled service (every 15 minutes in active integrations):
def process_pending_856s(partner_id: int): sftp_cfg = SFTP_CONFIG sftp, ssh = get_sftp_client(sftp_cfg) files = fetch_pending_856_files(sftp_cfg) for fname, raw_bytes, lock_path in files: try: raw = read_edi_file_safe(raw_bytes) asn_data = parse_856(raw) # Idempotency: have we already processed this ASN? if asn_already_imported(asn_data["asn_number"]): move_to_processed(sftp, lock_path, fname) continue picking_id = create_picking_from_856(asn_data, partner_id) mark_asn_imported(asn_data["asn_number"], picking_id) move_to_processed(sftp, lock_path, fname) send_997_ack(sftp, sftp_cfg, asn_data) # Functional Ack log_success(fname, picking_id) except Exception as e: move_to_error(sftp, lock_path, fname) alert_ops_team(fname, str(e)) # Slack / PagerDuty log_error(fname, str(e)) sftp.close(); ssh.close()
The 997 confirms to your vendor that you received their 856 correctly at the EDI envelope level. It does not confirm that goods arrived — only that the file was structurally accepted or rejected. Most vendors who send 856s expect a 997 back in the /ack/ folder.
ISA*00* *00* *ZZ*YOURCOMPANY *ZZ*VENDOR *260510*0815*^*00501*000000043*0*P*: GS*FA*YOURCOMPANY*VENDOR*20260510*0815*43*X*005010 ST*997*0001 AK1*SH*42 ~ SH = Ship Notice group; 42 = GS control # from the 856 AK2*856*0001 ~ Reference to the 856 ST segment AK5*A ~ A = Accepted. E = Error. R = Rejected AK9*A*1*1*1 ~ Group accepted, 1 transaction received/accepted SE*5*0001 GE*1*43 IEA*1*000000043
A silently failing 856 means your warehouse team has no idea what's inbound — and when the freight arrives, the operational chaos is immediate. Define at minimum these monitors:
/error/ folder has new files (any failure)/outbound/ has files sitting unprocessed for more than 2 hoursThese are the most common failures we see in 856→Odoo integrations across the LATAM region. Most are invisible in testing and devastating in production.
Not all 856 files use * as the element separator. The ISA envelope defines delimiters at positions 3 and 105 — always detect them dynamically.
Some vendors skip the package loop in their 856 and go directly from HL O (order) to HL I (item). Your parser must handle both structures.
In the 856, the SKU comes in the LIN segment with qualifiers like VP (Vendor Part), BP (Buyer Part), or UP (UPC). Never assume it matches your Odoo internal reference.
Retries, parallel processes, and files retransmitted by the vendor can create the same picking twice in Odoo, doubling your expected inventory.
EDI files from legacy systems in Mexico frequently use ISO-8859-1. Opening with UTF-8 corrupts accented characters in product descriptions, causing matching failures.
Calling button_validate immediately upon creating the picking marks stock as received before the goods physically arrive. This throws off your Odoo inventory.
Without the original file you can't audit discrepancies, resolve vendor disputes, or replay data. Many commercial agreements require 5-year retention.
The picking_type_id with ID 1 in dev is not the same in production. Hardcoding location, type, or partner IDs breaks the integration when migrating or restoring the database.
Use this list before activating any 856→Odoo integration with a new vendor. Check off each item with an artifact (screenshot, config file, or test result).
/processing/ before parsing (atomic lock)/processed/ or /error/ in every possible code path/ack/ on successful processing/error/ and triggers alert/error/ → immediate Slack/emailBuilding this integration from scratch takes between 3 and 8 weeks depending on your operation's complexity. Teams without prior EDI experience tend to trip on the same points: the 856's HL loops, SKU resolution in Odoo, and handling legacy encodings.
At Molten Logistics we've built exactly this kind of integration for operations in Mexico, Colombia, Costa Rica, and beyond. We don't sell generic software — we understand your operation before writing a single line of code. We've processed over 100,000 shipments with real automation, not promises.
We connect your EDI vendors, SFTP, and Odoo into a fully automated flow — no manual processes, no data entry, no inventory discrepancies. Luis Alba and the Molten team are ready to talk about your specific operation.
Schedule a free consultation →