Ship cross-border

Updated May 31, 20265 min read

Ship items across an international border. Versus a domestic shipment, you need to carry the data customs needs: HS codes, item values, country of origin, exporter and importer details, incoterms, tax IDs.

Get this right and the carrier generates a clean commercial invoice and the parcel clears smoothly. Get it wrong and the parcel is held at customs, returned, or destroyed.

Scenario

A US merchant ships a clothing order to a customer in Germany. Same code as a domestic shipment plus a layer of customs-relevant fields on each item.

This recipe focuses on the payload differences versus a domestic booking. The flow itself (POST /shipments) is the same as Book a shipment. Go there for the basics.

If you use Carriyo Orders

This recipe books the shipment directly, the path for clients not using the Carriyo Order object. If you do use Carriyo Orders, fulfilling a fulfillment order creates and books the shipment for you; the customs data still comes from the order's items and your product catalog.

Prerequisites

  • API credentials: see Getting started.
  • A Carrier account that serves the destination country, with cross-border settings configured. For DHL specifically, see Create a DHL account for the customs / dangerous-goods / commercial-invoice fields the carrier needs.
  • For each item: HS code, value, country of origin.
  • For the merchant: where applicable, EORI (EU), VAT or tax ID.

Step 1, create the shipment with cross-border data

curl -X POST 'https://api.carriyo.com/shipments' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  -H 'tenant-id: YOUR_TENANT_ID' \
  -H 'x-api-key: YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "merchant": "ACME",
    "references": {
      "partner_order_reference": "YOUR_ORDER_REF",
      "partner_shipment_reference": "YOUR_ORDER_REF-1"
    },
    "pickup": {
      "partner_location_code": "NJ-WH-01"
    },
    "dropoff": {
      "address1": "10 Hauptstrasse",
      "city": "Berlin",
      "country": "DE",
      "postcode": "10115",
      "contact_name": "Hans Muller",
      "contact_phone": "+49301234567",
      "contact_email": "hans@example.de"
    },
    "parcels": [
      {
        "weight": { "value": 1.2, "unit": "kg" },
        "dimension": { "width": 25, "height": 15, "depth": 30, "unit": "cm" }
      }
    ],
    "items": [
      {
        "sku": "TSHIRT-NAVY-L",
        "quantity": 2,
        "description": "Cotton T-shirt, navy, large",
        "price": { "amount": 35, "currency": "USD" },
        "weight": { "value": 0.25, "unit": "kg" },
        "hs_code": "6109.10",
        "origin_country": "PT",
        "material_composition": "100% cotton"
      },
      {
        "sku": "JEANS-DARK-32",
        "quantity": 1,
        "description": "Dark wash jeans, size 32",
        "price": { "amount": 80, "currency": "USD" },
        "weight": { "value": 0.7, "unit": "kg" },
        "hs_code": "6203.42",
        "origin_country": "PT",
        "material_composition": "98% cotton, 2% elastane"
      }
    ],
    "payment": {
      "total_amount": 150,
      "currency": "USD"
    },
    "customs": {
      "declared_value": { "amount": 150, "currency": "USD" },
      "incoterms": "DDP",
      "exporter": {
        "contact_name": "Alex Chen",
        "company_name": "Acme Inc",
        "address1": "350 5th Avenue",
        "city": "New York",
        "state": "NY",
        "country": "US",
        "postcode": "10118",
        "registration_numbers": [
          { "type_code": "EIN", "value": "12-3456789", "issuer_country_code": "US" },
          { "type_code": "IOSS", "value": "IM1234567890", "issuer_country_code": "IE" }
        ]
      },
      "importer": {
        "contact_name": "Hans Muller",
        "address1": "10 Hauptstrasse",
        "city": "Berlin",
        "country": "DE",
        "postcode": "10115",
        "registration_numbers": [
          { "type_code": "EOR", "value": "DE123456789012345", "issuer_country_code": "DE" }
        ]
      }
    }
  }'

The cross-border-specific fields:

  • items[*].hs_code: the Harmonized System code customs uses to classify the item. Wrong or missing HS codes are the #1 cause of customs holds.
  • items[*].price: declared value per unit. Customs computes duties / VAT against this.
  • items[*].origin_country: country of manufacture, not the pickup country. Drives preferential trade-agreement tariffs.
  • items[*].material_composition: sometimes required for textiles, dangerous goods, or specific HS classifications.
  • customs.exporter / customs.importer: the commercial parties for the cross-border movement, separate from pickup / dropoff (which are physical addresses for the carrier). Each party carries a full address plus registration_numbers: VAT / EIN / EOR / GST / IOSS / etc. as required by the destination's customs authority. Each registration entry has a type_code, a value, and an issuer_country_code. Carriyo also supports customs.seller, customs.buyer, and customs.broker when they differ from exporter/importer.
  • customs.incoterms: shipping terms enum. DDP (duties paid by exporter), DAP (duties paid by importer), and others from the Incoterms 2020 set. Choose based on contract with the customer.
  • customs.declared_value: total value to declare. Optional; computed from item prices if omitted.

Let the product catalog fill in item customs data

