UbiAPI para comercios

API para comercios

Con la API, tu sistema crea las órdenes, las arma, imprime las etiquetas y recibe un aviso cada vez que un paquete cambia de estado. Todo va en JSON y por HTTPS.

Para empezar

Crea una clave en Integraciones dentro del portal (la ve solo el administrador del comercio) y mándala en cada pedido:

curl https://api.ubi.pa/v1/me \
  -H "Authorization: Bearer sk_live_…"

La clave se muestra una sola vez. Guárdala como un secreto: quien la tenga puede crear órdenes a tu nombre. Si se filtra, revócala y crea otra.

Errores y límites

Los errores responden con su código HTTP y un mensaje en español:

{
  "error": {
    "code": "validation_failed",
    "message": "Revisa los campos marcados.",
    "fields": { "recipient.phone": "Revisa el teléfono: no parece de Panamá (8 dígitos que empiezan con 6)." }
  }
}
  • 401 la clave falta, no es válida o fue revocada.
  • 404 la orden o el paquete no existe (o es de otro comercio).
  • 409 choca con algo que ya existe, como una orden externa repetida.
  • 422 faltan datos o están mal.
  • 429 pasaste el límite de 120 solicitudes por minuto; la cabecera Retry-After dice cuántos segundos esperar.

Reintentos seguros. En los POST, manda una cabecera Idempotency-Key con un valor único (por ejemplo, el número de tu pedido). Si se corta la conexión y repites el mismo pedido con la misma llave, recibes la misma respuesta y no se crea nada dos veces. La llave dura 24 horas.

Órdenes

POST/orders

Crea una orden. Si mandas packages, cada paquete sale con su tracking y la orden queda lista para despacho. Si no, queda pendiente de armar. La dirección es la que tengas; el pin exacto y el día los confirma quien recibe en el link que le mandamos por WhatsApp.

curl -X POST https://api.ubi.pa/v1/orders \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pedido-1001" \
  -d '{
    "external_ref": "F-1001",
    "recipient": { "name": "Ana Castillo", "phone": "6123-4567", "email": "ana@correo.com" },
    "address": "PH Premium Tower, Torre B, apto 14-B, San Francisco",
    "address_notes": "Garita del primer edificio",
    "declared_value": "250.00",
    "locker_id": null,
    "origin_warehouse_id": "CEN",
    "packages": [{ "size": "M" }, { "size": "L", "origin_tracking": "TBA000123456" }]
  }'

Tamaños: S, M o L. Teléfonos de Panamá (8 dígitos que empiezan con 6) o internacionales con +. En lugar de la dirección puedes mandar xip_code. El valor declarado (en dólares) es opcional: si pasa del monto que acordaste con Ubi, al entregar se le pide un código de un solo uso a quien recibe. Responde 201 con la orden:

{
  "id": "207208df-6ba1-44a5-a0ef-6c86283fd849",
  "number": 100001,
  "external_ref": "F-1001",
  "status": "ready_for_dispatch",
  "status_label": "Lista para enviar",
  "recipient": { "name": "Ana Castillo", "phone": "+50761234567", "email": "ana@correo.com" },
  "packages": [
    {
      "id": "…",
      "tracking": "0527948810",
      "origin_tracking": null,
      "sequence": 1,
      "size": "M",
      "status": "ready_for_dispatch",
      "status_label": "Listo para enviar",
      "tracking_url": "https://api.ubi.pa/v1/r/…"
    }
  ]
}

Dos trackings por paquete

Cada paquete tiene su tracking, que generamos siempre, y puede traer su origin_tracking: el que trae la caja desde Amazon, Shein o cualquier empresa. Se busca y se escanea por cualquiera de los dos. No se puede repetir entre tus paquetes vivos: si ya está en otra orden, la API responde 409 y te dice cuál. Un paquete cancelado, entregado o retirado libera su código. El nombre anterior, external_barcode, sigue funcionando.

De qué bodega sale

