# Z-Bombilla Shipping — API Specification

**Version:** 1.0
**Base URL:** `https://shipping.z-bombilla.com`
**Contact:** envios@z-bombilla.com

---

## Overview

Z-Bombilla Shipping lets you quote rates, confirm shipments and generate carrier
labels across all agencies configured on your account (UPS, MRW, Correos, CTT,
DHL, Envialia, ViaXpress, Paquetería, Paletería…), plus connect a marketplace or
ecommerce store to receive tracking callbacks.

Two authentication modes coexist:

- **Session** (`JSESSIONID`) — used by the web panel itself.
- **Bearer token** — used by API integrations: `Authorization: Bearer {accessToken}`.

Only the endpoints below marked **API** accept a Bearer token. `/envio/doEnvio`
(no `Api` suffix) is session-only and is **not** part of the public API.

For the standalone tracking gateway spec (`GET /envio/seguimiento`), see
[ZBombillaShipping_Tracking_API.md](ZBombillaShipping_Tracking_API.md).

---

## Departments (`idDepartamento`) — multi-origin accounts

Since 2026-08-28 an account can have several shipping departments/origins
(warehouses, offices…), each with its own sender address and, optionally, its
own negotiated rates. This is fully backwards compatible:

- Pass `idDepartamento` (`calcularEnvio`) or `envio.idDepartamento`
  (`doEnvioApi`) to pick which department to quote/ship from.
- It is the **correlative number** shown next to each department in
  `/perfil/envio` (1, 2, 3…) — **not** the internal database id, which is
  never exposed outside the panel.
- If omitted, rates are quoted from the account's **default department**. On
  `doEnvioApi` the sender printed on the label is left untouched in that case
  (each carrier keeps using the address of its own credentials account), so no
  existing integration changes the way its labels look.
- The server always re-reads the sender address from the database using this
  number; any sender data sent by the client is ignored, so a request can
  never forge a shipment from an address it doesn't own.

---

## 1. Calculate Rates — `POST /envio/calcularEnvio` (API)

Quotes shipping rates for a package across every carrier configured on the
account, applies the account's agency rules (exclusions, preferences, price
tolerances…) and returns the ordered list. **The first entry with
`isGood: true` is always the recommended carrier.**

### Auth & headers

| Header          | Value                               | Required |
|-----------------|--------------------------------------|----------|
| `Authorization` | `Bearer {accessToken}`               | Yes      |
| `Content-Type`  | `application/x-www-form-urlencoded`  | Yes      |

### Body parameters

| Parameter                                        | Type            | Required    | Description |
|---------------------------------------------------|-----------------|-------------|-------------|
| `codigoPostal`                                     | string          | **Yes**     | Destination postal code. In Spain, also resolves the province. |
| `paisCliente`                                      | string          | No          | ISO country code (`ES`, `PT`, `FR`…) or internal numeric id. Default `ES`. |
| `tipoPortes`                                       | string          | No          | `Pagados` (default) or `Contrareembolso`. |
| `valorMercancia`                                   | decimal         | Conditional | Declared goods value (€). Optional for Spain; **required and > 0** for international shipments or when `tipoPortes=Contrareembolso`. |
| `marketplaceName`                                  | string          | No          | Marketplace origin (`Amazon`, `Temu`…), used for per-marketplace exclusion rules. |
| `fechaLimiteEntrega`                               | string          | No          | Delivery deadline, `YYYY-MM-DD`. Carriers unable to deliver in time are excluded. |
| `idDepartamento`                                   | integer         | No          | Department number (see above). Defaults to the account's default department. |
| `largo[]`, `ancho[]`, `alto[]`, `peso[]`           | repeatable array| **Yes**     | Per-parcel dimensions (cm) and weight (kg). Repeat the group of four once per parcel. |

Example, 2 parcels:
`largo[]=20&ancho[]=30&alto[]=40&peso[]=5&largo[]=10&ancho[]=10&alto[]=10&peso[]=2`

### Response (HTTP 200)

