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)." }
}
}401la clave falta, no es válida o fue revocada.404la orden o el paquete no existe (o es de otro comercio).409choca con algo que ya existe, como una orden externa repetida.422faltan datos o están mal.429pasaste el límite de 120 solicitudes por minuto; la cabeceraRetry-Afterdice 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_buildPendiente de armarready_for_dispatchLista para enviarpickup_requestedPickup pedidoin_progressEn cursopartialParcialcompletedCompletadareturnedDevueltacancelledCancelada
Estado de cada paquete (status del paquete):
createdCreadoready_for_dispatchListo para enviarpending_pickupPor recoger en el comerciopicked_upRecogido en el comercioat_facilityEn la bodegaawaiting_addressEsperando la direcciónscheduledCon día de entregaout_for_deliveryEn rutadeliveredEntregadofailed_attemptIntento fallidorescheduledReprogramadoready_for_collectionListo para retirarcollectedRetiradoreturningEn devoluciónreturnedDevueltocancelledCanceladounder_reviewEn revisióndamagedDañado, retenido en la bodegain_transitEn 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.