origin_warehouse_id acepta el id o el código de una de tus bodegas. Si tienes una sola y no lo mandas, se usa esa; si tienes varias, es obligatorio. Una compra que sale de varias bodegas lo dice por paquete, con packages[].origin_warehouse_id: se crea un envío por bodega en la misma orden, cada uno entra a su pickup y su devolución vuelve a su bodega. La respuesta trae shipments con cada envío, y cada paquete dice su shipment_id.

GET/orders/{id}

Una orden con sus paquetes y sus estados.

GET/orders?external_ref=F-1001

Busca por tu número de orden. Sin external_ref, devuelve las más recientes (limit hasta 100); para la página siguiente, manda before con el next_before de la respuesta.

POST/orders/{id}/packages

Arma una orden que llegó sin paquetes:

{ "packages": [{ "size": "S" }, { "size": "S" }] }

POST/orders/{id}/cancel

Cancela la orden mientras no hayamos recogido sus paquetes. Después, escríbele a servicio al cliente.

{ "reason": "El cliente desistió de la compra" }

POST/orders/{id}/reverse-pickups

Si quien recibió una orden entregada quiere devolver algo, lo recogemos donde está y lo llevamos a tu bodega (la que indiques o la de origen). Le escribimos para que elija el día. Crea una orden nueva, la devolución, y responde 201 con ella:

{ "packages": [{ "size": "M" }], "notes": "Talla equivocada", "warehouse_id": "…" }

GET/checkout/options

Las opciones de entrega para tu checkout: a domicilio (con los próximos días con cupo en la zona de tu cliente) y los lockers más cercanos donde cabe el paquete, con el día desde el que llega. Parámetros: lat, lng (opcionales) y size (S, M o L; por defecto M).

{
  "door": { "available": true, "dates": ["2026-11-03", "2026-11-04"] },
  "lockers": [{ "id": "…", "code": "VIA-ESPANA", "name": "Vía España", "distance_km": 1.2, "first_date": "2026-11-03" }]
}

GET/lockers

Los lockers activos con sus puertas libres por tamaño (free_doors) y si hay cupo (available). Úsalo para ofrecer «retirar en un locker» en tu checkout. Para mandar una orden a un locker, pon su locker_id al crearla: no hace falta dirección y a quien recibe le mandamos el código para retirar.

Retiro en tienda: si tu comercio tiene puertas propias en un locker de tu local, crea la orden con "store_pickup": true, un paquete y, si lo tienes, su origin_tracking (tu número de factura o de pedido: es lo que escanea tu personal en el kiosco). Sin código de barras también sirve: tu personal lo busca en el kiosco por el nombre de quien retira. Toma una de tus puertas enseguida, sin entrega a domicilio ni costo. La respuesta trae store_pickup.state: door_reserved con door_number, o waiting_door si tus puertas de ese tamaño están llenas (en tu portal puedes usar una de Ubi con costo).

Cobro contra entrega: agrega cod_amount_cents (en centavos) al crear la orden. Solo en una orden de un paquete que va a un locker o a un punto; a domicilio no está disponible. Quien retira lo paga antes de que abra la puerta, siempre, y te lo liquidamos por periodo. Si todavía no hay un proveedor de pagos configurado, la orden se rechaza con un error en ese campo. Lo mismo si el monto pasa del máximo por cobro con tarjeta y en ese locker no hay otra forma de cobrarlo; si la hay, la orden se crea y la respuesta trae warning con el motivo. La orden devuelve cod_amount_cents y cod_paid_cents.

GET/packages/{tracking}

Un paquete por cualquiera de sus dos trackings, con su orden.

Etiquetas

GET/orders/{id}/labels?format=pdf

Las etiquetas de la orden. pdf: una de 4x6 pulgadas por página, para impresoras térmicas. letter: dos por hoja carta. zpl: para mandar directo a una impresora Zebra de 203 ppp.

Estados

Estado de la orden (status de la orden):

  • pending_build Pendiente de armar
  • ready_for_dispatch Lista para enviar
  • pickup_requested Pickup pedido
  • in_progress En curso
  • partial Parcial
  • completed Completada
  • returned Devuelta
  • cancelled Cancelada