```json
{
  "agencias": [
    {
      "nombreAgenciaCalculada": "Correos Paq. Estándar (48-72h) (Zona 2)",
      "precioPorte": 4.52,
      "precioSeguro": 100.00,
      "precioReembolso": 0.00,
      "precioTotal": 4.52,
      "isGood": true,
      "errors": []
    },
    {
      "nombreAgenciaCalculada": "Correos (Paq Standard Internacional)",
      "precioPorte": 0,
      "precioSeguro": 0,
      "precioReembolso": 0,
      "precioTotal": 0,
      "isGood": false,
      "errors": ["No existe ningún baremo con la provincia o el País en su configuración."]
    }
  ],
  "autoSeleccionPermitida": true,
  "motivoNoAutoSeleccion": "",
  "usuarioValidado": true
}
```

| Field                              | Type    | Description |
|-------------------------------------|---------|-------------|
| `agencias`                          | array   | Quoted carriers, already ordered per the account's rules. |
| `agencias[].nombreAgenciaCalculada` | string  | Carrier service name, including zone/rate applied. Pass this **verbatim** into `doEnvioApi`. |
| `agencias[].precioPorte`            | decimal | Net freight cost. |
| `agencias[].precioSeguro`           | decimal | Coverage included — **not** an extra cost. |
| `agencias[].precioReembolso`        | decimal | COD fee, only when `tipoPortes=Contrareembolso`. |
| `agencias[].precioTotal`            | decimal | `precioPorte + precioReembolso`. |
| `agencias[].isGood`                 | boolean | Whether this carrier/rate can actually handle the shipment. |
| `agencias[].errors`                 | array   | Reasons `isGood` is `false`. Empty otherwise. |
| `autoSeleccionPermitida`            | boolean | Whether it's safe to auto-pick the first recommended carrier. |
| `motivoNoAutoSeleccion`             | string  | Human-readable reason when auto-selection is disabled. |
| `usuarioValidado`                   | boolean | Whether the account is active/configured. |

The list is **not** sorted by price — it follows the account's agency rules.
Filter by `isGood: true` before displaying or selecting a carrier.

### Errors

| HTTP | Cause                          | Body |
|------|----------------------------------|------|
| 400  | Missing/invalid parameters        | `{"errores": ["codigoPostal es obligatorio", ...]}` |
| 401  | Missing/invalid token             | `{"errores": "Usuario no autenticado"}` |
| 500  | Internal error                    | `{"error": "Error interno: ..."}` |

---

## 2. Confirm Shipment — `POST /envio/doEnvioApi` (API)

Confirms the shipment with the chosen carrier (from step 1), generates
tracking and returns Base64-encoded labels.

### Auth & headers

| Header          | Value                    | Required |
|-----------------|---------------------------|----------|
| `Authorization` | `Bearer {accessToken}`    | Yes      |
| `Content-Type`  | `application/json`        | Yes      |

### Body (JSON)

| Field                                | Type    | Required     | Description |
|----------------------------------------|---------|--------------|-------------|
| `agencia.nombreAgenciaCalculada`       | string  | **Yes**      | Must match **exactly** the value returned by `calcularEnvio`. |
| `agencia.precioPorte`                  | decimal | No           | Informational only — the server recomputes and reselects by name. |
| `envio.tipoPortes`                     | string  | Recommended  | `Pagados` or `Contrareembolso`. |
| `envio.refEnvio`                       | string  | No           | Internal reference (e.g. order number). |
| `envio.descripcionEnvio`               | string  | No           | Content description. Recommended for international/some carriers. |
| `envio.idDepartamento`                 | integer | No           | Department number (see above). Omit and the sender printed on the label stays the one configured per carrier (pre-existing behavior), while rates are quoted from the account's default department. |
| `envio.destinatario.nombre`            | string  | **Yes**      | Recipient name (max 120). |
| `envio.destinatario.direccion`         | string  | **Yes**      | Recipient address (max 300). |
| `envio.destinatario.numero`            | string  | No           | Street number (max 45). |
| `envio.destinatario.paisISO`           | string  | No           | ISO country. Default `ES`. |
| `envio.destinatario.provincia`         | string  | No           | Recalculated from postal code for Spain (max 45). |
| `envio.destinatario.poblacion`         | string  | No           | Recipient town (max 45). |
| `envio.destinatario.codigoPostal`      | string  | **Yes**      | Postal code (max 10). |
| `envio.destinatario.telefono`          | string  | No           | Contact phone (max 20). |
| `envio.destinatario.email`             | string  | No           | Contact email (max 120). |
| `envio.destinatario.observaciones`     | string  | No           | Notes (max 300). |
| `envio.bultos`                         | array   | **Yes**      | 1 to 100 parcels. |
| `envio.bultos[].largo`                 | decimal | **Yes**      | Length (cm). |
| `envio.bultos[].ancho`                 | decimal | **Yes**      | Width (cm). |
| `envio.bultos[].alto`                  | decimal | **Yes**      | Height (cm). |
| `envio.bultos[].peso`                  | decimal | **Yes**      | Weight (kg). |
| `envio.valorMercancia`                 | decimal | Conditional  | Required and > 0 if `paisISO != ES` or `tipoPortes = Contrareembolso`. |
| `envio.envioConRetorno`                | boolean | No           | Return shipment flag (only some carriers). |

