EDI 856 Odoo SFTP LATAM ASN
Published · May 2026 Read time · 12 min Standard · X12 856 / Ship Notice Target ERP · Odoo 16 / 17 Region · Mexico · LATAM
Vendor / WMS
Generates 856
856 .edi file
PUT /outbound/
SFTP Server
Secure Dropbox
Molten
Parser + Map
Molten Engine
856 → JSON
REST API
stock.picking
ERP Destino
Odoo
ASN FLOW · EDI 856 → SFTP → MOLTEN LOGISTICS ENGINE → ODOO RECEIPT

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.

Who this is for: Technical teams, logistics managers, and developers who need to automate EDI Ship Notice ingestion into Odoo — without relying on expensive integrators or manual processes.

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)

X12 856 · Ship Notice — shipment with 2 items
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
Regional note: Vendors in Mexico and LATAM frequently use 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.

Recommended SFTP folder structure
/edi/ ├── outbound/ # vendor drops 856 files here │ ├── ASN-20260510-001.edi │ └── ASN-20260510-002.edi ├── processing/ # molten moves file here to lock it while processing ├── processed/ # successfully integrated files │ └── 2026/05/ │ └── ASN-20260510-001.edi ├── error/ # failed files — never silently discard │ └── ASN-20260510-002.edi.err └── ack/ # 997 functional acknowledgments back to vendor └── 997-20260510-001.edi

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

Python · SFTP connection with public key authentication
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.

Python · 856 parser → structured dictionary
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
BSN02ASN Numberstock.pickingoriginPrefijo "ASN-" recomendado
BSN03Ship datestock.pickingscheduled_dateFormat YYYYMMDD → datetime
DTM 017Estimated delivery datestock.pickingdate_deadlineAvailable from Odoo 15
TD5-03Transportistastock.pickingcarrier_idMap code → res.partner
REF CNTracking numberstock.pickingcarrier_tracking_ref
PRF01PO numberstock.pickingpurchase_idLook up by name in purchase.order
MAN02 (SSCC)Package/carton codestock.quant.packagenameCreate if not found
LIN-03 (VP)Vendor's SKUstock.moveproduct_idSearch product.supplierinfo first
SN1-02Shipped quantitystock.move.linereserved_qty / qty_doneDo not validate yet — only reserve
Odoo tip: To resolve the vendor SKU (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.

Python · Create stock.picking in Odoo from 856 data
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
Important: The IDs for 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

1

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.

2

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.

3

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).

Python · Encoding handling for legacy EDI files
# 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")
4

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):

Python · Main orchestrator 856 → Odoo
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()
5

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.

X12 997 · Functional Acknowledgment for the 856
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
8

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.

Read elem_sep = raw[3] and segment_sep = raw[105] before parsing any segment
⚠️

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.

If there's no HL P, assign all items to a virtual package with an empty SSCC
⚠️

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.

Always use product.supplierinfo to resolve, with fallback to product.default_code
⚠️

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.

Store the ASN number in a control table; check before creating any picking
⚠️

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.

Detect encoding with chardet, or try utf-8 → iso-8859-1 → cp1252 in order
⚠️

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.

Only confirm (action_confirm). Let the warehouse team validate upon physical receipt
⚠️

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.

Store the raw .edi in S3 / Blob Storage with a date-based path. Save the path in the Odoo picking
⚠️

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.

Always look up by unique field (name, code, xml_id) — never by numeric ID

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 →
Quick flow recap: Vendor drops 856.edi on SFTP → Molten Engine detects it and moves to /processing/ → Parser extracts HL hierarchy → Maps to stock.picking in Odoo → 997 back to vendor → File archived → Your warehouse team sees the ready receipt in Odoo. That's it.
Molten Logistics