Estado de cada paquete (status del paquete):

  • created Creado
  • ready_for_dispatch Listo para enviar
  • pending_pickup Por recoger en el comercio
  • picked_up Recogido en el comercio
  • at_facility En la bodega
  • awaiting_address Esperando la dirección
  • scheduled Con día de entrega
  • out_for_delivery En ruta
  • delivered Entregado
  • failed_attempt Intento fallido
  • rescheduled Reprogramado
  • ready_for_collection Listo para retirar
  • collected Retirado
  • returning En devolución
  • returned Devuelto
  • cancelled Cancelado
  • under_review En revisión
  • damaged Dañado, retenido en la bodega
  • in_transit En camino

Webhooks

En Integraciones agregas la URL de tu sistema y eliges qué avisar. Cada aviso es un POST con este cuerpo:

{
  "type": "package.updated",
  "timestamp": "2026-11-02T14:05:11.000Z",
  "data": { "package_id": "…", "order": { …la orden completa, como en GET /orders/{id}… } }
}
  • order.created: orden creada.
  • order.ready_for_dispatch: orden empacada y lista para salir de tu bodega.
  • order.cancelled: orden cancelada.
  • order.delivered: orden entregada.
  • order.delivery_failed: intento de entrega fallido o parcial (trae el motivo).
  • order.return_created: lo que no se entregó vuelve a tu bodega (trae el motivo).
  • package.updated: un paquete cambió de estado.
  • package.size_corrected: se corrigió el tamaño de un paquete (trae el antes, el después, el motivo y la foto).
  • locker.deposited: el paquete quedó en el locker, listo para retiro.
  • locker.pickup_ready: el enlace de retiro, para que tú le envíes el aviso a tu cliente (nunca trae el código).
  • locker.collected: lo retiraron del locker.
  • locker.expired: venció en el locker: el chofer lo va a sacar.
  • locker.retrieved: el chofer lo sacó del locker.
  • locker.recovered: lo retiraron en la bodega.
  • locker.unclaimed_returned: nadie lo retiró en la bodega: vuelve a tu bodega.

En order.delivery_failed, data.reason trae el motivo: nobody_home (no había nadie), address_not_found (no se encontró la dirección), refused (no lo quiso recibir), no_access (no se pudo entrar), rescheduled_by_customer (pidió otro día), damaged (paquete dañado), not_living_here (ya no vive aquí). Si la entrega fue parcial, los paquetes entregados vienen con estado delivered.

Si tú le mandas el aviso de retiro a tu cliente, locker.pickup_ready trae data.pickup_url: el enlace de retiro, cuyo QR abre la puerta. Trátalo como el código: mándalo solo a quien retira. El código de 6 dígitos nunca viene en el webhook; lo ve en el portal quien tenga el permiso, y queda constancia.

Si Ubi corrige el tamaño de un paquete (en el pickup, la bodega o el locker), package.size_corrected trae data.size_change: from y to (S, M o L), reason, where y photo_url, un enlace a la foto que vence a los 7 días. La foto y el motivo también están en la historia del paquete en tu portal.

Responde con un código 2xx en menos de 10 segundos. Si no, reintentamos al minuto, a los 5 y 30 minutos, a las 2, 12 y 24 horas. Un mismo aviso puede llegar dos veces: usa la cabecera webhook-id para no procesarlo dos veces.

Verifica la firma antes de confiar en el aviso. Usamos el formato de Standard Webhooks, así que puedes usar sus librerías, o hacerlo así en Node.js:

import crypto from "node:crypto";

function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  // Rechaza avisos de hace más de 5 minutos.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace("whsec_", ""), "base64");
  const expected = "v1," + crypto.createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("base64");
  return headers["webhook-signature"].split(" ").some((signature) =>
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)));
}

El secreto (whsec_…) se muestra una sola vez al crear el webhook. Si lo pierdes, cámbialo desde Integraciones.