Re-sending hs_code, origin_country, material_composition, and weights on every cross-border booking is repetitive and error-prone. A single wrong HS code means a customs hold. If you maintain a product catalog in Carriyo, you can store that data once per SKU and let the shipment inherit it at booking.

Populate the catalog up front:

curl -X POST 'https://api.carriyo.com/products' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  -H 'tenant-id: YOUR_TENANT_ID' \
  -H 'x-api-key: YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "merchant": "ACME",
    "products": [
      {
        "sku": "TSHIRT-NAVY-L",
        "description": "Cotton T-shirt, navy, large",
        "hs_code": "6109.10",
        "origin_country": "PT",
        "material_composition": "100% cotton",
        "weight": { "value": 0.25, "unit": "kg" }
      }
    ]
  }'

Then a cross-border booking carries the SKU, quantity, and price. Carriyo enriches the customs and physical fields (description, hs_code, origin_country, material_composition, weight) from the catalog record at booking time, so they reach the carrier without you repeating them:

"items": [
  { "sku": "TSHIRT-NAVY-L", "quantity": 2, "price": { "amount": 35, "currency": "USD" } }
]

price is never enriched: it isn't a catalog field, so always send it on the item. For the fields the catalog does hold, anything you send on the item overrides the catalog record (handy when one shipment differs). Keep the catalog in sync with your source of truth so bookings don't inherit stale customs data.

Restricted and dangerous goods

Some products require additional declarations:

  • Dangerous goods: batteries, perfumes, alcohol, lithium-ion electronics. Carriers need a dangerous goods service code configured on the account and per-item flags. See Create a DHL account → dangerous goods for the fields.
  • Restricted commodities: certain HS codes are restricted in certain destinations. The carrier rejects with an error in that case; check the response.

For dangerous-goods shipments, set the per-item fields in the create call:

{
  "items": [
    {
      "...": "...",
      "battery": {
        "material_type": "lithium_ion",
        "packing_type": "contained_in_equipment"
      },
      "dangerous_goods": true
    }
  ]
}

Step 2, confirm the commercial invoice was generated

Like any booking, the create call returns pending: Carriyo submits to the carrier and the outcome arrives by webhook. Once the carrier accepts and the shipment reaches booked, the carrier-produced documents are attached. For cross-border, expect both a label and a commercial invoice on the booked Shipment (read it from the booked webhook or GET /shipments/{id}):

{
  "shipment_id": "MPQ9XCB2NKADT5",
  "post_shipping_info": {
    "status": "booked",
    "tracking_no": "1Z999AA10123456784",
    "default_label_url": "https://documents.carriyo.com/redirect/ACME/DHL/SHIPMENT/MPQ9XCB2NKADT5.pdf?expiry=...&token=...",
    "documents": [
      {
        "name": "Commercial Invoice",
        "type": "commercial_invoice",
        "format": "pdf",
        "url": "https://documents.carriyo.com/documents/ACME/shipment/MPQ9XCB2NKADT5/commercial_invoice/carriyo-standard?token=..."
      }
    ]
  },
  "...": "(other fields elided)"
}

If the commercial invoice document is missing on a cross-border shipment, the carrier didn't generate one, check the carrier account's Customs Declarable toggle (in the Product & Service section, DHL example) is on.

Customs holds and rejections

Cross-border shipments can fail at the carrier or at customs:

  • Carrier rejection at booking comes back as post_shipping_info.status: "error" with the carrier's reason in post_shipping_info.error_details. Often: missing HS code, invalid registration number, malformed address, restricted commodity. Fix and re-book, usually easiest in the Dashboard, see Resolve shipment errors.
  • Customs hold post-booking has no dedicated status. It appears on the shipment's tracking events as a suspended or delayed status carrying a customs-specific reason code, for example customs_pending (awaiting clearance) or customs_clearance_issue. Handle it in your webhook receiver (see Platform → Webhooks); the full list is in Reason codes. Resolution often needs customer intervention (provide a tax ID, pay duties).

Successful clearance, by contrast, comes through as a customs_cleared reason code. No action needed.

What flows where

FieldComes fromUsed by
items[*].hs_codeyour catalogcustoms classification
items[*].priceorder linecustoms valuation, duty calc
items[*].origin_countryyour catalogpreferential tariffs
customs.exporter.registration_numbersmerchant configexporter-side tax IDs (VAT, EIN, IOSS)
customs.importer.registration_numberscustomer / order dataimporter-side tax IDs (EOR, VAT)
customs.incotermsyour contract with the customerwho pays duties
post_shipping_info.documents[] (response)carrier-generatedcommercial invoice and other paperwork

Pitfalls

  • HS codes have to be correct per SKU. A blanket generic code triggers customs holds when descriptions and codes don't line up. Maintain HS codes in the product catalog.
  • Currency consistency. Item price.currency should match payment.currency. Don't mix currencies inside one shipment.
  • Registration number formats vary per authority. VAT, EIN, EOR, IOSS each have their own format and validation rules. Carriers typically pre-validate at booking; format failures come back as 400-shaped errors.
  • Incoterms have downstream financial implications. DDP commits the merchant to paying duties; DAP shifts the duty bill to the customer at delivery. The choice belongs to your commercial setup, not Carriyo, but it has to be made deliberately.