### Response (HTTP 200)

```json
{
  "numSeguimiento": "1234567890",
  "olEtiquetas": [
    "Base64Etiqueta1",
    "Base64Etiqueta2"
  ]
}
```

- `olEtiquetas`: Base64 of **PDF** or **image** labels, depending on carrier.
- `numSeguimiento`: tracking number assigned to the shipment.

You can preview a raw response's labels at
`https://shipping.z-bombilla.com/envio/testVerEtiqueta`.

### Errors

| HTTP | Cause |
|------|-------|
| 400  | Validation error / missing field / carrier unavailable / `idDepartamento` doesn't exist or doesn't belong to your account. |
| 401  | Missing/invalid token. |
| 404  | Wrong URL (e.g. calling `/api/...`). |

```json
{ "error": "Falta campo obligatorio: envio.destinatario.nombre" }
```

---

## 3. Postal Code Lookup — `GET /codigoPostalLookup`

No authentication required.

```
GET https://shipping.z-bombilla.com/codigoPostalLookup?codigoPostal=03500
```

| Parameter      | Type   | Required | Description |
|----------------|--------|----------|-------------|
| `codigoPostal` | string | **Yes**  | Postal code to resolve. |

Response — always a JSON array, `[]` when there are no matches:

```json
[
  { "poblacion": "Benidorm", "codigoPostal": "03501", "provincia": "Alicante" }
]
```

---

## 4. New Shipment Gateway — `POST /nuevoEnvio` (session)

Opens the Shipping web panel pre-filled with shipment + recipient + parcels
and, optionally, order line items and marketplace alerts. Session-based —
intended to be embedded/linked from a marketplace or ecommerce backoffice.

| Parameter            | Type                   | Required | Description |
|-----------------------|------------------------|----------|-------------|
| `envioJson`            | string (Base64 JSON)   | **Yes**  | Base64 (UTF-8) of an `EnvioV3DTO` JSON — the main payload. |
| `idExpedicion`         | number                 | No       | Loads an existing shipment to edit/duplicate instead. |
| `marketplaceName`      | string                 | No       | Labels the product block (`WooCommerce`, `PrestaShop`…). |
| `itemsJson`            | string (Base64 JSON)   | No       | Order line items — see below. |
| `alertasMarketplace`   | string (JSON/Base64)   | No       | Order alerts — see below. |
| `order_id`             | string/number          | No       | Marketplace order id, for later tracking association. |
| `combined`              | 0/1                    | No       | `1` renders items grouped by order (combined mode). |
| `accessToken`          | string                 | No       | Token for auto-login/authorization, if your integration supports it. |
| `salesChannelUrl`      | string (URL)           | No       | Deep link back to the order in the marketplace backoffice. Also accepted inside `envioJson`. |

`envioJson` construction:

```js
const codificado = btoa(unescape(encodeURIComponent(JSON.stringify(envio))));
```

```json
{
  "refEnvio": "WC-12345",
  "tipoPortes": "Pagados",
  "valorTotal": 59.90,
  "destinatario": {
    "nombre": "Cliente SL",
    "direccion": "Calle Falsa 123, 1",
    "codigoPostal": "28001",
    "poblacion": "Madrid",
    "provincia": "Madrid",
    "pais": { "isoCode": "ES" },
    "telefono": "600000000",
    "email": "cliente@email.com",
    "observaciones": ""
  },
  "olBultos": [{ "largo": 30, "ancho": 20, "alto": 10, "peso": 1.2 }],
  "salesChannelUrl": "https://tu-tienda.com/panel/index.php?controller=AdminOrders&id_order=12345&vieworder"
}
```

### `itemsJson` — order line items

Base64 of a JSON array (UTF-8). The frontend accepts flexible field names:

