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.
What is the EDI 856 and what it contains
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.
Hierarchical structure of the 856
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 |
Real-world 856 file example (abbreviated)
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.
SFTP folder structure
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.
Credentials and SSH key configuration
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
Parse the 856 and map to Odoo
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
Field mapping table: 856 → Odoo
| 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.
Create the receipt in Odoo
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.
The complete process step by step
Agree on the SFTP structure with your vendor
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.
Configure the SFTP server and folder structure
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.
Implement and validate the 856 parser
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")
Build the complete integration flow
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()
Send the 997 Functional Acknowledgment
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
Configure monitoring and alerts
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:
- Alert if
/error/folder has new files (any failure) - Alert if
/outbound/has files sitting unprocessed for more than 2 hours - Alert if the vendor has sent no 856s in more than 48 hours (may indicate a failure on their end)
- Daily dashboard: ASNs received vs pickings created in Odoo (reconciliation)
Mistakes that break prod
These are the most common failures we see in 856→Odoo integrations across the LATAM region. Most are invisible in testing and devastating in production.
Assuming fixed delimiters
Not all 856 files use * as the element separator. The ISA envelope defines delimiters at positions 3 and 105 — always detect them dynamically.
Ignoring the HL P (package) level
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.
Vendor's SKU ≠ SKU interno de Odoo
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.
No idempotency — duplicate pickings
Retries, parallel processes, and files retransmitted by the vendor can create the same picking twice in Odoo, doubling your expected inventory.
Incorrect encoding — corrupted characters
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.
Validating the picking too early
Calling button_validate immediately upon creating the picking marks stock as received before the goods physically arrive. This throws off your Odoo inventory.
Not archiving the raw EDI file
Without the original file you can't audit discrepancies, resolve vendor disputes, or replay data. Many commercial agreements require 5-year retention.
Hardcoding Odoo IDs in code
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.
Go-live checklist
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).
📋 Pre-Integration
- Received and reviewed at least 3 real sample 856 files from the vendor
- Confirmed: SFTP host, port, username, authentication method (key or password)
- Folder structure created and permissions validated with the vendor's team
- Identified the SKU qualifier in LIN (VP, BP, IN, UP, or other)
- product.supplierinfo table populated with all vendor SKUs in Odoo
- Confirmed picking_type, location_id, and location_dest_id for your warehouse in Odoo
- Vendor's carrier codes mapped to carrier_id in Odoo
⚙️ Development
- Parser detects delimiters dynamically (not hardcoded)
- Parser handles structure with and without HL P (package) level
- Encoding auto-detected (UTF-8 / ISO-8859-1 / CP1252)
- Idempotency mechanism implemented and tested (ASN number as key)
- File moved to
/processing/before parsing (atomic lock) - File moved to
/processed/or/error/in every possible code path - 997 generated and deposited in
/ack/on successful processing - Picking created in "Ready" state (confirmed, NOT validated)
- Raw .edi file archived in persistent storage
- Archive path saved in the Odoo picking (note or chatter)
🧪 Testing
- Parser validated against all 3 sample files — every field correct
- Picking created in Odoo test environment and reviewed manually field by field
- Duplicate test: same file processed twice → only 1 picking created
- Malformed file test → goes to
/error/and triggers alert - Unknown SKU test → error handled without crash, clear log entry
- 997 received and correctly acknowledged by the vendor's system
- Vendor confirmed test pickings match their sample 856s
🚀 Go-Live & Operations
- Scheduled job active (cron / scheduler) at frequency agreed with vendor
- Alert configured: files in
/error/→ immediate Slack/email - Alert configured: vendor with no activity for more than 48 hours
- Reconciliation dashboard active: ASNs received vs pickings created in Odoo
- Runbook documented: how to manually replay a failed 856
- Warehouse team trained: know how to identify EDI-created pickings in Odoo
- Raw file retention configured (minimum 5 years for Mexico / SAT compliance)
Ready to automate your logistics?
Building 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.
Molten Logistics — Your trusted partner in Logistics & E-commerce
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 →