| Use          | Accepted fields |
|--------------|------------------|
| Title        | `name` or `title` |
| Image        | `image` or `img` |
| SKU          | `sku` / `sku_id` / `sellerSKU` |
| Quantity     | `qty` / `quantity` / `quantityOrdered` |
| Price        | `price` or `itemPriceAmount` |
| Product link | `idProduct` + `linkProduct` (if present) |
| Shipping fee row | `isShippingFee = true` + `name` + `price` |

```json
[
  { "name": "Producto 1", "sku": "REF1", "qty": 2, "price": 8.264, "image": "https://tuweb/img1.jpg" },
  { "isShippingFee": true, "name": "Gastos de envío", "price": 4.95 }
]
```

### `alertasMarketplace` — order alerts

Tolerant format: JSON array, JSON string wrapping an array, or Base64 of
either. Falls back to a single alert with the raw text if it can't be parsed.

```json
[
  "Este pedido ya consta como ENVIADO (TRK: 1234567890).",
  "El pedido no está listo porque su estado actual es En espera.",
  "Pedido marcado como urgente"
]
```

---

## 5. Connect a Store — `GET /oauth/ecommerce/callback`

Registers (or updates) a store/marketplace instance so Shipping can send it
tracking callbacks. Normally opened from the marketplace backoffice via a
"Connect" button; redirects (302) to a connection confirmation screen.

| Parameter             | Type          | Required | Description |
|------------------------|---------------|----------|-------------|
| `platform`              | string        | **Yes**  | Connector, e.g. `prestashop`, `woocommerce`. |
| `siteUrl`               | string (URL)  | **Yes**  | Store base URL, e.g. `https://www.tienda.com`. |
| `apiBase`               | string (URL)  | No       | Base URL for marketplace API calls, if applicable. Usually same as `siteUrl`. |
| `shopId`                | string/number | No       | Store id (multishop setups). |
| `psVersion`             | string        | No       | PrestaShop version, if applicable. |
| `moduleVersion`         | string        | No       | Connector module version. |
| `trackingCallbackUrl`   | string (URL)  | **Yes**  | URL Shipping will POST tracking JSON to. E.g. `https://www.tienda.com/module/shippingzb/trackingupdate`. |
| `token`                 | string        | **Yes**  | Shared secret. Sent back as `Authorization: Bearer <token>` on every callback. Use HTTPS; never log it. |

---

## 6. Tracking Callback (webhook you implement) — `POST {trackingCallbackUrl}`

Shipping calls the URL you registered as `trackingCallbackUrl` once tracking
(and/or ship date) is available for an `order_id`.

### Headers you must accept

```
Content-Type: application/json; charset=UTF-8
Authorization: Bearer {token}
```

Validate the token against the one registered when the store was connected.
Optional fallback: an `accessToken` field inside the JSON body, only if your
integration relies on it (some proxies strip `Authorization`).

### Body (JSON)

| Field         | Type              | Required | Description |
|---------------|-------------------|----------|-------------|
| `order_id`    | number/string     | **Yes**  | Marketplace order id (the one you sent to `/nuevoEnvio`). |
| `tracking`    | string            | **Yes**  | Tracking number. |
| `carrier`     | string            | No       | Carrier name, free text. |
| `shipped_at`  | string (ISO-8601) | No       | Ship date/time, e.g. `2026-02-21T22:10:00Z`. |
| `accessToken` | string            | No       | Optional fallback if `Authorization` doesn't arrive. |

```json
{
  "order_id": 127308,
  "tracking": "PQ29BT071019809B",
  "carrier": "Correos Paq. Estándar (48-72h) (Zona 1)",
  "shipped_at": "2026-02-21T22:10:00Z"
}
```

Expected response: `{"ok": true}`.

### Errors you should return

| HTTP | Reason |
|------|--------|
| 405  | Method not allowed (only POST). |
| 401  | Invalid/missing token. |
| 400  | Empty body or invalid JSON. |
| 422  | Missing `order_id` or `tracking`. |
| 404  | Order not found in the marketplace. |
| 500  | Internal error saving tracking/changing status/sending email. |

**Idempotency:** if the tracking already matches, respond `200` anyway.

---

## 7. Tracking Gateway — `GET /envio/seguimiento`

See the dedicated spec:
[ZBombillaShipping_Tracking_API.md](ZBombillaShipping_Tracking_API.md).
