# SendIt > Documentación del API de SendIt — cotiza con múltiples paqueterías, genera guías y rastrea envíos en México. # Documentación de SendIt Source: https://docs.sendit.mx/ :::warning **SendIt está en desarrollo y todavía no opera comercialmente.** Publicamos esta documentación desde ahora para que veas exactamente cómo funciona el API y cómo tratamos tus envíos, tu dinero y tus datos, antes de que decidas integrarte. Léela como la descripción del producto, no como una invitación a mover envíos reales. Los contratos que describimos aquí son los que estamos construyendo, y pueden cambiar antes de la apertura comercial. Te avisaremos con anticipación cuando cambien. ::: SendIt es la infraestructura de envíos para México. Una sola API REST conecta tu negocio con las principales paqueterías del país. Cotizas en tiempo real, generas guías y rastreas con estados normalizados. Los precios están en pesos y fondeas por SPEI. Todas las peticiones van a la misma URL base: ```text https://api.sendit.mx/v1 ``` Y toda respuesta usa el mismo sobre: ```json { "success": true, "data": { "...": "..." }, "meta": { "...": "..." } } ``` ## Empieza aquí De cero a una guía en PDF en minutos. En modo de prueba, sin tarjeta. Crea llaves de API, distingue los prefijos sk_test_ y sk_live_, y protege tus credenciales. Un sandbox completo. Paqueterías simuladas y $10,000 MXN de saldo virtual. Recibe cada cambio de estado en tu servidor, con firmas verificables. ## El flujo esencial Tu integración llega a su primera guía en tres pasos: 1. **Crea un envío.** `POST /v1/shipments` devuelve el envío y `rates[]` en la misma respuesta. Ahí vienen las cotizaciones de todas las paqueterías. 2. **Elige una cotización.** Cada tarifa trae un `id`, el desglose con IVA y el tiempo estimado de entrega. El total que ves es lo que se debita de tu monedero. 3. **Compra la guía.** `POST /v1/shipments/:id/label` con el `rateId` elegido devuelve el PDF y el número de rastreo. Si ya sabes con qué paquetería enviar, o quieres la más barata, compra la guía en una sola llamada con el objeto `purchase`. Ver [Compra en una llamada](/shipping/shipments#compra-en-una-llamada). ```bash curl -X POST https://api.sendit.mx/v1/shipments \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "from": { "contactName": "Almacén CDMX", "contactPhone": "+5215512345678", "street": "Av. Insurgentes Sur", "exteriorNumber": "1602", "neighborhood": "Crédito Constructor", "city": "Ciudad de México", "state": "CDMX", "postalCode": "03940", "country": "MX" }, "to": { "contactName": "María López", "contactPhone": "+5213312345678", "street": "Av. López Mateos", "exteriorNumber": "45", "neighborhood": "Jardines del Sol", "city": "Zapopan", "state": "JAL", "postalCode": "45050", "country": "MX" }, "parcel": { "length": 30, "width": 20, "height": 15, "weight": 2.5 } }' ``` ## Explora por tema El objeto shipment y su ciclo de vida. Compara tarifas de todas las paqueterías en una llamada. Pedidos de tu tienda con artículos y múltiples envíos. Saldo prepagado en MXN. Fondea por SPEI o con tarjeta. Configura tu perfil fiscal y consulta registros de factura (beta). Errores, paginación, idempotencia y versionamiento. :::note Esta documentación también está en formato legible por máquinas. Agrega `.md` a la URL de cualquier página, o consulta [/llms.txt](/llms.txt). ::: --- # Operaciones asíncronas Source: https://docs.sendit.mx/api-conventions/asynchronous-operations Una operación asíncrona acepta el trabajo y devuelve un recurso que puedes consultar. No necesitas mantener abierta la conexión hasta recibir la guía. Hoy este patrón es opcional para la compra de guías. El mismo endpoint admite los dos modos. Esta página explica cómo elegir y operar cada modo. Consulta [Guías](/shipping/labels#compra-asíncrona) para ver el contrato completo, todos los campos y los errores del endpoint. ## Elige el modo correcto | Modo | Cómo pedirlo | Respuesta inicial | Úsalo cuando | | --- | --- | --- | --- | | Síncrono | Omite `async` o envía `false` | `201 Created` con la guía | Compras pocas guías y quieres el resultado en la misma petición | | Asíncrono | Envía `async: true` | `202 Accepted` con un intento | Procesas volumen o necesitas liberar la conexión pronto | El modo síncrono es el predeterminado. No necesitas cambiar una integración existente. :::note Una petición síncrona puede devolver `202` si el resultado no se puede cerrar con seguridad dentro de esa conexión. Maneja siempre `201` y `202`. ::: ## Inicia una compra asíncrona ```bash curl -i -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/label \ -H "X-API-Key: sk_test_..." \ -H "Idempotency-Key: 1f0e6f0e-59a4-4a6f-9d2e-9b1a7b2c3d4e" \ -H "Content-Type: application/json" \ -d '{ "rateId": "DHL_standard_a1b2c3", "async": true }' ``` La respuesta `202` incluye `Location`, `Retry-After: 2` y `statusUrl`. `Retry-After` es una sugerencia, no una garantía de finalización. Este extracto muestra los campos necesarios para empezar a consultar; la página de Guías contiene el recurso completo. ```json { "success": true, "data": { "id": "lat_550e8400-e29b-41d4-a716-446655440000", "object": "label_purchase_attempt", "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "pending", "livemode": false, "async": true, "statusUrl": "/v1/label-purchase-attempts/lat_550e8400-e29b-41d4-a716-446655440000", "createdAt": "2026-08-01T10:00:00.000Z", "updatedAt": "2026-08-01T10:00:00.000Z", "completedAt": null } } ``` ## Consulta el resultado Envía `GET` al `statusUrl` con una llave que tenga `labels:read`: ```bash curl https://api.sendit.mx/v1/label-purchase-attempts/lat_550e8400-e29b-41d4-a716-446655440000 \ -H "X-API-Key: sk_test_..." ``` | `status` | Qué significa | Qué haces | | --- | --- | --- | | `pending` | La compra fue aceptada | Espera antes de consultar otra vez | | `processing` | La compra está en curso | Sigue consultando con espera gradual | | `succeeded` | La guía está lista | Usa `label` y termina el sondeo | | `failed` | La compra falló y el intento terminó | Lee `error`; puedes corregir la causa y hacer una compra nueva | | `action_required` | El resultado no es concluyente | No compres otra guía para ese envío; conserva el intento y contacta a soporte | El endpoint oculta si un ID pertenece a otra organización o a otro modo. Esos casos y un ID inexistente devuelven el mismo `404`. ## Recibe la finalización por webhook Suscríbete a [`label.purchase.completed`](/webhooks-and-events/webhooks#tipos-de-evento) para evitar sondeo continuo. El evento cubre `succeeded`, `failed` y `action_required`. Las entregas de webhook son al menos una vez. Deduplica con el `id` del evento y vuelve a consultar el intento antes de actualizar tu estado final. ## Reintenta sin duplicar la compra Envía un `Idempotency-Key` y conserva la llave con tu operación. Una repetición exacta devuelve el mismo intento activo. Si cambias el `rateId`, el formato, la referencia externa o el valor de `async`, la API devuelve `409 SHIPMENT_LABEL_IN_PROGRESS`. Consulta el intento existente. Un saldo insuficiente devuelve `402 INSUFFICIENT_BALANCE` antes de aceptar trabajo. No queda un intento pendiente. Consulta el contrato completo en [Guías](/shipping/labels#compra-asíncrona) y la protección de reintentos en [Idempotencia](/api-conventions/idempotency). --- # Errores y formato de respuesta Source: https://docs.sendit.mx/api-conventions/errors Todas las respuestas del API comparten la misma estructura, sean de éxito o de error. Apréndela una vez y te sirve para todos los endpoints. ## El sobre de respuesta Las respuestas exitosas envuelven el resultado en `data`, con metadatos opcionales en `meta`: ```json { "success": true, "data": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "DRAFT" }, "meta": { "page": 1, "limit": 20, "total": 47, "totalPages": 3 } } ``` Los errores llevan `success: false` y un objeto `error`: ```json { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Validation failed", "details": { "errors": { "contactName": ["contactName must be longer than or equal to 1 characters"], "postalCode": ["Postal code must be 4-6 digits"] } }, "timestamp": "2026-07-17T12:00:00.000Z", "requestId": "req_abc123" } } ``` | Campo | Descripción | | --- | --- | | `code` | Código estable y legible por máquinas — usa este para programar, no `message` | | `message` | Descripción legible por humanos; puede cambiar sin previo aviso | | `details` | Contexto adicional específico del error (campos inválidos, pistas, IDs) | | `timestamp` | Cuándo ocurrió el error | | `requestId` | Identificador de la petición — inclúyelo cuando contactes a soporte | ## Todos los códigos de error | HTTP | Código | Cuándo ocurre | Cómo resolverlo | | --- | --- | --- | --- | | 400 | `VALIDATION_ERROR` | El cuerpo o los parámetros no pasan validación | Revisa `details.errors`: lista cada campo inválido y por qué | | 400 | `INVALID_INPUT` | Combinación inválida (p. ej. dirección por ID e inline a la vez) | Revisa `details`; envía exactamente una variante por campo. Algunos casos traen un código más específico en `details.code` | | 400 | `API_VERSION_UNSUPPORTED` | Pediste una versión del API que ya llegó a su fecha de retiro | Sube a la versión vigente — ver [versionamiento](/api-conventions/versioning) | | 401 | `UNAUTHORIZED` | Falta la credencial o está mal formada | Envía tu llave en `X-API-Key` o `Authorization: Bearer` | | 401 | `INVALID_API_KEY` | La llave no existe o fue revocada | Verifica la llave; genera una nueva si fue revocada | | 401 | `EXPIRED_API_KEY` | La llave expiró | Genera una llave nueva y actualiza tu integración | | 402 | `INSUFFICIENT_BALANCE` | El monedero no alcanza para la operación | Fondea tu [monedero](/wallet-and-billing/wallet) y reintenta | | 403 | `FORBIDDEN` | Credencial válida sin permisos suficientes (rol u organización) | Usa una credencial del rol/organización correctos | | 403 | `INSUFFICIENT_SCOPE` | A la llave le faltan alcances (vienen listados en `details`) | Agrega los [alcances](/api-conventions/scopes) faltantes a la llave | | 403 | `IP_NOT_ALLOWED` | La IP de origen no está en la lista de la llave | Agrega la IP al `ipAllowlist` de la llave o llama desde una IP permitida | | 403 | `PLAN_LIMIT_REACHED` | Alcanzaste el tope de tu plan (llaves de API, endpoints de webhook o miembros) | Sube de plan; `details` trae `resource`, `limit` y `plan` — ver [planes](/wallet-and-billing/subscriptions-and-quotas) | | 403 | `SOLE_OWNER_OF_ORGANIZATION` | Intentas eliminar tu cuenta siendo el único OWNER de una organización de equipo | Transfiere la propiedad a otro miembro y reintenta | | 404 | `RESOURCE_NOT_FOUND` | El recurso no existe o no pertenece a tu organización | Verifica el ID y la organización de tu llave | | 409 | `SHIPMENT_ALREADY_PROCESSED` | El envío ya no está en un estado modificable | Consulta el `status` actual; solo los DRAFT se editan | | 409 | `IDEMPOTENCY_KEY_REUSED` | Reusaste una Idempotency-Key con otro cuerpo u otro endpoint | Usa una llave nueva para cada operación distinta | | 409 | `IDEMPOTENCY_KEY_IN_USE` | Una petición concurrente con la misma llave sigue en curso | Espera un momento y reintenta con la **misma** llave | | 409 | `SHIPMENT_LABEL_IN_PROGRESS` | Dos compras concurrentes sobre el mismo envío | Reintenta en 1–2 segundos | | 410 | `RATES_EXPIRED` | Las cotizaciones del envío expiraron (24 h) | `GET /v1/shipments/:id/rates` para refrescar y elige un nuevo `rateId` | | 422 | `CARRIER_NOT_SUPPORTED` | Pediste rastreo de una paquetería que SendIt no puede consultar | Usa `DHL`, `FEDEX` o `ESTAFETA` — ver [rastreadores](/shipping/trackers) | | 422 | `CARRIER_CREDENTIALS_REQUIRED` | Esa paquetería necesita credenciales de cuenta para rastrear | Conecta tus credenciales — ver [cuentas de paquetería](/shipping/carrier-accounts) | | 422 | `ONE_CALL_BUY_RATES_PENDING` | Las paqueterías tardaron más de 8 s en cotizar durante una compra en una llamada | Sondea `ratesPollUrl` y compra con el `rateId` | | 422 | `ONE_CALL_BUY_NO_MATCHING_RATE` | Ninguna tarifa coincidió con la selección de tu objeto `purchase` | Elige una de `details.availableRates` | | 429 | `RATE_LIMIT_EXCEEDED` | Excediste el límite de peticiones | Respeta `Retry-After` y aplica backoff exponencial — ver [límites](/api-conventions/rate-limits) | | 500 | `INTERNAL_ERROR` | Error del servidor | Reintenta; si persiste, contacta a soporte con el `requestId` | | 502 | `CARRIER_ERROR` | La paquetería falló al generar la guía | Cualquier cargo ya fue reembolsado; reintenta o elige otra tarifa | | 503 | `CHECKOUT_NOT_CONFIGURED` | El plan que pediste no tiene checkout disponible | Contacta a ventas para ese plan | ## Maneja errores por código, no por mensaje ```js const res = await fetch(url, options); const body = await res.json(); if (!body.success) { switch (body.error.code) { case "RATES_EXPIRED": // refresca cotizaciones y reintenta break; case "INSUFFICIENT_BALANCE": // notifica al equipo de operaciones break; default: log.error(body.error.requestId, body.error.code); } } ``` :::note Los `message` están pensados para humanos y pueden mejorar con el tiempo. Los `code` son un contrato estable: programar contra ellos es seguro. ::: ## Revisa también `details.code` Algunas validaciones devuelven `400 INVALID_INPUT` con un código más específico anidado en `error.details.code`. Léelo cuando necesites distinguir el caso concreto: | `details.code` | Cuándo ocurre | | --- | --- | | `PRECONDITION_FAILED` | El `If-Match` de una orden no coincide con su `etag` actual | | `ORDER_HAS_ACTIVE_LABELS` | Intentas cancelar una orden con guías vigentes | | `SHIPMENTS_NOT_FOUND` | Un lote referencia envíos que no existen o no son tuyos | | `PRODUCT_NOT_FOUND` | Un artículo apunta a un producto inexistente | | `LINE_ITEM_INCOMPLETE` | Un artículo inline no trae `name` o `unitPrice` | --- # Idempotencia Source: https://docs.sendit.mx/api-conventions/idempotency Las redes fallan. La idempotencia garantiza que reintentar una petición interrumpida nunca duplique la operación ni el cargo. ## Tú generas la llave La llave de idempotencia **la creas tú, del lado del cliente**. El API nunca te entrega una. Antes de enviar la petición: 1. **Genera un valor único** para esa operación de negocio. Recomendado: un UUID v4 (`crypto.randomUUID()` en Node.js, `uuid.uuid4()` en Python) o cualquier cadena con aleatoriedad suficiente para no repetirse (hasta 255 caracteres). 2. **Envíalo** en el encabezado `Idempotency-Key`. 3. **Guárdalo** junto a tu operación (la orden, el pedido) para que cualquier reintento use exactamente la misma llave. Una llave representa **un** intento de negocio: la misma llave para los reintentos de esa operación, una llave nueva para cada operación distinta. ## Cómo funciona Toda mutación (`POST`, `PUT`, `PATCH`) acepta un encabezado `Idempotency-Key` (recomendado: UUIDv4, máximo 255 caracteres): ```bash curl -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/label \ -H "X-API-Key: sk_test_..." \ -H "Idempotency-Key: 1f0e6f0e-59a4-4a6f-9d2e-9b1a7b2c3d4e" \ -H "Content-Type: application/json" \ -d '{ "rateId": "ESTAFETA_economy_x9y8z7" }' ``` Reintentar con la misma llave dentro de una ventana de **24 horas** reproduce el resultado original: mismo código de estado y mismo cuerpo, haya sido éxito **o** falla. La respuesta trae el encabezado `Idempotent-Replayed: true` y tu llave de vuelta. Dos matices importantes: - Los errores de validación (`400`) **no se almacenan**: corrige el payload y reusa la misma llave sin problema. - Cualquier otra falla reproducida (p. ej. un `402`) seguirá reproduciéndose: tras resolver la causa, reintenta con una llave **nueva**. ## Dónde importa más La llave es **opcional en todos los endpoints**. Si no mandas una, la petición corre normal, sin reproducción de reintento. Nunca recibes un `400` por falta de llave. Envíala sobre todo en las operaciones que mueven dinero: | Endpoint | Operación | | --- | --- | | `POST /v1/shipments/:id/label` | Compra de guía | | `POST /v1/shipments/:id/label/void` | Cancelación de guía | | `POST /v1/wallet/fund/card` | Fondeo con tarjeta | | `POST /v1/wallet/fund/paypal` | Fondeo con PayPal | | `POST /v1/shipments` con `purchase` | Compra en una llamada — reproduce la creación **y** la compra como una sola unidad | En los fondeos, la llave además garantiza que los reintentos reutilicen el mismo intento de pago. Nunca hay dos cobros a tu tarjeta. Tu saldo queda protegido aunque no mandes llave. El monedero deduplica cada movimiento del lado del servidor, así que reintentar una compra o un fondeo interrumpido jamás genera un cargo doble. La llave agrega la reproducción del **resultado** original, con el mismo estado y el mismo cuerpo. Por eso la recomendamos en todo flujo de dinero. **Rastreadores:** `POST /v1/trackers` no usa idempotencia y no la necesita. Registrar el mismo número dos veces devuelve el rastreador existente, sin cobro doble. ## Conflictos | Código | Cuándo | Cómo resolverlo | | --- | --- | --- | | `409 IDEMPOTENCY_KEY_REUSED` | Reusaste una llave con **otro** cuerpo u **otro** endpoint | Genera una llave nueva para cada operación distinta | | `409 IDEMPOTENCY_KEY_IN_USE` | Una petición concurrente con la misma llave sigue en vuelo | Espera un momento y reintenta con la **misma** llave | ## Patrón recomendado Genera la llave **antes** de la primera petición y guárdala junto a tu operación de negocio, como la orden o el pedido. Así cualquier reintento usa la misma llave, sea inmediato o después de reiniciar tu proceso: ```js // Al crear tu orden interna, genera y guarda la llave const idempotencyKey = crypto.randomUUID(); await db.orders.update(orderId, { senditIdempotencyKey: idempotencyKey }); // Cualquier reintento reutiliza la misma llave const res = await fetch(`https://api.sendit.mx/v1/shipments/${shipmentId}/label`, { method: "POST", headers: { "X-API-Key": process.env.SENDIT_API_KEY, "Idempotency-Key": idempotencyKey, "Content-Type": "application/json", }, body: JSON.stringify({ rateId }), }); ``` :::warning No derives la llave del contenido (p. ej. un hash del cuerpo): si necesitas repetir legítimamente la misma operación mañana, la llave debe ser distinta. Una llave = un intento de negocio. ::: --- # Paginación y filtros Source: https://docs.sendit.mx/api-conventions/pagination-and-filtering La paginación es específica de cada recurso. No existe un contrato único para todos los listados. Antes de programar un listado, revisa el bloque `meta` que devuelve ese endpoint: ahí está la verdad. Esta página describe el modelo de envíos, que es el más completo, y luego el modelo simple que usan los demás recursos. ## Pagina envíos por página (modo por defecto) `GET /v1/shipments` usa paginación por número de página. ```text GET /v1/shipments?page=1&limit=50 ``` | Parámetro | Por defecto | Máximo | Descripción | | --- | --- | --- | --- | | `page` | 1 | — | Número de página | | `limit` | 20 | 100 | Elementos por página | ```json { "success": true, "data": ["..."], "meta": { "pagination": { "mode": "offset", "page": 1, "limit": 50, "total": 1234, "totalPages": 25, "hasNextPage": true, "hasPrevPage": false } } } ``` Avanza mientras `hasNextPage` sea `true`. ## Pagina por cursor cuando el volumen crece En listados grandes, el conteo por página se vuelve caro. Activa el cursor con `useCursor=true`. ```text GET /v1/shipments?useCursor=true&limit=50 GET /v1/shipments?useCursor=true&limit=50&cursor=eyJjcmVhdGVkQXQiOi... ``` `useCursor` solo acepta `true` o `false`. Omítelo o mándalo en `false` para paginar por offset. ```json { "success": true, "data": ["..."], "meta": { "pagination": { "mode": "cursor", "limit": 50, "hasNextPage": true, "nextCursor": "eyJjcmVhdGVkQXQiOi..." } } } ``` El `nextCursor` es opaco. Pásalo tal cual, sin decodificarlo ni construirlo tú. Detente cuando `hasNextPage` sea `false`. ```js let cursor; do { const url = new URL("https://api.sendit.mx/v1/shipments"); url.searchParams.set("useCursor", "true"); url.searchParams.set("limit", "100"); if (cursor) url.searchParams.set("cursor", cursor); const { data, meta } = await fetch(url, { headers: { "X-API-Key": process.env.SENDIT_API_KEY }, }).then((r) => r.json()); process(data); cursor = meta.pagination.hasNextPage ? meta.pagination.nextCursor : null; } while (cursor); ``` :::note El campo `meta.pagination.mode` te dice qué modelo recibiste. Léelo en lugar de suponerlo. ::: ## Pagina los demás recursos El resto de los listados paginados usa un `meta` plano, sin el nivel `pagination` y sin cursor: ```text GET /v1/wallet/transactions?page=2&limit=50 ``` ```json { "success": true, "data": ["..."], "meta": { "page": 2, "limit": 50, "total": 340, "totalPages": 7 } } ``` Así funcionan monedero, órdenes, productos y facturas. Algunos recursos no paginan del todo. En todos los casos, el `meta` de la respuesta manda. ## Filtra los resultados Los filtros son parámetros con nombre. **No hay operadores tipo `campo[gte]`, ni `sort=`, ni `fields=`, ni `expand[]`.** El conjunto exacto depende del recurso. Para `GET /v1/shipments`: | Parámetro | Descripción | | --- | --- | | `status` | Un solo estado | | `statuses` | Varios estados, separados por coma. Tiene precedencia sobre `status` si mandas ambos | | `carrierCode` | Paquetería | | `trackingNumber` | Coincidencia parcial del número de guía | | `externalId` | Coincidencia exacta | | `search` | Texto libre sin distinguir mayúsculas, sobre número de guía, `externalId` y destino (nombre de contacto, ciudad, estado) | | `createdFrom` | Creados en esa fecha o después (inclusivo) | | `createdTo` | Creados antes de esa fecha (exclusivo) | ```text GET /v1/shipments?status=DELIVERED&carrierCode=DHL GET /v1/shipments?statuses=DELIVERED,RETURNED GET /v1/shipments?search=FEDMX123 GET /v1/shipments?createdFrom=2026-01-01&createdTo=2026-04-01 ``` El rango de fechas es semiabierto: incluye `createdFrom` y excluye `createdTo`. Así puedes encadenar meses sin duplicar registros. ## Ordena El orden es fijo: `createdAt` descendente, con el `id` como desempate. No hay parámetro de ordenamiento personalizado en los listados de envíos. ## Trae muchos recursos conocidos de una sola vez Si ya tienes los IDs, evita paginar. Los endpoints bulk traen hasta 100 recursos en una llamada: ```text POST /v1/bulk/shipments/fetch { "ids": ["shp_aaa", "shp_bbb", "shp_ccc"] } ``` Los errores vienen por elemento. Un ID inexistente no tumba el lote completo. Los cuatro endpoints bulk (envíos, órdenes, rastreo y validación de direcciones) están documentados junto a [lotes](/shipping/batches#endpoints-bulk). --- # Límites de peticiones Source: https://docs.sendit.mx/api-conventions/rate-limits Cada petición se clasifica en una de tres **clases de endpoint** y se cuenta contra el presupuesto por minuto de esa clase. Las clases son independientes: cotizar mucho nunca te deja sin capacidad para comprar guías, ni al revés. | Clase | Qué entra | | --- | --- | | `read` | `GET`, `HEAD` y `OPTIONS` | | `write` | `POST`, `PUT`, `PATCH` y `DELETE` que no sean cotización | | `quote` | Cotización: `POST /v1/rates`, `POST /v1/rates/carrier/:carrierCode` y `GET /v1/shipments/:id/rates` | ## Cómo se agrupan tus peticiones La ventana es de 60 segundos y se comparte entre todos los servidores de la API. | Tráfico | Se mide por | Presupuesto por minuto | | --- | --- | --- | | Llave de producción (`sk_live_`) | Organización, por clase | El de tu plan (ver abajo) | | Llave de prueba (`sk_test_`) | Organización, por clase, en un **cubo aislado** | 25% del plan, con piso de 10/min | | Sesión del dashboard | Sesión, por clase | `read` 300 · `write` 100 · `quote` 30 | | Sin autenticar | IP, por clase | `read` 60 · `write` 30 · `quote` 10 | Todas las llaves de una organización comparten el mismo presupuesto. El tráfico de prueba tiene sus **propios** cubos: lo que consumas en el sandbox nunca le quita capacidad a producción, ni al revés. ## Presupuestos por plan Para llaves de producción, peticiones por minuto: | Plan | `read` | `write` | `quote` | | --- | --- | --- | --- | | Free | 60 | 30 | 20 | | Growth | 300 | 150 | 60 | | Scale | 1,000 | 500 | 200 | | Enterprise | 5,000 | 2,500 | 1,000 | Las llaves `sk_test_` reciben el 25% de cada número, con un piso de 10/min. Por ejemplo: `quote` de prueba en Free es 10, y `read` de prueba en Scale es 250. ## Lee los encabezados Toda respuesta limitada incluye estos encabezados, sin sufijo: | Encabezado | Descripción | | --- | --- | | `X-RateLimit-Limit` | Presupuesto de la clase en la que cayó **esta** petición | | `X-RateLimit-Remaining` | Peticiones restantes en ese cubo este minuto | | `X-RateLimit-Reset` | Momento en que el cubo se reinicia, en **segundos epoch Unix** | | `Retry-After` | Segundos para poder reintentar (solo en 429) | Los encabezados describen la clase de la petición que los devolvió: un `POST` y un `GET` enviados en el mismo segundo pueden reportar límites distintos, y eso es correcto. ## Maneja un 429 ```json { "success": false, "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Too many requests", "details": { "retryAfter": 30 } } } ``` Al pasarte, las peticiones se rechazan hasta que la ventana de 60 segundos cierre; no hay castigo adicional ni bloqueo extendido. Respeta `Retry-After` y aplica backoff exponencial: ```js async function withRetry(fn, maxAttempts = 5) { for (let attempt = 1; ; attempt++) { const res = await fn(); if (res.status !== 429 || attempt === maxAttempts) return res; const retryAfter = Number(res.headers.get("Retry-After") ?? 1); const backoff = retryAfter * 1000 * 2 ** (attempt - 1); await new Promise((r) => setTimeout(r, backoff)); } } ``` :::note Si procesas volúmenes grandes, usa los endpoints de [lote](/shipping/batches) antes de paralelizar peticiones individuales. Una sola llamada procesa hasta 100 elementos y cuenta como una petición `write`. ::: --- # Registros de peticiones Source: https://docs.sendit.mx/api-conventions/request-logs Cada llamada al API deja un registro con su método, su ruta, su código de estado y cuánto tardó. Úsalo para depurar una integración, rastrear qué llave hizo qué, o encontrar el patrón detrás de una racha de errores. El registro guarda **metadatos**, no contenido. Nunca devuelve la dirección IP, el user agent ni los valores de tu query string. ## Consulta tus llamadas recientes | Método | Ruta | Acceso | | --- | --- | --- | | `GET` | `/v1/api-requests` | Cualquier miembro autenticado, o llave con `api_keys:read` | Una sesión del dashboard entra sin importar el rol: `VIEWER` también lee. Con llave de API necesitas el alcance `api_keys:read` (o `*`). ```bash curl "https://api.sendit.mx/v1/api-requests?method=POST&statusCode=402&limit=20" \ -H "X-API-Key: sk_test_..." ``` #### Parámetros de consulta | Prop | Type | Default | Description | | --- | --- | --- | --- | | `method?` | `string` | - | Filtra por método HTTP, por ejemplo POST. | | `statusCode?` | `integer` | - | Filtra por código de estado exacto, por ejemplo 402. | | `apiKeyId?` | `string` | - | Filtra por la llave que hizo la llamada. | | `after?` | `string` | - | Fecha ISO-8601. Solo peticiones creadas después de ese momento. | | `before?` | `string` | - | Fecha ISO-8601. Solo peticiones creadas antes de ese momento. | | `cursor?` | `string` | - | Cursor opaco de la página anterior. Pásalo tal cual. | | `limit?` | `integer` | - | Cuántos registros traer por página. | ```json { "success": true, "data": { "data": [ { "id": "req_01J8Z9K2M4N6P8Q0R2S4T6", "apiKeyId": "key_a1b2c3d4e5f6", "method": "POST", "path": "/v1/shipments", "statusCode": 201, "durationMs": 142, "requestId": "01J8Z9K2M4N6P8Q0R2S4T6", "livemode": true, "createdAt": "2026-08-03T18:00:00.000Z" } ], "total": 1, "nextCursor": null, "hasMore": false } } ``` :::warning **Este listado anida una capa de más.** El arreglo no está en `data`, sino en `data.data`. El resto de los listados del API ponen el arreglo directamente en `data` y la paginación en `meta`. Aquí el objeto de página vive completo dentro del sobre. ::: Los campos `apiKeyId`, `durationMs` y `requestId` pueden llegar en `null`. | Campo | Descripción | | --- | --- | | `id` | Identificador del registro | | `apiKeyId` | La llave que hizo la llamada; `null` si fue una sesión del dashboard | | `method` | Método HTTP | | `path` | Ruta que se llamó | | `statusCode` | Código de estado que respondimos | | `durationMs` | Cuánto tardó la petición | | `requestId` | El mismo identificador que devolvemos en los [errores](/api-conventions/errors) | | `livemode` | `false` cuando la llamada ocurrió en [modo de prueba](/getting-started/test-mode) | | `createdAt` | Cuándo entró la petición | ## Recorre las páginas Los resultados llegan del más reciente al más antiguo. Pasa el `nextCursor` sin modificarlo y detente cuando `hasMore` sea `false`: ```js let cursor = null; do { const url = new URL("https://api.sendit.mx/v1/api-requests"); url.searchParams.set("limit", "100"); if (cursor) url.searchParams.set("cursor", cursor); const res = await fetch(url, { headers: { "X-API-Key": process.env.SENDIT_KEY } }); const { data } = await res.json(); for (const row of data.data) process(row); cursor = data.hasMore ? data.nextCursor : null; } while (cursor); ``` ## Separa prueba de producción Los resultados siempre quedan acotados al modo activo. Con una llave de API el modo lo fija su propio ambiente. Con una sesión del dashboard agrega `?livemode=false` para leer las peticiones de prueba. El endpoint **se excluye a sí mismo**, así que consultar el registro no genera registros nuevos. Puedes hacer polling sin ensuciar tus datos. ## Maneja los errores | Código | Cuándo ocurre | Cómo resolverlo | | --- | --- | --- | | `400 VALIDATION_ERROR` | Un filtro trae un valor inválido, por ejemplo una fecha que no es ISO-8601 | Corrige el parámetro y reintenta | | `401 UNAUTHORIZED` | Falta la credencial o no es válida | Manda una llave vigente o inicia sesión | | `403 INSUFFICIENT_SCOPE` | La llave no tiene `api_keys:read` | Emite una llave con ese [alcance](/api-conventions/scopes) | | `429 RATE_LIMIT_EXCEEDED` | Excediste el límite de lectura | Respeta los encabezados de [límite](/api-conventions/rate-limits) antes de reintentar | --- # Alcances (scopes) Source: https://docs.sendit.mx/api-conventions/scopes Los alcances limitan lo que cada llave de API puede hacer. Emite llaves restringidas por caso de uso: una de solo lectura para reportes, una de escritura para tu servicio de fulfillment. ## Lista canónica | Alcance | Permite | | --- | --- | | `*` | Acceso total (valor por defecto en llaves nuevas) | | `shipments:read` | Leer envíos y su detalle | | `shipments:write` | Crear y actualizar envíos | | `labels:read` | Leer detalles de guías | | `labels:write` | Comprar guías (incluida la [compra en una llamada](/shipping/shipments#compra-en-una-llamada), que además requiere `shipments:write`) | | `addresses:read` | Leer direcciones guardadas | | `addresses:write` | Crear y actualizar direcciones | | `wallet:read` | Leer saldo y transacciones del monedero | | `wallet:write` | Iniciar fondeos del monedero | | `webhooks:read` | Leer endpoints de webhook y sus intentos de entrega | | `webhooks:write` | Crear, actualizar y eliminar endpoints de webhook | | `organizations:read` | Leer la organización y sus miembros | | `organizations:write` | Actualizar configuración y gestionar miembros | | `api_keys:read` | Leer la lista de llaves y sus registros de uso | | `api_keys:write` | Crear, actualizar, rotar y revocar llaves | | `rates:read` | Obtener cotizaciones | | `carrier_preferences:write` | Configurar [paqueterías habilitadas](/shipping/carrier-accounts) y credenciales de tu propia cuenta | | `carrier_services:read` | Leer el [catálogo de servicios](/shipping/carriers) | | `tracking:read` | Leer eventos de rastreo | | `trackers:read` | Leer rastreadores registrados | | `trackers:write` | Registrar y eliminar rastreadores | | `orders:read` | Leer órdenes | | `orders:write` | Crear, actualizar, cancelar y eliminar órdenes | | `batches:read` | Leer lotes | | `batches:write` | Crear lotes y manifiestos | | `products:read` | Leer el catálogo de productos | | `products:write` | Crear, actualizar y eliminar productos | | `notification_settings:read` | Leer los interruptores de notificaciones al destinatario, su marca y sus plantillas | | `notification_settings:write` | Actualizar los interruptores, asuntos y marca de las notificaciones | | `shipping_rules:read` | Leer y previsualizar [reglas de automatización](/shipping/shipping-rules) | | `shipping_rules:write` | Crear, actualizar, reordenar y eliminar reglas | | `insurance_claims:read` | Leer [reclamaciones de seguro](/wallet-and-billing/insurance-claims) | | `insurance_claims:write` | Presentar reclamaciones y administrar su estado | ## Cómo se aplican - Las sesiones del dashboard tienen acceso implícito completo. Los alcances aplican a las llaves de API. - Una llave con `scopes: ["*"]` accede a todo. - Si a una llave le falta un alcance requerido, la petición devuelve `403 INSUFFICIENT_SCOPE` con los alcances faltantes listados en `details`. ## Crea llaves restringidas ```bash curl -X POST https://api.sendit.mx/v1/api-keys \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "Servicio de fulfillment", "environment": "LIVE", "scopes": ["shipments:read", "labels:write"] }' ``` ## Restringe por IP (opcional) Beta Esta función está en beta. Su comportamiento puede ajustarse antes de la versión final. Limita una llave a IPs o bloques CIDR específicos. Por defecto la lista está vacía (todas las IPs permitidas): ```bash curl -X POST https://api.sendit.mx/v1/api-keys \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "Servidor de fulfillment", "scopes": ["labels:write"], "ipAllowlist": ["203.0.113.0/24", "198.51.100.42"] }' ``` Las peticiones desde IPs fuera de la lista devuelven `403 IP_NOT_ALLOWED`. ## Rota llaves con ventana de gracia Rotar crea un reemplazo con los mismos alcances y la misma lista de IPs. La llave anterior sigue siendo válida **24 horas** para que las peticiones en vuelo terminen de drenar: ```bash curl -X POST https://api.sendit.mx/v1/api-keys/{id}/rotate \ -H "Authorization: Bearer " ``` Durante la ventana verás ambas llaves en `GET /v1/api-keys`: la que rota y la nueva. --- # Versionamiento del API Source: https://docs.sendit.mx/api-conventions/versioning SendIt usa versionamiento por fecha. Al crear tu cuenta, tu organización queda fijada a la versión vigente. Nada se rompe solo: tú decides cuándo adoptar cambios. ## Versión actual ```text 2026-05-01 ``` ## Fija una versión Tres mecanismos, en orden de prioridad: ### 1. Encabezado de petición (máxima prioridad, ideal para probar) ```http SendIt-Version: 2026-05-01 ``` Útil para verificar tu código contra una versión nueva antes de adoptarla permanentemente. ### 2. Fijación por llave de API ```bash curl -X PATCH https://api.sendit.mx/v1/api-keys/{id} \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "apiVersion": "2026-05-01" }' ``` Las peticiones con esa llave siempre usan la versión indicada, por encima de la fijación de la organización. ### 3. Fijación de la organización (por defecto) ```bash curl -X PUT https://api.sendit.mx/v1/organizations/me \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "apiVersion": "2026-05-01" }' ``` Toda llave sin fijación propia hereda esta versión. ## Actualiza sin sobresaltos 1. Prueba la versión nueva con el encabezado `SendIt-Version` en tu ambiente de staging. 2. Ajusta tu código a los cambios incompatibles documentados. 3. Actualiza la fijación de tu organización (o de llaves individuales) cuando todo esté verificado. ## Política de deprecación | Momento | Qué pasa | | --- | --- | | 6 meses antes | Se agrega el encabezado `SendIt-Version-Deprecated` a cada respuesta | | 1 mes antes | Notificación por correo a los OWNER de la organización | | Tras el retiro | Las peticiones devuelven `400 API_VERSION_UNSUPPORTED` | Cada versión se mantiene **al menos 18 meses**. ## Qué amerita una versión nueva Las versiones nuevas son raras. Solo un cambio incompatible la requiere: - Renombrar o eliminar campos de respuesta - Cambiar el tipo de un campo (p. ej. string → objeto) - Eliminar valores de un enum - Cambiar campos obligatorios de una petición - Cambiar comportamiento por defecto Agregar campos, endpoints, parámetros opcionales o tipos de evento **no** requiere versión nueva. Tu integración debe tolerar campos desconocidos. ## Versionamiento de webhooks Los payloads de [webhook](/webhooks-and-events/webhooks#el-payload) **no llevan un campo de versión**, y los endpoints de webhook no se fijan a una versión por separado. Hoy existe una sola versión del API, así que la forma de cada evento es la misma para todos tus endpoints. No necesitas ramificar tu handler por versión. --- # SendIt documentation Source: https://docs.sendit.mx/en :::warning **SendIt is in development and not yet commercially available.** We publish this documentation now so you can see exactly how the API works, and how we handle your shipments, your money, and your data, before you decide to integrate. Read it as a description of the product, not as an invitation to move real shipments. The contracts described here are the ones we are building, and they can change before commercial launch. We will announce changes in advance. ::: SendIt is shipping infrastructure for Mexico. One REST API connects your business to the country's major carriers (paqueterías). You get real-time rates, label generation, and normalized tracking. Prices are in Mexican pesos, and you fund by SPEI transfer. Every request goes to the same base URL: ```text https://api.sendit.mx/v1 ``` And every response uses the same envelope: ```json { "success": true, "data": { "...": "..." }, "meta": { "...": "..." } } ``` ## Start here From zero to a PDF label (guía) in minutes. In test mode, no card required. Create API keys, understand the sk_test_ and sk_live_ prefixes, and protect your credentials. A complete sandbox. Simulated carriers and a $10,000 MXN virtual balance. Receive every status change on your server, with verifiable signatures. ## The essential flow Your integration reaches its first label in three steps: 1. **Create a shipment.** `POST /v1/shipments` returns the shipment and `rates[]` in one response. `rates[]` holds quotes from every available carrier. 2. **Pick a rate.** Each rate carries an `id`, a tax-inclusive breakdown, and the estimated delivery time. The total you see is what your wallet is charged. 3. **Buy the label.** `POST /v1/shipments/:id/label` with the chosen `rateId` returns the PDF label and the tracking number. If you already know your carrier, or want the cheapest, buy the label in one call with the `purchase` object. See [One-call buy](/en/shipping/shipments#one-call-buy). ```bash curl -X POST https://api.sendit.mx/v1/shipments \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "from": { "contactName": "Almacén CDMX", "contactPhone": "+5215512345678", "street": "Av. Insurgentes Sur", "exteriorNumber": "1602", "neighborhood": "Crédito Constructor", "city": "Ciudad de México", "state": "CDMX", "postalCode": "03940", "country": "MX" }, "to": { "contactName": "María López", "contactPhone": "+5213312345678", "street": "Av. López Mateos", "exteriorNumber": "45", "neighborhood": "Jardines del Sol", "city": "Zapopan", "state": "JAL", "postalCode": "45050", "country": "MX" }, "parcel": { "length": 30, "width": 20, "height": 15, "weight": 2.5 } }' ``` ## Explore by topic The shipment object and its full lifecycle. Compare carrier rates in a single call. Store orders with line items and multiple shipments. Prepaid MXN balance, funded via SPEI or card. Configure your fiscal profile and review invoice records (beta). Errors, pagination, idempotency, and versioning. :::note These docs are also machine-readable. Append `.md` to any page URL, or fetch [/llms.txt](/llms.txt). ::: --- # Asynchronous operations Source: https://docs.sendit.mx/en/api-conventions/asynchronous-operations An asynchronous operation accepts work and returns a resource that you can retrieve. You do not keep the connection open for the label (guía). This pattern is currently optional for label purchases. The same endpoint supports both modes. This page explains how to choose and operate each mode. See [Labels](/en/shipping/labels#asynchronous-purchase) for the complete endpoint contract, all fields, and errors. ## Choose the correct mode | Mode | How to request it | Initial response | Use it when | | --- | --- | --- | --- | | Synchronous | Omit `async` or send `false` | `201 Created` with the label | You buy few labels and want the result in one request | | Asynchronous | Send `async: true` | `202 Accepted` with an attempt | You process volume or must release the connection quickly | Synchronous mode is the default. An existing integration does not need to change. :::note A synchronous request can return `202` when the result cannot finish safely on that connection. Always handle both `201` and `202`. ::: ## Start an asynchronous purchase ```bash curl -i -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/label \ -H "X-API-Key: sk_test_..." \ -H "Idempotency-Key: 1f0e6f0e-59a4-4a6f-9d2e-9b1a7b2c3d4e" \ -H "Content-Type: application/json" \ -d '{ "rateId": "DHL_standard_a1b2c3", "async": true }' ``` The `202` response includes `Location`, `Retry-After: 2`, and `statusUrl`. `Retry-After` is a suggestion, not a completion guarantee. This excerpt shows the fields needed to start polling; the Labels page has the complete resource. ```json { "success": true, "data": { "id": "lat_550e8400-e29b-41d4-a716-446655440000", "object": "label_purchase_attempt", "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "pending", "livemode": false, "async": true, "statusUrl": "/v1/label-purchase-attempts/lat_550e8400-e29b-41d4-a716-446655440000", "createdAt": "2026-08-01T10:00:00.000Z", "updatedAt": "2026-08-01T10:00:00.000Z", "completedAt": null } } ``` ## Retrieve the result Send `GET` to `statusUrl` with a key that has `labels:read`: ```bash curl https://api.sendit.mx/v1/label-purchase-attempts/lat_550e8400-e29b-41d4-a716-446655440000 \ -H "X-API-Key: sk_test_..." ``` | `status` | What it means | What you do | | --- | --- | --- | | `pending` | The purchase was accepted | Wait before the next request | | `processing` | The purchase is in progress | Continue polling with gradual backoff | | `succeeded` | The label is ready | Use `label` and stop polling | | `failed` | The purchase failed and the attempt ended | Read `error`; fix the cause before a new purchase | | `action_required` | The outcome is not conclusive | Do not buy another label for this shipment; keep the attempt and contact support | The endpoint does not reveal whether an ID belongs to another organization or mode. Those cases and a missing ID return the same `404`. ## Receive completion by webhook Subscribe to [`label.purchase.completed`](/en/webhooks-and-events/webhooks#event-types) to avoid continuous polling. The event covers `succeeded`, `failed`, and `action_required`. Webhook delivery is at least once. Deduplicate with the event `id`, then retrieve the attempt before updating your final state. ## Retry without duplicating the purchase Send an `Idempotency-Key` and store it with your operation. An exact repeat returns the same active attempt. If you change the `rateId`, format, external reference, or `async` value, the API returns `409 SHIPMENT_LABEL_IN_PROGRESS`. Retrieve the existing attempt. Insufficient funds return `402 INSUFFICIENT_BALANCE` before the API accepts work. No pending attempt remains. See the complete contract in [Labels](/en/shipping/labels#asynchronous-purchase) and retry protection in [Idempotency](/en/api-conventions/idempotency). --- # Errors & response format Source: https://docs.sendit.mx/en/api-conventions/errors Every API response shares the same structure, whether it succeeds or fails. Learn it once and it applies to every endpoint. ## The response envelope Successful responses wrap the result in `data`, with optional metadata in `meta`: ```json { "success": true, "data": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "DRAFT" }, "meta": { "page": 1, "limit": 20, "total": 47, "totalPages": 3 } } ``` Errors carry `success: false` and an `error` object: ```json { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Validation failed", "details": { "errors": { "contactName": ["contactName must be longer than or equal to 1 characters"], "postalCode": ["Postal code must be 4-6 digits"] } }, "timestamp": "2026-07-17T12:00:00.000Z", "requestId": "req_abc123" } } ``` | Field | Description | | --- | --- | | `code` | Stable, machine-readable code — program against this, not `message` | | `message` | Human-readable description; may change without notice | | `details` | Error-specific context (invalid fields, hints, IDs) | | `timestamp` | When the error occurred | | `requestId` | Request identifier — include it when contacting support | ## Every error code | HTTP | Code | When it happens | How to resolve it | | --- | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Body or params failed validation | Check `details.errors`: it lists each invalid field and why | | 400 | `INVALID_INPUT` | Invalid combination (e.g. address by ID and inline at once) | Check `details`; send exactly one variant per field. Some cases carry a more specific code in `details.code` | | 400 | `API_VERSION_UNSUPPORTED` | You asked for an API version that reached its sunset date | Move to the current version. See [versioning](/en/api-conventions/versioning) | | 401 | `UNAUTHORIZED` | Credential missing or malformed | Send your key in `X-API-Key` or `Authorization: Bearer` | | 401 | `INVALID_API_KEY` | Key doesn't exist or was revoked | Verify the key; generate a new one if revoked | | 401 | `EXPIRED_API_KEY` | Key expired | Generate a new key and update your integration | | 402 | `INSUFFICIENT_BALANCE` | Wallet can't cover the operation | Fund your [wallet](/en/wallet-and-billing/wallet) and retry | | 403 | `FORBIDDEN` | Valid credential without sufficient permissions (role or org) | Use a credential with the right role/organization | | 403 | `INSUFFICIENT_SCOPE` | Key is missing scopes (listed in `details`) | Add the missing [scopes](/en/api-conventions/scopes) to the key | | 403 | `IP_NOT_ALLOWED` | Source IP is not on the key's allowlist | Add the IP to the key's `ipAllowlist` or call from an allowed IP | | 403 | `PLAN_LIMIT_REACHED` | You hit your plan's cap (API keys, webhook endpoints, or members) | Upgrade your plan; `details` carries `resource`, `limit`, and `plan` — see [plans](/en/wallet-and-billing/subscriptions-and-quotas) | | 403 | `SOLE_OWNER_OF_ORGANIZATION` | You are deleting your account while sole OWNER of a team organization | Transfer ownership to another member, then retry | | 404 | `RESOURCE_NOT_FOUND` | Resource doesn't exist or belongs to another organization | Verify the ID and your key's organization | | 409 | `SHIPMENT_ALREADY_PROCESSED` | Shipment is no longer in an editable state | Check the current `status`; only DRAFT shipments can be edited | | 409 | `IDEMPOTENCY_KEY_REUSED` | Idempotency-Key reused with a different body or endpoint | Use a fresh key for each distinct operation | | 409 | `IDEMPOTENCY_KEY_IN_USE` | A concurrent request with the same key is still in flight | Wait a moment and retry with the **same** key | | 409 | `SHIPMENT_LABEL_IN_PROGRESS` | Two concurrent purchases on the same shipment | Retry in 1–2 seconds | | 410 | `RATES_EXPIRED` | The shipment's rates expired (24 h) | `GET /v1/shipments/:id/rates` to refresh, then pick a new `rateId` | | 422 | `CARRIER_NOT_SUPPORTED` | You asked to track a carrier SendIt cannot poll | Use `DHL`, `FEDEX`, or `ESTAFETA`. See [trackers](/en/shipping/trackers) | | 422 | `CARRIER_CREDENTIALS_REQUIRED` | That carrier needs account credentials to track | Connect your credentials. See [carrier accounts](/en/shipping/carrier-accounts) | | 422 | `ONE_CALL_BUY_RATES_PENDING` | Carriers took longer than 8 s to quote during a one-call buy | Poll `ratesPollUrl` and buy with the `rateId` | | 422 | `ONE_CALL_BUY_NO_MATCHING_RATE` | No rate matched your `purchase` selection | Pick one from `details.availableRates` | | 429 | `RATE_LIMIT_EXCEEDED` | You exceeded the request limit | Honor `Retry-After` with exponential backoff — see [rate limits](/en/api-conventions/rate-limits) | | 500 | `INTERNAL_ERROR` | Server error | Retry; if it persists, contact support with the `requestId` | | 502 | `CARRIER_ERROR` | The carrier failed to generate the label | Any charge was already refunded; retry or pick another rate | | 503 | `CHECKOUT_NOT_CONFIGURED` | The plan you requested has no self-serve checkout | Contact sales for that plan | ## Handle errors by code, not by message ```js const res = await fetch(url, options); const body = await res.json(); if (!body.success) { switch (body.error.code) { case "RATES_EXPIRED": // refresh rates and retry break; case "INSUFFICIENT_BALANCE": // notify your operations team break; default: log.error(body.error.requestId, body.error.code); } } ``` :::note `message` strings are meant for humans and may improve over time. `code` values are a stable contract: programming against them is safe. ::: ## Check `details.code` too Some validations return `400 INVALID_INPUT` with a more specific code nested in `error.details.code`. Read it when you need to tell the exact case apart: | `details.code` | When it happens | | --- | --- | | `PRECONDITION_FAILED` | An order's `If-Match` does not match its current `etag` | | `ORDER_HAS_ACTIVE_LABELS` | You tried to cancel an order that still has valid labels | | `SHIPMENTS_NOT_FOUND` | A batch references shipments that do not exist or are not yours | | `PRODUCT_NOT_FOUND` | A line item points at a product that does not exist | | `LINE_ITEM_INCOMPLETE` | An inline line item is missing `name` or `unitPrice` | --- # Idempotency Source: https://docs.sendit.mx/en/api-conventions/idempotency Networks fail. Idempotency guarantees that retrying an interrupted request never duplicates the operation or the charge. ## You generate the key The idempotency key is **created by you, client-side**. The API never issues one. Before you send the request: 1. **Generate a unique value** for that business operation. Recommended: a UUID v4 (`crypto.randomUUID()` in Node.js, `uuid.uuid4()` in Python) or any string with enough randomness to never repeat (up to 255 characters). 2. **Send it** in the `Idempotency-Key` header. 3. **Store it** next to your operation (the order, the job) so every retry uses exactly the same key. One key represents **one** business attempt: the same key for that operation's retries, a fresh key for each distinct operation. ## How it works Every mutation (`POST`, `PUT`, `PATCH`) accepts an `Idempotency-Key` header (recommended: UUIDv4, up to 255 characters): ```bash curl -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/label \ -H "X-API-Key: sk_test_..." \ -H "Idempotency-Key: 1f0e6f0e-59a4-4a6f-9d2e-9b1a7b2c3d4e" \ -H "Content-Type: application/json" \ -d '{ "rateId": "ESTAFETA_economy_x9y8z7" }' ``` Retrying with the same key within a **24-hour** window replays the original result: the same status code and the same body, whether it was a success **or** a failure. The response sets `Idempotent-Replayed: true` and echoes your key back. Two important nuances: - Validation errors (`400`) are **never stored**: fix the payload and reuse the same key freely. - Any other replayed failure (e.g. a `402`) keeps replaying: after resolving the cause, retry with a **new** key. ## Where it matters most The key is **optional on every endpoint**. If you do not send one, the request runs normally with no replay protection. You never get a `400` for a missing key. Send it above all on the operations that move money: | Endpoint | Operation | | --- | --- | | `POST /v1/shipments/:id/label` | Label purchase | | `POST /v1/shipments/:id/label/void` | Label void | | `POST /v1/wallet/fund/card` | Card funding | | `POST /v1/wallet/fund/paypal` | PayPal funding | | `POST /v1/shipments` with `purchase` | One-call buy — replays the create **and** the purchase as a single unit | On funding calls, the key also guarantees that retries reuse the same payment intent. Your card is never charged twice. Your balance is protected even without a key. The wallet de-duplicates every movement server-side, so retrying an interrupted purchase or funding call never double-charges. The key adds a replay of the original **result**, with the same status and the same body. That is why we recommend it on every money flow. **Trackers:** `POST /v1/trackers` does not use idempotency and does not need it. Registering the same number twice returns the existing tracker, with no double charge. ## Conflicts | Code | When | How to resolve it | | --- | --- | --- | | `409 IDEMPOTENCY_KEY_REUSED` | Key reused with a **different** body or endpoint | Generate a fresh key for each distinct operation | | `409 IDEMPOTENCY_KEY_IN_USE` | A concurrent request with the same key is still in flight | Wait a moment and retry with the **same** key | ## Recommended pattern Generate the key **before** the first request and store it next to your business operation, such as the order or the fulfillment job. Any retry then uses the same key, whether it is immediate or comes after your process restarts: ```js // When creating your internal order, generate and store the key const idempotencyKey = crypto.randomUUID(); await db.orders.update(orderId, { senditIdempotencyKey: idempotencyKey }); // Every retry reuses the same key const res = await fetch(`https://api.sendit.mx/v1/shipments/${shipmentId}/label`, { method: "POST", headers: { "X-API-Key": process.env.SENDIT_API_KEY, "Idempotency-Key": idempotencyKey, "Content-Type": "application/json", }, body: JSON.stringify({ rateId }), }); ``` :::warning Don't derive the key from the content (e.g. a hash of the body): if you legitimately need to repeat the same operation tomorrow, the key must differ. One key = one business attempt. ::: --- # Pagination and filtering Source: https://docs.sendit.mx/en/api-conventions/pagination-and-filtering Pagination is resource-specific. There is no single contract that covers every list. Before you wire up a list, read the `meta` block that endpoint returns. That block is authoritative. This page describes the shipment model, which is the most complete, and then the simple model the other resources use. ## Paginate shipments by page (the default) `GET /v1/shipments` uses page-number pagination. ```text GET /v1/shipments?page=1&limit=50 ``` | Parameter | Default | Maximum | Description | | --- | --- | --- | --- | | `page` | 1 | — | Page number | | `limit` | 20 | 100 | Items per page | ```json { "success": true, "data": ["..."], "meta": { "pagination": { "mode": "offset", "page": 1, "limit": 50, "total": 1234, "totalPages": 25, "hasNextPage": true, "hasPrevPage": false } } } ``` Keep going while `hasNextPage` is `true`. ## Paginate by cursor when volume grows On large lists, counting every page becomes expensive. Turn on cursor mode with `useCursor=true`. ```text GET /v1/shipments?useCursor=true&limit=50 GET /v1/shipments?useCursor=true&limit=50&cursor=eyJjcmVhdGVkQXQiOi... ``` `useCursor` accepts only `true` or `false`. Omit it, or send `false`, to paginate by offset. ```json { "success": true, "data": ["..."], "meta": { "pagination": { "mode": "cursor", "limit": 50, "hasNextPage": true, "nextCursor": "eyJjcmVhdGVkQXQiOi..." } } } ``` The `nextCursor` value is opaque. Pass it back unchanged. Do not decode it or build one yourself. Stop when `hasNextPage` is `false`. ```js let cursor; do { const url = new URL("https://api.sendit.mx/v1/shipments"); url.searchParams.set("useCursor", "true"); url.searchParams.set("limit", "100"); if (cursor) url.searchParams.set("cursor", cursor); const { data, meta } = await fetch(url, { headers: { "X-API-Key": process.env.SENDIT_API_KEY }, }).then((r) => r.json()); process(data); cursor = meta.pagination.hasNextPage ? meta.pagination.nextCursor : null; } while (cursor); ``` :::note The `meta.pagination.mode` field tells you which model you got. Read it instead of assuming. ::: ## Paginate the other resources Every other paginated list uses a flat `meta` block. There is no `pagination` level and no cursor: ```text GET /v1/wallet/transactions?page=2&limit=50 ``` ```json { "success": true, "data": ["..."], "meta": { "page": 2, "limit": 50, "total": 340, "totalPages": 7 } } ``` The wallet (monedero), orders, products, and invoices all work this way. Some resources do not paginate at all. In every case, the `meta` block in the response is authoritative. ## Filter the results Filters are named query parameters. **There are no `field[gte]` operators, no `sort=`, no `fields=`, and no `expand[]`.** The exact set depends on the resource. For `GET /v1/shipments`: | Parameter | Description | | --- | --- | | `status` | A single status | | `statuses` | Several statuses, comma-separated. Takes precedence over `status` when you send both | | `carrierCode` | Carrier | | `trackingNumber` | Partial match on the tracking number | | `externalId` | Exact match | | `search` | Case-insensitive free text across tracking number, `externalId`, and destination (contact name, city, state) | | `createdFrom` | Created on or after this date (inclusive) | | `createdTo` | Created before this date (exclusive) | ```text GET /v1/shipments?status=DELIVERED&carrierCode=DHL GET /v1/shipments?statuses=DELIVERED,RETURNED GET /v1/shipments?search=FEDMX123 GET /v1/shipments?createdFrom=2026-01-01&createdTo=2026-04-01 ``` The date range is half-open. It includes `createdFrom` and excludes `createdTo`. You can chain months without fetching a record twice. ## Sorting The order is fixed: `createdAt` descending, with `id` as the tie-breaker. Shipment lists accept no custom sort parameter. ## Fetch many known resources at once If you already have the IDs, do not paginate. The bulk endpoints return up to 100 resources in one call: ```text POST /v1/bulk/shipments/fetch { "ids": ["shp_aaa", "shp_bbb", "shp_ccc"] } ``` Errors come back per item. One missing ID does not fail the whole batch. All four bulk endpoints (shipments, orders, tracking, address validation) are documented alongside [batches](/en/shipping/batches#bulk-endpoints). --- # Rate limits Source: https://docs.sendit.mx/en/api-conventions/rate-limits Every request is classified into one of three **endpoint classes** and counted against that class's per-minute budget. The classes are independent: heavy quoting never starves your label purchases, and vice versa. | Class | What falls in it | | --- | --- | | `read` | `GET`, `HEAD`, and `OPTIONS` | | `write` | `POST`, `PUT`, `PATCH`, and `DELETE` that aren't quotes | | `quote` | Rate quoting: `POST /v1/rates`, `POST /v1/rates/carrier/:carrierCode`, and `GET /v1/shipments/:id/rates` | ## How your requests are bucketed The window is 60 seconds and is shared across every API server. | Traffic | Metered by | Per-minute budget | | --- | --- | --- | | Live key (`sk_live_`) | Organization, per class | Your plan's budgets (below) | | Test key (`sk_test_`) | Organization, per class, in an **isolated bucket** | 25% of the plan, floor of 10/min | | Dashboard session | Session, per class | `read` 300 · `write` 100 · `quote` 30 | | Unauthenticated | IP, per class | `read` 60 · `write` 30 · `quote` 10 | All of an organization's keys share the same budget. Test traffic gets its **own** buckets: what you spend in the sandbox never eats into production headroom, and vice versa. ## Per-plan budgets For live keys, requests per minute: | Plan | `read` | `write` | `quote` | | --- | --- | --- | --- | | Free | 60 | 30 | 20 | | Growth | 300 | 150 | 60 | | Scale | 1,000 | 500 | 200 | | Enterprise | 5,000 | 2,500 | 1,000 | `sk_test_` keys get 25% of each number, with a floor of 10/min. For example: Free test `quote` is 10, and Scale test `read` is 250. ## Read the headers Every throttled response carries these headers, unsuffixed: | Header | Description | | --- | --- | | `X-RateLimit-Limit` | The budget for the class **this** request landed in | | `X-RateLimit-Remaining` | Requests remaining in that bucket this minute | | `X-RateLimit-Reset` | When the bucket resets, in **Unix epoch seconds** | | `Retry-After` | Seconds until you can retry (on 429 only) | The headers describe the class of the request that returned them: a `POST` and a `GET` sent in the same second can legitimately report different limits. ## Handle a 429 ```json { "success": false, "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Too many requests", "details": { "retryAfter": 30 } } } ``` Once you're over, requests are rejected until the 60-second window rolls; there is no extra penalty or extended block. Honor `Retry-After` and apply exponential backoff: ```js async function withRetry(fn, maxAttempts = 5) { for (let attempt = 1; ; attempt++) { const res = await fn(); if (res.status !== 429 || attempt === maxAttempts) return res; const retryAfter = Number(res.headers.get("Retry-After") ?? 1); const backoff = retryAfter * 1000 * 2 ** (attempt - 1); await new Promise((r) => setTimeout(r, backoff)); } } ``` :::note Processing high volumes? Before parallelizing individual requests, consider the [batch](/en/shipping/batches) endpoints: a single call processes up to 100 items and counts as one `write` request. ::: --- # Request logs Source: https://docs.sendit.mx/en/api-conventions/request-logs Every API call leaves a record of its method, its path, its status code, and how long it took. Use it to debug an integration, trace which key did what, or find the pattern behind a run of errors. The log stores **metadata**, not content. It never returns the IP address, the user agent, or your query-string values. ## Read your recent calls | Method | Path | Access | | --- | --- | --- | | `GET` | `/v1/api-requests` | Any authenticated member, or a key with `api_keys:read` | A dashboard session gets in regardless of role: `VIEWER` can read it too. With an API key you need the `api_keys:read` scope (or `*`). ```bash curl "https://api.sendit.mx/v1/api-requests?method=POST&statusCode=402&limit=20" \ -H "X-API-Key: sk_test_..." ``` #### Query parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `method?` | `string` | - | Filter by HTTP method, for example POST. | | `statusCode?` | `integer` | - | Filter by exact status code, for example 402. | | `apiKeyId?` | `string` | - | Filter by the key that made the call. | | `after?` | `string` | - | ISO-8601 date. Only requests created after that moment. | | `before?` | `string` | - | ISO-8601 date. Only requests created before that moment. | | `cursor?` | `string` | - | Opaque cursor from the previous page. Pass it back unchanged. | | `limit?` | `integer` | - | How many records to return per page. | ```json { "success": true, "data": { "data": [ { "id": "req_01J8Z9K2M4N6P8Q0R2S4T6", "apiKeyId": "key_a1b2c3d4e5f6", "method": "POST", "path": "/v1/shipments", "statusCode": 201, "durationMs": 142, "requestId": "01J8Z9K2M4N6P8Q0R2S4T6", "livemode": true, "createdAt": "2026-08-03T18:00:00.000Z" } ], "total": 1, "nextCursor": null, "hasMore": false } } ``` :::warning **This list nests one level deeper.** The array is not at `data`, it is at `data.data`. Every other list endpoint puts the array directly in `data` and the pagination in `meta`. Here the whole page object sits inside the envelope. ::: The `apiKeyId`, `durationMs`, and `requestId` fields can arrive as `null`. | Field | Description | | --- | --- | | `id` | Identifier of the log row | | `apiKeyId` | The key that made the call; `null` when it came from a dashboard session | | `method` | HTTP method | | `path` | Path that was called | | `statusCode` | Status code we returned | | `durationMs` | How long the request took | | `requestId` | The same identifier we return on [errors](/en/api-conventions/errors) | | `livemode` | `false` when the call happened in [test mode](/en/getting-started/test-mode) | | `createdAt` | When the request arrived | ## Page through the results Results come back newest first. Pass `nextCursor` back unchanged and stop when `hasMore` is `false`: ```js let cursor = null; do { const url = new URL("https://api.sendit.mx/v1/api-requests"); url.searchParams.set("limit", "100"); if (cursor) url.searchParams.set("cursor", cursor); const res = await fetch(url, { headers: { "X-API-Key": process.env.SENDIT_KEY } }); const { data } = await res.json(); for (const row of data.data) process(row); cursor = data.hasMore ? data.nextCursor : null; } while (cursor); ``` ## Separate test from live Results are always scoped to the active mode. With an API key, the key's own environment fixes the mode. With a dashboard session, add `?livemode=false` to read test-mode requests. The endpoint **excludes itself**, so reading the log never creates new rows. You can poll it without polluting your own data. ## Handle errors | Code | When it happens | How to resolve it | | --- | --- | --- | | `400 VALIDATION_ERROR` | A filter carries an invalid value, for example a date that is not ISO-8601 | Correct the parameter and retry | | `401 UNAUTHORIZED` | The credential is missing or invalid | Send a valid key, or sign in | | `403 INSUFFICIENT_SCOPE` | The key lacks `api_keys:read` | Issue a key with that [scope](/en/api-conventions/scopes) | | `429 RATE_LIMIT_EXCEEDED` | You exceeded the read limit | Respect the [rate-limit](/en/api-conventions/rate-limits) headers before retrying | --- # Scopes Source: https://docs.sendit.mx/en/api-conventions/scopes Scopes limit what each API key can do. Issue restricted keys per use case: a read-only key for reporting, a write key for your fulfillment service. ## Canonical list | Scope | Allows | | --- | --- | | `*` | Full access (default for new keys) | | `shipments:read` | Read shipments and their details | | `shipments:write` | Create and update shipments | | `labels:read` | Read label details | | `labels:write` | Purchase labels (including [one-call buy](/en/shipping/shipments#one-call-buy), which also requires `shipments:write`) | | `addresses:read` | Read saved addresses | | `addresses:write` | Create and update addresses | | `wallet:read` | Read wallet balance and transactions | | `wallet:write` | Initiate wallet funding | | `webhooks:read` | Read webhook endpoints and delivery attempts | | `webhooks:write` | Create, update, and delete webhook endpoints | | `organizations:read` | Read the organization and its members | | `organizations:write` | Update settings and manage members | | `api_keys:read` | Read the key list and usage logs | | `api_keys:write` | Create, update, rotate, and revoke keys | | `rates:read` | Fetch rates | | `carrier_preferences:write` | Configure [enabled carriers](/en/shipping/carrier-accounts) and your own account's credentials | | `carrier_services:read` | Read the [service catalog](/en/shipping/carriers) | | `tracking:read` | Read tracking events | | `trackers:read` | Read registered trackers | | `trackers:write` | Register and delete trackers | | `orders:read` | Read orders | | `orders:write` | Create, update, cancel, and delete orders | | `batches:read` | Read batches | | `batches:write` | Create batches and manifests | | `products:read` | Read the product catalog | | `products:write` | Create, update, and delete products | | `notification_settings:read` | Read recipient-notification toggles, branding, and templates | | `notification_settings:write` | Update notification toggles, subjects, and branding | | `shipping_rules:read` | Read and preview [automation rules](/en/shipping/shipping-rules) | | `shipping_rules:write` | Create, update, reorder, and delete rules | | `insurance_claims:read` | Read [insurance claims](/en/wallet-and-billing/insurance-claims) | | `insurance_claims:write` | File claims and administer their status | ## How enforcement works - Dashboard sessions have implicit full access. Scopes apply to API keys. - A key with `scopes: ["*"]` can access everything. - If a key is missing a required scope, the request returns `403 INSUFFICIENT_SCOPE` with the missing scopes listed in `details`. ## Create restricted keys ```bash curl -X POST https://api.sendit.mx/v1/api-keys \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "Fulfillment service", "environment": "LIVE", "scopes": ["shipments:read", "labels:write"] }' ``` ## Restrict by IP (optional) Beta This feature is in beta. Its behavior may change before the final release. Limit a key to specific IPs or CIDR blocks. By default the list is empty (all IPs allowed): ```bash curl -X POST https://api.sendit.mx/v1/api-keys \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "Fulfillment server", "scopes": ["labels:write"], "ipAllowlist": ["203.0.113.0/24", "198.51.100.42"] }' ``` Requests from IPs outside the list return `403 IP_NOT_ALLOWED`. ## Rotate keys with a grace window Rotation creates a replacement with the same scopes and IP allowlist. The old key stays valid for **24 hours** so in-flight requests can drain: ```bash curl -X POST https://api.sendit.mx/v1/api-keys/{id}/rotate \ -H "Authorization: Bearer " ``` During the window you see both keys in `GET /v1/api-keys`: the rotating one and the new one. --- # API versioning Source: https://docs.sendit.mx/en/api-conventions/versioning SendIt uses date-based versioning. When you create your account, your organization is pinned to the current version. Nothing breaks on its own: you decide when to adopt changes. ## Current version ```text 2026-05-01 ``` ## Pin a version Three mechanisms, in priority order: ### 1. Request header (highest priority, ideal for testing) ```http SendIt-Version: 2026-05-01 ``` Useful for verifying your code against a new version before permanently upgrading. ### 2. API key pin ```bash curl -X PATCH https://api.sendit.mx/v1/api-keys/{id} \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "apiVersion": "2026-05-01" }' ``` Requests on that key always use the specified version, overriding the organization pin. ### 3. Organization pin (default) ```bash curl -X PUT https://api.sendit.mx/v1/organizations/me \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "apiVersion": "2026-05-01" }' ``` Every key without its own pin inherits this version. ## Upgrade without surprises 1. Test the new version with the `SendIt-Version` header in your staging environment. 2. Adjust your code for the documented breaking changes. 3. Update your organization pin (or individual key pins) once everything is verified. ## Deprecation policy | Timeline | What happens | | --- | --- | | 6 months before | The `SendIt-Version-Deprecated` header is added to every response | | 1 month before | Email notification to the organization's OWNERs | | After sunset | Requests return `400 API_VERSION_UNSUPPORTED` | Every version is supported for **at least 18 months**. ## What requires a new version New versions are rare. Only a breaking change requires one: - Renaming or removing response fields - Changing a field's type (e.g. string → object) - Removing enum values - Changing required request fields - Changing default behavior Adding fields, endpoints, optional parameters, or event types does **not** require a new version. Your integration must tolerate unknown fields. ## Webhook versioning [Webhook](/en/webhooks-and-events/webhooks#the-payload) payloads **carry no version field**, and webhook endpoints are not pinned to a version of their own. There is one API version today, so every event has the same shape across all your endpoints. Your handler does not need to branch on version. --- # Authentication & API keys Source: https://docs.sendit.mx/en/getting-started/authentication Every API request authenticates with an API key. Keys belong to an organization, carry configurable scopes, and come in two environments: test and live. ## Anatomy of a key ```text sk_test_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345 │ │ │ │ │ └── 32 random characters │ └── Environment: test (sandbox) or live (production) └── Prefix: sk = secret key ``` | Prefix | Environment | Effect | | --- | --- | --- | | `sk_test_` | Test | Simulated carriers, virtual balance — no real money | | `sk_live_` | Live | Real labels, real charges to your wallet | A `sk_test_` key can never read or modify live data, and the reverse is also true. The isolation is total. See [Test mode](/en/getting-started/test-mode). ## Send your key Two equivalent options; use whichever your HTTP client prefers: ```bash X-API-Key curl https://api.sendit.mx/v1/shipments \ -H "X-API-Key: sk_test_..." ``` ```bash Authorization curl https://api.sendit.mx/v1/shipments \ -H "Authorization: Bearer sk_test_..." ``` :::warning Your key is a secret. Use it only from your server: never embed it in browser code, mobile apps, or repositories. Keep it in an environment variable or a secrets manager. ::: ## Create a key From the dashboard (**Settings → API keys → Create key**) or via the API: ```bash curl -X POST https://api.sendit.mx/v1/api-keys \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "Online store integration", "environment": "live", "scopes": ["shipments:write", "shipments:read", "rates:read", "labels:write", "tracking:read"] }' ``` ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `name` | `string` | - | A descriptive name for the key (to identify it in the dashboard). | | `environment?` | `string` | `test` | test (sandbox) \| live (production). | | `scopes?` | `string[]` | `["*"]` | The key's scopes. The full list is in Scopes. | | `ipAllowlist?` | `string[]` | - | (Beta) IPs or CIDR blocks the key may be used from. | The full key is shown **exactly once** in the response. If you lose it, you cannot recover it: generate a new one. In the dashboard you identify each key by its visible prefix (`sk_live_aBcD...`). Each plan caps how many active keys you can have. Creating one more returns `403 PLAN_LIMIT_REACHED`. The count excludes keys in their 24-hour rotation window. See [plans & quotas](/en/wallet-and-billing/subscriptions-and-quotas). ## Limit each key's scope Every key carries a list of scopes in the `resource:action` pattern. A key can only do what its scopes allow. Everything else returns `403`. ```json { "name": "Online store integration", "scopes": [ "shipments:write", "shipments:read", "rates:read", "labels:write", "tracking:read" ] } ``` Issue each key with the minimum privilege that integration needs. The full scope list and its semantics live in [Scopes](/en/api-conventions/scopes). :::note **A scoped key cannot list keys.** `GET /v1/api-keys` is role-gated and declares no scope, and a route like that rejects any key without `*`. A key holding `api_keys:read` gets `403`. To list keys, use a dashboard session with the ADMIN role or above. The `api_keys:read` scope does govern the [request logs](/en/api-conventions/request-logs). ::: ## Rotate a key 1. Generate a new key with the same scopes. 2. Update your integration to use the new one. 3. Revoke the old one. Keep both active during the transition and watch the old key's `lastUsedAt` field to confirm nothing still uses it before revoking. ## Restrict by IP Beta This feature is in beta. Its behavior may change before the final release. Optionally, limit a key to an IP range with a CIDR list. A request from an IP outside the list is rejected even if the key is valid. Use it on live keys that should only run from your servers. ## Dashboard users Anyone signing in to the dashboard authenticates with a user session and operates under their role in the organization: | Role | Can | | --- | --- | | VIEWER | Read-only | | OPERATOR | Create and manage shipments, addresses, packages; buy labels | | ADMIN | All of the above + members, settings, and API keys | | OWNER | Everything + billing and plan | Roles apply to people; scopes apply to keys. For server-to-server integrations, always use API keys. ## Authentication errors | Code | When it happens | How to resolve it | | --- | --- | --- | | `401 UNAUTHORIZED` | Key missing or header malformed | Send `X-API-Key` or `Authorization: Bearer sk_...` | | `401 INVALID_API_KEY` | Key doesn't exist or was revoked | Check you copied the full key; generate a new one if revoked | | `401 EXPIRED_API_KEY` | Key passed its expiration date | Generate a new key and update your integration | | `403 INSUFFICIENT_SCOPE` | Key is valid but lacks scopes (listed in `details`) | Add the needed scope or use a key with sufficient permissions | --- # Organizations & personal sandbox Source: https://docs.sendit.mx/en/getting-started/organizations-and-sandbox Everything in SendIt lives inside an organization: shipments, addresses, wallet, and keys. Your account can belong to several and switch context without signing in again. ## Your personal organization When you sign up, SendIt creates a **personal organization**. It is a full-featured organization that always stays with you. The only things you cannot do are leave it or delete it: it is your account's permanent home. | Property | Value at creation | | --- | --- | | Plan | Free (upgrade any time) | | Real balance | $0 MXN — funded via CLABE/SPEI, card, or PayPal | | Test balance | $10,000 MXN virtual, resettable any time | | Default key | `sk_test_...` with all scopes | With that test key you can generate your [first label](/en/getting-started/quickstart) in minutes, with no payment method on file. ## Sandbox is a mode, not another account There is no separate "sandbox account". **Every** organization has both modes, personal or team. You switch between them with your key's prefix (`sk_test_` or `sk_live_`) or, in the dashboard, with the mode toggle (`?livemode=false`). The simulated behavior is detailed in [Test mode](/en/getting-started/test-mode). ### The wallet's two balances Every wallet keeps two completely separate balances: - **Real balance.** Funded via CLABE/SPEI, card, or PayPal. Live label purchases debit it. Low-balance alerts apply here only. - **Test balance.** Virtual, starts at $10,000 MXN. Test-mode purchases debit it. Real money is never touched, and test activity never triggers alerts. `GET /v1/wallet/balance` and `GET /v1/wallet/transactions` respond according to the request's mode: with `sk_test_` you see the test balance and test transactions; with `sk_live_`, real money only. ## Team organizations Create additional organizations to separate businesses, clients, or environments: ```bash curl -X POST https://api.sendit.mx/v1/organizations \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "Mi Tienda MX" }' ``` And switch your account's active context: ```bash curl -X POST https://api.sendit.mx/v1/organizations/switch/{organizationId} \ -H "Authorization: Bearer " ``` Everything you create is scoped to the organization (and mode) active at creation time. API keys never need to "switch" organizations: each key belongs to one and always operates on it. ### Personal vs team | Action | Personal org | Team org | | --- | --- | --- | | Funding (CLABE, card, PayPal) | Yes | Yes | | Plan upgrade | Yes | Yes | | Invite members | Yes | Yes | | Test + live keys | Yes | Yes | | Reset test balance | Yes | Yes | | Leave | **No (403)** — it is your account's home | Yes (if not the sole OWNER) | ## Invite your team Members are invited by email with a role: VIEWER, OPERATOR, ADMIN, or OWNER (see [roles](/en/getting-started/authentication#dashboard-users)). Invitations expire after 7 days and the acceptance link is bound to the invited email. ## Delete your account Closing your account is **irreversible** and takes effect immediately: ```bash curl -X DELETE https://api.sendit.mx/v1/users/me \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "confirm": "DELETE" }' ``` - **User session only.** API keys can never delete accounts: this endpoint rejects them. It acts on your own identity, so no role is required. - The body must be exactly `{ "confirm": "DELETE" }`. Any other value returns `400`. This is a deliberate guard against accidental calls. ### Before deleting If you are the **sole OWNER of any team organization**, deletion is rejected with `SOLE_OWNER_OF_ORGANIZATION`. The `organizationIds` come in `details`. Promote another member to OWNER in each one and retry. Your personal organization never blocks deletion: it is closed as part of the process. ### What happens on deletion 1. You leave every organization you were a member of. 2. Your personal organization is closed: its API keys are revoked, its webhooks are disabled, and its pending invitations are cancelled. 3. Your profile is anonymized (name, phone, avatar, and email). The account can no longer sign in, and the same identity cannot register again. ### Wallet balance A pending balance does **not** block deletion. If your real wallet holds a positive balance at deletion time, a support case opens to settle it with you. The response includes its `refundCaseId`. The test balance is virtual and is discarded. ### What is retained (and why) Deletion never erases the financial and audit history the law requires keeping. That history is **anonymized and retained**, tied to the closed organization and never to you personally: | Retained | Why | | --- | --- | | CFDI invoices and fiscal data | SAT requires keeping them at least 5 years | | Wallet transactions and ledger entries | Financial regulatory retention (≈ 7 years) | | Audit log | Verifiable integrity of the organization's history | Everything that identifies you personally (name, email, phone, avatar) is removed. Any active paid subscription is cancelled as part of the closure. --- # Try it in Postman Source: https://docs.sendit.mx/en/getting-started/postman The entire SendIt API is described in a public **OpenAPI 3.1** specification. Import it into Postman and every endpoint is ready to fire, with parameters, bodies, and responses included. You write no requests by hand. ```text https://docs.sendit.mx/openapi.yaml ``` ## Import the API into Postman 1. **Import the spec** In Postman: **Import → Link**, paste `https://docs.sendit.mx/openapi.yaml`, and confirm. Postman generates a collection with every endpoint, organized by area: shipments, rates, labels, wallet, orders, and webhooks. 2. **Set your variables** In the collection, open **Variables** and set: | Variable | Value | | --- | --- | | `baseUrl` | `https://api.sendit.mx/v1` (comes from the spec) | | `apiKey` | Your `sk_test_...` test key | The collection authenticates with the `X-API-Key` header. Use your [test key](/en/getting-started/authentication) to experiment with no real money. 3. **Fire the essential flow** With [test mode](/en/getting-started/test-mode) active (`sk_test_` key): 1. `POST /shipments` creates a shipment. The response carries `rates[]`. 2. `POST /shipments/{id}/label` buys with a `rateId`. Add the `Idempotency-Key` header. 3. `GET /shipments/{id}` shows the shipment moving. The same journey as the [quickstart](/en/getting-started/quickstart), now with clicks. The same spec works in **Insomnia**, **Bruno**, **Hoppscotch**, and any OpenAPI 3.1-compatible client generator. ## Integrate AI agents These docs are machine-readable, so you do not need to scrape them: | Resource | What it is | | --- | --- | | [`/llms.txt`](/llms.txt) | Compact index of the whole documentation: every page with its summary, organized by section. The entry point for an agent. | | [`/llms-full.txt`](/llms-full.txt) | The full corpus: each page's complete markdown in a single file. | | Any URL + `.md` | That page's raw markdown — for example, [`/en/getting-started/quickstart.md`](/en/getting-started/quickstart.md). | | "Copy as Markdown" button | On every page, under the table of contents — copies the page ready to paste into a chat or an issue. | Point your agent (Claude Code, Cursor, Copilot) at `https://docs.sendit.mx/llms.txt` and it can navigate the entire documentation on its own. :::note `llms.txt`, `llms-full.txt`, and `openapi.yaml` are published with the production site. They are not available on a local dev server. ::: --- # Your first label Source: https://docs.sendit.mx/en/getting-started/quickstart Here you create your first shipping label (guía) in test mode. Test mode uses simulated carriers and a virtual balance. No real money moves. The steps in production are the same. To go live, change the key. You need a SendIt account and your test key (`sk_test_...`). Find it in the dashboard under **Settings → API keys**, or via `GET /v1/api-keys`. 1. **Create a shipment** A shipment describes the origin, the destination, and the parcel. The response carries the new shipment and `rates[]`. `rates[]` holds quotes from every carrier available for that route. ```bash curl curl -X POST https://api.sendit.mx/v1/shipments \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "from": { "contactName": "Almacén CDMX", "contactPhone": "+5215512345678", "street": "Av. Insurgentes Sur", "exteriorNumber": "1602", "neighborhood": "Crédito Constructor", "city": "Ciudad de México", "state": "CDMX", "postalCode": "03940", "country": "MX" }, "to": { "contactName": "María López", "contactPhone": "+5213312345678", "street": "Av. López Mateos", "exteriorNumber": "45", "neighborhood": "Jardines del Sol", "city": "Zapopan", "state": "JAL", "postalCode": "45050", "country": "MX" }, "parcel": { "length": 30, "width": 20, "height": 15, "weight": 2.5 } }' ``` ```js Node.js const response = await fetch("https://api.sendit.mx/v1/shipments", { method: "POST", headers: { "X-API-Key": process.env.SENDIT_API_KEY, // sk_test_... "Content-Type": "application/json", }, body: JSON.stringify({ from: { contactName: "Almacén CDMX", contactPhone: "+5215512345678", street: "Av. Insurgentes Sur", exteriorNumber: "1602", neighborhood: "Crédito Constructor", city: "Ciudad de México", state: "CDMX", postalCode: "03940", country: "MX", }, to: { contactName: "María López", contactPhone: "+5213312345678", street: "Av. López Mateos", exteriorNumber: "45", neighborhood: "Jardines del Sol", city: "Zapopan", state: "JAL", postalCode: "45050", country: "MX", }, parcel: { length: 30, width: 20, height: 15, weight: 2.5 }, }), }); const { data: shipment } = await response.json(); console.log(shipment.id, shipment.rates.length); ``` The (trimmed) response carries the shipment in `DRAFT` status plus its rates: ```json { "success": true, "data": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "DRAFT", "rates": [ { "id": "DHL_standard_a1b2c3", "carrierCode": "DHL", "serviceCode": "EXPRESS_WORLDWIDE", "serviceName": "DHL Express Nacional", "serviceLevel": "standard", "totalPrice": 326.82, "currency": "MXN", "estimatedDays": { "min": 1, "max": 2 }, "expiresAt": "2026-07-18T14:30:00.000Z" }, { "id": "ESTAFETA_economy_x9y8z7", "carrierCode": "ESTAFETA", "serviceCode": "TERRESTRE", "serviceName": "Estafeta Terrestre", "serviceLevel": "economy", "totalPrice": 289.50, "currency": "MXN", "estimatedDays": { "min": 3, "max": 5 }, "expiresAt": "2026-07-18T14:30:00.000Z" } ], "ratesStatus": "ready", "ratesExpiresAt": "2026-07-18T14:30:00.000Z" } } ``` 2. **Pick a rate** Each element of `rates[]` is a firm offer. Its `totalPrice` includes tax and is **exactly** what your wallet is charged. Choose by price, speed, or carrier. For the next step you only need the rate's `id`. ```js Node.js const cheapest = shipment.rates .slice() .sort((a, b) => a.totalPrice - b.totalPrice)[0]; console.log(cheapest.id); // "ESTAFETA_economy_x9y8z7" ``` Rates stay valid for 24 hours. If they expire, fetch fresh ones with `GET /v1/shipments/:id/rates?refresh=true`. :::note If you already know your carrier, or want the cheapest, add the `purchase` object when you create the shipment. The label arrives in the same response. See [One-call buy](/en/shipping/shipments#one-call-buy). ::: 3. **Buy the label** Send the chosen `rateId`. The `Idempotency-Key` header is optional, but send one on this call. If your request is interrupted and you retry with the same key, there is no double charge. ```bash curl curl -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/label \ -H "X-API-Key: sk_test_..." \ -H "Idempotency-Key: 1f0e6f0e-59a4-4a6f-9d2e-9b1a7b2c3d4e" \ -H "Content-Type: application/json" \ -d '{ "rateId": "ESTAFETA_economy_x9y8z7" }' ``` ```js Node.js const purchase = await fetch( `https://api.sendit.mx/v1/shipments/${shipment.id}/label`, { method: "POST", headers: { "X-API-Key": process.env.SENDIT_API_KEY, "Idempotency-Key": crypto.randomUUID(), "Content-Type": "application/json", }, body: JSON.stringify({ rateId: cheapest.id }), } ); const { data: label } = await purchase.json(); console.log(label.trackingNumber, label.labelUrl); ``` ```json { "success": true, "data": { "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "labelId": "clxlbl456abc789def012ghi", "trackingNumber": "TEST-ESTAFETA-A1B2C3D4", "labelUrl": "https://labels.sendit.mx/test/clxq1w2e3r4t5y6u7i8o9p0a/TEST-ESTAFETA-A1B2C3D4.pdf", "carrierCode": "ESTAFETA", "serviceName": "Estafeta Terrestre", "charged": "289.50", "currency": "MXN", "walletBalanceAfter": "9710.50", "breakdown": { "ivaAmount": "39.93", "overageCharge": "0.00", "total": "289.50" } } } ``` Download the PDF from `labelUrl`. That is your label. The charge came out of the virtual test balance, not real money. 4. **Watch your shipment move** In test mode the parcel simulates its journey on its own. It goes from `PICKED_UP` to `DELIVERED` in under half an hour. To skip the wait, advance the status by hand: ```bash curl -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/test/advance-status \ -H "X-API-Key: sk_test_..." ``` Each call advances one step: ```text LABEL_PURCHASED → READY_FOR_PICKUP → PICKED_UP → IN_TRANSIT → OUT_FOR_DELIVERY → DELIVERED ``` Check the status and the event history any time with `GET /v1/shipments/:id`. ## Next steps Failure scenarios, virtual balance, and everything the sandbox simulates. Stop polling. Receive every event on your server. Saved addresses, insurance, external references, and more. How to retry money endpoints safely. --- # Test mode Source: https://docs.sendit.mx/en/getting-started/test-mode Every SendIt organization includes an isolated test environment. It mirrors production without touching real money or real carriers. A `sk_test_...` key activates it, and nothing else needs configuration. ## How mode is determined | Authentication | Mode | | --- | --- | | `sk_test_...` key | Test | | `sk_live_...` key | Live | | Dashboard session | Live by default; add `?livemode=false` to view test data | Mode is resolved once per request and applies to every read and write. A test request can never read or modify live data, and the reverse is also true, even if it knows the resource's exact ID. ## What test mode simulates | Feature | Test behavior | | --- | --- | | Carrier calls | Fully simulated — no request ever reaches DHL, FedEx, or Estafeta | | Tracking numbers | `TEST-{CARRIER}-{random}` — impossible to confuse with real ones | | Label URL | `https://labels.sendit.mx/test/{shipmentId}/{trackingNumber}.pdf` | | Wallet charges | Debited from the virtual **test balance** (starts at $10,000 MXN); the real balance is never touched | | Webhooks | Delivered normally, with `"livemode": false` in the payload | | Status progression | Automatic (paced simulation) or manual via the test endpoint | | Monthly quota | Not counted | | Rate limits | A separate bucket at 25% of your plan's limit, with a floor of 10 requests per minute | Test traffic never consumes the live bucket. On the FREE plan, for example, a quote limit of 20/min becomes 10/min for `sk_test_...`. The read, write, and quote buckets stay independent. See [rate limits](/en/api-conventions/rate-limits). ## Watch a full journey in minutes Every tracked parcel advances on its own, simulating a real journey anchored to when tracking started: ```text LABEL_CREATED (+0 min) → PICKED_UP (~+4) → IN_TRANSIT (~+9) → OUT_FOR_DELIVERY (~+14) → DELIVERED (~+27) ``` Some tracking numbers deliver straight from `IN_TRANSIT`, as they do in the real world. You can watch the whole journey, with the webhooks for each transition, complete in under half an hour and without calling anything. ### Advance status manually Don't want to wait? Advance one step per call: ```bash curl -X POST https://api.sendit.mx/v1/shipments/{id}/test/advance-status \ -H "X-API-Key: sk_test_..." ``` ```text LABEL_PURCHASED → READY_FOR_PICKUP → PICKED_UP → IN_TRANSIT → OUT_FOR_DELIVERY → DELIVERED ``` Each call returns the new status and records the event in the shipment's history. ## Simulate failures and returns Include a keyword in the recipient's `contactName` to trigger alternate progressions: | Keyword in contactName | Progression | | --- | --- | | `SENDIT_FAIL` | `... IN_TRANSIT → FAILED` | | `SENDIT_RETURN` | `... IN_TRANSIT → RETURNED` | | *(none)* | `... → DELIVERED` (default) | ```json { "contactName": "Test Customer SENDIT_FAIL", "...": "..." } ``` Use it to exercise your failed-delivery and return flows before they happen with real customers. ## Tell test events apart in your webhooks Every webhook payload originating in test mode carries `livemode: false`: ```json { "id": "evt_...", "type": "shipment.tracking.updated", "livemode": false, "data": { "...": "..." } } ``` Your handler should check `livemode` to route events correctly between your staging and production environments. ## Reset the test balance When the virtual balance runs low, restore it. This works on any organization and has no limit: ```bash curl -X POST https://api.sendit.mx/v1/wallet/test/reset \ -H "Authorization: Bearer " ``` ```json { "data": { "balance": 10000, "currency": "MXN" }, "message": "Test wallet reset to $10,000 MXN" } ``` The real balance is never affected by a reset. ## Read test data from a dashboard session Add `?livemode=false` to list endpoints to read test data with a JWT: ```http GET /v1/shipments?livemode=false Authorization: Bearer ``` It accepts only the literal strings `true` and `false`. Any other value, including `0`, `1`, `yes`, or empty, returns `400 VALIDATION_ERROR`. Omit it and you read live data. The parameter does not apply to API keys: the key's own environment always wins. It is accepted and ignored. | Endpoint | Effect of `?livemode=false` | | --- | --- | | `GET /v1/shipments` | Test shipments | | `GET /v1/trackers` | Test trackers | | `GET /v1/wallet/transactions`, `GET /v1/wallet/summary`, `GET /v1/wallet/balance` | Test-balance movements | | `GET /v1/orders` | Test orders | | `GET /v1/api-requests` | Test-mode [request logs](/en/api-conventions/request-logs) | | `GET /v1/webhook-endpoints/:id/events` | Test deliveries | | `GET /v1/billing/invoices`, `/v1/products`, `/v1/carrier-services` | Accepted, but these resources carry no mode: the result is the same either way | | `GET /v1/pickups` | Returns `400 LIVE_MODE_REQUIRED` | ## What is live-only Some operations are rejected in test mode before they touch any resource: - **Wallet funding.** The Stripe, CLABE, card, PayPal, and OXXO routes return `400 LIVE_MODE_REQUIRED` because they create or reveal real payment resources. For sandbox funds, use the reset above. - **Pickups (recolecciones).** Schedule, list, get, cancel, and refresh all return `400 LIVE_MODE_REQUIRED`. The one exception is `GET /v1/pickups/carriers`, which returns capability data only and carries no mode. - **Public tracking.** Test tokens never resolve on the [public page](/en/shipping/public-tracking). ## What is shared across modes Addresses, saved packages, and postal codes are shared resources: when creating a test shipment you can reference any saved address, regardless of the mode it was created in. Organization settings, members, and invitations are shared too. :::note Documented flows keep the same API shapes in TEST and LIVE. Real carrier behavior and performance can differ. ::: --- # Orders Source: https://docs.sendit.mx/en/orders/orders An order groups one or more shipments under a single business transaction: your store's order, with its line items, its customer, and its fulfillment state. Orders arrive via API, are created by hand in the dashboard, or are pushed by e-commerce integrations (Shopify, WooCommerce, Tiendanube, Mercado Libre). ## Endpoints | Method | Path | Description | | --- | --- | --- | | `POST` | `/v1/orders` | Create an order | | `GET` | `/v1/orders` | List orders (paginated) | | `GET` | `/v1/orders/:id` | Get an order | | `PUT` | `/v1/orders/:id` | Update (optimistic concurrency via `If-Match`) | | `POST` | `/v1/orders/:id/cancel` | Cancel an order | | `DELETE` | `/v1/orders/:id` | Delete (soft delete) | | `POST` | `/v1/orders/:id/shipments` | Link an existing shipment to the order | Any authenticated member can read. Every write requires the OPERATOR role or higher. ### Body parameters (create and update) | Prop | Type | Default | Description | | --- | --- | --- | --- | | `externalId?` | `string` | - | The channel's reference (your store's order number). | | `channel?` | `string` | - | MANUAL \| SHOPIFY \| WOOCOMMERCE \| TIENDANUBE \| MERCADOLIBRE \| API. | | `customerName?` | `string` | - | The end buyer. | | `customerEmail?` | `string` | - | Buyer's email for notifications. | | `customerPhone?` | `string` | - | Buyer's E.164 phone. | | `customerOptInToWhatsapp?` | `boolean` | `false` | Only true with the buyer's explicit consent (LFPDPPP). | | `shippingAddress?` | `object` | - | Delivery address. It is frozen into a snapshot at creation. | | `lineItems?` | `object[]` | - | The items (see the line items section). Unknown keys are rejected. | | `subtotal?` | `number` | - | Order subtotal, as the customer paid it. | | `shippingCost?` | `number` | - | Shipping charged to the customer at checkout. | | `totalPrice?` | `number` | - | Order total from the customer's view. | | `currency?` | `string` | `MXN` | | | `codEnabled?` | `boolean` | `false` | Cash on delivery: the carrier collects at the door. | | `codAmount?` | `number` | - | Amount to collect on delivery. | | `codPaymentMethod?` | `string` | - | Collection method (e.g. CASH). | | `codCollected?` | `boolean` | - | Set when the collection is confirmed (update only). | | `status?` | `string` | - | PENDING \| CONFIRMED \| PROCESSING \| SHIPPED \| DELIVERED \| CANCELLED \| REFUNDED. | | `paymentMethod?` | `string` | - | How the buyer paid at your checkout. | | `paymentStatus?` | `string` | - | PENDING \| PAID \| REFUNDED \| FAILED \| CHARGEBACK. | | `metadata?` | `object` | - | Your own key-value pairs; returned as-is. | ## The order object The main fields: | Field | Description | | --- | --- | | `externalId` | The channel's reference (a Shopify order number, for example) | | `channel` | `MANUAL`, `SHOPIFY`, `WOOCOMMERCE`, `TIENDANUBE`, `MERCADOLIBRE`, `API` | | `customerName` / `customerEmail` / `customerPhone` | The end buyer | | `customerOptInToWhatsapp` | `false` by default. Set to `true` **only** with the buyer's explicit consent (LFPDPPP, Mexico's data-protection law) — enables [WhatsApp notifications](/en/webhooks-and-events/notifications) | | `shippingAddress` | Snapshot of the delivery address at order time | | `lineItems` | The items (see below) | | `subtotal`, `shippingCost`, `totalPrice`, `currency` | The order's economics, as the customer paid them | | `status` | `PENDING`, `CONFIRMED`, `PROCESSING`, `SHIPPED`, `DELIVERED`, `CANCELLED`, `REFUNDED` | | `paymentMethod` / `paymentStatus` | How the buyer paid, and the state of that payment. `paymentStatus` is `PENDING`, `PAID`, `REFUNDED`, `FAILED`, or `CHARGEBACK` | | `fulfillmentStatus` | `UNFULFILLED`, `PARTIAL`, `FULFILLED`, `RETURNED`. Computed from the shipments | | `shipmentCount` / `labeledShipmentCount` / `cancelledShipmentCount` | Counters of linked, labeled, and cancelled shipments | | `mode` | `LIVE` or `TEST`, from the key that created it | | `missingFields` | Fields the integration couldn't fill in (see below) | | `etag` | Optimistic concurrency token | | `cancelledAt` | When the order was cancelled, if it was | | `metadata` | Your own key-value pairs | ## Line items Each item carries `qty` and can come **fully inline** or link to the [product catalog](/en/orders/products): - **`productId`** is the explicit link. The product's name, price, SKU, weight, and customs data are **frozen into the item** at order creation. Explicit fields in your request always win over catalog values. An unknown `productId` returns `400 PRODUCT_NOT_FOUND`. - **`sku` only** links the item if it matches an active catalog product, with the same snapshot. An unknown SKU stays inline and creates nothing. - **Pure inline** uses no catalog. `name` and `unitPrice` are required, and omitting them returns `400 LINE_ITEM_INCOMPLETE`. If you omit `totalPrice`, it defaults to `qty × unitPrice`. Line items are validated strictly and unknown keys are rejected. Put your channel's extras in each item's `metadata`. Editing or deleting a product **never** mutates existing orders: the snapshot rules. :::note Amounts inside `lineItems` are JSON numbers, not decimal strings. This is an exception to the rest of the API. Order-level totals follow the normal convention. ::: Filter orders by product: `GET /v1/orders?productId=prod_...`. ## Update with optimistic concurrency `PUT /v1/orders/:id` accepts an `If-Match` header with the order's current `etag`. If another process modified it first, you get `400`. Fetch the fresh version and retry: ```bash curl -X PUT https://api.sendit.mx/v1/orders/ord_abc123 \ -H "X-API-Key: sk_live_..." \ -H 'If-Match: "abc123def456"' \ -H "Content-Type: application/json" \ -d '{ "status": "CONFIRMED" }' ``` ```json { "success": false, "error": { "code": "INVALID_INPUT", "message": "The order was modified by another request.", "details": { "code": "PRECONDITION_FAILED" } } } ``` :::warning The specific code travels in `error.details.code`, not in `error.code`. Branch on `details.code` to tell this case apart from other validation failures. ::: ## Cancel with guards `POST /v1/orders/:id/cancel` is rejected with `400` if any shipment in the order has an active label: ```json { "success": false, "error": { "code": "INVALID_INPUT", "message": "Order has active labels. Void all labels before cancelling.", "details": { "code": "ORDER_HAS_ACTIVE_LABELS", "labelIds": ["clxlbl_xxx", "clxlbl_yyy"] } } } ``` [Void each listed label](/en/shipping/refunds) and retry the cancellation. ## Incomplete orders from integrations When a store pushes an order with missing data (an empty colonia, a missing phone), the `missingFields` array records it: ```json { "missingFields": ["shippingAddress.neighborhood", "customerPhone"] } ``` Filter your needs-attention orders and complete them with `PUT /v1/orders/:id` before generating their labels. ## Cash on delivery (contra reembolso) Enable it at order creation: ```json { "codEnabled": true, "codAmount": 850.00, "codPaymentMethod": "CASH" } ``` When the carrier delivers and collects, record it with `PUT /v1/orders/:id` and `codCollected: true`. The order stores the timestamp in `codCollectedAt`. ## Link an existing shipment ```bash curl -X POST https://api.sendit.mx/v1/orders/ord_abc123/shipments \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "shipmentId": "shp_a1b2c3d4e5f6" }' ``` ## Filter by channel and fulfillment ```text GET /v1/orders?channel=SHOPIFY&status=PENDING GET /v1/orders?fulfillmentStatus=UNFULFILLED ``` | Filter | Values | | --- | --- | | `channel` | `MANUAL`, `SHOPIFY`, `WOOCOMMERCE`, `TIENDANUBE`, `MERCADOLIBRE`, `API` | | `status` | `PENDING`, `CONFIRMED`, `PROCESSING`, `SHIPPED`, `DELIVERED`, `CANCELLED`, `REFUNDED` | | `fulfillmentStatus` | `UNFULFILLED`, `PARTIAL`, `FULFILLED`, `RETURNED`. Handy for a "needs fulfillment" dashboard | | `externalId` | Your channel's reference, exact match | | `productId` | Orders that include that product | | `page` / `limit` | Pagination. Defaults are 1 and 20 | Orders created by integrations carry the channel's `externalId`, ready to reconcile against your store. --- # Product catalog Source: https://docs.sendit.mx/en/orders/products The product catalog is optional. Order line items work inline with zero setup. If you sell the same products over and over, the catalog saves you from repeating name, price, weight, and customs data: they copy into every order. ## Endpoints | Method | Path | Scope | Description | | --- | --- | --- | --- | | `POST` | `/v1/products` | `products:write` | Create a product | | `GET` | `/v1/products` | `products:read` | List active products (`?sku=` exact, `?search=` contains, `?page=`, `?limit=`) | | `GET` | `/v1/products/all` | `products:read` (ADMIN) | List all, including inactive | | `GET` | `/v1/products/:id` | `products:read` | Get a product | | `PUT` | `/v1/products/:id` | `products:write` | Update a product | | `DELETE` | `/v1/products/:id` | `products:write` (ADMIN) | Delete (soft delete) | ## Create a product ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `name` | `string` | - | The product's name. | | `sku?` | `string` | - | Unique among your non-deleted products. Enables SKU auto-linking. | | `description?` | `string` | - | The product's description. | | `price?` | `number` | - | Per-unit price; copied into the order line item when linked. | | `currency?` | `string` | `MXN` | | | `weight?` | `number` | - | Weight in kg. It is the packing and quoting default. | | `length?` | `number` | - | Length in cm. | | `width?` | `number` | - | Width in cm. | | `height?` | `number` | - | Height in cm. | | `hsCode?` | `string` | - | HS tariff code (international shipments). | | `satProductClassCode?` | `string` | - | SAT product-classification key. | | `countryOfOrigin?` | `string` | - | Country of origin (ISO). | | `customsDescription?` | `string` | - | Customs description. | | `declaredValue?` | `number` | - | Per-unit declared value (insurance and customs). | | `declaredValueCurrency?` | `string` | `MXN` | | | `shipsSeparately?` | `boolean` | `false` | The product always travels in its own parcel. | | `imageUrl?` | `string` | - | URL of the product image. | | `isActive?` | `boolean` | `true` | Inactive products don't list by default and never auto-link. | | `metadata?` | `object` | - | Your own key-value pairs; returned as-is. | ```bash curl -X POST https://api.sendit.mx/v1/products \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "sku": "TSHIRT-L-ROJO", "name": "Red t-shirt size L", "description": "100% cotton t-shirt, round neck", "price": 450.00, "weight": 0.25, "length": 30, "width": 25, "height": 3, "hsCode": "610910", "satProductClassCode": "53102002", "countryOfOrigin": "MX", "declaredValue": 450.00 }' ``` ```json { "success": true, "data": { "id": "prod_x1y2z3", "sku": "TSHIRT-L-ROJO", "name": "Red t-shirt size L", "description": "100% cotton t-shirt, round neck", "price": 450.00, "currency": "MXN", "weight": 0.25, "length": 30, "width": 25, "height": 3, "hsCode": "610910", "satProductClassCode": "53102002", "countryOfOrigin": "MX", "declaredValue": 450.00, "declaredValueCurrency": "MXN", "shipsSeparately": false, "isActive": true, "createdAt": "2026-07-18T10:00:00.000Z" } } ``` ## How it links to orders When [creating an order](/en/orders/orders#line-items), each line item can reference the catalog: 1. **By `productId`.** This is the explicit link. The product's data is frozen into the item. 2. **By `sku`.** If the SKU matches an active product, it links on its own. 3. **No link.** The item lives inline with its own data. In every case, what stays on the order is a **snapshot**: editing or deleting the product later never touches existing orders. The snapshot freezes: `name`, `price` (as `unitPrice`), `sku`, `weight`, `hsCode`, `satProductClassCode`, `countryOfOrigin`, `customsDescription`, and `declaredValue`. :::note Explicit fields in your request always win over catalog values. The catalog provides defaults, not mandates. ::: ## SKU rules A `sku` is unique among your **non-deleted** products. An inactive product still holds its SKU. | Code | When | How to resolve it | | --- | --- | --- | | `400 SKU_ALREADY_EXISTS` | You create or update a product with a SKU another product already uses | The conflicting `productId` is in `details`; use another SKU or edit that product | Deleting a product (soft delete) frees its SKU for reuse. ## The catalog is not split by mode Products are **not** separated between live and test. Your `LIVE` and `TEST` orders point at the same catalog, and an `sk_test_` key sees exactly the same products. --- # Addresses & validation Source: https://docs.sendit.mx/en/shipping/addresses Mexican addresses are modeled at the colonia (neighborhood) level, which is the granularity carriers work with. SendIt validates against the official SEPOMEX catalog and gives you colonia autocomplete for your forms. ## Save reusable addresses | Method | Path | Scope | Description | | --- | --- | --- | --- | | `POST` | `/v1/addresses` | `addresses:write` | Create an address | | `GET` | `/v1/addresses` | `addresses:read` | List addresses | | `GET` | `/v1/addresses/:id` | `addresses:read` | Get an address | | `PATCH` | `/v1/addresses/:id` | `addresses:write` | Update an address | | `DELETE` | `/v1/addresses/:id` | `addresses:write` | Delete (soft delete) | | `POST` | `/v1/addresses/:id/validate` | `addresses:write` | Validate a saved address | ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `contactName` | `string` | - | Maximum 100 characters. | | `contactPhone` | `string` | - | E.164 format (+52...), maximum 20 characters. | | `contactEmail?` | `string` | - | A valid contact email. | | `company?` | `string` | - | Maximum 100 characters. | | `street` | `string` | - | Maximum 200 characters. | | `exteriorNumber` | `string` | - | Maximum 20 characters. | | `interiorNumber?` | `string` | - | Maximum 20 characters. | | `neighborhood` | `string` | - | The colonia. Validate it with the postal-code endpoints. | | `city` | `string` | - | Maximum 100 characters. | | `state` | `string` | - | Accepts an ISO 3166-2:MX code (MX-JAL) or the abbreviation. | | `postalCode` | `string` | - | 4 to 6 digits. | | `country?` | `string` | `MX` | 2-letter ISO code. | | `reference?` | `string` | - | Delivery hints for the courier, maximum 200 characters. | | `isResidential?` | `boolean` | `true` | | | `isDefault?` | `boolean` | `false` | | | `latitude?` | `number` | - | -90 to 90. | | `longitude?` | `number` | - | -180 to 180. | ```bash curl -X POST https://api.sendit.mx/v1/addresses \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "contactName": "María López", "contactPhone": "+5213312345678", "contactEmail": "maria@ejemplo.mx", "company": "Tienda MX", "street": "Av. López Mateos", "exteriorNumber": "45", "interiorNumber": "B-2", "neighborhood": "Jardines del Sol", "city": "Zapopan", "state": "JAL", "postalCode": "45050", "country": "MX", "reference": "Black gate, between Av. Patria and Moctezuma", "isResidential": true }' ``` ```json { "success": true, "data": { "id": "clx_origin_address", "contactName": "María López", "contactPhone": "+5213312345678", "street": "Av. López Mateos", "exteriorNumber": "45", "neighborhood": "Jardines del Sol", "city": "Zapopan", "state": "JAL", "postalCode": "45050", "country": "MX", "isResidential": true, "isDefault": false, "isVerified": false, "createdAt": "2026-07-18T10:00:00.000Z" } } ``` Use the returned `id` as `fromAddressId` / `toAddressId` when [creating shipments](/en/shipping/shipments#create-a-shipment). Deleting an address never affects historical shipments: each shipment freezes its own copy. ## Validate an address Verification checks the address against the SEPOMEX catalog and returns validity, confidence, and the normalized version: ```bash curl -X POST https://api.sendit.mx/v1/address-verifications \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "address": { "street": "Av. Insurgentes Sur", "exteriorNumber": "1235", "neighborhood": "Insurgentes Mixcoac", "postalCode": "03920", "city": "Ciudad de México", "state": "CDMX", "country": "MX" }, "provider": "SEPOMEX" }' ``` ```json { "success": true, "data": { "id": "av_1a2b3c4d5e", "isValid": true, "confidence": 0.95, "normalizedAddress": { "postalCode": "03920", "neighborhood": "Insurgentes Mixcoac", "city": "Ciudad de México", "state": "Ciudad de México", "country": "MX" }, "errors": [], "provider": "SEPOMEX", "createdAt": "2026-07-17T12:00:00.000Z" } } ``` ### Interpret the confidence | `confidence` | Meaning | | --- | --- | | `0.95` | Postal code found and colonia matched | | `0.75` | Postal code found; colonia not matched or not provided | | `0.0` | Postal code doesn't exist | When several colonias share the postal code, `suggestions[]` carries the alternatives for your user to pick from. Validation is **advisory** and never blocks shipment creation. Every verification is recorded in `GET /v1/address-verifications`. That record is useful evidence when a package turns out undeliverable. For many addresses, validate up to 100 per call with `POST /v1/bulk/addresses/validate`. See [batches](/en/shipping/batches#bulk-endpoints). ## Postal codes and colonias Four endpoints powered by the SEPOMEX catalog, ideal for form autocomplete: ### Look up a postal code ```text GET /v1/postal-codes/03100 ``` ```json { "success": true, "data": { "postalCode": "03100", "country": "MX", "state": { "code": "MX-CMX", "name": "Ciudad de México" }, "municipality": "Benito Juárez", "city": "Ciudad de México", "colonies": [ { "name": "Del Valle Centro", "type": "Colonia", "zone": "Urbano" }, { "name": "Del Valle Norte", "type": "Colonia", "zone": "Urbano" }, { "name": "Del Valle Sur", "type": "Colonia", "zone": "Urbano" } ] } } ``` One call pre-fills city, state, and the colonia dropdown. An unknown code returns `404 RESOURCE_NOT_FOUND`. ### Search colonias (autocomplete) ```text GET /v1/postal-codes/search?q=Del+Valle&stateCode=MX-CMX&limit=10 ``` Partial match on colonia name; optionally filter by state. Up to 50 results. ### List the states ```text GET /v1/postal-codes/states ``` Returns the 32 states with their **ISO 3166-2:MX** codes (`MX-JAL`, `MX-NLE`, `MX-CMX`). These are the same codes the rest of the API accepts. --- # Batches & manifests Source: https://docs.sendit.mx/en/shipping/batches A batch buys labels for up to 100 shipments in a single call. Processing is asynchronous: the request returns immediately with a `batchId` and you poll for progress. ## Buy in bulk ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `shipmentIds` | `string[]` | - | Up to 100 shipment IDs from your organization. Duplicates are deduplicated; shipments that already have a label are skipped. | ```bash curl -X POST https://api.sendit.mx/v1/batches \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "shipmentIds": ["clxship_aaa", "clxship_bbb", "clxship_ccc"] }' ``` Response `202 Accepted`: ```json { "success": true, "data": { "id": "bat_xyz", "status": "PENDING", "totalShipments": 3, "purchasedCount": 0, "failedCount": 0, "createdAt": "2026-07-17T12:00:00.000Z" } } ``` Batch rules: - Up to **100 shipments** per batch; all from your organization. - Duplicate IDs are deduplicated automatically. - Shipments that already have a valid label are skipped (counted as succeeded). ## Track the progress ```text PENDING → PROCESSING → COMPLETED | PARTIAL | FAILED ``` | Status | Meaning | | --- | --- | | `PENDING` | Queued, not started | | `PROCESSING` | Buying labels | | `COMPLETED` | All labels purchased | | `PARTIAL` | Some purchased, some failed | | `FAILED` | No labels purchased | Poll with `GET /v1/batches/:id`: ```json { "success": true, "data": { "id": "bat_xyz", "status": "PARTIAL", "totalShipments": 3, "purchasedCount": 2, "failedCount": 1, "purchasedShipmentIds": ["clxship_aaa", "clxship_bbb"], "failedItems": [ { "shipmentId": "clxship_ccc", "errorCode": "INSUFFICIENT_BALANCE", "errorMessage": "Wallet balance too low for this shipment" } ] } } ``` :::warning **Partial success.** Labels that did purchase are **not** rolled back when others fail. Review `failedItems[]`, fix the cause (balance, expired rates), and resubmit only the failed shipments in a new batch. ::: ## Manifests (scan forms) A manifest groups several labels from the same carrier into one document the courier scans once at pickup. It is generated automatically when the batch completes and all purchased labels belong to the same carrier. The manifest comes in the batch response when available: ```json { "scanForm": { "id": "scf_abc", "carrierCode": "DHL", "formUrl": "https://labels.sendit.mx/scan-forms/scf_abc.pdf", "formNumber": "MAN-DHL-20260717", "status": "GENERATED" } } ``` Print `formUrl` and hand it to the courier along with the packages. ## Bulk endpoints For high-volume **read and validation** operations, and not purchases, use the bulk endpoints. They have the same partial-success semantics, accept up to 100 items, and return one result per item in the same order: | Method | Path | Description | | --- | --- | --- | | `POST` | `/v1/bulk/addresses/validate` | Validate up to 100 addresses | | `POST` | `/v1/bulk/tracking/lookup` | Look up events for up to 100 tracking numbers | | `POST` | `/v1/bulk/shipments/fetch` | Fetch up to 100 shipments by ID | | `POST` | `/v1/bulk/orders/fetch` | Fetch up to 100 orders by ID | ```bash curl -X POST https://api.sendit.mx/v1/bulk/shipments/fetch \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "ids": ["clxship_aaa", "clxship_bbb", "clxship_zzz"] }' ``` ```json { "success": true, "data": [ { "id": "clxship_aaa", "found": true, "data": { "...": "..." } }, { "id": "clxship_bbb", "found": true, "data": { "...": "..." } }, { "id": "clxship_zzz", "found": false, "data": null } ] } ``` A missing ID, or one from another organization, returns `found: false` and never fails the whole batch. Deleted resources count as not found. --- # Carrier accounts Source: https://docs.sendit.mx/en/shipping/carrier-accounts Your organization decides which carriers take part in each quote, and with which services. You can also connect your own carrier contract with DHL, FedEx, or Estafeta. The carrier then bills you for freight directly, and SendIt charges only a per-label fee. :::note **Beta.** Every carrier is in beta. See [Carriers & services](/en/shipping/carriers) for what the label covers. ::: | Method | Path | Scope | Description | | --- | --- | --- | --- | | `GET` | `/v1/carrier-preferences` | — | List every carrier with your organization's configuration | | `GET` | `/v1/carrier-preferences/:carrierCode` | — | Get one carrier | | `PUT` | `/v1/carrier-preferences/:carrierCode` | `carrier_preferences:write` | Update one carrier | | `PATCH` | `/v1/carrier-preferences/bulk` | `carrier_preferences:write` | Enable or disable several at once | | `DELETE` | `/v1/carrier-preferences/:carrierCode` | `carrier_preferences:write` | Reset one carrier to its defaults | | `PUT` | `/v1/carrier-preferences/:carrierCode/credentials` | `carrier_preferences:write` | Store your own account's credentials | | `DELETE` | `/v1/carrier-preferences/:carrierCode/credentials` | `carrier_preferences:write` | Clear the credentials and return to SendIt's account | | `POST` | `/v1/carrier-preferences/:carrierCode/credentials/verify` | `carrier_preferences:write` | Verify the stored credentials | Write endpoints require the `ADMIN` role or above. ## Check your configuration ```bash curl https://api.sendit.mx/v1/carrier-preferences \ -H "X-API-Key: sk_test_..." ``` ```json { "success": true, "data": [ { "carrierCode": "FEDEX", "carrierName": "FedEx", "availableServices": [ { "serviceName": "FedEx Economy", "serviceLevel": "economy" }, { "serviceName": "FedEx Express", "serviceLevel": "express" } ], "supportsPickups": true, "isConfigured": true, "isEnabled": true, "enabledServices": ["economy", "express"], "defaultService": "express", "usesOwnAccount": false } ], "meta": { "count": 1 } } ``` | Field | Description | | --- | --- | | `availableServices` | The services the carrier offers, with their `serviceLevel` | | `supportsPickups` | Whether it accepts scheduled [pickups](/en/shipping/pickups) | | `isConfigured` | Whether your organization has saved its own configuration for this carrier | | `isEnabled` | Whether it takes part in `POST /v1/rates` | | `enabledServices` | Allowed services; `null` = all of them | | `defaultService` | Pre-selected service in your forms (convenience only) | | `usesOwnAccount` | Whether quotes and labels use your own carrier account | `GET /v1/carrier-preferences/:carrierCode` returns a single object with the same shape. ## Choose which carriers quote ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `isEnabled?` | `boolean` | - | Whether the carrier takes part in POST /v1/rates. | | `enabledServices?` | `array` | - | Allowed services (serviceLevel). Each entry must exist in that carrier's availableServices. null = all of them. | | `defaultService?` | `string` | - | Pre-selected service. Must exist in that carrier's availableServices. | ```bash curl -X PUT https://api.sendit.mx/v1/carrier-preferences/FEDEX \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "isEnabled": true, "enabledServices": ["express", "overnight"], "defaultService": "express" }' ``` The response is the updated carrier object. To flip several at once, send the complete list of the ones you want enabled. Anything absent is disabled: ```bash curl -X PATCH https://api.sendit.mx/v1/carrier-preferences/bulk \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "enabledCarriers": ["FEDEX", "DHL", "ESTAFETA"] }' ``` ```json { "success": true, "data": { "enabled": ["FEDEX", "DHL", "ESTAFETA"], "disabled": ["SENDEX", "AMPM"] } } ``` `DELETE /v1/carrier-preferences/FEDEX` resets that carrier (`204`, no body): back to `isEnabled: true`, `enabledServices: null`, `defaultService: null`, and it clears your own credentials if any were stored. | Code | When | How to resolve it | | --- | --- | --- | | `400 INVALID_INPUT` | `enabledServices` or `defaultService` names a service that carrier doesn't offer | Use a `serviceLevel` from its `availableServices` | | `403 INSUFFICIENT_SCOPE` | The key lacks `carrier_preferences:write` | Issue a key with that [scope](/en/api-conventions/scopes) | | `404 RESOURCE_NOT_FOUND` | The `carrierCode` isn't in the catalog | Check the [carrier catalog](/en/shipping/carriers) | ## How this affects your quotes | Endpoint | Honors `isEnabled` | Honors `enabledServices` | | --- | --- | --- | | `POST /v1/rates` | Yes | Yes | | `POST /v1/rates/carrier/:carrierCode` | No — asking for a carrier explicitly bypasses the toggle | Yes | That lets you offer a carrier-specific quoting flow without losing the service restrictions you configured. ## Use your own carrier account If you have a negotiated contract with DHL, FedEx, or Estafeta, store those credentials and SendIt will quote and buy with them. The carrier bills the freight to you, under your contract; SendIt charges only a per-label fee. ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `accountNumber?` | `string` | - | The account number the carrier issued you. | | `apiKey?` | `string` | - | API key issued by the carrier. | | `apiSecret?` | `string` | - | API secret issued by the carrier. | | `meta?` | `object` | - | Extra fields that specific carrier requires (for example meterNumber). | Send **at least one** of the four. ```bash curl -X PUT https://api.sendit.mx/v1/carrier-preferences/FEDEX/credentials \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "accountNumber": "123456789", "apiKey": "carrier-issued-key", "apiSecret": "carrier-issued-secret", "meta": { "meterNumber": "987654" } }' ``` The response is the normal carrier object, now with `usesOwnAccount: true`: ```json { "success": true, "data": { "carrierCode": "FEDEX", "carrierName": "FedEx", "isConfigured": true, "isEnabled": true, "enabledServices": null, "defaultService": null, "usesOwnAccount": true } } ``` The response **never** echoes the credentials you sent. Do not keep them in frontend state or write them to logs after submitting them. ### Verify and clear ```bash curl -X POST https://api.sendit.mx/v1/carrier-preferences/FEDEX/credentials/verify \ -H "X-API-Key: sk_test_..." ``` ```json { "success": true, "data": { "carrierCode": "FEDEX", "valid": true } } ``` `DELETE /v1/carrier-preferences/FEDEX/credentials` clears the credentials and returns the object with `usesOwnAccount: false`; from then on quotes use SendIt's account again. | Code | When | How to resolve it | | --- | --- | --- | | `400 INVALID_INPUT` | You sent none of the four fields | Include at least `accountNumber`, `apiKey`, `apiSecret`, or `meta` | | `403 INSUFFICIENT_SCOPE` | The key lacks `carrier_preferences:write` | Issue a key with that [scope](/en/api-conventions/scopes) | | `404 RESOURCE_NOT_FOUND` | The `carrierCode` doesn't exist, or there are no stored credentials to verify or clear | Store the credentials first with `PUT` | ## What changes in the quote A quote made with your own account carries two extra fields: ```json { "carrierCode": "FEDEX", "serviceLevel": "express", "totalPrice": 1.39, "currency": "MXN", "usesOwnAccount": true, "carrierChargeEstimate": 287.43, "breakdown": { "baseRate": 1.20, "fuelSurcharge": 0.00, "insuranceCost": 0.00, "subtotal": 1.20, "ivaRate": 0.16, "ivaAmount": 0.19, "total": 1.39 } } ``` - **`totalPrice`** is what SendIt charges you: your plan's per-label fee plus IVA. It's exactly what gets debited from your [wallet (monedero)](/en/wallet-and-billing/wallet) when you buy the label, not a peso more. - **`carrierChargeEstimate`** is the freight estimate from **your own contract**. It's informational: it never enters SendIt's wallet or your [CFDI](/en/wallet-and-billing/cfdi), because the carrier invoices it to you separately. It may differ from the final invoice if the carrier applies adjustments. - **`usesOwnAccount: true`** marks those semantics. Quotes on SendIt's account carry neither field and are unchanged. Display them separately in your UI: SendIt's charge and the carrier's estimated charge are two different things. ### Per-label fee | Plan | Fee per label on your own account (before IVA) | | --- | --- | | Free | $1.20 MXN | | Growth | $0.90 MXN | | Scale | $0.60 MXN | | Enterprise | $0.00 MXN | This fee replaces the normal label price, and the plan [overage](/en/wallet-and-billing/subscriptions-and-quotas) is **not** added on top: it is SendIt's only charge for that label. The label still counts toward your monthly usage. On Enterprise the fee is zero, so no wallet movement is generated, but the label is still created and counted. ## Rules when switching accounts - **Re-quote** after storing or clearing credentials. Earlier quotes describe the earlier account and no longer apply. - To **void** a label you bought on your own account, you need usable credentials for that carrier. If you cleared or rotated them, store them again before requesting the [refund](/en/shipping/refunds). - Insurance on a label bought with your account is covered and billed by your carrier, not by SendIt. --- # Carriers and services Source: https://docs.sendit.mx/en/shipping/carriers The catalog contains the services that SendIt can show in rate results. It is read-only: use it to populate selectors and validate a carrier and service combination. :::note **Beta.** Every carrier in the catalog is in beta. Coverage, codes, and transit times can change. Query the catalog instead of hard-coding its values. ::: ## Carriers Beta | Carrier | `carrierCode` | Status | | --- | --- | --- | | DHL Express | `DHL` | Beta | | FedEx | `FEDEX` | Beta | | Estafeta | `ESTAFETA` | Beta | | Sendex | `SENDEX` | Beta | | AM PM | `AMPM` | Beta | | Other | `OTHER` | Beta | The effective list for a route is the `rates[]` array returned when you [rate a shipment](/en/shipping/rates). ## Endpoints | Method | Path | Scope | Description | | --- | --- | --- | --- | | `GET` | `/v1/carrier-services` | `carrier_services:read` | List enabled services; accepts `carrierCode` | | `GET` | `/v1/carrier-services/:carrierCode/:serviceCode` | `carrier_services:read` | Get one exact service | ## List services ```bash curl "https://api.sendit.mx/v1/carrier-services?carrierCode=FEDEX" \ -H "X-API-Key: sk_test_..." ``` ```json { "success": true, "data": [ { "id": "cs_fedex_priority_overnight", "carrierCode": "FEDEX", "serviceCode": "PRIORITY_OVERNIGHT", "serviceName": "FedEx Priority Overnight", "serviceLevel": "overnight", "isInternational": false, "maxWeightKg": "68.00", "maxDimensionCm": "274.00", "minDays": 1, "maxDays": 1, "enabled": true, "countries": ["MX"] } ] } ``` ## Get one exact service ```bash curl https://api.sendit.mx/v1/carrier-services/FEDEX/PRIORITY_OVERNIGHT \ -H "X-API-Key: sk_test_..." ``` The response contains the same service object as the list response. ## Select by exact code Each rate carries two different fields: | Field | Use | | --- | --- | | `serviceCode` | The carrier-native exact product identifier. Use it to select the service for a one-call purchase. | | `serviceLevel` | A broad normalized category for filtering or display. It does not identify one exact product. | Send `carrierCode + serviceCode` to select an exact service when creating and purchasing a shipment. When buying an existing rate through the labels endpoint, send its `rateId`. Codes that are not yet in the catalog do not appear in customer rates. ## Handle errors | Code | When it happens | How to resolve it | | --- | --- | --- | | `403 INSUFFICIENT_SCOPE` | The key lacks `carrier_services:read` | Issue a key with that [scope](/en/api-conventions/scopes) | | `404 RESOURCE_NOT_FOUND` | The carrier and code combination does not exist | Query the list and use a current `serviceCode` | --- # Labels Source: https://docs.sendit.mx/en/shipping/labels Buying a label turns a rate into a print-ready shipping label (guía). Your wallet is debited at the quoted price, never more and never less. There are [two ways to buy](/en/shipping/shipments#two-ways-to-buy-a-label). **Two-step** compares rates and buys with a `rateId`, as shown below. **One-call** adds `purchase` when you create the shipment. See [One-call buy](#one-call-buy). ## Buy a label You need a shipment with [valid rates](/en/shipping/rates) and the chosen `rateId`. The `Idempotency-Key` header is optional but **recommended**: retrying with the same key replays the result and avoids a double charge (see [Idempotency](/en/api-conventions/idempotency)). ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `rateId` | `string` | - | The chosen rate's id, taken from rates[]. Valid for 24 hours. | | `labelFormat?` | `string` | `PDF` | PDF or ZPL. If omitted, PDF is used. | | `externalReference?` | `string` | - | Your own reference for this purchase. Up to 255 characters. | | `async?` | `boolean` | `false` | true returns 202 and an attempt you poll later. See Asynchronous purchase. | ```bash curl curl -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/label \ -H "X-API-Key: sk_test_..." \ -H "Idempotency-Key: 1f0e6f0e-59a4-4a6f-9d2e-9b1a7b2c3d4e" \ -H "Content-Type: application/json" \ -d '{ "rateId": "DHL_standard_a1b2c3", "labelFormat": "PDF" }' ``` ```js Node.js const res = await fetch( `https://api.sendit.mx/v1/shipments/${shipmentId}/label`, { method: "POST", headers: { "X-API-Key": process.env.SENDIT_API_KEY, "Idempotency-Key": idempotencyKey, // generated and persisted by you "Content-Type": "application/json", }, body: JSON.stringify({ rateId, labelFormat: "PDF" }), } ); const { data: label } = await res.json(); ``` ```json { "success": true, "data": { "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "attemptId": "lat_550e8400-e29b-41d4-a716-446655440000", "labelId": "clxlbl456abc789def012ghi", "trackingNumber": "1234567890", "labelUrl": "https://labels.sendit.mx/clxq1w2e3r4t5y6u7i8o9p0a/1234567890.pdf", "carrierCode": "DHL", "serviceCode": "EXPRESS_WORLDWIDE", "serviceName": "DHL Express Nacional", "charged": "326.82", "currency": "MXN", "walletBalanceAfter": "12158.43", "breakdown": { "quotedTotal": "326.82", "ivaAmount": "45.08", "overageCharge": "0.00", "total": "326.82" } } } ``` The shipment moves to `LABEL_PURCHASED`, tracking goes live, and `labelUrl` points at the print-ready PDF. ## One-call buy If you already know the carrier and service, or just want the cheapest, skip the second step. Add a `purchase` object when you create the shipment. Choose `carrierCode` + `serviceCode` for the exact product, or `strategy: "cheapest"`. ```bash curl curl -X POST https://api.sendit.mx/v1/shipments \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "fromAddressId": "clx_origin_address", "toAddress": { "contactName": "María López", "contactPhone": "+5213312345678", "street": "Av. López Mateos", "exteriorNumber": "45", "neighborhood": "Jardines del Sol", "city": "Zapopan", "state": "JAL", "postalCode": "45050", "country": "MX" }, "parcel": { "length": 30, "width": 20, "height": 15, "weight": 2.5 }, "purchase": { "carrierCode": "DHL", "serviceCode": "EXPRESS_WORLDWIDE", "labelFormat": "PDF" } }' ``` ```js Node.js const { data: shipment } = await fetch("https://api.sendit.mx/v1/shipments", { method: "POST", headers: { "X-API-Key": process.env.SENDIT_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ fromAddressId: "clx_origin_address", toAddress: { /* ...destination... */ }, parcel: { length: 30, width: 20, height: 15, weight: 2.5 }, purchase: { strategy: "cheapest", labelFormat: "PDF" }, }), }).then((r) => r.json()); // The label arrives in the same response console.log(shipment.label.trackingNumber, shipment.label.labelUrl); ``` The response includes `purchasedRate` and `label`, with `trackingNumber`, `labelUrl`, and `charged`. The shipment is always created first, so a failed purchase leaves you a recoverable `DRAFT`. The full failure semantics are in [Shipments](/en/shipping/shipments#one-call-buy). With a scoped API key you need `labels:write` in addition to `shipments:write`. ## The price contract A purchase gives you three guarantees: 1. **If the balance is short, nothing happens.** The purchase fails with `402 INSUFFICIENT_BALANCE`. There is no charge and no label. 2. **You are charged the quoted price.** The chosen rate's `totalPrice` is debited exactly, IVA included. The amount is not recalculated at purchase. 3. **A proven failure returns the charge.** The attempt reaches `failed` after the refund is credited. An inconclusive outcome reaches `action_required`. That status confirms neither a label nor a refund. Do not start another purchase for the shipment. Retrying with the same `Idempotency-Key` replays the original result. It never creates a second charge. Details in [Idempotency](/en/api-conventions/idempotency). ## Asynchronous purchase A purchase is synchronous by default: you wait and the label comes back in the response. With `async: true` the request returns at once and you read the outcome later. Use it for volume without one open connection per label. See [Asynchronous operations](/en/api-conventions/asynchronous-operations). ```bash curl -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/label \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "rateId": "DHL_standard_a1b2c3", "async": true }' ``` The response is `202 Accepted`. The `Location` header points at the attempt, and `Retry-After: 2` is a polling hint: ```json { "success": true, "data": { "id": "lat_550e8400-e29b-41d4-a716-446655440000", "object": "label_purchase_attempt", "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "externalReference": null, "status": "pending", "livemode": false, "async": true, "pricing": { "quotedTotal": "326.82", "ivaAmount": "45.08", "overageCharge": "0.00", "netWalletCost": "326.82" }, "charged": "326.82", "currency": "MXN", "walletBalanceAfter": "12158.43", "refundedAmount": null, "label": null, "error": null, "statusUrl": "/v1/label-purchase-attempts/lat_550e8400-e29b-41d4-a716-446655440000", "createdAt": "2026-08-01T10:00:00.000Z", "updatedAt": "2026-08-01T10:00:00.000Z", "completedAt": null } } ``` Poll the attempt until it reaches a terminal status: ```text GET /v1/label-purchase-attempts/lat_550e8400-e29b-41d4-a716-446655440000 ``` | `status` | Meaning | | --- | --- | | `pending` | Accepted, not started | | `processing` | In progress | | `succeeded` | Done. The attempt carries the label | | `failed` | The failure ended and the charge was refunded. No label exists | | `action_required` | The outcome is inconclusive. No label or refund is implied | Three things worth knowing: - **Funds are checked before the attempt is accepted.** If the balance is short you get `402 INSUFFICIENT_BALANCE` immediately, and no half-finished attempt is left behind. - **Repeating the same purchase returns the same attempt**, as long as `rateId`, format, external reference, and the `async` preference all match. Change any of them and you get `409 SHIPMENT_LABEL_IN_PROGRESS`. - **To hear about it without polling**, subscribe to the `label.purchase.completed` webhook: it fires on all three terminal outcomes. ## Label formats | `labelFormat` | Use | | --- | --- | | `PDF` | Default. Letter size, print-ready | | `ZPL` | Zebra thermal printers (raw) | If you omit `labelFormat`, `PDF` is used. ## Purchase errors | Code | When | How to resolve it | | --- | --- | --- | | `402 INSUFFICIENT_BALANCE` | Balance can't cover `totalPrice` | Fund your [wallet](/en/wallet-and-billing/wallet); `details` includes the shortfall | | `410 RATES_EXPIRED` | The rate expired (24 h) | `GET /v1/shipments/:id/rates` and buy with the fresh `rateId` | | `409 SHIPMENT_ALREADY_PROCESSED` | Shipment already has a label or isn't `DRAFT` | Check the shipment; for another label, create another shipment | | `409 SHIPMENT_LABEL_IN_PROGRESS` | An active attempt already owns the shipment | Retrieve the existing attempt; do not start another purchase | | `502 CARRIER_ERROR` | The carrier conclusively rejected the purchase | Confirm that the attempt reached `failed` before buying again | ## After the purchase - **Tracking:** the `trackingNumber` starts producing [tracking events](/en/shipping/shipments#retrieve-the-detail) and [webhooks](/en/webhooks-and-events/webhooks). - **Pickup:** schedule the carrier to collect the package. See [Pickups](/en/shipping/pickups). - **If you made a mistake:** void an unused label for a full refund. See [Refunds & voids](/en/shipping/refunds). --- # Pickups Source: https://docs.sendit.mx/en/shipping/pickups A pickup (recolección) asks the carrier to collect your packages at an address, on a date, within a time window. Schedule, check, and cancel pickups with any carrier through the same API. :::warning Pickup management works in LIVE mode only. With an `sk_test_` key, scheduling, listing, viewing, cancelling, or refreshing a pickup returns `400 LIVE_MODE_REQUIRED`. You can call `/v1/pickups/carriers` in either mode. ::: ## The lifecycle ```text PENDING → CONFIRMED → IN_PROGRESS → COMPLETED ↘ CANCELLED ↘ FAILED ``` ## Endpoints | Method | Path | Description | | --- | --- | --- | | `POST` | `/v1/pickups` | Schedule a pickup | | `GET` | `/v1/pickups` | List pickups (filterable) | | `GET` | `/v1/pickups/carriers` | Carriers with pickup support | | `GET` | `/v1/pickups/:id` | Pickup detail | | `PATCH` | `/v1/pickups/:id/cancel` | Cancel a pickup | | `POST` | `/v1/pickups/:id/refresh-status` | Fetch the latest status from the carrier | ## Schedule a pickup ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `carrierCode` | `string` | - | A carrier with pickup support (GET /v1/pickups/carriers). | | `pickupDate` | `string` | - | ISO date (YYYY-MM-DD), today or later. | | `readyTime` | `string` | - | HH:mm time from which the packages are ready. | | `closingTime` | `string` | - | HH:mm closing time. It must be later than readyTime. | | `packageCount` | `number` | - | Number of packages (1–999). | | `totalWeight` | `number` | - | Total weight in kg (minimum 0.1). | | `contactName` | `string` | - | The person who will meet the courier. | | `contactPhone` | `string` | - | Contact phone number. | | `pickupAddressId?` | `string` | - | A saved address in your organization. | | `specialInstructions?` | `string` | - | Instructions for the courier (up to 500 characters). | | `shipmentIds?` | `string[]` | - | 1–50 shipments to associate with the pickup. | ```bash curl -X POST https://api.sendit.mx/v1/pickups \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "carrierCode": "DHL", "pickupAddressId": "clx_origin_address", "pickupDate": "2026-07-20", "readyTime": "09:00", "closingTime": "18:00", "packageCount": 5, "totalWeight": 12.5, "contactName": "Juan Pérez", "contactPhone": "+525512345678", "specialInstructions": "Ring the bell twice, ask for Juan.", "shipmentIds": ["clxship001", "clxship002"] }' ``` ```json { "success": true, "data": { "id": "clxpickup_xyz", "carrierCode": "DHL", "pickupDate": "2026-07-20", "readyTime": "09:00", "closingTime": "18:00", "packageCount": 5, "totalWeight": "12.5", "status": "CONFIRMED", "confirmationNumber": "DHL-A1B2C3D4", "confirmedAt": "2026-07-17T10:30:00.000Z" } } ``` Keep the `confirmationNumber`. It is the reference the carrier recognizes if you need to sort something out by phone. ## Check which carriers pick up ```text GET /v1/pickups/carriers ``` ```json { "success": true, "data": [ { "carrierCode": "DHL", "carrierName": "DHL Express", "supportsPickups": true }, { "carrierCode": "ESTAFETA", "carrierName": "Estafeta", "supportsPickups": true }, { "carrierCode": "FEDEX", "carrierName": "FedEx", "supportsPickups": true } ] } ``` ## List and filter ```text GET /v1/pickups?carrierCode=DHL&status=CONFIRMED&dateFrom=2026-07-01&dateTo=2026-07-31 ``` | Filter | Description | | --- | --- | | `carrierCode` | By carrier | | `status` | By lifecycle status | | `dateFrom` / `dateTo` | Pickup-date range (ISO) | Pagination follows the page model described in [Pagination & filtering](/en/api-conventions/pagination-and-filtering). ## Cancel a pickup ```bash curl -X PATCH https://api.sendit.mx/v1/pickups/clxpickup_xyz/cancel \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "reason": "Packages will not be ready in time" }' ``` Pickups already `CANCELLED` or `COMPLETED` can't be cancelled again (`400`). ## Refresh the status ```text POST /v1/pickups/:id/refresh-status ``` Queries the carrier directly and updates the local record. Use it when you need a status fresher than the last sync. ## Handle errors | Code | When it happens | How to resolve it | | --- | --- | --- | | `400 INVALID_INPUT` | The date, time window, address, or carrier is invalid | Correct the input and try again | | `400 LIVE_MODE_REQUIRED` | You tried to manage a pickup in TEST mode | Switch to an `sk_live_` key | | `404 RESOURCE_NOT_FOUND` | The pickup does not exist or belongs to another organization | Check the `id` | --- # Public tracking Source: https://docs.sendit.mx/en/shipping/public-tracking Public tracking is for **your buyer**, not your systems. It needs no authentication and returns a view with reduced personal data. :::note Do not confuse this with [trackers](/en/shipping/trackers). A tracker is an authenticated call that registers an external number. Public tracking uses the link that you share with the recipient. ::: ## Get the shipment link The token is minted when the label is purchased. A `DRAFT` shipment carries `publicTrackingToken` as `null`. Read the token or the URL from any of these sources: | Source | Fields | | --- | --- | | `GET /v1/shipments/:id` | `publicTrackingToken` and `trackingUrl` | | `POST /v1/bulk/shipments/fetch` | `trackingUrl` | | The `shipment.label.created` webhook | `trackingUrl` | ```text https://app.sendit.mx/track/2f68c611-30af-4a86-9cde-793413af5f65 ``` `GET /v1/shipments` **omits both on purpose**: a bearer credential does not belong on a collection you can enumerate. To build a "share tracking" affordance from a list row, fetch the shipment by its `id`. Do not construct the URL from the tracking number, and there is no public lookup by tracking number and name. If the recipient loses the link, share it again from your system. The token is a bearer credential. Anyone with the link can retrieve the status. ## Retrieve the status with the token ```bash curl https://api.sendit.mx/v1/tracking/public/2f68c611-30af-4a86-9cde-793413af5f65 ``` ```json { "success": true, "data": { "object": "public_tracking", "trackingNumber": "DHL123456789MX", "carrierCode": "DHL", "status": "IN_TRANSIT", "estimatedDeliveryDate": "2026-08-04T00:00:00.000Z", "actualDeliveryDate": null, "destination": { "city": "Monterrey", "state": "Nuevo León" }, "events": [ { "eventCode": "IN_TRANSIT", "description": "En tránsito", "occurredAt": "2026-08-01T15:30:00.000Z", "isException": false, "location": { "city": "San Luis Potosí", "state": "San Luis Potosí", "country": "MX" } } ], "branding": null } } ``` The response excludes the name, street, neighborhood (colonia), postal code, organization settings, and prices. Responses can be cached for 60 seconds. Each event's `location` carries `city`, `state`, and `country`. Each one can be `null`. **It has no `postalCode`**: that field is stripped deliberately, so do not model one. ### Read the status with shipment vocabulary The `status` field uses shipment statuses, not tracker statuses. The values are `DRAFT`, `PENDING`, `LABEL_PURCHASED`, `READY_FOR_PICKUP`, `PICKED_UP`, `IN_TRANSIT`, `OUT_FOR_DELIVERY`, `DELIVERED`, `RETURNED`, `FAILED`, and `CANCELLED`. A shared link almost always starts at `LABEL_PURCHASED`, because the token is minted at label purchase. Handle that status explicitly instead of letting it fall through to your default branch. `UNKNOWN` and `PRE_TRANSIT` belong to [trackers](/en/shipping/trackers) and never appear here. ## Show your brand when enabled Turn on `publicTrackingPage` in your [notification branding](/en/webhooks-and-events/notifications#put-your-brand-on-the-emails). It is off by default. When active, the response can add only these fields: ```json { "branding": { "displayName": "Tienda Ejemplo", "logoUrl": "https://cdn.example.com/logo.png", "accentColor": "#1D4ED8", "footer": "Gracias por tu compra" } } ``` If you do not enable the branded page, `branding` is `null` in full. The `footerText` you stored arrives here as `footer`. The `logoUrl` and `accentColor` are re-validated on this response and come back `null` if the stored value fails. The `replyTo` value is never public. ## Invalidate a leaked link If the link reached the wrong person, issue a new one: ```bash curl -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/tracking-token/rotate \ -H "X-API-Key: sk_live_..." ``` ```json { "success": true, "data": { "object": "public_tracking_token", "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "publicTrackingToken": "6f1c2b90-1f0a-4f2e-9a3f-6b7c8d9e0f11", "trackingUrl": "/v1/tracking/public/6f1c2b90-1f0a-4f2e-9a3f-6b7c8d9e0f11", "rotatedAt": "2026-08-04T18:04:11.000Z" } } ``` You need the OPERATOR role or above. API keys need the `shipments:write` scope. :::warning Rotation is immediate and irreversible. The previous link starts returning `404` as soon as the call succeeds, and there is no way to restore it. Webhooks already delivered still carry the old URL: that is exactly what rotation leaves behind. ::: Voiding a label clears the token. Only a new purchase mints another. ## Protect the token - Share the link only with the recipient. - Do not send the token to analytics tools. - Do not include it in referral data to third-party sites. - Generate links only from resources returned by the API. ## Handle errors | Code | When | How to resolve it | | --- | --- | --- | | `404 RESOURCE_NOT_FOUND` | The token does not exist or is no longer valid | Ask the merchant to share the link again | | `429 RATE_LIMIT_EXCEEDED` | You exceeded the public traffic limit | Respect `Retry-After` before retrying | ## Use live shipments only Public tracking works only for live shipments. Retrieve [test-mode](/en/getting-started/test-mode) shipments through authenticated endpoints. --- # Rates Source: https://docs.sendit.mx/en/shipping/rates Rates come to you: when you [create a shipment](/en/shipping/shipments), the response already includes `rates[]` with quotes from every carrier enabled for that route. There is no separate quoting call to manage. :::note **The quoted price is the charged price.** Each rate's `totalPrice` includes tax and is exactly what your wallet is debited at purchase. There is no recalculation and no surprise. ::: If you already know which carrier and service you want, or just want the cheapest, add the `purchase` object when you create the shipment. See [One-call buy](/en/shipping/shipments#one-call-buy). ## The rate object ```json { "id": "DHL_standard_a1b2c3", "carrierCode": "DHL", "serviceCode": "EXPRESS_WORLDWIDE", "carrierName": "DHL Express", "serviceName": "DHL Express Nacional", "serviceLevel": "standard", "totalPrice": 326.82, "currency": "MXN", "isInsured": false, "estimatedDays": { "min": 1, "max": 2 }, "expiresAt": "2026-07-18T14:30:00.000Z", "breakdown": { "baseRate": 264.50, "fuelSurcharge": 17.24, "insuranceCost": 0.00, "subtotal": 281.74, "ivaRate": 0.16, "ivaAmount": 45.08, "total": 326.82 } } ``` | Field | Description | | --- | --- | | `id` | The `rateId` — pass it to the label purchase | | `serviceCode` | The carrier-native exact product code | | `serviceLevel` | Normalized across carriers (`express`, `standard`, `economy`, ...) | | `totalPrice` | The total to pay, IVA included — matches `breakdown.total` | | `isInsured` | `true` if the rate includes insurance (you created the shipment with `requestInsurance`) | | `estimatedDays` | Estimated business-day delivery range | | `expiresAt` | Rate validity (24 hours) | | `breakdown` | Transparent split: base rate, surcharges, insurance, subtotal, and IVA as a separate line | If you created the shipment with `requestInsurance: true` and a `declaredValue`, rates come back with the premium in `breakdown.insuranceCost` and `isInsured: true`. Buying an insured rate creates a real policy you can claim against. See [Insurance & claims](/en/wallet-and-billing/insurance-claims). ## A rate's lifecycle ```text POST /v1/shipments → creates the shipment (DRAFT) → quotes every enabled carrier → returns the shipment + rates[] (valid: 24 h) ↓ GET /v1/shipments/:id/rates ← fetch or refresh any time ↓ POST /v1/shipments/:id/label { rateId } ← buy at the quoted price ``` ## If carriers are slow Quoting has an 8-second budget. If a carrier lags, the shipment returns immediately with rates on the way: ```json { "success": true, "data": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "DRAFT", "rates": [], "ratesStatus": "pending", "ratesExpiresAt": null, "ratesPollUrl": "/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/rates" } } ``` Poll `ratesPollUrl` (e.g. every 2 seconds) until `ratesStatus` is `"ready"`. ## Fetch or refresh rates ```text GET /v1/shipments/:id/rates ← returns the current rates GET /v1/shipments/:id/rates?refresh=true ← re-quotes the carriers ``` Without `?refresh=true` you get the cached rates while their 24-hour validity lasts. With `?refresh=true` everything is re-quoted and the validity resets. ### When a rate expires Buying with an expired `rateId` returns `410 RATES_EXPIRED`: ```json { "success": false, "error": { "code": "RATES_EXPIRED", "message": "Shipping rates have expired. Please refresh rates and select again.", "details": { "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "hint": "GET /v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/rates" } } } ``` Refresh, pick a new `rateId`, and buy again. You'll also see this error if you use a `rateId` belonging to a different shipment. ## Quote without creating a shipment For checkout widgets or pre-purchase estimates, use the standalone endpoint: ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `origin` | `object` | - | The origin. It needs at least { postalCode }. | | `destination` | `object` | - | The destination. It needs at least { postalCode }. | | `parcel` | `object` | - | The package: length, width, height (cm) and weight (kg). | ```bash curl -X POST https://api.sendit.mx/v1/rates \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "origin": { "postalCode": "03940" }, "destination": { "postalCode": "45050" }, "parcel": { "weight": 1.5, "length": 20, "width": 15, "height": 10 } }' ``` The response returns `rates[]` (the same rate object above) and its validity: ```json { "success": true, "data": { "rates": [ { "id": "DHL_standard_a1b2c3", "carrierCode": "DHL", "serviceCode": "EXPRESS_WORLDWIDE", "serviceLevel": "standard", "totalPrice": 88.40, "currency": "MXN", "isInsured": false } ], "expiresAt": "2026-07-18T14:30:00.000Z" } } ``` These rates are **display-only**: they can't be used to buy a label. To buy, create the shipment first. You can limit to one carrier (`POST /v1/rates/carrier/DHL`) or sort with `?sortBy=price` or `?sortBy=speed`. ## Sort rates ```js const byPrice = [...rates].sort((a, b) => a.totalPrice - b.totalPrice); const bySpeed = [...rates].sort( (a, b) => a.estimatedDays.min - b.estimatedDays.min || a.totalPrice - b.totalPrice ); ``` --- # Refunds & voids Source: https://docs.sendit.mx/en/shipping/refunds A refund in SendIt is a label void: voiding a label credits the full purchase amount back to your wallet. :::note Refund ≠ return. Voiding an **unused** label gets its cost back; shipping a package back to the sender is a new [return shipment](/en/shipping/shipments#create-a-return-shipment), paid like any other label. ::: ## Void a label ```bash curl -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/label/void \ -H "X-API-Key: sk_test_..." \ -H "Idempotency-Key: 7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f" ``` Voiding is allowed while the shipment is in `LABEL_PURCHASED` or `READY_FOR_PICKUP`, that is, before the carrier collects the package. On void: 1. The full original charge is credited to your wallet (monedero), including any plan [overage](/en/wallet-and-billing/subscriptions-and-quotas) you were charged for that label. 2. The label becomes `VOIDED`, with `voidedAt`, `refundedAmount`, and `refundedAt`. 3. The shipment moves to `CANCELLED` and the event is recorded. The credit returns to the **same balance** it came from. A test-mode purchase is credited to the virtual balance. A live purchase is credited to the real balance. ```json { "success": true, "data": { "id": "clxlbl456abc789def012ghi", "status": "VOIDED", "voidedAt": "2026-07-17T12:00:00.000Z", "refundedAmount": "326.82", "refundedAt": "2026-07-17T12:00:00.000Z" } } ``` The credit shows up immediately in `GET /v1/wallet/transactions`. ## Cross-state rules with insurance Labels with an open insurance claim can't be voided, and vice versa: | Code | When | How to resolve it | | --- | --- | --- | | `CLAIM_PENDING_NO_REFUND_ALLOWED` | The shipment has an open claim (the `claimId` is in the response) | Resolve or cancel the [claim](/en/wallet-and-billing/insurance-claims) first | | `SHIPMENT_REFUNDED_NO_CLAIMS_ALLOWED` | Filing a claim on an already-voided label | No insurance applies to voided labels — the cost was already refunded | ## Overweight charges Overweight adjustments billed by the carrier after delivery are **not** refunded automatically: they are charges for service already rendered. If you disagree with an overweight charge, dispute it directly with the carrier, tracking number in hand. ## In test mode Voiding a test label credits the virtual balance, with no real-world effects. You can also reset the test balance with `POST /v1/wallet/test/reset`. See [Test mode](/en/getting-started/test-mode). --- # Shipments Source: https://docs.sendit.mx/en/shipping/shipments The shipment is the API's central entity: it describes a parcel traveling from an origin to a destination. It starts in `DRAFT` status, becomes a label (guía) when you buy a rate, and moves through its lifecycle until delivery. ## The lifecycle ```text DRAFT → LABEL_PURCHASED → READY_FOR_PICKUP → PICKED_UP → IN_TRANSIT → OUT_FOR_DELIVERY → DELIVERED ``` | Status | Meaning | | --- | --- | | `DRAFT` | Just created; has rates, editable and cancelable at no cost | | `PENDING` | Submitted, awaiting label purchase | | `LABEL_PURCHASED` | Label bought, awaiting pickup | | `READY_FOR_PICKUP` | Carrier notified for pickup | | `PICKED_UP` | Carrier picked up the package | | `IN_TRANSIT` | On the way | | `OUT_FOR_DELIVERY` | Last-mile delivery | | `DELIVERED` | Delivered | | `RETURNED` | Returned to sender | | `FAILED` | Delivery failed | | `CANCELLED` | Cancelled | ## Two ways to buy a label `POST /v1/shipments` serves two flows. Pick the one that fits each shipment: **1. Two-step (compare and choose).** Create the shipment; the response carries `rates[]` with quotes from the available carriers. You compare price and speed, pick one, and buy with its `rateId` at `POST /v1/shipments/:id/label`. Best when cost or speed decides on each shipment. If you know the carrier, pass `carrierCode` and `serviceLevel` at create to filter rates to a category. **2. One-call.** If you already know the carrier and service, or just want the cheapest, add a `purchase` object at create. The label comes back in the same response, with no second step. Use it for automation and predictable volume. See [One-call buy](#one-call-buy). | | Two-step | One-call | | --- | --- | --- | | When to use it | Price or speed decides on each shipment | You know the service, or want the cheapest rate | | Requests | Create → buy with `rateId` | One: `POST /v1/shipments` with `purchase` | | What it returns | `rates[]` to compare | The label (`label` + `purchasedRate`) ready | | Ideal for | Stores optimizing for cost | Automation and predictable volume | :::note Automating the selection with business logic (weight, destination, value)? [Shipping rules](/en/shipping/shipping-rules) pick the carrier for you, shipment by shipment. ::: ## Endpoints | Method | Path | Scope | Description | | --- | --- | --- | --- | | `POST` | `/v1/shipments` | `shipments:write` | Create a shipment (returns `rates[]` inline; with `purchase`, buys the label in the same call) | | `GET` | `/v1/shipments` | `shipments:read` | List shipments (paginated and filterable) | | `GET` | `/v1/shipments/stats` | `shipments:read` | Shipment counts by status | | `GET` | `/v1/shipments/:id` | `shipments:read` | Detail with snapshots, label, and events | | `PUT` | `/v1/shipments/:id` | `shipments:write` | Update a `DRAFT` shipment | | `DELETE` | `/v1/shipments/:id` | `shipments:write` | Cancel a shipment | | `POST` | `/v1/shipments/:id/return` | `shipments:write` | Create a return shipment (route reversed) | ## Create a shipment For each address role (`from`, `to`, `return`) send **exactly one** of two variants: a saved address ID (`fromAddressId`) or an inline object (`fromAddress`). Inline addresses can be persisted to your address book with `saveToAddressBook: true`. ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `parcel` | `object` | - | The package: length, width, height (cm) and weight (kg). Accepts dimensionUnit (cm \| in), weightUnit (kg \| g \| lb \| oz), and description. | | `fromAddressId?` | `string` | - | Saved origin. Send exactly one of fromAddressId or fromAddress. | | `fromAddress?` | `object` | - | Inline origin (see the address object). Can be persisted with saveToAddressBook: true. | | `toAddressId?` | `string` | - | Saved destination. Send exactly one of toAddressId or toAddress. | | `toAddress?` | `object` | - | Inline destination (see the address object). | | `returnAddressId?` | `string` | - | Saved return address (at most one; defaults to the origin). | | `returnAddress?` | `object` | - | Inline return address. | | `purchase?` | `object` | - | Buy the label in the same call: carrierCode + serviceCode (exact service) or strategy: cheapest. See One-call buy. | | `externalId?` | `string` | - | Your own reference, such as an order number or folio. Query it later with ?externalId=. | | `carrierCode?` | `string` | - | Optional: scope the quotes to this carrier from creation (does not buy on its own). | | `serviceLevel?` | `string` | - | Optional: scope the service (e.g. standard, express). | | `requestInsurance?` | `boolean` | `false` | Request insurance on the declared value. Buying an insured rate creates a real policy (see Insurance & claims). | | `declaredValue?` | `number` | - | Declared value in MXN (insurance coverage). | | `metadata?` | `object` | - | Your own key-value pairs; returned as-is. | ```bash curl -X POST https://api.sendit.mx/v1/shipments \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "externalId": "ORD-2026-001", "fromAddressId": "clx_origin_address", "toAddress": { "contactName": "María López", "contactPhone": "+5213312345678", "street": "Av. López Mateos", "exteriorNumber": "45", "neighborhood": "Jardines del Sol", "city": "Zapopan", "state": "JAL", "postalCode": "45050", "country": "MX", "saveToAddressBook": false }, "parcel": { "length": 30, "width": 20, "height": 15, "weight": 2.5, "description": "Electronics" }, "requestInsurance": true, "declaredValue": 5000, "metadata": { "orderId": "shopify-12345" } }' ``` The `201` response returns the shipment in `DRAFT` with its address snapshots, the event history, and inline rates (`rates[]`, see [Rates](/en/shipping/rates)): ```json { "success": true, "data": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "DRAFT", "externalId": "ORD-2026-001", "fromAddressSnapshot": { "contactName": "Bruno Sánchez", "city": "Ciudad de México", "postalCode": "03100", "savedAddressId": "clx_origin_address" }, "toAddressSnapshot": { "contactName": "María López", "city": "Zapopan", "postalCode": "45050", "savedAddressId": null }, "parcel": { "length": 30, "width": 20, "height": 15, "weight": 2.5 }, "rates": [ { "id": "DHL_standard_a1b2c3", "carrierCode": "DHL", "serviceCode": "EXPRESS_WORLDWIDE", "serviceLevel": "standard", "totalPrice": 326.82, "currency": "MXN" } ], "events": [ { "status": "DRAFT", "description": "Shipment created", "occurredAt": "2026-07-17T12:00:00.000Z" } ], "createdAt": "2026-07-17T12:00:00.000Z" } } ``` ## One-call buy Add a `purchase` object to `POST /v1/shipments` to create the shipment **and** buy the label in one request. Pick **one** way to select the service: `carrierCode` + `serviceCode` for the exact product, **or** `strategy: "cheapest"` for the lowest-priced rate. Never send both, and never omit both. With a scoped API key you need `labels:write` in addition to `shipments:write`. ### `purchase` parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `carrierCode?` | `string` | - | Exact carrier; used together with serviceCode. Mutually exclusive with strategy. | | `serviceCode?` | `string` | - | Carrier-native service code; used together with carrierCode. | | `strategy?` | `string` | - | cheapest: auto-selects the lowest-priced rate. Mutually exclusive with carrierCode + serviceCode. | | `labelFormat?` | `string` | `PDF` | Format of the generated label: PDF \| ZPL. | | `async?` | `boolean` | `false` | true returns labelPurchaseAttempt inside the 201 response. Retrieve its statusUrl. | ```bash curl -X POST https://api.sendit.mx/v1/shipments \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "fromAddressId": "clx_origin_address", "toAddress": { "contactName": "María López", "contactPhone": "+5213312345678", "street": "Av. López Mateos", "exteriorNumber": "45", "neighborhood": "Jardines del Sol", "city": "Zapopan", "state": "JAL", "postalCode": "45050", "country": "MX" }, "parcel": { "length": 30, "width": 20, "height": 15, "weight": 2.5 }, "purchase": { "strategy": "cheapest", "labelFormat": "PDF" } }' ``` On success, the `201` response additionally includes `label` (with `trackingNumber`, `labelUrl`, `charged`) and `purchasedRate`: ```json { "success": true, "data": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "LABEL_PURCHASED", "label": { "trackingNumber": "1234567890", "labelUrl": "https://labels.sendit.mx/clxq1w2e3r4t5y6u7i8o9p0a/1234567890.pdf", "charged": "326.82", "currency": "MXN" }, "purchasedRate": { "id": "DHL_standard_a1b2c3", "carrierCode": "DHL", "serviceCode": "EXPRESS_WORLDWIDE", "serviceLevel": "standard", "totalPrice": 326.82 } } } ``` With `purchase.async: true`, the outer response remains `201`. You receive `labelPurchaseAttempt` instead of `label`. Retrieve its `statusUrl` or use `label.purchase.completed`. See [Asynchronous operations](/en/api-conventions/asynchronous-operations). The shipment is **always created first**: if the purchase fails, you're left with a `DRAFT` you can finish through the two-step flow. | Code | When | How to resolve it | | --- | --- | --- | | `402 INSUFFICIENT_BALANCE` | The wallet can't cover the label | The shipment stays `DRAFT`; fund the [wallet](/en/wallet-and-billing/wallet) and buy with `POST /v1/shipments/:id/label` | | `422 ONE_CALL_BUY_RATES_PENDING` | Carriers were slow (>8s) to quote | Poll `ratesPollUrl`, then buy with the `rateId` | | `422 ONE_CALL_BUY_NO_MATCHING_RATE` | No rate matched your selection | Pick one from `details.availableRates` | | `400 INVALID_INPUT` | `purchase` had both selection modes, or neither | Send `carrierCode` + `serviceCode` **or** `strategy`, not both | ## Send several boxes together A shipment carries **one** `parcel`. To dispatch several boxes in the same operation, create one shipment per box and group them into a [batch](/en/shipping/batches). You buy every label in one call and generate one manifest (manifiesto) for the carrier. ## Addresses freeze into snapshots At creation, every address is copied into an **immutable snapshot** (`fromAddressSnapshot`, `toAddressSnapshot`, `returnAddressSnapshot`). Editing or deleting the saved address later **never** alters historical shipments. Always read addresses from the snapshot, not the ID: ```js // Correct: the snapshot is the canonical value const origin = shipment.fromAddressSnapshot; // Wrong: the saved address may have changed or been deleted const origin = await getAddress(shipment.fromAddressId); ``` Inside each snapshot, `savedAddressId` records which address-book entry it came from (`null` for one-time addresses). ## List and filter ```text GET /v1/shipments?status=IN_TRANSIT&carrierCode=DHL&limit=50 ``` | Filter | Match | | --- | --- | | `status` | Exact (`DRAFT`, `IN_TRANSIT`, ...) | | `statuses` | Several statuses at once (repeat the param or comma-separate) | | `carrierCode` | Exact (`DHL`, `FEDEX`, `ESTAFETA`, ...) | | `trackingNumber` | Partial (contains) | | `externalId` | Exact | | `search` | Free-text over tracking number and `externalId` | | `createdFrom` / `createdTo` | Creation date range (ISO; `from` inclusive, `to` exclusive) | Cursor pagination and the advanced operators are covered in [Pagination & filtering](/en/api-conventions/pagination-and-filtering). For dashboards, `GET /v1/shipments/stats` returns per-status counts in one call. ## Retrieve the detail `GET /v1/shipments/:id` returns the full shipment: snapshots, parcel, label summary (if any), and the event history: ```json { "success": true, "data": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "IN_TRANSIT", "trackingNumber": "TEST-DHL-A1B2C3D4", "publicTrackingToken": "2f68c611-30af-4a86-9cde-793413af5f65", "trackingUrl": "https://app.sendit.mx/track/2f68c611-30af-4a86-9cde-793413af5f65", "events": [ { "status": "IN_TRANSIT", "description": "Package in transit", "occurredAt": "2026-07-17T09:12:00.000Z" }, { "status": "PICKED_UP", "description": "Package picked up", "occurredAt": "2026-07-17T08:03:00.000Z" } ] } } ``` ## Update a draft Only `DRAFT` shipments can be edited; after that they lock: ```bash curl -X PUT https://api.sendit.mx/v1/shipments/{id} \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "externalId": "ORD-2026-001-v2" }' ``` A shipment that already moved on returns `409 SHIPMENT_ALREADY_PROCESSED`. ## Cancel a shipment ```bash curl -X DELETE https://api.sendit.mx/v1/shipments/{id} \ -H "X-API-Key: sk_test_..." ``` You cannot cancel in `DELIVERED`, `RETURNED`, `FAILED`, or if it is already `CANCELLED`. If the shipment already has a purchased label and you want the money back, void the label first. See [Refunds & voids](/en/shipping/refunds). ## Create a return shipment `POST /v1/shipments/:id/return` creates a **new** `DRAFT` shipment with the route reversed, linked to the original via `returnForShipmentId`: - **from** = the original destination (where the package is now) - **to** = the original return address, falling back to the origin - **parcel** = copied from the original (override it if the return is re-boxed) The response is identical to `POST /v1/shipments`, with inline `rates[]`. You buy the return label through the normal flow. Rules: - The original must have a label (`DRAFT`, `PENDING`, `CANCELLED`, and `RETURNED` originals are rejected). - **One active return per shipment.** Cancelling the return frees the slot. - No returns of returns. - Works end-to-end in test mode. :::note A return is not a refund. A return moves the package back and is paid like any label. A refund voids an unused label and credits its cost back. See [Refunds & voids](/en/shipping/refunds). ::: --- # Shipping rules Source: https://docs.sendit.mx/en/shipping/shipping-rules Shipping rules automate decisions at shipment creation: choosing a carrier, pinning a service, auto-insuring, switching the label format, and more. They are evaluated in priority order, before quoting. ## Endpoints | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/shipping-rules` | List rules (ordered by priority) | | `POST` | `/v1/shipping-rules` | Create a rule | | `GET` | `/v1/shipping-rules/:id` | Get a rule | | `PUT` | `/v1/shipping-rules/:id` | Update a rule | | `DELETE` | `/v1/shipping-rules/:id` | Delete (soft delete) | | `PATCH` | `/v1/shipping-rules/reorder` | Bulk-update priorities | | `POST` | `/v1/shipping-rules/preview` | Dry run: evaluate without persisting | ### Body parameters (create and update) | Prop | Type | Default | Description | | --- | --- | --- | --- | | `priority` | `number` | - | Evaluation order (1–9999). Unique per organization. | | `conditions` | `object` | - | all/any tree of { field, op, value } conditions. See the table below. | | `actions` | `object[]` | - | Actions applied when the rule fires. See the actions table. | | `name?` | `string` | - | A descriptive name for the rule. | | `isActive?` | `boolean` | `true` | Inactive rules are not evaluated. | ```bash curl -X POST https://api.sendit.mx/v1/shipping-rules \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "name": "DHL for heavy shipments to Jalisco", "priority": 10, "conditions": { "all": [ { "field": "parcel.weight", "op": ">=", "value": 5 }, { "field": "to.state", "op": "==", "value": "JAL" } ] }, "actions": [{ "type": "select_carrier", "carrierCode": "DHL" }] }' ``` ```json { "success": true, "data": { "id": "clxrule1", "name": "DHL for heavy shipments to Jalisco", "priority": 10, "isActive": true, "conditions": { "all": [ { "field": "parcel.weight", "op": ">=", "value": 5 }, { "field": "to.state", "op": "==", "value": "JAL" } ] }, "actions": [{ "type": "select_carrier", "carrierCode": "DHL" }], "createdAt": "2026-07-18T10:00:00.000Z" } } ``` The sections below detail the `conditions` and `actions` syntax. ## Write conditions Conditions nest with `all` (AND) and `any` (OR): ```json { "all": [ { "field": "parcel.weight", "op": ">=", "value": 5 }, { "any": [ { "field": "to.state", "op": "in", "value": ["CDMX", "JAL", "NLE"] }, { "field": "to.isResidential", "op": "==", "value": false } ]} ] } ``` ### Available fields | Field | Type | Description | | --- | --- | --- | | `parcel.weight` | number | Weight in kg | | `parcel.length` / `width` / `height` | number | Dimensions in cm | | `parcel.packagingType` | string | Packaging type | | `to.country` | string | Destination country (ISO) | | `to.state` | string | Destination state | | `to.postalCode` | string | Destination postal code | | `to.isResidential` | boolean | Residential delivery | | `order.totalPrice` | number | Order total (MXN) | | `order.channel` | string | `SHOPIFY`, `WOOCOMMERCE`, ... | | `shipment.declaredValue` | number | Declared value | | `shipment.isInternational` | boolean | Cross-border shipment | ### Operators `==`, `!=`, `>`, `>=`, `<`, `<=`, `in`, `not_in`, `contains`, `starts_with` ## Define actions | Action | Fields | Effect | | --- | --- | --- | | `select_carrier` | `carrierCode` | Quote only this carrier | | `select_service` | `carrierCode`, `serviceCode` | Pin a specific service | | `exclude_carrier` | `carrierCode` | Exclude a carrier | | `add_insurance` | `declaredValue` | Auto-insure | | `set_label_format` | `format` | Override the label format: `PDF`, `ZPL`, or `PNG` | | `add_signature_required` | — | Require a delivery signature | | `tag` | `tags: string[]` | Tag the shipment | ## Priority and cascading Rules are evaluated in ascending `priority` order. **Every** matching rule fires, and later rules can override earlier ones: ```text Priority 1: select_carrier DHL Priority 2: select_service DHL EXPRESS ← refines what priority 1 set ``` ## Test before you activate `preview` evaluates a hypothetical shipment without writing anything: ```bash curl -X POST https://api.sendit.mx/v1/shipping-rules/preview \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "shipment": { "parcel": { "weight": 8, "length": 40, "width": 30, "height": 20 }, "to": { "country": "MX", "state": "JAL", "postalCode": "44100" }, "shipment": { "declaredValue": 5000, "isInternational": false } } }' ``` ```json { "success": true, "data": { "finalActions": { "carrierCode": "DHL", "serviceCode": "EXPRESS" }, "evaluations": [ { "ruleId": "clxrule1", "fired": true, "actionsApplied": [{ "type": "select_carrier", "carrierCode": "DHL" }] }, { "ruleId": "clxrule2", "fired": true, "actionsApplied": [{ "type": "select_service", "carrierCode": "DHL", "serviceCode": "EXPRESS" }] } ] } } ``` `evaluations[]` shows the exact cascade: which rule fired, which conditions it evaluated, and which actions it applied. The same information is recorded on every real shipment for auditing. ## Limits and utilities - Up to **100 active rules** per organization. - Priority is unique per organization (1–9999). - For debugging, skip all rules on a request with the `SendIt-Rules: skip` header. --- # Trackers Source: https://docs.sendit.mx/en/shipping/trackers Trackers give you tracking for labels **not generated through SendIt**. Register any supported carrier's tracking number. You get the same normalized statuses, event history, and webhooks as a SendIt shipment. :::note **Beta.** The tracker API and its webhook payload shapes may still evolve before the stable release. Breaking changes will be announced in advance. ::: Labels purchased through SendIt are tracked **automatically and free of charge**. Trackers are only for external numbers. :::note Trackers are for **your** systems: authenticated, with webhooks, and metered against quota. If you want **your buyer** to check their own package without an account, use [public tracking](/en/shipping/public-tracking). ::: ## Endpoints | Method | Path | Role and scope | Description | | --- | --- | --- | --- | | `POST` | `/v1/trackers` | OPERATOR+ · `trackers:write` | Register an external number (may charge overage) | | `GET` | `/v1/trackers` | VIEWER+ | List trackers | | `GET` | `/v1/trackers/:id` | VIEWER+ | One tracker with its full event history | | `DELETE` | `/v1/trackers/:id` | OPERATOR+ · `trackers:write` | Permanently stop tracking (irreversible) | List filters: `search` (partial tracking-number match), `status`, `carrier`, `origin`, `isFinalized`, `createdAfter`, and `createdBefore`. It also accepts `livemode` and is paginated. `isFinalized` accepts `true` or `false`. Send `isFinalized=false` to list only the trackers still being polled. ## Register an external number ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `trackingNumber` | `string` | - | The external label's tracking number. | | `carrier?` | `string` | - | Optional carrier hint (DHL \| FEDEX \| ESTAFETA today). | | `metadata?` | `object` | - | Your own key-value pairs; returned as-is. | ```bash curl -X POST https://api.sendit.mx/v1/trackers \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "trackingNumber": "1234567890123456", "carrier": "DHL", "metadata": { "orderId": "ORD-2026-001" } }' ``` ```json { "success": true, "data": { "id": "trk_a1b2c3d4e5f6", "trackingNumber": "1234567890123456", "carrier": "DHL", "status": "UNKNOWN", "events": [], "metadata": { "orderId": "ORD-2026-001" }, "createdAt": "2026-07-18T10:00:00.000Z" } } ``` - `carrier` is optional. SendIt infers it only when the number format identifies one carrier unambiguously. If the format is ambiguous, you receive `400 INVALID_INPUT`: send the request again with a `carrier` hint. - An unsupported carrier returns `422 CARRIER_NOT_SUPPORTED`; one that needs account credentials returns `422 CARRIER_CREDENTIALS_REQUIRED` until you configure that integration. - The response arrives with `status: "UNKNOWN"` and an empty `events` array. The first carrier poll happens within about a minute. From then on, every status change fires a `tracker.updated` webhook. - `metadata` is returned as-is, never interpreted. ### Duplicates are free Registering a number that already has an **active** tracker in your organization returns the existing tracker with `meta.deduplicated: true`, and is **never charged again**. This applies to the same number and carrier within the last 3 months. Client-side retries are always safe: this endpoint needs no `Idempotency-Key`, though one is honored if sent. ## Statuses ```text UNKNOWN → PRE_TRANSIT → IN_TRANSIT → OUT_FOR_DELIVERY → DELIVERED | RETURNED | FAILED ``` Exception scans (customs holds, failed delivery attempts, damage) appear in `events[]` with `isException: true`, without necessarily changing the status. ## Polling cadence and finalization Carrier polling adapts to the status: ~6 h before first movement, ~2 h in transit, ~30 min out for delivery, backing off to ~12 h after 5 days without new scans. A tracker **finalizes** in the cases below. On finalization, `isFinalized` becomes `true`, polling stops, and the record stays queryable: | Condition | `finalizedReason` | | --- | --- | | Delivered / returned / failed | `DELIVERED` / `RETURNED` / `FAILED` | | 45 days without leaving pre-transit | `TTL_PRE_TRANSIT` (fires `tracker.expired`) | | 60 days without any new event | `TTL_NO_UPDATES` (fires `tracker.expired`) | | Manual `DELETE /v1/trackers/:id` | `CANCELLED` | ## Webhooks Subscribe your endpoint to `tracker.created`, `tracker.updated`, or `tracker.expired`. They use the same signatures and retries as every [SendIt webhook](/en/webhooks-and-events/webhooks). There are deliberately no separate delivered or exception events: read `data.object.status` inside `tracker.updated`. ## Pricing | Plan | Included trackers / month | Overage per tracker (MXN) | | --- | --- | --- | | Free | 100 | $0.80 | | Growth | 2,000 | $0.50 | | Scale | 10,000 | $0.30 | | Enterprise | Unlimited | — | - Only **external** registrations count; SendIt-generated labels never consume quota. - Over quota, the overage is debited from your wallet at registration time (`402 INSUFFICIENT_BALANCE` if it can't be covered). There is no hard cap. - The amount charged is returned as `overageCharged` on the tracker. ## In test mode Registrations with a `sk_test_` key never charge overage or consume quota. Test trackers progress to `DELIVERED` on their own with simulated data, and their webhooks carry `livemode: false`. ## Handle errors | Code | When it happens | How to resolve it | | --- | --- | --- | | `400 INVALID_INPUT` | The number does not identify one carrier unambiguously | Send a compatible `carrier` hint | | `402 INSUFFICIENT_BALANCE` | The record exceeds quota and the wallet lacks funds | Fund the wallet and try again | | `422 CARRIER_NOT_SUPPORTED` | SendIt cannot poll that carrier | Use a supported carrier | | `422 CARRIER_CREDENTIALS_REQUIRED` | The carrier needs a configured account | Configure the carrier credentials | --- # CFDI and invoicing Source: https://docs.sendit.mx/en/wallet-and-billing/cfdi Configure your organization's fiscal information and read its available invoice records. This CFDI 4.0 surface is in beta. ## Endpoints | Method | Path | Description | | --- | --- | --- | | `POST` | `/v1/billing/info` | Create or update the fiscal profile | | `GET` | `/v1/billing/info` | Read the fiscal profile | | `GET` | `/v1/billing/invoices` | List invoice records | | `GET` | `/v1/billing/invoices/:id` | Read an invoice record | ## Configure the fiscal profile ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `rfc` | `string` | - | The organization's valid Mexican tax ID. | | `razonSocial` | `string` | - | Legal name registered with SAT. | | `regimenFiscal` | `string` | - | Fiscal-regime code. | | `usoCfdi` | `string` | - | CFDI-use code, such as G03. | | `street` | `string` | - | Street in the fiscal address. | | `exteriorNumber` | `string` | - | Exterior number. | | `interiorNumber?` | `string` | - | Interior number, when applicable. | | `neighborhood` | `string` | - | Neighborhood. | | `city` | `string` | - | City or municipality. | | `state` | `string` | - | State. | | `postalCode` | `string` | - | Fiscal postal code. | | `billingEmail` | `string` | - | Billing email. | | `billingPhone?` | `string` | - | Billing phone number. | | `requireInvoice?` | `boolean` | - | Whether the organization requires invoices. | ```bash curl -X POST https://api.sendit.mx/v1/billing/info \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "rfc": "XAXX010101000", "razonSocial": "Ejemplo Comercio SA de CV", "regimenFiscal": "601", "usoCfdi": "G03", "street": "Avenida Reforma", "exteriorNumber": "123", "interiorNumber": "4B", "neighborhood": "Juárez", "city": "Ciudad de México", "state": "CDMX", "postalCode": "06600", "billingEmail": "facturacion@example.com", "billingPhone": "+525512345678", "requireInvoice": true }' ``` The response uses the standard envelope and returns the saved profile. `GET /v1/billing/info` returns the same profile. If no profile exists, it returns `404 RESOURCE_NOT_FOUND`. ## List invoices ```bash curl "https://api.sendit.mx/v1/billing/invoices?page=1&limit=20" \ -H "X-API-Key: sk_live_..." ``` ```json { "success": true, "data": [ { "id": "inv_123", "billingInfoId": "bi_123", "uuid": null, "folioNumber": null, "series": null, "invoiceType": "INGRESO", "paymentMethod": "PUE", "paymentForm": "03", "subtotal": "1000.00", "tax": "160.00", "total": "1160.00", "currency": "MXN", "status": "PENDING", "xmlUrl": null, "pdfUrl": null, "issuedAt": null, "createdAt": "2026-04-20T12:00:00.000Z", "updatedAt": "2026-04-20T12:00:00.000Z" } ], "meta": { "total": 1, "page": 1, "limit": 20 } } ``` `page` starts at 1. `limit` starts at 20 and accepts up to 100. If no fiscal profile exists, the list returns `200` with `data: []`. ## Read an invoice ```bash curl https://api.sendit.mx/v1/billing/invoices/inv_123 \ -H "X-API-Key: sk_live_..." ``` The response returns one invoice record for your organization with the same shape as the list. ## Handle errors | Code | When it happens | How to resolve it | | --- | --- | --- | | `400 INVALID_INPUT` | The RFC, email, or another field is invalid | Correct the fiscal information | | `403 FORBIDDEN` | The user cannot modify the profile | Use an ADMIN or higher member | | `404 RESOURCE_NOT_FOUND` | The profile or invoice does not exist | Configure the profile or check the `id` | --- # Insurance & claims Source: https://docs.sendit.mx/en/wallet-and-billing/insurance-claims Insure a shipment when you create it (`requestInsurance: true` + `declaredValue`) and, if the package is lost, damaged, or stolen, file a claim with evidence directly through the API. ## Prerequisites to claim 1. The shipment must have insurance (requested at [shipment creation](/en/shipping/shipments#create-a-shipment)). 2. The label must **not** be voided. A refunded label is not claimable. 3. No other open claim may exist for the same shipment. ## How a shipment gets insured Insurance is bought **at label time**, not separately. Create the shipment with `requestInsurance: true` and a `declaredValue` in MXN. Rates come back with the premium included: `breakdown.insuranceCost` and `isInsured: true` on each rate. When you buy an insured rate, the premium is part of the label charge and an `ACTIVE` policy is created. That is the policy you claim against. [Voiding the label](/en/shipping/refunds) refunds the premium and voids the policy. With no insurance there is no policy, and the claim is rejected. ## Endpoints | Method | Path | Role and scope | Description | | --- | --- | --- | --- | | `POST` | `/v1/insurance-claims` | OPERATOR+ · `insurance_claims:write` | File a claim | | `GET` | `/v1/insurance-claims` | Any member | List claims | | `GET` | `/v1/insurance-claims/:id` | Any member | Claim detail | | `PATCH` | `/v1/insurance-claims/:id/status` | ADMIN+ · `insurance_claims:write` | Update the status (e.g. withdraw a claim) | ## File a claim ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `shipmentId` | `string` | - | The insured shipment with the incident. | | `reason` | `string` | - | lost \| damaged \| stolen. | | `description` | `string` | - | What happened, in as much detail as possible. | | `claimedAmount` | `number` | - | Claimed amount. It cannot exceed the insured declared value. | | `evidenceUrls?` | `string[]` | - | Photos of the damage and packaging, purchase invoice, police report if applicable. | ```bash curl -X POST https://api.sendit.mx/v1/insurance-claims \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "reason": "damaged", "description": "Package arrived with visible damage to contents", "claimedAmount": 1500.00, "evidenceUrls": [ "https://cdn.mitienda.mx/evidence/photo1.jpg", "https://cdn.mitienda.mx/evidence/photo2.jpg" ] }' ``` The `201` response returns the claim in `FILED` status with its claim number: ```json { "success": true, "data": { "id": "clm_a1b2c3d4e5f6g7h8", "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "FILED", "reason": "damaged", "claimedAmount": "1500.00", "claimNumber": "CLM-2026-000123", "createdAt": "2026-07-18T10:00:00.000Z" } } ``` The better the initial evidence, the faster the resolution: photos of the damage **and** the outer packaging, plus proof of value. ## The claim lifecycle ```text FILED → INVESTIGATING → EVIDENCE_REQUIRED ↓ APPROVED → PAID PARTIALLY_APPROVED → PAID DENIED CANCELLED ``` | Status | Meaning | | --- | --- | | `FILED` | Received and forwarded to the insurer | | `INVESTIGATING` | Under review | | `EVIDENCE_REQUIRED` | Additional documentation needed — upload it and update the claim | | `APPROVED` | Full amount approved | | `PARTIALLY_APPROVED` | A lower amount was approved (`approvedAmount`) | | `DENIED` | Rejected | | `PAID` | Payout disbursed by the insurer | | `CANCELLED` | Withdrawn by you | `DENIED`, `PAID`, and `CANCELLED` are terminal. ## Cross-state rules | Code | Situation | How to resolve it | | --- | --- | --- | | `409 CLAIM_PENDING_NO_REFUND_ALLOWED` | Trying to [void the label](/en/shipping/refunds) with an open claim | Resolve or withdraw the claim first | | `409 SHIPMENT_REFUNDED_NO_CLAIMS_ALLOWED` | Trying to claim on a voided label | The cost was already refunded; no insurance applies | | `409 CLAIM_ALREADY_OPEN` | An open claim already exists for that shipment | Follow up on the existing one | --- # Plans & quotas Source: https://docs.sendit.mx/en/wallet-and-billing/subscriptions-and-quotas Every plan includes a monthly quota of labels and trackers. There are no hard caps. Past your quota you pay a per-label overage fee, charged at purchase time. ## The plans | Plan | Labels / month | Overage per label | Own account per label | Trackers / month | Overage per tracker | Requests / min (`read`/`write`/`quote`) | API keys | Webhook endpoints | Members | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | Free | 500 | $0.90 MXN | $1.20 MXN + IVA | 100 | $0.80 MXN | 60 / 30 / 20 | 2 | 2 | 3 | | Growth | 3,000 | $0.70 MXN | $0.90 MXN + IVA | 2,000 | $0.50 MXN | 300 / 150 / 60 | 10 | 10 | Unlimited | | Scale | 15,000 | $0.50 MXN | $0.60 MXN + IVA | 10,000 | $0.30 MXN | 1,000 / 500 / 200 | 50 | 50 | Unlimited | | Enterprise | Unlimited | — | No charge | Unlimited | — | 5,000 / 2,500 / 1,000 | 500 | 500 | Unlimited | On every plan: the full API, [test mode](/en/getting-started/test-mode), webhooks, and every carrier. Requests per minute are metered by endpoint class. Details are in [rate limits](/en/api-conventions/rate-limits). The own-account column applies when you buy with your own carrier credentials. In that case you pay that fee and nothing else: the label does **not** consume normal overage, though it still counts toward your monthly quota. See [carrier accounts](/en/shipping/carrier-accounts). ## How overage works **There is no monthly cap.** A Free organization can buy 600 labels in a month: the first 500 at the normal price, and the remaining 100 with an extra $0.90 MXN each. The overage: - Is charged **at each purchase**, together with the label. It never arrives as a surprise invoice at month end. - Shows up explicitly in the purchase response (`overageCharge`) and on the label (`overageAmount`); it's `"0.00"` within quota or on unlimited plans. - Counts **live operations only**: test-mode purchases never consume quota or charge overage. The same model applies to external [trackers](/en/shipping/trackers#pricing). Only external registrations consume quota. Trackers created automatically with a SendIt label are free and are not counted. ## Hard caps: keys, webhooks, and members Labels and trackers have no cap: you pay overage. **API keys** and **webhook endpoints** are hard-capped per plan. Creating one past the limit returns `403 PLAN_LIMIT_REACHED`, with `resource`, `limit`, and `plan` in `details`. Upgrade the plan to raise the cap. The key count excludes keys in their 24-hour rotation window; the webhook count excludes deleted endpoints. ### Members on the Free plan The Free plan allows **3 members**, counting current members **plus pending invitations**. Inviting past the cap returns the same `403 PLAN_LIMIT_REACHED`. Accepting an invitation into an organization that already holds 3 members does too. In that case the invitation **stays pending** and works as soon as you upgrade. Paid plans do not cap members. Organizations that already had more than 3 members before this cap keep all of them, but they cannot invite anyone else without upgrading. ## Check this month's usage `GET /v1/organizations/me/usage` returns the current **calendar month's** consumption against your plan's quotas: ```json { "success": true, "data": { "month": "2026-07", "plan": "FREE", "labelsCreated": 562, "labelsQuota": 500, "overageLabels": 62, "overagePerLabel": "0.90", "trackersCreated": 30, "trackersQuota": 100, "overageTrackers": 0, "overagePerTracker": "0.80", "totalSpent": "18234.50", "currency": "MXN" } } ``` | Field | Description | | --- | --- | | `labelsQuota` / `trackersQuota` | Plan quota (`null` = unlimited) | | `overageLabels` | Labels above quota this month | | `totalSpent` | Real spend this month (live operations only) | Ideal for usage bars in your own dashboard, or to alert yourself before entering overage. ## Usage alerts When your **live** usage first crosses **80%** and **100%** of a quota (labels or trackers), we email the organization **owner**. Each threshold alerts **at most once per calendar month** and re-arms the next month. Unlimited (Enterprise) plans never alert, and test mode never counts. ## Check your subscription `GET /v1/billing/subscription` returns the active plan, its cycle, and the full limits: ```json { "success": true, "data": { "plan": "GROWTH", "status": "ACTIVE", "currentPeriodStart": "2026-07-01T00:00:00.000Z", "currentPeriodEnd": "2026-08-01T00:00:00.000Z", "cancelledAt": null, "trialEndsAt": null, "isStripeManaged": true, "limits": { "labelsPerMonth": 3000, "overagePerLabel": "0.70", "trackersPerMonth": 2000, "overagePerTracker": "0.50", "rateLimits": { "read": 300, "write": 150, "quote": 60 }, "maxApiKeys": 10, "maxWebhookEndpoints": 10, "maxMembers": null } } } ``` - `status`: `TRIALING`, `ACTIVE`, `PAST_DUE`, `CANCELLED`, or `UNPAID`. - `limits.rateLimits` carries the per-minute budget for each [endpoint class](/en/api-conventions/rate-limits). - `maxMembers` is `null` on paid plans (no cap) and `3` on Free. - Organizations on the Free plan, with no paid subscription, report `isStripeManaged: false` and `null` periods. The endpoint never 404s. ## Personal organizations Your [personal organization](/en/getting-started/organizations-and-sandbox) starts on Free and can upgrade like any other. Quotas and overage apply normally. Sandbox is a **mode**, not the organization: nothing you do in test mode consumes quota. ## Change plans You can upgrade or manage your plan yourself (ADMIN role or above): - `POST /v1/billing/checkout-session` with `{ "plan": "GROWTH" }` returns a **Stripe Checkout** URL: redirect the user there to complete payment. On completion, your plan updates automatically. It accepts `GROWTH` or `SCALE`. - `POST /v1/billing/portal-session` returns a Stripe **Billing Portal** URL to change the payment method, view receipts, or cancel. It requires that your organization has upgraded at least once. Both endpoints use your dashboard session (JWT), not API keys. `ENTERPRISE` is sales-led: requesting it through checkout returns `503 CHECKOUT_NOT_CONFIGURED`. Upgrading applies the new quota immediately. Downgrading applies at the end of the current period. --- # Wallet & funding Source: https://docs.sendit.mx/en/wallet-and-billing/wallet The wallet (monedero) is your prepaid balance in Mexican pesos: every label is debited from it at the quoted price. Fund it by SPEI transfer to your dedicated CLABE, by card, or with PayPal. ## Endpoints | Method | Path | Description | | --- | --- | --- | | `POST` | `/v1/wallet/funding-instructions` | Provision your CLABE (idempotent) | | `GET` | `/v1/wallet/funding-instructions` | Get your CLABE | | `POST` | `/v1/wallet/fund/card` | Start a card funding | | `POST` | `/v1/wallet/fund/paypal` | Start a PayPal funding | | `POST` | `/v1/wallet/fund/oxxo` | Generate a cash voucher (OXXO) | | `GET` | `/v1/wallet/fund/:paymentIntentId/status` | Payment status | | `GET` | `/v1/wallet/balance` | Current balance | | `GET` | `/v1/wallet/summary` | Income/expenses/net summary for a period | | `GET` | `/v1/wallet/transactions` | Transaction history | | `PATCH` | `/v1/wallet/settings` | Set the low-balance threshold (ADMIN+) | | `POST` | `/v1/wallet/test/reset` | Reset the test balance (dashboard session only) | ## Fund via SPEI (recommended) :::warning Funding and payment-status lookup work in LIVE mode only. An `sk_test_` key returns `400 LIVE_MODE_REQUIRED` on these routes. Use the dashboard control to replenish the test balance. ::: Every organization gets a **dedicated CLABE**: any SPEI transfer to that account is credited to your wallet automatically, with no manual reconciliation. ```bash curl -X POST https://api.sendit.mx/v1/wallet/funding-instructions \ -H "Authorization: Bearer " ``` ```json { "success": true, "data": { "id": "clxfund123abc", "type": "mx_bank_transfer", "clabe": "646180111812345678", "bankName": "STP", "bankCode": "646", "status": "ACTIVE", "createdAt": "2026-07-17T01:00:00.000Z" } } ``` The endpoint is idempotent: calling it again returns the same CLABE. Share it with your finance team and fund by SPEI from any bank. The credit shows up within minutes as a `CREDIT` transaction. ## Fund with a card ```bash curl -X POST https://api.sendit.mx/v1/wallet/fund/card \ -H "Authorization: Bearer " \ -H "Idempotency-Key: 9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d" \ -H "Content-Type: application/json" \ -d '{ "amount": 500 }' ``` ```json { "success": true, "data": { "paymentIntentId": "pi_3QyAbc123xyz", "clientSecret": "pi_3QyAbc123xyz_secret_def456", "amount": 500, "currency": "MXN", "publishableKey": "pk_test_xxxxx" } } ``` ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `amount` | `number` | - | Amount to fund in MXN. Minimum $20, maximum $50,000 per operation. | | `returnUrl?` | `string` | - | fund/paypal only: where to send the user back after approval. | Use the `clientSecret` with Stripe.js on your frontend to collect the card and complete 3D Secure. Once confirmed, the wallet is credited automatically. **PayPal** funding works the same via `POST /v1/wallet/fund/paypal` (accepts an optional `returnUrl` for the post-approval redirect). ### Check the payment status ```text GET /v1/wallet/fund/pi_3QyAbc123xyz/status ``` ```json { "success": true, "data": { "paymentIntentId": "pi_3QyAbc123xyz", "status": "succeeded", "amount": 500, "currency": "MXN", "paymentMethodType": "card", "walletCredited": true } } ``` When `status` is `succeeded` and `walletCredited` is `true`, the balance already reflects the funding. :::note The `Idempotency-Key` header is optional but **recommended** on funding calls. With it, retries reuse the same payment intent and there are never two charges. See [Idempotency](/en/api-conventions/idempotency). ::: ## Fund with OXXO (cash) To pay in cash, use `POST /v1/wallet/fund/oxxo`. It generates a **voucher with a barcode**: show or print it and pay at any OXXO store. The voucher expires in 3 days. The wallet is credited once OXXO confirms payment, so treat it as pending until then. Check it through `/v1/wallet/fund/:paymentIntentId/status`. ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `amount` | `number` | - | Amount to fund in MXN. Minimum $20, maximum $10,000, the OXXO voucher cap. | ```bash curl -X POST https://api.sendit.mx/v1/wallet/fund/oxxo \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "amount": 500 }' ``` ```json { "success": true, "data": { "paymentIntentId": "pi_3Oxxo123xyz", "hostedVoucherUrl": "https://payments.stripe.com/oxxo/voucher/...", "expiresAfter": 1800000000, "amount": 500, "currency": "MXN" } } ``` `hostedVoucherUrl` is the printable voucher. `expiresAfter` is a Unix timestamp in seconds. The balance updates after the cash payment clears. Until then the transaction stays pending. ## Check your balance ```text GET /v1/wallet/balance ``` ```json { "success": true, "data": { "id": "clxwallet123", "balance": "1500.00", "currency": "MXN", "lowBalanceThreshold": "100.00", "lowBalanceAlertSent": false, "hasFundingSource": true } } ``` Amounts are decimal strings. Never do floating-point arithmetic on money. With a `sk_test_` key this endpoint returns the **test balance**. See [Test mode](/en/getting-started/test-mode). ### Set the low-balance alert `PATCH /v1/wallet/settings` sets the balance at which you want a warning. It requires the ADMIN role or higher, and the `wallet:write` scope if you use an API key. ```bash curl -X PATCH https://api.sendit.mx/v1/wallet/settings \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "lowBalanceThreshold": 500 }' ``` Send `null` to turn the alert off. ## Movements summary For "income vs. expenses" cards without paging through every transaction, `GET /v1/wallet/summary` aggregates the window you ask for. Both date params are optional (`from` inclusive, `to` exclusive): ```text GET /v1/wallet/summary?from=2026-07-01&to=2026-08-01 ``` ```json { "success": true, "data": { "income": "1050.00", "expenses": "730.50", "net": "314.50", "adjustments": "-5.00", "transactionCount": 7, "currency": "MXN", "byType": { "CREDIT": { "count": 2, "amount": "1000.00" }, "REFUND": { "count": 1, "amount": "50.00" }, "DEBIT": { "count": 3, "amount": "-730.50" }, "ADJUSTMENT": { "count": 1, "amount": "-5.00" } } } } ``` `income` sums credits and refunds. `expenses` is the magnitude of the debits, which are stored negative. All amounts are decimal strings. The summary respects the mode of the request. With `?livemode=false` it aggregates test movements. ## Review your transactions ```text GET /v1/wallet/transactions?type=CREDIT&page=1&limit=20 ``` ```json { "success": true, "data": [ { "id": "clxtx_spei_456", "type": "CREDIT", "amount": "1000.00", "currency": "MXN", "balanceAfter": "2500.00", "description": "Depósito vía transferencia bancaria SPEI", "referenceType": "stripe_bank_transfer", "status": "COMPLETED", "createdAt": "2026-07-17T10:00:00.000Z" }, { "id": "clxtx_label_789", "type": "DEBIT", "amount": "326.82", "currency": "MXN", "balanceAfter": "1500.00", "description": "Compra de guía DHL Express Nacional", "referenceType": "label_purchase", "referenceId": "clxq1w2e3r4t5y6u7i8o9p0a", "metadata": { "carrierCode": "DHL" }, "status": "COMPLETED", "createdAt": "2026-07-17T09:00:00.000Z" } ], "meta": { "page": 1, "limit": 20, "total": 143, "totalPages": 8 } } ``` | Parameter | Values | | --- | --- | | `type` | `CREDIT`, `DEBIT`, `REFUND`, `ADJUSTMENT` | | `referenceType` | `stripe_bank_transfer` (SPEI), `stripe_payment_intent` (card, PayPal, or OXXO), `label_purchase`, `label_purchase_refund`, `label_void_refund` | | `page` | Page number. Default 1 | | `limit` | Items per page. Default 20, maximum 100 | | `sortOrder` | `asc` or `desc`. Default `desc` | Card, PayPal, and OXXO fundings share the `stripe_payment_intent` reference type. To tell them apart, read `metadata.funding_method`. It holds `card`, `paypal`, or `oxxo`. Every transaction carries `balanceAfter`. The history is a complete, auditable statement. ## Handle errors | Code | When it happens | How to resolve it | | --- | --- | --- | | `400 INVALID_INPUT` | The amount or parameters are invalid | Correct the request and try again | | `400 LIVE_MODE_REQUIRED` | You tried to fund or check a payment in TEST mode | Switch to a LIVE session or key | | `402 INSUFFICIENT_BALANCE` | An operation needs more than the available balance | Fund the wallet | --- # Recipient notifications Source: https://docs.sendit.mx/en/webhooks-and-events/notifications SendIt can notify **your buyer**, the person receiving the package, as their order moves. Notices go out by email and WhatsApp, in Spanish, with nothing for you to build. You control which events notify and over which channel. ## What gets notified | Event | When it fires | Email | WhatsApp | | --- | --- | --- | --- | | `order_confirmed` | The order moves to `CONFIRMED` | Yes | Yes | | `label_purchased` | A label is purchased for a shipment | Yes | Yes | | `shipment_delivered` | Tracking reaches `DELIVERED` | Yes | Yes | | `claim_filed` | An insurance claim is filed | Yes | Yes | Delivery happens in the background. It never delays the label purchase or the API response. ## Consent rules - **WhatsApp requires explicit opt-in.** LFPDPPP (Mexico's data-protection law) requires consent before any message. WhatsApp is sent **only** when the shipment's linked [order](/en/orders/orders) has `customerOptInToWhatsapp: true`. A shipment with no linked order can never receive WhatsApp. - **Email is transactional.** These are service messages about the recipient's own shipment, so no per-recipient opt-in is needed. The org-level toggle below is the control. The email comes from the order's `customerEmail`, and falls back to the destination address's contact email. - **Test mode never notifies.** Any operation with `livemode: false` is recorded as `SKIPPED` and sends nothing real. WhatsApp messages use Meta pre-approved templates, in Spanish (es-MX). For example, the `label_purchased` template includes the tracking number, the carrier, and the estimated delivery date. ## Control what gets sent Toggles are **per organization, per event, per channel**. A missing toggle means **enabled** (the feature ships on): you only create rows to turn things off. ### Fetch the full matrix ```bash curl https://api.sendit.mx/v1/notification-settings \ -H "X-API-Key: sk_live_..." ``` ```json { "success": true, "data": [ { "eventType": "order_confirmed", "channel": "EMAIL", "isEnabled": true, "subjectOverride": null }, { "eventType": "order_confirmed", "channel": "WHATSAPP", "isEnabled": true }, { "eventType": "label_purchased", "channel": "EMAIL", "isEnabled": true, "subjectOverride": "Tu guía {{trackingNumber}} está lista" }, { "eventType": "shipment_delivered", "channel": "WHATSAPP", "isEnabled": false } ] } ``` 4 events × 2 channels = 8 rows with their effective values. Email rows also carry `subjectOverride`, which is `null` when you use SendIt's own subject. WhatsApp rows omit it. Any authenticated member can read the settings; the `notification_settings:read` scope applies to API keys only. ### Turn toggles off or on ```bash curl -X PUT https://api.sendit.mx/v1/notification-settings \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "settings": [ { "eventType": "shipment_delivered", "channel": "WHATSAPP", "isEnabled": false } ] }' ``` Requires the `OPERATOR` role or above and the `notification_settings:write` scope. It's a partial update: you only touch the rows you send. #### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `settings` | `object[]` | - | Toggles to update. Each element carries eventType (order_confirmed \| label_purchased \| shipment_delivered \| claim_filed), channel (EMAIL \| WHATSAPP), and isEnabled (boolean). EMAIL rows also accept subjectOverride. | Turning a channel off stops that notification org-wide. Turning WhatsApp off has no effect on messages already suppressed for lack of opt-in. ### Customize the email subject On an `EMAIL` row, `subjectOverride` replaces SendIt's own subject. Omit it to leave the subject as is, send a string of **up to 150 characters** to replace it, or send `null` to restore the original. ```bash curl -X PUT https://api.sendit.mx/v1/notification-settings \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "settings": [ { "eventType": "label_purchased", "channel": "EMAIL", "isEnabled": true, "subjectOverride": "Tu guía {{trackingNumber}} está lista" } ] }' ``` Each event accepts only its own variables: | Event | Variables allowed in the subject | | --- | --- | | `order_confirmed` | `customerName`, `orderNumber`, `totalPrice` | | `label_purchased` | `customerName`, `carrier`, `trackingNumber`, `estimatedDelivery` | | `shipment_delivered` | `customerName`, `trackingNumber`, `deliveredAt` | | `claim_filed` | `customerName`, `claimNumber`, `shipmentId` | | Code | When | How to resolve it | | --- | --- | --- | | `400 INVALID_INPUT` | You sent `subjectOverride` on a `WHATSAPP` row | WhatsApp copy is Meta-approved and not editable — drop the field | | `400 INVALID_INPUT` | The subject uses a variable that event doesn't publish | Use only the variables in the table above | | `400 INVALID_INPUT` | The subject contains a line break or exceeds 150 characters | Send a single line of at most 150 characters | ### Put your brand on the emails Recipient emails can carry your logo, your color, and your footer. With `publicTrackingPage` on, that same branding appears on the [public tracking page](/en/shipping/public-tracking#show-your-brand-when-enabled). | Method | Path | Access | | --- | --- | --- | | `GET` | `/v1/notification-settings/branding` | Any member, or a key with `notification_settings:read` | | `PUT` | `/v1/notification-settings/branding` | `ADMIN` role or above + `notification_settings:write` | #### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `logoUrl?` | `string` | - | https URL of the logo, at most 500 characters. Renders in the email header. | | `accentColor?` | `string` | - | Accent color as a six-digit hex value (for example #4361EE). | | `replyTo?` | `string` | - | Address your buyer replies to, at most 320 characters. | | `footerText?` | `string` | - | Plain-text footer, at most 500 characters. It arrives as footer on the public page. | | `publicTrackingPage?` | `boolean` | `false` | true = show your branding on the public tracking page. It applies there only. | `replyTo` applies to **email only** and never appears in the public response. `logoUrl` and `accentColor` apply to both. ```bash curl -X PUT https://api.sendit.mx/v1/notification-settings/branding \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "logoUrl": "https://cdn.tutienda.mx/logo.png", "accentColor": "#4361EE", "replyTo": "hola@tutienda.mx", "footerText": "Gracias por comprar en Tu Tienda.", "publicTrackingPage": true }' ``` ```json { "success": true, "data": { "logoUrl": "https://cdn.tutienda.mx/logo.png", "accentColor": "#4361EE", "replyTo": "hola@tutienda.mx", "footerText": "Gracias por comprar en Tu Tienda.", "publicTrackingPage": true } } ``` Every field is optional, and `GET` omits the ones you never set. A brand-new organization returns `{ "success": true, "data": {} }`. :::warning `PUT` **replaces** the whole branding: it is not a patch. Any field you omit is cleared, and sending `{}` restores SendIt's defaults. A `PUT` that sends only `logoUrl` leaves `publicTrackingPage` at `false` and turns off branding on the public page for every recipient. Always `GET` first, apply your change on top of what is there, and send the complete object. ::: WhatsApp templates never change. | Code | When | How to resolve it | | --- | --- | --- | | `400 INVALID_INPUT` | `logoUrl` isn't `https` or exceeds 500 characters | Host the logo on your own https URL | | `400 INVALID_INPUT` | `accentColor` isn't a six-digit hex value | Use the `#RRGGBB` format | | `400 INVALID_INPUT` | `replyTo` isn't a valid email, or `footerText` exceeds 500 characters | Fix the value and resend | | `403 INSUFFICIENT_SCOPE` | The key lacks `notification_settings:write` | Issue a key with that [scope](/en/api-conventions/scopes) | ### Browse the templates `GET /v1/notification-templates` returns the template catalog, one entry per event and channel (4 × 2 = 8), so your dashboard can render a preview gallery. It is read-only and uses the `notification_settings:read` scope. Templates are **not editable**: the copy is Meta-approved for WhatsApp, or code-owned for email. ```bash curl https://api.sendit.mx/v1/notification-templates \ -H "X-API-Key: sk_live_..." ``` ```json { "success": true, "data": [ { "eventType": "label_purchased", "channel": "EMAIL", "variables": ["customerName", "carrier", "trackingNumber", "estimatedDelivery"], "subject": "Tu envío está en camino — FEDMX123456789", "exampleBody": "Hola María,\n\nGeneramos la guía de tu envío con FedEx.\n…" }, { "eventType": "label_purchased", "channel": "WHATSAPP", "variables": ["customerName", "carrier", "trackingNumber", "estimatedDelivery"], "templateName": "label_purchased", "exampleBody": "Hola María, …", "configured": true } ] } ``` Email entries render with **your active branding and subject**: `subject` reflects your `subjectOverride` if you set one, and `exampleHtml` returns the preview with your logo, color, and footer. WhatsApp templates are in Spanish (es-MX), Meta pre-approved, and shown unbranded. `configured` indicates whether the template is available. The email channel works independently. ## Delivery semantics - **No duplicates.** Reprocessing the same event does not send a second message through the same channel. - **Channels are independent.** A failure on one channel never blocks the other or your [webhooks](/en/webhooks-and-events/webhooks). :::note These notifications are for the **end buyer**. To notify *your system*, use [webhooks](/en/webhooks-and-events/webhooks). ::: --- # Webhooks Source: https://docs.sendit.mx/en/webhooks-and-events/webhooks Webhooks notify your server when a resource changes, such as a label purchase, tracking update, or wallet movement. Register an HTTPS endpoint and verify each delivery's signature. ## Register an endpoint ### Body parameters | Prop | Type | Default | Description | | --- | --- | --- | --- | | `url` | `string` | - | HTTPS with a direct response. Redirects are not followed, and a 3xx counts as a failure. | | `events` | `string[]` | - | The event types this endpoint subscribes to (see the table below). | | `description?` | `string` | - | Optional name that identifies the destination. | ```bash curl -X POST https://api.sendit.mx/v1/webhook-endpoints \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://mitienda.mx/webhooks/sendit", "description": "Production receiver", "events": ["shipment.label.created", "shipment.tracking.updated"] }' ``` ```json { "success": true, "data": { "id": "whe_a1b2c3d4e5f6", "url": "https://mitienda.mx/webhooks/sendit", "description": "Production receiver", "events": ["shipment.label.created", "shipment.tracking.updated"], "status": "ACTIVE", "failureCount": 0, "disabledAt": null, "createdAt": "2026-07-18T10:00:00.000Z", "signingSecret": "whsec_9f8e7d6c5b4a" } } ``` The field is named `signingSecret`. SendIt generates it and returns it only when you create or rotate the endpoint. Store it in a secrets manager because you cannot retrieve it again. The endpoint list never includes it. Each plan caps how many active endpoints you can have. Registering one more returns `403 PLAN_LIMIT_REACHED`. See [plans and quotas](/en/wallet-and-billing/subscriptions-and-quotas). ### Manage your endpoints | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/webhook-endpoints` | List your endpoints | | `GET`/`PATCH`/`DELETE` | `/v1/webhook-endpoints/:id` | Read, update, or delete one | | `POST` | `/v1/webhook-endpoints/:id/rotate-secret` | Issue a new secret (returned once) | | `POST` | `/v1/webhook-endpoints/:id/test` | Send a `webhook.test` event immediately | | `GET` | `/v1/webhook-endpoints/:id/events` | Delivery history for that endpoint | | `POST` | `/v1/webhook-endpoints/:id/events/:eventId/redeliver` | Retry one delivery | Any authenticated member can read. Writes require the ADMIN role and the `webhooks:write` scope. ## Event types You can subscribe to these documented events: | Event | When it fires | | --- | --- | | `shipment.created` | A shipment was created | | `shipment.updated` | A `DRAFT` shipment was updated | | `shipment.label.created` | A label was purchased | | `label.purchase.completed` | A durable purchase finished: success, compensated failure, or `action_required` | | `shipment.label.voided` | A label was voided and the wallet refunded | | `shipment.tracking.updated` | Carrier tracking caused a status transition | | `wallet.credited` | A wallet credit was confirmed | | `wallet.debited` | A label-purchase debit was confirmed | | `wallet.low_balance` | A LIVE debit crossed the configured threshold | | `tracker.created` | An external tracker was registered | | `tracker.updated` | An external tracker changed status | | `tracker.expired` | An external tracker hit its TTL without a terminal status | Subscribe each endpoint only to the events it cares about. Your handler must ignore types it does not recognize. :::warning **There is no `shipment.delivered`, `shipment.exception`, or `shipment.cancelled`.** Delivery and exception outcomes arrive as `shipment.tracking.updated`: read `data.object.status`. The same holds for trackers, where there is no `tracker.delivered`: read `data.object.status` from `tracker.updated`. ::: ## The payload ```json { "id": "evt_1a2b3c4d5e", "type": "shipment.tracking.updated", "created": "2026-07-17T12:00:00.000Z", "livemode": true, "data": { "object": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "IN_TRANSIT", "trackingNumber": "1234567890", "carrierCode": "DHL", "organizationId": "clxorg123" } } } ``` - `created` is an **ISO-8601** timestamp, not epoch seconds. - `data.object` is the full resource projection, not a flat subset. Shipment events embed their `fromAddress`, `toAddress`, and `label` projections. - **There is no top-level `apiVersion` or `organizationId`.** A shipment's `data.object` does carry its `organizationId`; a tracker's does not. - `livemode: false` marks [test-mode](/en/getting-started/test-mode) events. Route them to your staging. - `id` is unique per event. Use it to deduplicate if you receive a repeated delivery. :::note `shipment.tracking.updated` does **not** carry `previousStatus`. To detect a specific transition, compare against the status you already stored. `tracker.updated` events do carry the previous status. ::: ## Verify the signature Every delivery arrives signed with HMAC-SHA256 in the `X-SendIt-Signature` header, formatted `t=,v1=`: ```http POST /webhooks/sendit HTTP/1.1 X-SendIt-Signature: t=1752750000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd Content-Type: application/json ``` **Always** verify before processing. The signature is the only proof the event came from SendIt: ```js Node.js import crypto from "node:crypto"; function verifySenditSignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) { const parts = Object.fromEntries( signatureHeader.split(",").map((kv) => kv.split("=")) ); const { t, v1 } = parts; // 1. Reject stale timestamps (replay protection) if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false; // 2. Recompute the signature over `${t}.${rawBody}` const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex"); // 3. Constant-time compare return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)); } ``` :::warning Compute the HMAC over the request's **raw body**, the exact bytes, not re-serialized JSON. `JSON.stringify(JSON.parse(body))` can produce different bytes and signatures that never match. ::: ## Respond fast, process later Return `2xx` as soon as you have persisted the event, ideally under a second. Process in the background. Anything other than `2xx` counts as a failure, including redirects. ## Retry policy | Attempt | Wait | | --- | --- | | 1 | Immediate | | 2 | 2 min | | 3 | 4 min | | 4 | 8 min | | 5 | 16 min | After the fifth attempt the event becomes `EXHAUSTED` and is not retried again. An endpoint that keeps failing for **24 hours is automatically disabled**. When that happens, we email your organization's admins. To re-enable it, fix your server and send a test delivery with `POST /v1/webhook-endpoints/:id/test`. A successful test re-enables the endpoint. Since deliveries can repeat, make your processing idempotent using the event `id`. ## Try it risk-free In [test mode](/en/getting-started/test-mode), every status advance fires real webhooks with `livemode: false`. Buy a test label, advance its status, and watch the deliveries arrive at your endpoint. ## Handle errors | Code | When it happens | How to resolve it | | --- | --- | --- | | `400 INVALID_INPUT` | The URL or an event type is invalid | Use HTTPS and a documented event | | `403 INSUFFICIENT_SCOPE` | The key lacks `webhooks:write` for a write | Issue a key with the required scope | | `403 PLAN_LIMIT_REACHED` | The organization reached its endpoint limit | Delete an unused endpoint or change plans | | `404 RESOURCE_NOT_FOUND` | The endpoint or event does not exist | Check the identifiers | --- # Autenticación y llaves de API Source: https://docs.sendit.mx/getting-started/authentication Toda petición al API se autentica con una llave de API. Las llaves pertenecen a una organización, tienen alcances (scopes) configurables y vienen en dos ambientes: prueba y producción. ## Anatomía de una llave ```text sk_test_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345 │ │ │ │ │ └── 32 caracteres aleatorios │ └── Ambiente: test (sandbox) o live (producción) └── Prefijo: sk = secret key ``` | Prefijo | Ambiente | Efecto | | --- | --- | --- | | `sk_test_` | Prueba | Paqueterías simuladas, saldo virtual — sin dinero real | | `sk_live_` | Producción | Guías reales, cargos reales a tu monedero | Una llave `sk_test_` nunca puede leer ni modificar datos de producción, ni al revés. El aislamiento es total. Consulta [Modo de prueba](/getting-started/test-mode). ## Envía tu llave Dos formas equivalentes; usa la que prefiera tu cliente HTTP: ```bash X-API-Key curl https://api.sendit.mx/v1/shipments \ -H "X-API-Key: sk_test_..." ``` ```bash Authorization curl https://api.sendit.mx/v1/shipments \ -H "Authorization: Bearer sk_test_..." ``` :::warning Tu llave es un secreto. Úsala solo desde tu servidor: nunca la incluyas en código de navegador, apps móviles ni repositorios. Guárdala en una variable de entorno o en un gestor de secretos. ::: ## Crea una llave Desde el dashboard (**Configuración → Llaves de API → Crear llave**) o por API: ```bash curl -X POST https://api.sendit.mx/v1/api-keys \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "Integración tienda en línea", "environment": "live", "scopes": ["shipments:write", "shipments:read", "rates:read", "labels:write", "tracking:read"] }' ``` ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `name` | `string` | - | Nombre descriptivo de la llave (para identificarla en el dashboard). | | `environment?` | `string` | `test` | test (sandbox) \| live (producción). | | `scopes?` | `string[]` | `["*"]` | Alcances de la llave. La lista completa está en Alcances. | | `ipAllowlist?` | `string[]` | - | (Beta) IPs o bloques CIDR desde los que la llave puede usarse. | La llave completa se muestra **una sola vez** en la respuesta. Si la pierdes, no hay forma de recuperarla: genera una nueva. En el dashboard identificas cada llave por su prefijo visible (`sk_live_aBcD...`). Cada plan tiene un tope de llaves activas. Crear una de más devuelve `403 PLAN_LIMIT_REACHED`. El conteo excluye las llaves en su ventana de rotación de 24 h. Ver [planes y cuotas](/wallet-and-billing/subscriptions-and-quotas). ## Limita el alcance de cada llave Cada llave lleva una lista de alcances con el patrón `recurso:acción`. Una llave solo puede hacer lo que sus alcances permiten. Todo lo demás responde `403`. ```json { "name": "Integración tienda en línea", "scopes": [ "shipments:write", "shipments:read", "rates:read", "labels:write", "tracking:read" ] } ``` Emite cada llave con el mínimo privilegio que necesita esa integración. La lista completa de alcances y su semántica está en [Alcances](/api-conventions/scopes). :::note **Una llave con alcances no puede listar llaves.** `GET /v1/api-keys` se protege por rol y no declara alcance, y una ruta así rechaza cualquier llave que no traiga `*`. Una llave con `api_keys:read` recibe `403`. Para listar llaves usa una sesión del dashboard con rol ADMIN o superior. El alcance `api_keys:read` sí gobierna los [registros de peticiones](/api-conventions/request-logs). ::: ## Rota una llave 1. Genera una llave nueva con los mismos alcances. 2. Actualiza tu integración para usar la nueva. 3. Revoca la anterior. Mantén ambas activas durante la transición y vigila el campo `lastUsedAt` de la llave vieja para confirmar que ya nadie la usa antes de revocarla. ## Restringe por IP Beta Esta función está en beta. Su comportamiento puede ajustarse antes de la versión final. Opcionalmente, limita una llave a un rango de IPs con una lista CIDR. Una petición desde una IP fuera de la lista se rechaza aunque la llave sea válida. Úsalo en llaves de producción que solo deben usarse desde tus servidores. ## Usuarios del dashboard Quien inicia sesión en el dashboard se autentica con una sesión de usuario y opera bajo el rol que tiene en la organización: | Rol | Puede | | --- | --- | | VIEWER | Solo lectura | | OPERATOR | Crear y gestionar envíos, direcciones, paquetes; comprar guías | | ADMIN | Todo lo anterior + miembros, configuración y llaves de API | | OWNER | Todo + facturación y plan | Los roles aplican a personas; los alcances aplican a llaves. Para integraciones servidor a servidor usa siempre llaves de API. ## Errores de autenticación | Código | Cuándo ocurre | Cómo resolverlo | | --- | --- | --- | | `401 UNAUTHORIZED` | Falta la llave o el encabezado está mal formado | Envía `X-API-Key` o `Authorization: Bearer sk_...` | | `401 INVALID_API_KEY` | La llave no existe o fue revocada | Verifica que copiaste la llave completa; genera una nueva si fue revocada | | `401 EXPIRED_API_KEY` | La llave pasó su fecha de expiración | Genera una llave nueva y actualiza tu integración | | `403 INSUFFICIENT_SCOPE` | La llave es válida pero le faltan alcances (listados en `details`) | Agrega el alcance necesario o usa una llave con permisos suficientes | --- # Organizaciones y sandbox personal Source: https://docs.sendit.mx/getting-started/organizations-and-sandbox Todo en SendIt vive dentro de una organización: envíos, direcciones, monedero y llaves. Tu cuenta puede pertenecer a varias y cambiar de contexto sin volver a iniciar sesión. ## Tu organización personal Al registrarte, SendIt crea una **organización personal**. Es una organización completa que siempre te acompaña. Lo único que no puedes hacer es abandonarla o eliminarla: es el hogar permanente de tu cuenta. | Propiedad | Valor al crearse | | --- | --- | | Plan | Gratuito (puedes subir de plan cuando quieras) | | Saldo real | $0 MXN — se fondea por CLABE/SPEI, tarjeta o PayPal | | Saldo de prueba | $10,000 MXN virtuales, restablecibles cuando quieras | | Llave por defecto | `sk_test_...` con todos los alcances | Con esa llave de prueba puedes hacer tu [primera guía](/getting-started/quickstart) en minutos, sin registrar ningún método de pago. ## El sandbox es un modo, no otra cuenta No existe una "cuenta sandbox" separada. **Toda** organización tiene los dos modos, sea personal o de equipo. Cambias entre ellos con el prefijo de tu llave (`sk_test_` o `sk_live_`) o, en el dashboard, con el interruptor de modo (`?livemode=false`). Los detalles del comportamiento simulado están en [Modo de prueba](/getting-started/test-mode). ### Los dos saldos del monedero Cada monedero mantiene dos saldos totalmente separados: - **Saldo real.** Se fondea por CLABE/SPEI, tarjeta o PayPal. Las compras de guías en producción lo debitan. Las alertas de saldo bajo aplican solo aquí. - **Saldo de prueba.** Virtual, inicia en $10,000 MXN. Las compras en modo de prueba lo debitan. El dinero real jamás se toca, y la actividad de prueba nunca dispara alertas. `GET /v1/wallet/balance` y `GET /v1/wallet/transactions` responden según el modo de la petición: con `sk_test_` ves el saldo y los movimientos de prueba; con `sk_live_`, solo dinero real. ## Organizaciones de equipo Crea organizaciones adicionales para separar negocios, clientes o ambientes: ```bash curl -X POST https://api.sendit.mx/v1/organizations \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "Mi Tienda MX" }' ``` Y cambia el contexto activo de tu cuenta: ```bash curl -X POST https://api.sendit.mx/v1/organizations/switch/{organizationId} \ -H "Authorization: Bearer " ``` Todo lo que crees queda dentro de la organización (y el modo) activos al momento de crearlo. Las llaves de API no necesitan "cambiar" de organización: cada llave pertenece a una y opera siempre sobre ella. ### Personal vs equipo | Acción | Org. personal | Org. de equipo | | --- | --- | --- | | Fondeo (CLABE, tarjeta, PayPal) | Sí | Sí | | Subir de plan | Sí | Sí | | Invitar miembros | Sí | Sí | | Llaves de prueba y producción | Sí | Sí | | Restablecer saldo de prueba | Sí | Sí | | Abandonar | **No (403)** — es el hogar de tu cuenta | Sí (si no eres el único OWNER) | ## Invita a tu equipo Los miembros se invitan por correo con un rol: VIEWER, OPERATOR, ADMIN u OWNER (ver [roles](/getting-started/authentication#usuarios-del-dashboard)). Las invitaciones expiran a los 7 días y el enlace de aceptación está ligado al correo invitado. ## Elimina tu cuenta Cerrar tu cuenta es **irreversible** y surte efecto de inmediato: ```bash curl -X DELETE https://api.sendit.mx/v1/users/me \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "confirm": "DELETE" }' ``` - **Solo con sesión de usuario.** Las llaves de API no pueden eliminar cuentas: este endpoint las rechaza. Actúa sobre tu propia identidad, así que no requiere ningún rol. - El cuerpo debe traer exactamente `{ "confirm": "DELETE" }`. Cualquier otro valor devuelve `400`. Es una guarda deliberada contra llamadas accidentales. ### Antes de eliminar Si eres el **único OWNER de alguna organización de equipo**, la eliminación se rechaza con `SOLE_OWNER_OF_ORGANIZATION`. Los `organizationIds` vienen en `details`. Promueve a otro miembro a OWNER en cada una y reintenta. Tu organización personal nunca bloquea la eliminación: se cierra como parte del proceso. ### Qué pasa al eliminar 1. Sales de todas las organizaciones de las que eras miembro. 2. Tu organización personal se cierra: sus llaves de API se revocan, sus webhooks se deshabilitan y sus invitaciones pendientes se cancelan. 3. Tu perfil se anonimiza (nombre, teléfono, avatar y correo). La cuenta ya no puede iniciar sesión y la misma identidad no puede registrarse de nuevo. ### Saldo del monedero Un saldo pendiente **no** bloquea la eliminación. Si tu monedero real tiene saldo a favor al momento de eliminar, se abre un caso de soporte para liquidarlo contigo. La respuesta incluye su `refundCaseId`. El saldo de prueba es virtual y se descarta. ### Qué se conserva (y por qué) La eliminación nunca borra el historial financiero y de auditoría que la ley exige conservar. Ese historial se **anonimiza y se retiene**, ligado a la organización cerrada y jamás a tu persona: | Se conserva | Por qué | | --- | --- | | Facturas CFDI y datos fiscales | El SAT exige conservarlas al menos 5 años | | Transacciones y asientos del monedero | Retención regulatoria financiera (≈ 7 años) | | Bitácora de auditoría | Integridad verificable del historial de la organización | Todo lo que te identifica personalmente (nombre, correo, teléfono, avatar) se elimina. Cualquier suscripción de pago activa se cancela como parte del cierre. --- # Pruébalo en Postman Source: https://docs.sendit.mx/getting-started/postman Todo el API de SendIt está descrito en una especificación **OpenAPI 3.1** pública. Impórtala en Postman y tendrás cada endpoint listo para disparar, con sus parámetros, cuerpos y respuestas. No escribes ninguna petición a mano. ```text https://docs.sendit.mx/openapi.yaml ``` ## Importa el API en Postman 1. **Importa la especificación** En Postman: **Import → Link**, pega `https://docs.sendit.mx/openapi.yaml` y confirma. Postman genera una colección con todos los endpoints, organizados por área: envíos, cotizaciones, guías, monedero, órdenes y webhooks. 2. **Configura tus variables** En la colección, abre **Variables** y define: | Variable | Valor | | --- | --- | | `baseUrl` | `https://api.sendit.mx/v1` (ya viene de la especificación) | | `apiKey` | Tu llave de prueba `sk_test_...` | La colección autentica con el encabezado `X-API-Key`. Usa tu [llave de prueba](/getting-started/authentication) para experimentar sin dinero real. 3. **Dispara el flujo esencial** Con el [modo de prueba](/getting-started/test-mode) activo (llave `sk_test_`): 1. `POST /shipments` crea un envío. La respuesta trae `rates[]`. 2. `POST /shipments/{id}/label` compra con un `rateId`. Agrega el encabezado `Idempotency-Key`. 3. `GET /shipments/{id}` te muestra el envío avanzar. El mismo recorrido del [quickstart](/getting-started/quickstart), ahora con clics. La misma especificación funciona en **Insomnia**, **Bruno**, **Hoppscotch** y cualquier generador de clientes compatible con OpenAPI 3.1. ## Integra agentes de IA Esta documentación es legible por máquinas, sin scraping: | Recurso | Qué es | | --- | --- | | [`/llms.txt`](/llms.txt) | Índice compacto de toda la documentación: cada página con su resumen, organizada por secciones. El punto de entrada para un agente. | | [`/llms-full.txt`](/llms-full.txt) | El corpus completo: el markdown íntegro de cada página en un solo archivo. | | Cualquier URL + `.md` | El markdown crudo de esa página — por ejemplo, [`/getting-started/quickstart.md`](/getting-started/quickstart.md). | | Botón "Copy as Markdown" | En cada página, bajo la tabla de contenidos — copia la página lista para pegarla en un chat o un issue. | Apunta a tu agente (Claude Code, Cursor, Copilot) a `https://docs.sendit.mx/llms.txt` y podrá navegar la documentación completa por su cuenta. :::note `llms.txt`, `llms-full.txt` y `openapi.yaml` se publican con el sitio de producción. No están disponibles en un servidor de desarrollo local. ::: --- # Tu primera guía Source: https://docs.sendit.mx/getting-started/quickstart Aquí creas tu primera guía de envío en modo de prueba. El modo de prueba usa paqueterías simuladas y saldo virtual. No se mueve dinero real. Los pasos en producción son los mismos. Para pasar a producción, cambia la llave. Necesitas una cuenta de SendIt y tu llave de prueba (`sk_test_...`). La encuentras en el dashboard, en **Configuración → Llaves de API**, o vía `GET /v1/api-keys`. 1. **Crea un envío** Un envío describe origen, destino y paquete. La respuesta trae el envío recién creado y `rates[]`. En `rates[]` vienen las cotizaciones de todas las paqueterías disponibles para esa ruta. ```bash curl curl -X POST https://api.sendit.mx/v1/shipments \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "from": { "contactName": "Almacén CDMX", "contactPhone": "+5215512345678", "street": "Av. Insurgentes Sur", "exteriorNumber": "1602", "neighborhood": "Crédito Constructor", "city": "Ciudad de México", "state": "CDMX", "postalCode": "03940", "country": "MX" }, "to": { "contactName": "María López", "contactPhone": "+5213312345678", "street": "Av. López Mateos", "exteriorNumber": "45", "neighborhood": "Jardines del Sol", "city": "Zapopan", "state": "JAL", "postalCode": "45050", "country": "MX" }, "parcel": { "length": 30, "width": 20, "height": 15, "weight": 2.5 } }' ``` ```js Node.js const response = await fetch("https://api.sendit.mx/v1/shipments", { method: "POST", headers: { "X-API-Key": process.env.SENDIT_API_KEY, // sk_test_... "Content-Type": "application/json", }, body: JSON.stringify({ from: { contactName: "Almacén CDMX", contactPhone: "+5215512345678", street: "Av. Insurgentes Sur", exteriorNumber: "1602", neighborhood: "Crédito Constructor", city: "Ciudad de México", state: "CDMX", postalCode: "03940", country: "MX", }, to: { contactName: "María López", contactPhone: "+5213312345678", street: "Av. López Mateos", exteriorNumber: "45", neighborhood: "Jardines del Sol", city: "Zapopan", state: "JAL", postalCode: "45050", country: "MX", }, parcel: { length: 30, width: 20, height: 15, weight: 2.5 }, }), }); const { data: shipment } = await response.json(); console.log(shipment.id, shipment.rates.length); ``` La respuesta (recortada) trae el envío en estado `DRAFT` y sus cotizaciones: ```json { "success": true, "data": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "DRAFT", "rates": [ { "id": "DHL_standard_a1b2c3", "carrierCode": "DHL", "serviceCode": "EXPRESS_WORLDWIDE", "serviceName": "DHL Express Nacional", "serviceLevel": "standard", "totalPrice": 326.82, "currency": "MXN", "estimatedDays": { "min": 1, "max": 2 }, "expiresAt": "2026-07-18T14:30:00.000Z" }, { "id": "ESTAFETA_economy_x9y8z7", "carrierCode": "ESTAFETA", "serviceCode": "TERRESTRE", "serviceName": "Estafeta Terrestre", "serviceLevel": "economy", "totalPrice": 289.50, "currency": "MXN", "estimatedDays": { "min": 3, "max": 5 }, "expiresAt": "2026-07-18T14:30:00.000Z" } ], "ratesStatus": "ready", "ratesExpiresAt": "2026-07-18T14:30:00.000Z" } } ``` 2. **Elige una cotización** Cada elemento de `rates[]` es una oferta en firme. Su `totalPrice` incluye IVA y es **exactamente** lo que se debitará de tu monedero. Elige por precio, por velocidad o por paquetería. Para el siguiente paso solo necesitas el `id` de la tarifa. ```js Node.js const cheapest = shipment.rates .slice() .sort((a, b) => a.totalPrice - b.totalPrice)[0]; console.log(cheapest.id); // "ESTAFETA_economy_x9y8z7" ``` Las cotizaciones son válidas 24 horas. Si expiran, pide unas frescas con `GET /v1/shipments/:id/rates?refresh=true`. :::note Si ya sabes con qué paquetería enviar, o quieres la más barata, agrega el objeto `purchase` al crear el envío. La guía llega en la misma respuesta. Ver [Compra en una llamada](/shipping/shipments#compra-en-una-llamada). ::: 3. **Compra la guía** Envía el `rateId` elegido. El encabezado `Idempotency-Key` es opcional, pero mándalo en esta llamada. Si tu petición se interrumpe y la reintentas con la misma llave, no habrá doble cargo. ```bash curl curl -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/label \ -H "X-API-Key: sk_test_..." \ -H "Idempotency-Key: 1f0e6f0e-59a4-4a6f-9d2e-9b1a7b2c3d4e" \ -H "Content-Type: application/json" \ -d '{ "rateId": "ESTAFETA_economy_x9y8z7" }' ``` ```js Node.js const purchase = await fetch( `https://api.sendit.mx/v1/shipments/${shipment.id}/label`, { method: "POST", headers: { "X-API-Key": process.env.SENDIT_API_KEY, "Idempotency-Key": crypto.randomUUID(), "Content-Type": "application/json", }, body: JSON.stringify({ rateId: cheapest.id }), } ); const { data: label } = await purchase.json(); console.log(label.trackingNumber, label.labelUrl); ``` ```json { "success": true, "data": { "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "labelId": "clxlbl456abc789def012ghi", "trackingNumber": "TEST-ESTAFETA-A1B2C3D4", "labelUrl": "https://labels.sendit.mx/test/clxq1w2e3r4t5y6u7i8o9p0a/TEST-ESTAFETA-A1B2C3D4.pdf", "carrierCode": "ESTAFETA", "serviceName": "Estafeta Terrestre", "charged": "289.50", "currency": "MXN", "walletBalanceAfter": "9710.50", "breakdown": { "ivaAmount": "39.93", "overageCharge": "0.00", "total": "289.50" } } } ``` Descarga el PDF de `labelUrl`. Esa es tu guía. El cargo salió del saldo virtual de prueba, no de dinero real. 4. **Ve tu envío avanzar** En modo de prueba el paquete simula su viaje solo. Va de `PICKED_UP` a `DELIVERED` en menos de media hora. Para no esperar, avanza el estado a mano: ```bash curl -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/test/advance-status \ -H "X-API-Key: sk_test_..." ``` Cada llamada avanza un paso: ```text LABEL_PURCHASED → READY_FOR_PICKUP → PICKED_UP → IN_TRANSIT → OUT_FOR_DELIVERY → DELIVERED ``` Consulta el estado y el historial de eventos en cualquier momento con `GET /v1/shipments/:id`. ## Siguientes pasos Escenarios de falla, saldo virtual y todo lo que simula el sandbox. Deja el polling. Recibe cada evento en tu servidor. Direcciones guardadas, seguro, referencias externas y más. Cómo reintentar sin riesgo en los endpoints que mueven dinero. --- # Modo de prueba Source: https://docs.sendit.mx/getting-started/test-mode Toda organización de SendIt incluye un ambiente de prueba aislado. Replica producción sin tocar dinero real ni paqueterías reales. Se activa con una llave `sk_test_...` y no requiere ninguna otra configuración. ## Cómo se determina el modo | Autenticación | Modo | | --- | --- | | Llave `sk_test_...` | Prueba | | Llave `sk_live_...` | Producción | | Sesión del dashboard | Producción por defecto; agrega `?livemode=false` para ver datos de prueba | El modo se resuelve una vez por petición y aplica a todas las lecturas y escrituras. Una petición de prueba no puede leer ni modificar datos de producción, ni al revés, aunque conozca el ID exacto del recurso. ## Qué simula el modo de prueba | Función | Comportamiento en prueba | | --- | --- | | Llamadas a paqueterías | Totalmente simuladas — ninguna petición llega a DHL, FedEx ni Estafeta | | Números de rastreo | `TEST-{PAQUETERÍA}-{aleatorio}` — imposibles de confundir con reales | | URL de la guía | `https://labels.sendit.mx/test/{shipmentId}/{trackingNumber}.pdf` | | Cargos al monedero | Se debitan del **saldo de prueba** virtual (inicia en $10,000 MXN); el saldo real nunca se toca | | Webhooks | Se entregan normalmente, con `"livemode": false` en el payload | | Avance de estado | Automático (simulación pausada) o manual vía endpoint de prueba | | Cuota mensual | No cuenta | | Límites de tasa | Cubeta aparte al 25% del límite de tu plan, con piso de 10 peticiones por minuto | El tráfico de prueba nunca consume la cubeta de producción. En el plan FREE, por ejemplo, un límite de cotización de 20/min queda en 10/min para `sk_test_...`. Las cubetas de lectura, escritura y cotización siguen siendo independientes. Ver [límites de tasa](/api-conventions/rate-limits). ## Ve un envío completo en minutos Cada paquete rastreado avanza solo, simulando un viaje real anclado al momento en que empezó el rastreo: ```text LABEL_CREATED (+0 min) → PICKED_UP (~+4) → IN_TRANSIT (~+9) → OUT_FOR_DELIVERY (~+14) → DELIVERED (~+27) ``` Algunos números de rastreo se entregan directamente desde `IN_TRANSIT`, igual que en el mundo real. Puedes observar el viaje completo, con los webhooks de cada transición, en menos de media hora y sin llamar nada. ### Avanza el estado manualmente ¿No quieres esperar? Avanza un paso por llamada: ```bash curl -X POST https://api.sendit.mx/v1/shipments/{id}/test/advance-status \ -H "X-API-Key: sk_test_..." ``` ```text LABEL_PURCHASED → READY_FOR_PICKUP → PICKED_UP → IN_TRANSIT → OUT_FOR_DELIVERY → DELIVERED ``` Cada llamada devuelve el nuevo estado y registra el evento en el historial del envío. ## Simula fallas y devoluciones Incluye una palabra clave en el `contactName` del destinatario para activar progresiones alternas: | Palabra en contactName | Progresión | | --- | --- | | `SENDIT_FAIL` | `... IN_TRANSIT → FAILED` | | `SENDIT_RETURN` | `... IN_TRANSIT → RETURNED` | | *(ninguna)* | `... → DELIVERED` (por defecto) | ```json { "contactName": "Cliente Prueba SENDIT_FAIL", "...": "..." } ``` Úsalo para probar tus flujos de entrega fallida y devolución antes de que ocurran con clientes reales. ## Distingue eventos de prueba en tus webhooks Todo payload de webhook originado en modo de prueba lleva `livemode: false`: ```json { "id": "evt_...", "type": "shipment.tracking.updated", "livemode": false, "data": { "...": "..." } } ``` Tu handler debe revisar `livemode` para enrutar los eventos correctamente entre tus ambientes de staging y producción. ## Restablece el saldo de prueba Cuando el saldo virtual se agote, restáuralo. Funciona en cualquier organización y no tiene límite: ```bash curl -X POST https://api.sendit.mx/v1/wallet/test/reset \ -H "Authorization: Bearer " ``` ```json { "data": { "balance": 10000, "currency": "MXN" }, "message": "Test wallet reset to $10,000 MXN" } ``` El saldo real nunca se ve afectado por el reset. ## Consulta datos de prueba con una sesión del dashboard Agrega `?livemode=false` a los listados para leer datos de prueba con un JWT: ```http GET /v1/shipments?livemode=false Authorization: Bearer ``` Solo acepta las cadenas `true` y `false`. Cualquier otro valor, incluidos `0`, `1`, `yes` o vacío, devuelve `400 VALIDATION_ERROR`. Si lo omites, lees producción. El parámetro no aplica a las llaves de API: el ambiente de la llave manda siempre. Se acepta y se ignora. | Endpoint | Efecto de `?livemode=false` | | --- | --- | | `GET /v1/shipments` | Envíos de prueba | | `GET /v1/trackers` | Rastreadores de prueba | | `GET /v1/wallet/transactions`, `GET /v1/wallet/summary`, `GET /v1/wallet/balance` | Movimientos del saldo de prueba | | `GET /v1/orders` | Órdenes de prueba | | `GET /v1/api-requests` | [Registros](/api-conventions/request-logs) de peticiones de prueba | | `GET /v1/webhook-endpoints/:id/events` | Entregas de prueba | | `GET /v1/billing/invoices`, `/v1/products`, `/v1/carrier-services` | Se acepta, pero estos recursos no tienen modo: el resultado es el mismo | | `GET /v1/pickups` | Devuelve `400 LIVE_MODE_REQUIRED` | ## Qué es solo de producción Algunas operaciones se rechazan en modo de prueba antes de tocar cualquier recurso: - **Fondeo del monedero.** Las rutas de Stripe, CLABE, tarjeta, PayPal y OXXO devuelven `400 LIVE_MODE_REQUIRED` porque crean o exponen recursos de pago reales. Para saldo de prueba usa el reset de arriba. - **Recolecciones.** Programar, listar, consultar, cancelar y refrescar responden `400 LIVE_MODE_REQUIRED`. La única excepción es `GET /v1/pickups/carriers`, que solo devuelve capacidades y no distingue modo. - **Rastreo público.** Los tokens de prueba nunca resuelven en la [página pública](/shipping/public-tracking). ## Qué se comparte entre modos Las direcciones, los paquetes guardados y los códigos postales son recursos compartidos. Al crear un envío de prueba puedes usar cualquier dirección guardada, sin importar en qué modo se creó. La configuración de la organización, los miembros y las invitaciones también se comparten. :::note Los flujos documentados conservan las mismas formas de API entre TEST y LIVE. El comportamiento y el rendimiento de las paqueterías reales pueden ser distintos. ::: --- # Órdenes Source: https://docs.sendit.mx/orders/orders Una orden agrupa uno o más envíos bajo una sola transacción de negocio: el pedido de tu tienda, con sus artículos, su cliente y su estado de cumplimiento. Las órdenes llegan por API, se crean a mano en el dashboard, o las empujan las integraciones de e-commerce (Shopify, WooCommerce, Tiendanube, Mercado Libre). ## Endpoints | Método | Ruta | Descripción | | --- | --- | --- | | `POST` | `/v1/orders` | Crear una orden | | `GET` | `/v1/orders` | Listar órdenes (paginado) | | `GET` | `/v1/orders/:id` | Obtener una orden | | `PUT` | `/v1/orders/:id` | Actualizar (concurrencia optimista vía `If-Match`) | | `POST` | `/v1/orders/:id/cancel` | Cancelar una orden | | `DELETE` | `/v1/orders/:id` | Eliminar (borrado suave) | | `POST` | `/v1/orders/:id/shipments` | Ligar un envío existente a la orden | Las lecturas las puede hacer cualquier miembro autenticado. Toda escritura requiere rol OPERATOR o superior. ### Parámetros del cuerpo (crear y actualizar) | Prop | Type | Default | Description | | --- | --- | --- | --- | | `externalId?` | `string` | - | La referencia del canal (número de orden de tu tienda). | | `channel?` | `string` | - | MANUAL \| SHOPIFY \| WOOCOMMERCE \| TIENDANUBE \| MERCADOLIBRE \| API. | | `customerName?` | `string` | - | El comprador final. | | `customerEmail?` | `string` | - | Correo del comprador para notificaciones. | | `customerPhone?` | `string` | - | Teléfono E.164 del comprador. | | `customerOptInToWhatsapp?` | `boolean` | `false` | Solo true con consentimiento explícito del comprador (LFPDPPP). | | `shippingAddress?` | `objeto` | - | Dirección de entrega. Se congela en snapshot al crear la orden. | | `lineItems?` | `objeto[]` | - | Los artículos (ver la sección de artículos). Llaves desconocidas se rechazan. | | `subtotal?` | `number` | - | Subtotal del pedido, como lo pagó el cliente. | | `shippingCost?` | `number` | - | Envío cobrado al cliente en el checkout. | | `totalPrice?` | `number` | - | Total del pedido desde la vista del cliente. | | `currency?` | `string` | `MXN` | | | `codEnabled?` | `boolean` | `false` | Contra reembolso: la paquetería cobra al entregar. | | `codAmount?` | `number` | - | Monto a cobrar contra reembolso. | | `codPaymentMethod?` | `string` | - | Método de cobro (p. ej. CASH). | | `codCollected?` | `boolean` | - | Márcalo al confirmar el cobro (solo actualización). | | `status?` | `string` | - | PENDING \| CONFIRMED \| PROCESSING \| SHIPPED \| DELIVERED \| CANCELLED \| REFUNDED. | | `paymentMethod?` | `string` | - | Cómo pagó el comprador en tu checkout. | | `paymentStatus?` | `string` | - | PENDING \| PAID \| REFUNDED \| FAILED \| CHARGEBACK. | | `metadata?` | `objeto` | - | Pares llave-valor tuyos; se devuelven tal cual. | ## El objeto orden Los campos principales: | Campo | Descripción | | --- | --- | | `externalId` | La referencia del canal (número de orden de Shopify, por ejemplo) | | `channel` | `MANUAL`, `SHOPIFY`, `WOOCOMMERCE`, `TIENDANUBE`, `MERCADOLIBRE`, `API` | | `customerName` / `customerEmail` / `customerPhone` | El comprador final | | `customerOptInToWhatsapp` | `false` por defecto. Ponlo en `true` **solo** con el consentimiento explícito del comprador (LFPDPPP) — habilita las [notificaciones por WhatsApp](/webhooks-and-events/notifications) | | `shippingAddress` | Snapshot de la dirección de entrega al momento de la orden | | `lineItems` | Los artículos (ver abajo) | | `subtotal`, `shippingCost`, `totalPrice`, `currency` | La economía del pedido, como la pagó el cliente | | `status` | `PENDING`, `CONFIRMED`, `PROCESSING`, `SHIPPED`, `DELIVERED`, `CANCELLED`, `REFUNDED` | | `paymentMethod` / `paymentStatus` | Cómo y en qué estado pagó el comprador. `paymentStatus` es `PENDING`, `PAID`, `REFUNDED`, `FAILED` o `CHARGEBACK` | | `fulfillmentStatus` | `UNFULFILLED`, `PARTIAL`, `FULFILLED`, `RETURNED`. Calculado desde los envíos | | `shipmentCount` / `labeledShipmentCount` / `cancelledShipmentCount` | Contadores de envíos ligados, con guía y cancelados | | `mode` | `LIVE` o `TEST`, según la llave con la que se creó | | `missingFields` | Campos que la integración no pudo completar (ver abajo) | | `etag` | Token de concurrencia optimista | | `cancelledAt` | Cuándo se canceló la orden, si aplica | | `metadata` | Tus pares llave-valor | ## Artículos (line items) Cada artículo lleva `qty` y puede venir **totalmente inline** o ligarse al [catálogo de productos](/orders/products): - **`productId`** es la liga explícita. El nombre, precio, SKU, peso y datos aduanales del producto se **congelan en el artículo** al crear la orden. Los campos explícitos de tu petición siempre ganan sobre los del catálogo. Un `productId` inexistente devuelve `400 PRODUCT_NOT_FOUND`. - **Solo `sku`** liga el artículo si coincide con un producto activo del catálogo, con el mismo snapshot. Un SKU desconocido se queda inline y no crea nada. - **Inline puro** no usa el catálogo. `name` y `unitPrice` son obligatorios, y faltan devuelve `400 LINE_ITEM_INCOMPLETE`. Si omites `totalPrice`, se calcula como `qty × unitPrice`. Los artículos se validan estrictamente y las llaves desconocidas se rechazan. Pon los extras de tu canal en el `metadata` de cada artículo. Editar o borrar un producto **jamás** modifica órdenes existentes: el snapshot manda. :::note Los montos dentro de `lineItems` viajan como números JSON, no como cadenas decimales. Es una excepción al resto del API. Los totales de la orden siguen la convención normal. ::: Filtra órdenes por producto: `GET /v1/orders?productId=prod_...`. ## Actualiza con concurrencia optimista `PUT /v1/orders/:id` acepta el encabezado `If-Match` con el `etag` actual de la orden. Si otro proceso la modificó antes, recibes `400`. Trae la versión fresca y reintenta: ```bash curl -X PUT https://api.sendit.mx/v1/orders/ord_abc123 \ -H "X-API-Key: sk_live_..." \ -H 'If-Match: "abc123def456"' \ -H "Content-Type: application/json" \ -d '{ "status": "CONFIRMED" }' ``` ```json { "success": false, "error": { "code": "INVALID_INPUT", "message": "The order was modified by another request.", "details": { "code": "PRECONDITION_FAILED" } } } ``` :::warning El código específico viaja en `error.details.code`, no en `error.code`. Programa contra `details.code` para distinguir este caso de otras validaciones. ::: ## Cancela con guardas `POST /v1/orders/:id/cancel` se rechaza con `400` si algún envío de la orden tiene una guía activa: ```json { "success": false, "error": { "code": "INVALID_INPUT", "message": "Order has active labels. Void all labels before cancelling.", "details": { "code": "ORDER_HAS_ACTIVE_LABELS", "labelIds": ["clxlbl_xxx", "clxlbl_yyy"] } } } ``` [Cancela cada guía](/shipping/refunds) listada y reintenta la cancelación. ## Órdenes incompletas de integraciones Cuando una tienda empuja una orden con datos faltantes (una colonia vacía, un teléfono ausente), el campo `missingFields` lo registra: ```json { "missingFields": ["shippingAddress.neighborhood", "customerPhone"] } ``` Filtra tus órdenes pendientes de atención y complétalas con `PUT /v1/orders/:id` antes de generar sus guías. ## Contra reembolso (COD) Habilítalo al crear la orden: ```json { "codEnabled": true, "codAmount": 850.00, "codPaymentMethod": "CASH" } ``` Cuando la paquetería entregue y cobre, marca el cobro con `PUT /v1/orders/:id` y `codCollected: true`. La orden guarda la marca de tiempo en `codCollectedAt`. ## Liga un envío existente ```bash curl -X POST https://api.sendit.mx/v1/orders/ord_abc123/shipments \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "shipmentId": "shp_a1b2c3d4e5f6" }' ``` ## Filtra por canal y cumplimiento ```text GET /v1/orders?channel=SHOPIFY&status=PENDING GET /v1/orders?fulfillmentStatus=UNFULFILLED ``` | Filtro | Valores | | --- | --- | | `channel` | `MANUAL`, `SHOPIFY`, `WOOCOMMERCE`, `TIENDANUBE`, `MERCADOLIBRE`, `API` | | `status` | `PENDING`, `CONFIRMED`, `PROCESSING`, `SHIPPED`, `DELIVERED`, `CANCELLED`, `REFUNDED` | | `fulfillmentStatus` | `UNFULFILLED`, `PARTIAL`, `FULFILLED`, `RETURNED`. Útil para un tablero de "pendientes de surtir" | | `externalId` | La referencia de tu canal, coincidencia exacta | | `productId` | Órdenes que incluyen ese producto | | `page` / `limit` | Paginación. Por defecto 1 y 20 | Las órdenes creadas por integraciones llevan el `externalId` del canal, listo para conciliar contra tu tienda. --- # Catálogo de productos Source: https://docs.sendit.mx/orders/products El catálogo de productos es opcional. Los artículos de una orden funcionan inline sin configurar nada. Si vendes los mismos productos una y otra vez, el catálogo te ahorra repetir nombre, precio, peso y datos aduanales: se copian solos a cada orden. ## Endpoints | Método | Ruta | Alcance | Descripción | | --- | --- | --- | --- | | `POST` | `/v1/products` | `products:write` | Crear un producto | | `GET` | `/v1/products` | `products:read` | Listar productos activos (`?sku=` exacto, `?search=` contiene, `?page=`, `?limit=`) | | `GET` | `/v1/products/all` | `products:read` (ADMIN) | Listar todos, incluidos inactivos | | `GET` | `/v1/products/:id` | `products:read` | Obtener un producto | | `PUT` | `/v1/products/:id` | `products:write` | Actualizar un producto | | `DELETE` | `/v1/products/:id` | `products:write` (ADMIN) | Eliminar (borrado suave) | ## Crea un producto ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `name` | `string` | - | Nombre del producto. | | `sku?` | `string` | - | Único entre tus productos no eliminados. Habilita el auto-vínculo por SKU. | | `description?` | `string` | - | Descripción del producto. | | `price?` | `number` | - | Precio por unidad; se copia al artículo de la orden al vincular. | | `currency?` | `string` | `MXN` | | | `weight?` | `number` | - | Peso en kg. Es el valor por defecto para empacar y cotizar. | | `length?` | `number` | - | Largo en cm. | | `width?` | `number` | - | Ancho en cm. | | `height?` | `number` | - | Alto en cm. | | `hsCode?` | `string` | - | Fracción arancelaria (envíos internacionales). | | `satProductClassCode?` | `string` | - | Clave de clasificación del producto ante el SAT. | | `countryOfOrigin?` | `string` | - | País de origen (ISO). | | `customsDescription?` | `string` | - | Descripción para aduana. | | `declaredValue?` | `number` | - | Valor declarado por unidad (seguro y aduanas). | | `declaredValueCurrency?` | `string` | `MXN` | | | `shipsSeparately?` | `boolean` | `false` | El producto siempre viaja en su propio paquete. | | `imageUrl?` | `string` | - | URL de la imagen del producto. | | `isActive?` | `boolean` | `true` | Los inactivos no se listan por defecto y nunca se auto-vinculan. | | `metadata?` | `objeto` | - | Pares llave-valor tuyos; se devuelven tal cual. | ```bash curl -X POST https://api.sendit.mx/v1/products \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "sku": "TSHIRT-L-ROJO", "name": "Playera roja talla L", "description": "Playera 100% algodón, cuello redondo", "price": 450.00, "weight": 0.25, "length": 30, "width": 25, "height": 3, "hsCode": "610910", "satProductClassCode": "53102002", "countryOfOrigin": "MX", "declaredValue": 450.00 }' ``` ```json { "success": true, "data": { "id": "prod_x1y2z3", "sku": "TSHIRT-L-ROJO", "name": "Playera roja talla L", "description": "Playera 100% algodón, cuello redondo", "price": 450.00, "currency": "MXN", "weight": 0.25, "length": 30, "width": 25, "height": 3, "hsCode": "610910", "satProductClassCode": "53102002", "countryOfOrigin": "MX", "declaredValue": 450.00, "declaredValueCurrency": "MXN", "shipsSeparately": false, "isActive": true, "createdAt": "2026-07-18T10:00:00.000Z" } } ``` ## Cómo se vincula con las órdenes Al [crear una orden](/orders/orders#artículos-line-items), cada artículo puede referenciar el catálogo: 1. **Por `productId`.** Es el vínculo explícito. Los datos del producto se congelan en el artículo. 2. **Por `sku`.** Si el SKU coincide con un producto activo, se vincula solo. 3. **Sin vínculo.** El artículo vive inline con sus propios datos. En todos los casos, lo que queda en la orden es un **snapshot**: editar o borrar el producto después no toca ninguna orden existente. Lo que se congela en el artículo es: `name`, `price` (como `unitPrice`), `sku`, `weight`, `hsCode`, `satProductClassCode`, `countryOfOrigin`, `customsDescription` y `declaredValue`. :::note Los campos explícitos de tu petición siempre ganan sobre los valores del catálogo. El catálogo aporta valores por defecto, no imposiciones. ::: ## Reglas del SKU El `sku` es único entre tus productos **no eliminados**. Un producto inactivo sigue ocupando su SKU. | Código | Cuándo | Cómo resolverlo | | --- | --- | --- | | `400 SKU_ALREADY_EXISTS` | Creas o actualizas un producto con un SKU que ya usa otro | El `productId` en conflicto viene en `details`; usa otro SKU o edita ese producto | Al eliminar un producto (borrado suave), su SKU queda libre para reutilizarse. ## El catálogo no separa modos Los productos **no** están divididos entre producción y prueba. Tus órdenes `LIVE` y `TEST` apuntan al mismo catálogo, y una llave `sk_test_` ve exactamente los mismos productos. --- # Direcciones y validación Source: https://docs.sendit.mx/shipping/addresses Las direcciones mexicanas se modelan a nivel colonia, que es la granularidad que usan las paqueterías. SendIt valida contra el catálogo oficial de SEPOMEX y te da autocompletado de colonias para tus formularios. ## Guarda direcciones reutilizables | Método | Ruta | Alcance | Descripción | | --- | --- | --- | --- | | `POST` | `/v1/addresses` | `addresses:write` | Crear una dirección | | `GET` | `/v1/addresses` | `addresses:read` | Listar direcciones | | `GET` | `/v1/addresses/:id` | `addresses:read` | Obtener una dirección | | `PATCH` | `/v1/addresses/:id` | `addresses:write` | Actualizar una dirección | | `DELETE` | `/v1/addresses/:id` | `addresses:write` | Eliminar (borrado suave) | | `POST` | `/v1/addresses/:id/validate` | `addresses:write` | Validar una dirección guardada | ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `contactName` | `string` | - | Máximo 100 caracteres. | | `contactPhone` | `string` | - | Formato E.164 (+52...), máximo 20 caracteres. | | `contactEmail?` | `string` | - | Correo de contacto válido. | | `company?` | `string` | - | Máximo 100 caracteres. | | `street` | `string` | - | Máximo 200 caracteres. | | `exteriorNumber` | `string` | - | Máximo 20 caracteres. | | `interiorNumber?` | `string` | - | Máximo 20 caracteres. | | `neighborhood` | `string` | - | La colonia. Valídala con los endpoints de códigos postales. | | `city` | `string` | - | Máximo 100 caracteres. | | `state` | `string` | - | Acepta código ISO 3166-2:MX (MX-JAL) o abreviatura. | | `postalCode` | `string` | - | 4 a 6 dígitos. | | `country?` | `string` | `MX` | Código ISO de 2 letras. | | `reference?` | `string` | - | Referencias de entrega para el repartidor, máximo 200 caracteres. | | `isResidential?` | `boolean` | `true` | | | `isDefault?` | `boolean` | `false` | | | `latitude?` | `number` | - | -90 a 90. | | `longitude?` | `number` | - | -180 a 180. | ```bash curl -X POST https://api.sendit.mx/v1/addresses \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "contactName": "María López", "contactPhone": "+5213312345678", "contactEmail": "maria@ejemplo.mx", "company": "Tienda MX", "street": "Av. López Mateos", "exteriorNumber": "45", "interiorNumber": "B-2", "neighborhood": "Jardines del Sol", "city": "Zapopan", "state": "JAL", "postalCode": "45050", "country": "MX", "reference": "Portón negro, entre Av. Patria y Moctezuma", "isResidential": true }' ``` ```json { "success": true, "data": { "id": "clx_direccion_origen", "contactName": "María López", "contactPhone": "+5213312345678", "street": "Av. López Mateos", "exteriorNumber": "45", "neighborhood": "Jardines del Sol", "city": "Zapopan", "state": "JAL", "postalCode": "45050", "country": "MX", "isResidential": true, "isDefault": false, "isVerified": false, "createdAt": "2026-07-18T10:00:00.000Z" } } ``` Usa el `id` devuelto como `fromAddressId` / `toAddressId` al [crear envíos](/shipping/shipments#crea-un-envío). Borrar una dirección nunca afecta envíos históricos: cada envío congela su propia copia. ## Valida una dirección La verificación compara la dirección contra el catálogo SEPOMEX y devuelve validez, confianza y la versión normalizada: ```bash curl -X POST https://api.sendit.mx/v1/address-verifications \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "address": { "street": "Av. Insurgentes Sur", "exteriorNumber": "1235", "neighborhood": "Insurgentes Mixcoac", "postalCode": "03920", "city": "Ciudad de México", "state": "CDMX", "country": "MX" }, "provider": "SEPOMEX" }' ``` ```json { "success": true, "data": { "id": "av_1a2b3c4d5e", "isValid": true, "confidence": 0.95, "normalizedAddress": { "postalCode": "03920", "neighborhood": "Insurgentes Mixcoac", "city": "Ciudad de México", "state": "Ciudad de México", "country": "MX" }, "errors": [], "provider": "SEPOMEX", "createdAt": "2026-07-17T12:00:00.000Z" } } ``` ### Interpreta la confianza | `confidence` | Significado | | --- | --- | | `0.95` | Código postal encontrado y colonia coincidente | | `0.75` | Código postal encontrado; colonia sin coincidencia o no enviada | | `0.0` | Código postal inexistente | Cuando varias colonias comparten el código postal, `suggestions[]` trae las alternativas para que el usuario elija. La validación es **consultiva** y no bloquea la creación de envíos. Cada verificación queda registrada en `GET /v1/address-verifications`. Ese registro te sirve como evidencia ante paquetes no entregables. Para muchas direcciones, valida hasta 100 por llamada con `POST /v1/bulk/addresses/validate`. Ver [lotes](/shipping/batches#endpoints-bulk). ## Códigos postales y colonias Cuatro endpoints alimentados por el catálogo SEPOMEX, ideales para autocompletar formularios: ### Consulta un código postal ```text GET /v1/postal-codes/03100 ``` ```json { "success": true, "data": { "postalCode": "03100", "country": "MX", "state": { "code": "MX-CMX", "name": "Ciudad de México" }, "municipality": "Benito Juárez", "city": "Ciudad de México", "colonies": [ { "name": "Del Valle Centro", "type": "Colonia", "zone": "Urbano" }, { "name": "Del Valle Norte", "type": "Colonia", "zone": "Urbano" }, { "name": "Del Valle Sur", "type": "Colonia", "zone": "Urbano" } ] } } ``` Con una sola llamada pre-llenas ciudad, estado y el dropdown de colonias. Un código inexistente devuelve `404 RESOURCE_NOT_FOUND`. ### Busca colonias (autocompletado) ```text GET /v1/postal-codes/search?q=Del+Valle&stateCode=MX-CMX&limit=10 ``` Coincidencia parcial por nombre de colonia; filtra opcionalmente por estado. Máximo 50 resultados. ### Lista los estados ```text GET /v1/postal-codes/states ``` Devuelve los 32 estados con su código **ISO 3166-2:MX** (`MX-JAL`, `MX-NLE`, `MX-CMX`). Son los mismos códigos que acepta el resto del API. --- # Lotes y manifiestos Source: https://docs.sendit.mx/shipping/batches Un lote (batch) compra guías para hasta 100 envíos en una sola llamada. El procesamiento es asíncrono: la petición regresa de inmediato con un `batchId` y tú consultas el avance. ## Compra en lote ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `shipmentIds` | `string[]` | - | Hasta 100 IDs de envíos de tu organización. Los duplicados se deduplican; los que ya tienen guía se saltan. | ```bash curl -X POST https://api.sendit.mx/v1/batches \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "shipmentIds": ["clxship_aaa", "clxship_bbb", "clxship_ccc"] }' ``` Respuesta `202 Accepted`: ```json { "success": true, "data": { "id": "bat_xyz", "status": "PENDING", "totalShipments": 3, "purchasedCount": 0, "failedCount": 0, "createdAt": "2026-07-17T12:00:00.000Z" } } ``` Reglas del lote: - Máximo **100 envíos** por lote; todos de tu organización. - Los IDs duplicados se deduplican automáticamente. - Los envíos que ya tienen guía válida se saltan (cuentan como exitosos). ## Sigue el avance ```text PENDING → PROCESSING → COMPLETED | PARTIAL | FAILED ``` | Estado | Significado | | --- | --- | | `PENDING` | En cola, aún no inicia | | `PROCESSING` | Comprando guías | | `COMPLETED` | Todas las guías compradas | | `PARTIAL` | Algunas compradas, otras fallaron | | `FAILED` | Ninguna guía comprada | Consulta con `GET /v1/batches/:id`: ```json { "success": true, "data": { "id": "bat_xyz", "status": "PARTIAL", "totalShipments": 3, "purchasedCount": 2, "failedCount": 1, "purchasedShipmentIds": ["clxship_aaa", "clxship_bbb"], "failedItems": [ { "shipmentId": "clxship_ccc", "errorCode": "INSUFFICIENT_BALANCE", "errorMessage": "Wallet balance too low for this shipment" } ] } } ``` :::warning **Éxito parcial.** Las guías que sí se compraron **no** se revierten cuando otras fallan. Revisa `failedItems[]`, corrige la causa (saldo, cotización vencida) y vuelve a enviar solo los envíos fallidos en un lote nuevo. ::: ## Manifiestos (scan forms) Un manifiesto agrupa varias guías de la misma paquetería en un solo documento que el repartidor escanea una vez al recolectar. Se genera automáticamente al completarse el lote cuando todas las guías compradas son de la misma paquetería. El manifiesto viene en la respuesta del lote cuando está disponible: ```json { "scanForm": { "id": "scf_abc", "carrierCode": "DHL", "formUrl": "https://labels.sendit.mx/scan-forms/scf_abc.pdf", "formNumber": "MAN-DHL-20260717", "status": "GENERATED" } } ``` Imprime `formUrl` y entrégalo al repartidor junto con los paquetes. ## Endpoints bulk Para operaciones masivas de **lectura y validación**, y no de compra, usa los endpoints bulk. Tienen la misma semántica de éxito parcial, aceptan hasta 100 elementos y devuelven un resultado por elemento en el mismo orden: | Método | Ruta | Descripción | | --- | --- | --- | | `POST` | `/v1/bulk/addresses/validate` | Validar hasta 100 direcciones | | `POST` | `/v1/bulk/tracking/lookup` | Consultar eventos de hasta 100 números de rastreo | | `POST` | `/v1/bulk/shipments/fetch` | Traer hasta 100 envíos por ID | | `POST` | `/v1/bulk/orders/fetch` | Traer hasta 100 órdenes por ID | ```bash curl -X POST https://api.sendit.mx/v1/bulk/shipments/fetch \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "ids": ["clxship_aaa", "clxship_bbb", "clxship_zzz"] }' ``` ```json { "success": true, "data": [ { "id": "clxship_aaa", "found": true, "data": { "...": "..." } }, { "id": "clxship_bbb", "found": true, "data": { "...": "..." } }, { "id": "clxship_zzz", "found": false, "data": null } ] } ``` Un ID inexistente, o de otra organización, regresa `found: false` y nunca tumba el lote completo. Los recursos borrados cuentan como no encontrados. --- # Cuentas de paquetería Source: https://docs.sendit.mx/shipping/carrier-accounts Tu organización decide qué paqueterías participan en cada cotización, y con qué servicios. También puedes conectar tu propio contrato con DHL, FedEx o Estafeta. En ese caso la paquetería te factura el flete directamente, y SendIt solo cobra una tarifa por guía. :::note **Beta.** Todas las paqueterías están en beta. Ver [Paqueterías y servicios](/shipping/carriers) para el alcance de la etiqueta. ::: | Método | Ruta | Alcance | Descripción | | --- | --- | --- | --- | | `GET` | `/v1/carrier-preferences` | — | Listar todas las paqueterías con la configuración de tu organización | | `GET` | `/v1/carrier-preferences/:carrierCode` | — | Consultar una paquetería | | `PUT` | `/v1/carrier-preferences/:carrierCode` | `carrier_preferences:write` | Actualizar una paquetería | | `PATCH` | `/v1/carrier-preferences/bulk` | `carrier_preferences:write` | Habilitar o deshabilitar varias de golpe | | `DELETE` | `/v1/carrier-preferences/:carrierCode` | `carrier_preferences:write` | Restablecer una paquetería a sus valores por defecto | | `PUT` | `/v1/carrier-preferences/:carrierCode/credentials` | `carrier_preferences:write` | Guardar las credenciales de tu propia cuenta | | `DELETE` | `/v1/carrier-preferences/:carrierCode/credentials` | `carrier_preferences:write` | Borrar las credenciales y volver a la cuenta de SendIt | | `POST` | `/v1/carrier-preferences/:carrierCode/credentials/verify` | `carrier_preferences:write` | Verificar las credenciales guardadas | Los endpoints de escritura requieren rol `ADMIN` o superior. ## Consulta tu configuración ```bash curl https://api.sendit.mx/v1/carrier-preferences \ -H "X-API-Key: sk_test_..." ``` ```json { "success": true, "data": [ { "carrierCode": "FEDEX", "carrierName": "FedEx", "availableServices": [ { "serviceName": "FedEx Economy", "serviceLevel": "economy" }, { "serviceName": "FedEx Express", "serviceLevel": "express" } ], "supportsPickups": true, "isConfigured": true, "isEnabled": true, "enabledServices": ["economy", "express"], "defaultService": "express", "usesOwnAccount": false } ], "meta": { "count": 1 } } ``` | Campo | Descripción | | --- | --- | | `availableServices` | Los servicios que la paquetería ofrece, con su `serviceLevel` | | `supportsPickups` | Si acepta [recolecciones](/shipping/pickups) programadas | | `isConfigured` | Si tu organización ya guardó una configuración propia para esta paquetería | | `isEnabled` | Si participa en `POST /v1/rates` | | `enabledServices` | Servicios permitidos; `null` = todos | | `defaultService` | Servicio preseleccionado en tus formularios (solo conveniencia) | | `usesOwnAccount` | Si las cotizaciones y guías usan tu propia cuenta de paquetería | `GET /v1/carrier-preferences/:carrierCode` devuelve un solo objeto con la misma forma. ## Elige qué paqueterías cotizan ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `isEnabled?` | `boolean` | - | Si la paquetería participa en POST /v1/rates. | | `enabledServices?` | `arreglo` | - | Servicios permitidos (serviceLevel). Cada entrada debe existir en availableServices de esa paquetería. null = todos. | | `defaultService?` | `string` | - | Servicio preseleccionado. Debe existir en availableServices de esa paquetería. | ```bash curl -X PUT https://api.sendit.mx/v1/carrier-preferences/FEDEX \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "isEnabled": true, "enabledServices": ["express", "overnight"], "defaultService": "express" }' ``` La respuesta es el objeto de la paquetería ya actualizado. Para prender o apagar varias de una vez, manda la lista completa de las que quieres habilitadas. Las que no aparezcan quedan deshabilitadas: ```bash curl -X PATCH https://api.sendit.mx/v1/carrier-preferences/bulk \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "enabledCarriers": ["FEDEX", "DHL", "ESTAFETA"] }' ``` ```json { "success": true, "data": { "enabled": ["FEDEX", "DHL", "ESTAFETA"], "disabled": ["SENDEX", "AMPM"] } } ``` `DELETE /v1/carrier-preferences/FEDEX` restablece esa paquetería (`204`, sin cuerpo): vuelve a `isEnabled: true`, `enabledServices: null`, `defaultService: null` y borra las credenciales propias si las había. | Código | Cuándo | Cómo resolverlo | | --- | --- | --- | | `400 INVALID_INPUT` | `enabledServices` o `defaultService` traen un servicio que esa paquetería no ofrece | Usa un `serviceLevel` de su `availableServices` | | `403 INSUFFICIENT_SCOPE` | La llave no tiene `carrier_preferences:write` | Emite una llave con ese [alcance](/api-conventions/scopes) | | `404 RESOURCE_NOT_FOUND` | El `carrierCode` no existe en el catálogo | Revisa el [catálogo de paqueterías](/shipping/carriers) | ## Cómo afecta a tus cotizaciones | Endpoint | Respeta `isEnabled` | Respeta `enabledServices` | | --- | --- | --- | | `POST /v1/rates` | Sí | Sí | | `POST /v1/rates/carrier/:carrierCode` | No — pedir una paquetería explícitamente salta el interruptor | Sí | Así puedes ofrecer un flujo de cotización dirigido a una paquetería específica sin perder las restricciones de servicio que configuraste. ## Usa tu propia cuenta de paquetería Si tienes un contrato negociado con DHL, FedEx o Estafeta, guarda esas credenciales y SendIt cotizará y comprará con ellas. La paquetería te factura el flete a ti, bajo tu contrato; SendIt te cobra únicamente una tarifa por guía. ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `accountNumber?` | `string` | - | Número de cuenta que te dio la paquetería. | | `apiKey?` | `string` | - | Llave de API emitida por la paquetería. | | `apiSecret?` | `string` | - | Secreto de API emitido por la paquetería. | | `meta?` | `objeto` | - | Campos adicionales que pida esa paquetería en particular (por ejemplo meterNumber). | Manda **al menos uno** de los cuatro. ```bash curl -X PUT https://api.sendit.mx/v1/carrier-preferences/FEDEX/credentials \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "accountNumber": "123456789", "apiKey": "llave-de-la-paqueteria", "apiSecret": "secreto-de-la-paqueteria", "meta": { "meterNumber": "987654" } }' ``` La respuesta es el objeto normal de la paquetería, ahora con `usesOwnAccount: true`: ```json { "success": true, "data": { "carrierCode": "FEDEX", "carrierName": "FedEx", "isConfigured": true, "isEnabled": true, "enabledServices": null, "defaultService": null, "usesOwnAccount": true } } ``` La respuesta **nunca** devuelve las credenciales que enviaste. No las guardes en el estado de tu frontend ni las escribas en tus registros después de enviarlas. ### Verifica y borra ```bash curl -X POST https://api.sendit.mx/v1/carrier-preferences/FEDEX/credentials/verify \ -H "X-API-Key: sk_test_..." ``` ```json { "success": true, "data": { "carrierCode": "FEDEX", "valid": true } } ``` `DELETE /v1/carrier-preferences/FEDEX/credentials` borra las credenciales y devuelve el objeto con `usesOwnAccount: false`; a partir de ahí las cotizaciones vuelven a usar la cuenta de SendIt. | Código | Cuándo | Cómo resolverlo | | --- | --- | --- | | `400 INVALID_INPUT` | No mandaste ninguno de los cuatro campos | Incluye al menos `accountNumber`, `apiKey`, `apiSecret` o `meta` | | `403 INSUFFICIENT_SCOPE` | La llave no tiene `carrier_preferences:write` | Emite una llave con ese [alcance](/api-conventions/scopes) | | `404 RESOURCE_NOT_FOUND` | El `carrierCode` no existe, o no hay credenciales guardadas al verificar o borrar | Guarda las credenciales primero con `PUT` | ## Qué cambia en la cotización Una cotización con tu propia cuenta trae dos campos extra: ```json { "carrierCode": "FEDEX", "serviceLevel": "express", "totalPrice": 1.39, "currency": "MXN", "usesOwnAccount": true, "carrierChargeEstimate": 287.43, "breakdown": { "baseRate": 1.20, "fuelSurcharge": 0.00, "insuranceCost": 0.00, "subtotal": 1.20, "ivaRate": 0.16, "ivaAmount": 0.19, "total": 1.39 } } ``` - **`totalPrice`** es lo que SendIt te cobra: la tarifa por guía de tu plan más IVA. Es exactamente lo que se debita de tu [monedero](/wallet-and-billing/wallet) al comprar la guía, ni un peso más. - **`carrierChargeEstimate`** es el estimado del flete **de tu propio contrato**. Es informativo: nunca entra al monedero de SendIt ni a tu [CFDI](/wallet-and-billing/cfdi), porque la paquetería te lo factura a ti por separado. Puede diferir de la factura final si la paquetería aplica ajustes. - **`usesOwnAccount: true`** marca esa semántica. Las cotizaciones con la cuenta de SendIt no traen ninguno de los dos campos y no cambian en nada. Muéstralos por separado en tu interfaz: el cargo de SendIt y el estimado que te cobrará la paquetería son dos cosas distintas. ### Tarifa por guía | Plan | Tarifa por guía con tu cuenta (antes de IVA) | | --- | --- | | Free | $1.20 MXN | | Growth | $0.90 MXN | | Scale | $0.60 MXN | | Enterprise | $0.00 MXN | Esta tarifa reemplaza el precio normal de la guía y **no** se le suma el [excedente de plan](/wallet-and-billing/subscriptions-and-quotas): es el único cargo de SendIt por esa guía. La guía sí cuenta para tu consumo mensual. En Enterprise la tarifa es cero, así que no se genera ningún movimiento en el monedero, pero la guía se crea y se contabiliza igual. ## Reglas al cambiar de cuenta - **Vuelve a cotizar** después de guardar o borrar credenciales. Las cotizaciones anteriores describen la cuenta anterior y ya no aplican. - Para **cancelar** una guía que compraste con tu propia cuenta necesitas tener credenciales utilizables para esa paquetería. Si las borraste o rotaste, guárdalas de nuevo antes de pedir el [reembolso](/shipping/refunds). - El seguro de una guía comprada con tu cuenta lo cubre y factura tu paquetería, no SendIt. --- # Paqueterías y servicios Source: https://docs.sendit.mx/shipping/carriers El catálogo contiene los servicios que SendIt puede mostrar en las cotizaciones. Es de solo lectura: consúltalo para poblar selectores y validar una combinación de paquetería y servicio. :::note **Beta.** Todas las paqueterías del catálogo están en beta. La cobertura, los códigos y los tiempos de tránsito pueden cambiar. Consulta el catálogo en vez de fijar sus valores en tu código. ::: ## Paqueterías Beta | Paquetería | `carrierCode` | Estado | | --- | --- | --- | | DHL Express | `DHL` | Beta | | FedEx | `FEDEX` | Beta | | Estafeta | `ESTAFETA` | Beta | | Sendex | `SENDEX` | Beta | | AM PM | `AMPM` | Beta | | Otra | `OTHER` | Beta | La lista efectiva para una ruta es la que devuelve `rates[]` al [cotizar un envío](/shipping/rates). ## Endpoints | Método | Ruta | Alcance | Descripción | | --- | --- | --- | --- | | `GET` | `/v1/carrier-services` | `carrier_services:read` | Listar servicios habilitados; acepta `carrierCode` | | `GET` | `/v1/carrier-services/:carrierCode/:serviceCode` | `carrier_services:read` | Consultar un servicio exacto | ## Lista los servicios ```bash curl "https://api.sendit.mx/v1/carrier-services?carrierCode=FEDEX" \ -H "X-API-Key: sk_test_..." ``` ```json { "success": true, "data": [ { "id": "cs_fedex_priority_overnight", "carrierCode": "FEDEX", "serviceCode": "PRIORITY_OVERNIGHT", "serviceName": "FedEx Priority Overnight", "serviceLevel": "overnight", "isInternational": false, "maxWeightKg": "68.00", "maxDimensionCm": "274.00", "minDays": 1, "maxDays": 1, "enabled": true, "countries": ["MX"] } ] } ``` ## Consulta un servicio exacto ```bash curl https://api.sendit.mx/v1/carrier-services/FEDEX/PRIORITY_OVERNIGHT \ -H "X-API-Key: sk_test_..." ``` La respuesta contiene el mismo objeto de servicio que aparece en el listado. ## Elige por código exacto Cada cotización trae dos campos distintos: | Campo | Uso | | --- | --- | | `serviceCode` | Identificador exacto y nativo de la paquetería. Úsalo para elegir el servicio de una compra en una sola llamada. | | `serviceLevel` | Categoría amplia y normalizada para filtrar o presentar opciones. No identifica un producto exacto. | Envía `carrierCode + serviceCode` para elegir un servicio exacto al crear y comprar un envío. Si compras una cotización existente con el endpoint de guías, envía su `rateId`. Los códigos que todavía no están en el catálogo no aparecen en las cotizaciones. ## Errores | Código | Cuándo ocurre | Cómo resolverlo | | --- | --- | --- | | `403 INSUFFICIENT_SCOPE` | La llave no tiene `carrier_services:read` | Emite una llave con ese [alcance](/api-conventions/scopes) | | `404 RESOURCE_NOT_FOUND` | La combinación de paquetería y código no existe | Consulta el listado y usa un `serviceCode` vigente | --- # Guías Source: https://docs.sendit.mx/shipping/labels Comprar una guía convierte una cotización en una etiqueta lista para imprimir. El monedero se debita al precio cotizado, nunca más y nunca menos. Hay [dos maneras de comprar](/shipping/shipments#dos-maneras-de-comprar-una-guía). En **dos pasos** comparas cotizaciones y compras con un `rateId`, como se muestra aquí abajo. En **una llamada** agregas `purchase` al crear el envío. Ver [Compra en una llamada](#compra-en-una-llamada). ## Compra una guía Necesitas un envío con [cotizaciones vigentes](/shipping/rates) y el `rateId` elegido. El encabezado `Idempotency-Key` es opcional pero **recomendado**: reintentar con la misma llave reproduce el resultado y evita un doble cargo (ver [Idempotencia](/api-conventions/idempotency)). ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `rateId` | `string` | - | El id de la tarifa elegida, tomado de rates[]. Vigencia: 24 horas. | | `labelFormat?` | `string` | `PDF` | PDF o ZPL. Si lo omites, se usa PDF. | | `externalReference?` | `string` | - | Tu propia referencia para esta compra. Máximo 255 caracteres. | | `async?` | `boolean` | `false` | true devuelve 202 y un intento que consultas después. Ver Compra asíncrona. | ```bash curl curl -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/label \ -H "X-API-Key: sk_test_..." \ -H "Idempotency-Key: 1f0e6f0e-59a4-4a6f-9d2e-9b1a7b2c3d4e" \ -H "Content-Type: application/json" \ -d '{ "rateId": "DHL_standard_a1b2c3", "labelFormat": "PDF" }' ``` ```js Node.js const res = await fetch( `https://api.sendit.mx/v1/shipments/${shipmentId}/label`, { method: "POST", headers: { "X-API-Key": process.env.SENDIT_API_KEY, "Idempotency-Key": idempotencyKey, // generada y persistida por ti "Content-Type": "application/json", }, body: JSON.stringify({ rateId, labelFormat: "PDF" }), } ); const { data: label } = await res.json(); ``` ```json { "success": true, "data": { "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "attemptId": "lat_550e8400-e29b-41d4-a716-446655440000", "labelId": "clxlbl456abc789def012ghi", "trackingNumber": "1234567890", "labelUrl": "https://labels.sendit.mx/clxq1w2e3r4t5y6u7i8o9p0a/1234567890.pdf", "carrierCode": "DHL", "serviceCode": "EXPRESS_WORLDWIDE", "serviceName": "DHL Express Nacional", "charged": "326.82", "currency": "MXN", "walletBalanceAfter": "12158.43", "breakdown": { "quotedTotal": "326.82", "ivaAmount": "45.08", "overageCharge": "0.00", "total": "326.82" } } } ``` El envío pasa a `LABEL_PURCHASED`, el rastreo queda activo y `labelUrl` apunta al PDF listo para imprimir. ## Compra en una llamada Si ya sabes con qué paquetería y servicio enviarás, o solo quieres la más barata, sáltate el segundo paso. Agrega un objeto `purchase` al crear el envío y recibe la guía en la misma respuesta. Elige `carrierCode` + `serviceCode` para el producto exacto, o `strategy: "cheapest"`. ```bash curl curl -X POST https://api.sendit.mx/v1/shipments \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "fromAddressId": "clx_direccion_origen", "toAddress": { "contactName": "María López", "contactPhone": "+5213312345678", "street": "Av. López Mateos", "exteriorNumber": "45", "neighborhood": "Jardines del Sol", "city": "Zapopan", "state": "JAL", "postalCode": "45050", "country": "MX" }, "parcel": { "length": 30, "width": 20, "height": 15, "weight": 2.5 }, "purchase": { "carrierCode": "DHL", "serviceCode": "EXPRESS_WORLDWIDE", "labelFormat": "PDF" } }' ``` ```js Node.js const { data: shipment } = await fetch("https://api.sendit.mx/v1/shipments", { method: "POST", headers: { "X-API-Key": process.env.SENDIT_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ fromAddressId: "clx_direccion_origen", toAddress: { /* ...destino... */ }, parcel: { length: 30, width: 20, height: 15, weight: 2.5 }, purchase: { strategy: "cheapest", labelFormat: "PDF" }, }), }).then((r) => r.json()); // La guía llega en la misma respuesta console.log(shipment.label.trackingNumber, shipment.label.labelUrl); ``` La respuesta incluye `purchasedRate` y `label`, con `trackingNumber`, `labelUrl` y `charged`. El envío siempre se crea primero, así que una compra fallida te deja un `DRAFT` recuperable. La semántica de fallos completa está en [Envíos](/shipping/shipments#compra-en-una-llamada). Con una llave de alcance acotado necesitas `labels:write` además de `shipments:write`. ## El contrato de precio La compra te da tres garantías: 1. **Si el saldo no alcanza, no pasa nada.** La compra falla con `402 INSUFFICIENT_BALANCE`. No hay cargo, no hay guía. 2. **Se cobra el precio cotizado.** Se debita exactamente el `totalPrice` de la tarifa elegida, con IVA incluido. El monto no se recalcula al comprar. 3. **Una falla comprobada devuelve el cargo.** El intento termina en `failed` después de acreditar el reembolso. Un resultado no concluyente termina en `action_required`. Ese estado no confirma una guía ni un reembolso. No inicies otra compra para el mismo envío. Reintentar con la misma `Idempotency-Key` reproduce el resultado original. Nunca genera un segundo cargo. Detalles en [Idempotencia](/api-conventions/idempotency). ## Compra asíncrona Por defecto la compra es síncrona: esperas y recibes la guía en la respuesta. Con `async: true` la petición regresa de inmediato y tú consultas el resultado después. Sirve cuando compras en volumen y no quieres mantener una conexión abierta por cada guía. Ver [Operaciones asíncronas](/api-conventions/asynchronous-operations). ```bash curl -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/label \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "rateId": "DHL_standard_a1b2c3", "async": true }' ``` La respuesta es `202 Accepted`. El encabezado `Location` apunta al intento y `Retry-After: 2` te sugiere cada cuánto consultar: ```json { "success": true, "data": { "id": "lat_550e8400-e29b-41d4-a716-446655440000", "object": "label_purchase_attempt", "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "externalReference": null, "status": "pending", "livemode": false, "async": true, "pricing": { "quotedTotal": "326.82", "ivaAmount": "45.08", "overageCharge": "0.00", "netWalletCost": "326.82" }, "charged": "326.82", "currency": "MXN", "walletBalanceAfter": "12158.43", "refundedAmount": null, "label": null, "error": null, "statusUrl": "/v1/label-purchase-attempts/lat_550e8400-e29b-41d4-a716-446655440000", "createdAt": "2026-08-01T10:00:00.000Z", "updatedAt": "2026-08-01T10:00:00.000Z", "completedAt": null } } ``` Consulta el intento hasta que llegue a un estado terminal: ```text GET /v1/label-purchase-attempts/lat_550e8400-e29b-41d4-a716-446655440000 ``` | `status` | Significado | | --- | --- | | `pending` | Aceptado, aún no empieza | | `processing` | En curso | | `succeeded` | Listo. El intento trae la guía | | `failed` | La falla terminó y el cargo se reembolsó. No hay guía | | `action_required` | El resultado no es concluyente. No hay guía ni reembolso implícito | Tres cosas que conviene saber: - **El saldo se valida antes de aceptar.** Si no alcanza, recibes `402 INSUFFICIENT_BALANCE` de inmediato y no queda ningún intento a medias. - **Repetir la misma compra te devuelve el mismo intento**, siempre que coincidan `rateId`, formato, referencia externa y preferencia de `async`. Si cambia algo, recibes `409 SHIPMENT_LABEL_IN_PROGRESS`. - **Para enterarte sin sondear**, suscríbete al webhook `label.purchase.completed`: se dispara en los tres desenlaces terminales. ## Formatos de guía | `labelFormat` | Uso | | --- | --- | | `PDF` | Por defecto. Tamaño carta, listo para imprimir | | `ZPL` | Impresoras térmicas Zebra (raw) | Si no envías `labelFormat`, se usa `PDF`. ## Errores de compra | Código | Cuándo | Cómo resolverlo | | --- | --- | --- | | `402 INSUFFICIENT_BALANCE` | El saldo no cubre el `totalPrice` | Fondea tu [monedero](/wallet-and-billing/wallet); `details` incluye el faltante | | `410 RATES_EXPIRED` | La cotización venció (24 h) | `GET /v1/shipments/:id/rates` y compra con el nuevo `rateId` | | `409 SHIPMENT_ALREADY_PROCESSED` | El envío ya tiene guía o no está en `DRAFT` | Consulta el envío; si necesitas otra guía, crea otro envío | | `409 SHIPMENT_LABEL_IN_PROGRESS` | Ya existe un intento activo para el envío | Consulta el intento existente; no inicies otra compra | | `502 CARRIER_ERROR` | La paquetería rechazó la compra de forma concluyente | Confirma que el intento terminó en `failed` antes de volver a comprar | ## Después de la compra - **Rastreo:** el `trackingNumber` empieza a generar [eventos de rastreo](/shipping/shipments#consulta-el-detalle) y [webhooks](/webhooks-and-events/webhooks). - **Recolección:** programa que la paquetería pase por el paquete. Ver [Recolecciones](/shipping/pickups). - **Si te equivocaste:** una guía sin usar se cancela con reembolso completo. Ver [Cancelaciones y reembolsos](/shipping/refunds). --- # Recolecciones Source: https://docs.sendit.mx/shipping/pickups Una recolección le pide a la paquetería pasar por tus paquetes a una dirección, en una fecha y ventana horaria. Programa, consulta y cancela recolecciones con cualquier paquetería desde el mismo API. :::warning La administración de recolecciones funciona solo en modo LIVE. Con una llave `sk_test_`, programar, listar, consultar, cancelar o refrescar una recolección devuelve `400 LIVE_MODE_REQUIRED`. Puedes consultar `/v1/pickups/carriers` en ambos modos. ::: ## El ciclo de vida ```text PENDING → CONFIRMED → IN_PROGRESS → COMPLETED ↘ CANCELLED ↘ FAILED ``` ## Endpoints | Método | Ruta | Descripción | | --- | --- | --- | | `POST` | `/v1/pickups` | Programar una recolección | | `GET` | `/v1/pickups` | Listar recolecciones (filtrable) | | `GET` | `/v1/pickups/carriers` | Paqueterías con soporte de recolección | | `GET` | `/v1/pickups/:id` | Detalle de una recolección | | `PATCH` | `/v1/pickups/:id/cancel` | Cancelar una recolección | | `POST` | `/v1/pickups/:id/refresh-status` | Consultar el estado más reciente con la paquetería | ## Programa una recolección ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `carrierCode` | `string` | - | Paquetería con soporte de recolección (GET /v1/pickups/carriers). | | `pickupDate` | `string` | - | Fecha ISO (YYYY-MM-DD), hoy o futura. | | `readyTime` | `string` | - | Hora en formato HH:mm desde la que los paquetes están listos. | | `closingTime` | `string` | - | Hora HH:mm de cierre. Debe ser posterior a readyTime. | | `packageCount` | `number` | - | Cantidad de paquetes (1–999). | | `totalWeight` | `number` | - | Peso total en kg (mínimo 0.1). | | `contactName` | `string` | - | Persona que atenderá al repartidor. | | `contactPhone` | `string` | - | Teléfono de contacto. | | `pickupAddressId?` | `string` | - | Dirección guardada de tu organización. | | `specialInstructions?` | `string` | - | Instrucciones para el repartidor (hasta 500 caracteres). | | `shipmentIds?` | `string[]` | - | 1–50 envíos a asociar con la recolección. | ```bash curl -X POST https://api.sendit.mx/v1/pickups \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "carrierCode": "DHL", "pickupAddressId": "clx_direccion_origen", "pickupDate": "2026-07-20", "readyTime": "09:00", "closingTime": "18:00", "packageCount": 5, "totalWeight": 12.5, "contactName": "Juan Pérez", "contactPhone": "+525512345678", "specialInstructions": "Tocar el timbre dos veces, preguntar por Juan.", "shipmentIds": ["clxship001", "clxship002"] }' ``` ```json { "success": true, "data": { "id": "clxpickup_xyz", "carrierCode": "DHL", "pickupDate": "2026-07-20", "readyTime": "09:00", "closingTime": "18:00", "packageCount": 5, "totalWeight": "12.5", "status": "CONFIRMED", "confirmationNumber": "DHL-A1B2C3D4", "confirmedAt": "2026-07-17T10:30:00.000Z" } } ``` Guarda el `confirmationNumber`. Es la referencia que la paquetería reconoce si necesitas aclarar algo por teléfono. ## Consulta qué paqueterías recolectan ```text GET /v1/pickups/carriers ``` ```json { "success": true, "data": [ { "carrierCode": "DHL", "carrierName": "DHL Express", "supportsPickups": true }, { "carrierCode": "ESTAFETA", "carrierName": "Estafeta", "supportsPickups": true }, { "carrierCode": "FEDEX", "carrierName": "FedEx", "supportsPickups": true } ] } ``` ## Lista y filtra ```text GET /v1/pickups?carrierCode=DHL&status=CONFIRMED&dateFrom=2026-07-01&dateTo=2026-07-31 ``` | Filtro | Descripción | | --- | --- | | `carrierCode` | Por paquetería | | `status` | Por estado del ciclo de vida | | `dateFrom` / `dateTo` | Rango de fecha de recolección (ISO) | La paginación sigue el modelo de página descrito en [Paginación y filtros](/api-conventions/pagination-and-filtering). ## Cancela una recolección ```bash curl -X PATCH https://api.sendit.mx/v1/pickups/clxpickup_xyz/cancel \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "reason": "Los paquetes no estarán listos a tiempo" }' ``` Las recolecciones `CANCELLED` o `COMPLETED` ya no se pueden cancelar (`400`). ## Refresca el estado ```text POST /v1/pickups/:id/refresh-status ``` Consulta directamente a la paquetería y actualiza el registro local. Úsalo cuando necesites un estado más fresco que el último sincronizado. ## Errores | Código | Cuándo ocurre | Cómo resolverlo | | --- | --- | --- | | `400 INVALID_INPUT` | La fecha, horario, dirección o paquetería no es válida | Corrige los datos y vuelve a intentar | | `400 LIVE_MODE_REQUIRED` | Intentaste administrar una recolección en modo TEST | Cambia a una llave `sk_live_` | | `404 RESOURCE_NOT_FOUND` | La recolección no existe o no pertenece a tu organización | Verifica el `id` | --- # Rastreo público Source: https://docs.sendit.mx/shipping/public-tracking El rastreo público es para **tu comprador**, no para tus sistemas. No requiere autenticación y devuelve una vista con datos personales reducidos. :::note No confundas esto con los [rastreadores](/shipping/trackers). Un rastreador es una llamada autenticada con la que registras un número externo. El rastreo público usa el enlace que tú compartes con el destinatario. ::: ## Toma el enlace del envío El token se emite al comprar la guía. Un envío en `DRAFT` trae `publicTrackingToken` en `null`. Lee el token o la URL desde cualquiera de estas fuentes: | Fuente | Campos | | --- | --- | | `GET /v1/shipments/:id` | `publicTrackingToken` y `trackingUrl` | | `POST /v1/bulk/shipments/fetch` | `trackingUrl` | | Webhook `shipment.label.created` | `trackingUrl` | ```text https://app.sendit.mx/track/2f68c611-30af-4a86-9cde-793413af5f65 ``` `GET /v1/shipments` **omite los dos a propósito**: una credencial de portador no va en una colección que se puede recorrer. Para armar un botón de "compartir rastreo" desde una fila del listado, consulta el envío por su `id`. No construyas la URL a partir del número de rastreo, y no existe una búsqueda pública por número y nombre. Si el destinatario pierde el enlace, vuelve a compartirlo desde tu sistema. El token es una credencial de portador. Quien tenga el enlace puede consultar el estado. ## Consulta el estado con el token ```bash curl https://api.sendit.mx/v1/tracking/public/2f68c611-30af-4a86-9cde-793413af5f65 ``` ```json { "success": true, "data": { "object": "public_tracking", "trackingNumber": "DHL123456789MX", "carrierCode": "DHL", "status": "IN_TRANSIT", "estimatedDeliveryDate": "2026-08-04T00:00:00.000Z", "actualDeliveryDate": null, "destination": { "city": "Monterrey", "state": "Nuevo León" }, "events": [ { "eventCode": "IN_TRANSIT", "description": "En tránsito", "occurredAt": "2026-08-01T15:30:00.000Z", "isException": false, "location": { "city": "San Luis Potosí", "state": "San Luis Potosí", "country": "MX" } } ], "branding": null } } ``` La respuesta no incluye nombre, calle, colonia, código postal, configuración de la organización ni precios. Las respuestas se pueden cachear 60 segundos. El `location` de cada evento trae `city`, `state` y `country`. Cada uno puede ser `null`. **No lleva `postalCode`**: se retira a propósito, así que no lo modeles. ### Lee el estado con el vocabulario del envío El campo `status` usa los estados del envío, no los del rastreador. Los valores posibles son `DRAFT`, `PENDING`, `LABEL_PURCHASED`, `READY_FOR_PICKUP`, `PICKED_UP`, `IN_TRANSIT`, `OUT_FOR_DELIVERY`, `DELIVERED`, `RETURNED`, `FAILED` y `CANCELLED`. Un enlace compartido casi siempre arranca en `LABEL_PURCHASED`, porque el token nace al comprar la guía. Trata ese estado de forma explícita en lugar de dejarlo caer en tu rama por defecto. `UNKNOWN` y `PRE_TRANSIT` son de [rastreadores](/shipping/trackers) y nunca aparecen aquí. ## Muestra tu marca cuando la habilites Activa `publicTrackingPage` en la [marca de tus notificaciones](/webhooks-and-events/notifications#pon-tu-marca-en-los-correos). Viene apagada por defecto. Cuando está activa, la respuesta puede agregar solo estos campos: ```json { "branding": { "displayName": "Tienda Ejemplo", "logoUrl": "https://cdn.example.com/logo.png", "accentColor": "#1D4ED8", "footer": "Gracias por tu compra" } } ``` Si no habilitas la página con marca, `branding` es `null` completo. El `footerText` que guardaste llega aquí como `footer`. El `logoUrl` y el `accentColor` se vuelven a validar en esta respuesta y llegan en `null` si el valor guardado no pasa. El `replyTo` nunca es público. ## Invalida un enlace filtrado Si el enlace llegó a la persona equivocada, emite uno nuevo: ```bash curl -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/tracking-token/rotate \ -H "X-API-Key: sk_live_..." ``` ```json { "success": true, "data": { "object": "public_tracking_token", "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "publicTrackingToken": "6f1c2b90-1f0a-4f2e-9a3f-6b7c8d9e0f11", "trackingUrl": "/v1/tracking/public/6f1c2b90-1f0a-4f2e-9a3f-6b7c8d9e0f11", "rotatedAt": "2026-08-04T18:04:11.000Z" } } ``` Necesitas rol OPERATOR o superior. Las llaves de API requieren el alcance `shipments:write`. :::warning La rotación es inmediata e irreversible. El enlace anterior empieza a devolver `404` en cuanto la llamada responde, y no hay forma de restaurarlo. Los webhooks que ya entregamos siguen trayendo la URL vieja: eso es justo lo que la rotación deja atrás. ::: Cancelar una guía borra el token. Solo una compra nueva emite otro. ## Protege el token - Comparte el enlace solo con el destinatario. - No envíes el token a herramientas de analítica. - No lo incluyas en datos de referencia hacia sitios de terceros. - Genera enlaces solo desde los recursos que devuelve la API. ## Maneja los errores | Código | Cuándo ocurre | Cómo resolverlo | | --- | --- | --- | | `404 RESOURCE_NOT_FOUND` | El token no existe o ya no es válido | Solicita al comercio que vuelva a compartir el enlace | | `429 RATE_LIMIT_EXCEEDED` | Excediste el límite del tráfico público | Respeta `Retry-After` antes de reintentar | ## Usa solo envíos de producción El rastreo público funciona únicamente con envíos de producción. Consulta los envíos de [modo de prueba](/getting-started/test-mode) con endpoints autenticados. --- # Cotizaciones Source: https://docs.sendit.mx/shipping/rates Las cotizaciones llegan solas: al [crear un envío](/shipping/shipments), la respuesta ya incluye `rates[]` con las tarifas de todas las paqueterías habilitadas para esa ruta. No hay una llamada separada de cotización que administrar. :::note **El precio cotizado es el precio cobrado.** El `totalPrice` de cada tarifa incluye IVA y es exactamente lo que se debita de tu monedero al comprar. No hay recálculos ni sorpresas. ::: Si ya sabes qué paquetería y servicio quieres, o solo quieres la más barata, agrega el objeto `purchase` al crear el envío. Ver [Compra en una llamada](/shipping/shipments#compra-en-una-llamada). ## El objeto tarifa ```json { "id": "DHL_standard_a1b2c3", "carrierCode": "DHL", "serviceCode": "EXPRESS_WORLDWIDE", "carrierName": "DHL Express", "serviceName": "DHL Express Nacional", "serviceLevel": "standard", "totalPrice": 326.82, "currency": "MXN", "isInsured": false, "estimatedDays": { "min": 1, "max": 2 }, "expiresAt": "2026-07-18T14:30:00.000Z", "breakdown": { "baseRate": 264.50, "fuelSurcharge": 17.24, "insuranceCost": 0.00, "subtotal": 281.74, "ivaRate": 0.16, "ivaAmount": 45.08, "total": 326.82 } } ``` | Campo | Descripción | | --- | --- | | `id` | El `rateId` — pásalo a la compra de guía | | `serviceCode` | Código nativo exacto del producto de la paquetería | | `serviceLevel` | Nivel normalizado entre paqueterías (`express`, `standard`, `economy`, ...) | | `totalPrice` | El total a pagar, IVA incluido — coincide con `breakdown.total` | | `isInsured` | `true` si la tarifa incluye seguro (creaste el envío con `requestInsurance`) | | `estimatedDays` | Rango de días hábiles estimados de entrega | | `expiresAt` | Vigencia de la tarifa (24 horas) | | `breakdown` | Desglose transparente: tarifa base, sobrecargos, seguro, subtotal e IVA por separado | Si creaste el envío con `requestInsurance: true` y un `declaredValue`, las tarifas regresan con la prima en `breakdown.insuranceCost` e `isInsured: true`. Comprar una tarifa asegurada crea una póliza real que puedes reclamar. Ver [Seguro y reclamaciones](/wallet-and-billing/insurance-claims). ## El ciclo de vida de una cotización ```text POST /v1/shipments → crea el envío (DRAFT) → cotiza con todas las paqueterías habilitadas → devuelve el envío + rates[] (vigencia: 24 h) ↓ GET /v1/shipments/:id/rates ← consulta o refresca cuando quieras ↓ POST /v1/shipments/:id/label { rateId } ← compra al precio cotizado ``` ## Si las paqueterías tardan La cotización tiene un presupuesto de 8 segundos. Si alguna paquetería es lenta, el envío se devuelve de inmediato con las tarifas en camino: ```json { "success": true, "data": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "DRAFT", "rates": [], "ratesStatus": "pending", "ratesExpiresAt": null, "ratesPollUrl": "/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/rates" } } ``` Consulta `ratesPollUrl` (por ejemplo cada 2 segundos) hasta que `ratesStatus` sea `"ready"`. ## Consulta o refresca las tarifas ```text GET /v1/shipments/:id/rates ← devuelve las tarifas vigentes GET /v1/shipments/:id/rates?refresh=true ← recotiza con las paqueterías ``` Sin `?refresh=true` obtienes las tarifas en caché mientras su vigencia de 24 horas no haya vencido. Con `?refresh=true` se recotiza todo y la vigencia se reinicia. ### Cuando una tarifa expira Comprar con un `rateId` vencido devuelve `410 RATES_EXPIRED`: ```json { "success": false, "error": { "code": "RATES_EXPIRED", "message": "Shipping rates have expired. Please refresh rates and select again.", "details": { "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "hint": "GET /v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/rates" } } } ``` Refresca, elige un nuevo `rateId` y vuelve a comprar. También verás este error si usas un `rateId` que pertenece a otro envío. ## Cotiza sin crear un envío Para widgets de checkout o estimaciones previas, usa el endpoint independiente: ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `origin` | `objeto` | - | El origen. Requiere al menos { postalCode }. | | `destination` | `objeto` | - | El destino. Requiere al menos { postalCode }. | | `parcel` | `objeto` | - | El paquete: length, width, height (cm) y weight (kg). | ```bash curl -X POST https://api.sendit.mx/v1/rates \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "origin": { "postalCode": "03940" }, "destination": { "postalCode": "45050" }, "parcel": { "weight": 1.5, "length": 20, "width": 15, "height": 10 } }' ``` La respuesta trae `rates[]` (el mismo objeto tarifa de arriba) y su vigencia: ```json { "success": true, "data": { "rates": [ { "id": "DHL_standard_a1b2c3", "carrierCode": "DHL", "serviceCode": "EXPRESS_WORLDWIDE", "serviceLevel": "standard", "totalPrice": 88.40, "currency": "MXN", "isInsured": false } ], "expiresAt": "2026-07-18T14:30:00.000Z" } } ``` Estas tarifas son **solo informativas**: no se pueden usar para comprar una guía. Para comprar, crea primero el envío. Puedes limitar a una paquetería (`POST /v1/rates/carrier/DHL`) u ordenar con `?sortBy=price` o `?sortBy=speed`. ## Ordena las tarifas ```js const porPrecio = [...rates].sort((a, b) => a.totalPrice - b.totalPrice); const porVelocidad = [...rates].sort( (a, b) => a.estimatedDays.min - b.estimatedDays.min || a.totalPrice - b.totalPrice ); ``` --- # Cancelaciones y reembolsos Source: https://docs.sendit.mx/shipping/refunds Un reembolso en SendIt es la cancelación (void) de una guía: al cancelarla, el monto completo de la compra se acredita de vuelta a tu monedero. :::note Reembolso ≠ retorno. Cancelar una guía **no usada** te devuelve su costo; regresar un paquete al remitente es un [envío de retorno](/shipping/shipments#crea-un-envío-de-retorno) nuevo, que se paga como cualquier guía. ::: ## Cancela una guía ```bash curl -X POST https://api.sendit.mx/v1/shipments/clxq1w2e3r4t5y6u7i8o9p0a/label/void \ -H "X-API-Key: sk_test_..." \ -H "Idempotency-Key: 7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f" ``` La cancelación procede cuando el envío está en `LABEL_PURCHASED` o `READY_FOR_PICKUP`, es decir, antes de que la paquetería recoja el paquete. Al cancelar: 1. El monto completo del cargo original se acredita a tu monedero, incluido el [excedente de plan](/wallet-and-billing/subscriptions-and-quotas) que se te haya cobrado por esa guía. 2. La guía queda `VOIDED`, con `voidedAt`, `refundedAmount` y `refundedAt`. 3. El envío pasa a `CANCELLED` y se registra el evento. El crédito regresa al **mismo saldo** del que salió. Una compra en modo de prueba se acredita al saldo virtual, y una compra en producción al saldo real. ```json { "success": true, "data": { "id": "clxlbl456abc789def012ghi", "status": "VOIDED", "voidedAt": "2026-07-17T12:00:00.000Z", "refundedAmount": "326.82", "refundedAt": "2026-07-17T12:00:00.000Z" } } ``` El crédito aparece de inmediato en `GET /v1/wallet/transactions`. ## Reglas cruzadas con seguros Las guías con reclamación de seguro abierta no se pueden cancelar, y viceversa: | Código | Cuándo | Cómo resolverlo | | --- | --- | --- | | `CLAIM_PENDING_NO_REFUND_ALLOWED` | El envío tiene una reclamación abierta (el `claimId` viene en la respuesta) | Resuelve o cancela la [reclamación](/wallet-and-billing/insurance-claims) primero | | `SHIPMENT_REFUNDED_NO_CLAIMS_ALLOWED` | Intentas reclamar sobre una guía ya cancelada | No aplica seguro a guías canceladas — el costo ya fue reembolsado | ## Cargos por sobrepeso Los ajustes por sobrepeso que la paquetería factura después de la entrega **no** se reembolsan automáticamente: son cargos posteriores al servicio prestado. Si no estás de acuerdo con un sobrepeso, dispútalo directamente con la paquetería con el número de guía a la mano. ## En modo de prueba Cancelar una guía de prueba acredita el saldo virtual, sin efectos reales. También puedes restablecer el saldo de prueba con `POST /v1/wallet/test/reset`. Ver [Modo de prueba](/getting-started/test-mode). --- # Envíos Source: https://docs.sendit.mx/shipping/shipments El envío (shipment) es la entidad central del API: describe un paquete que viaja de un origen a un destino. Se crea en estado `DRAFT`, se convierte en guía al comprar una tarifa y avanza por su ciclo de vida hasta la entrega. ## El ciclo de vida ```text DRAFT → LABEL_PURCHASED → READY_FOR_PICKUP → PICKED_UP → IN_TRANSIT → OUT_FOR_DELIVERY → DELIVERED ``` | Estado | Significado | | --- | --- | | `DRAFT` | Recién creado; con cotizaciones, editable y cancelable sin costo | | `PENDING` | Enviado, en espera de compra de guía | | `LABEL_PURCHASED` | Guía comprada, en espera de recolección | | `READY_FOR_PICKUP` | Paquetería notificada para recolectar | | `PICKED_UP` | La paquetería recogió el paquete | | `IN_TRANSIT` | En camino | | `OUT_FOR_DELIVERY` | En reparto de última milla | | `DELIVERED` | Entregado | | `RETURNED` | Devuelto al remitente | | `FAILED` | Entrega fallida | | `CANCELLED` | Cancelado | ## Dos maneras de comprar una guía `POST /v1/shipments` sirve a dos flujos. Elige el que corresponda a cada envío: **1. En dos pasos (compara y elige).** Crea el envío; la respuesta trae `rates[]` con las cotizaciones de las paqueterías disponibles. Comparas precio y velocidad, eliges una y compras con su `rateId` en `POST /v1/shipments/:id/label`. Ideal cuando el costo o la velocidad deciden en cada envío. Si ya sabes con quién enviar, pasa `carrierCode` y `serviceLevel` al crear para acotar las cotizaciones a una categoría. **2. En una llamada.** Si ya sabes con qué paquetería y servicio enviar, o solo quieres la más barata, agrega un objeto `purchase` al crear el envío. La guía llega en la misma respuesta, sin segundo paso. Sirve para automatización y volumen predecible. Ver [Compra en una llamada](#compra-en-una-llamada). | | En dos pasos | En una llamada | | --- | --- | --- | | Cuándo usarla | El precio o la velocidad deciden en cada envío | Ya sabes el servicio, o quieres la tarifa más barata | | Peticiones | Crear → comprar con `rateId` | Una sola: `POST /v1/shipments` con `purchase` | | Qué regresa | `rates[]` para comparar | La guía (`label` + `purchasedRate`) lista | | Ideal para | Tiendas que optimizan por costo | Automatización y volumen predecible | :::note ¿Automatizas la selección con lógica de negocio (peso, destino, valor)? Las [reglas de envío](/shipping/shipping-rules) eligen la paquetería por ti, envío por envío. ::: ## Endpoints | Método | Ruta | Alcance | Descripción | | --- | --- | --- | --- | | `POST` | `/v1/shipments` | `shipments:write` | Crear un envío (devuelve `rates[]` inline; con `purchase`, compra la guía en la misma llamada) | | `GET` | `/v1/shipments` | `shipments:read` | Listar envíos (paginado y filtrable) | | `GET` | `/v1/shipments/stats` | `shipments:read` | Conteo de envíos por estado | | `GET` | `/v1/shipments/:id` | `shipments:read` | Detalle con snapshots, guía y eventos | | `PUT` | `/v1/shipments/:id` | `shipments:write` | Actualizar un envío en `DRAFT` | | `DELETE` | `/v1/shipments/:id` | `shipments:write` | Cancelar un envío | | `POST` | `/v1/shipments/:id/return` | `shipments:write` | Crear un envío de retorno (ruta invertida) | ## Crea un envío Para cada rol de dirección (`from`, `to`, `return`) envía **exactamente una** de dos variantes: el ID de una dirección guardada (`fromAddressId`) o un objeto inline (`fromAddress`). Las direcciones inline pueden guardarse en tu directorio con `saveToAddressBook: true`. ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `parcel` | `objeto` | - | El paquete: length, width y height (cm) y weight (kg). Acepta dimensionUnit (cm \| in), weightUnit (kg \| g \| lb \| oz) y description. | | `fromAddressId?` | `string` | - | Origen guardado. Envía exactamente uno de fromAddressId o fromAddress. | | `fromAddress?` | `objeto` | - | Origen inline (ver el objeto dirección). Puede guardarse con saveToAddressBook: true. | | `toAddressId?` | `string` | - | Destino guardado. Envía exactamente uno de toAddressId o toAddress. | | `toAddress?` | `objeto` | - | Destino inline (ver el objeto dirección). | | `returnAddressId?` | `string` | - | Retorno guardado (máximo uno; por defecto se usa el origen). | | `returnAddress?` | `objeto` | - | Retorno inline. | | `purchase?` | `objeto` | - | Compra la guía en la misma llamada: carrierCode + serviceCode (servicio exacto) o strategy: cheapest. Ver Compra en una llamada. | | `externalId?` | `string` | - | Tu propia referencia, como el número de orden o folio. Búscala después con ?externalId=. | | `carrierCode?` | `string` | - | Opcional: acota las cotizaciones a esta paquetería desde la creación (no compra por sí solo). | | `serviceLevel?` | `string` | - | Opcional: acota el servicio (p. ej. standard, express). | | `requestInsurance?` | `boolean` | `false` | Solicitar seguro sobre el valor declarado. Al comprar una tarifa asegurada se crea una póliza real (ver Seguro y reclamaciones). | | `declaredValue?` | `number` | - | Valor declarado en MXN (cobertura del seguro). | | `metadata?` | `objeto` | - | Pares llave-valor tuyos; se devuelven tal cual. | ```bash curl -X POST https://api.sendit.mx/v1/shipments \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "externalId": "ORD-2026-001", "fromAddressId": "clx_direccion_origen", "toAddress": { "contactName": "María López", "contactPhone": "+5213312345678", "street": "Av. López Mateos", "exteriorNumber": "45", "neighborhood": "Jardines del Sol", "city": "Zapopan", "state": "JAL", "postalCode": "45050", "country": "MX", "saveToAddressBook": false }, "parcel": { "length": 30, "width": 20, "height": 15, "weight": 2.5, "description": "Electrónicos" }, "requestInsurance": true, "declaredValue": 5000, "metadata": { "orderId": "shopify-12345" } }' ``` La respuesta `201` trae el envío en `DRAFT` con sus snapshots de dirección, el historial de eventos y las cotizaciones inline (`rates[]`, ver [Cotizaciones](/shipping/rates)): ```json { "success": true, "data": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "DRAFT", "externalId": "ORD-2026-001", "fromAddressSnapshot": { "contactName": "Bruno Sánchez", "city": "Ciudad de México", "postalCode": "03100", "savedAddressId": "clx_direccion_origen" }, "toAddressSnapshot": { "contactName": "María López", "city": "Zapopan", "postalCode": "45050", "savedAddressId": null }, "parcel": { "length": 30, "width": 20, "height": 15, "weight": 2.5 }, "rates": [ { "id": "DHL_standard_a1b2c3", "carrierCode": "DHL", "serviceCode": "EXPRESS_WORLDWIDE", "serviceLevel": "standard", "totalPrice": 326.82, "currency": "MXN" } ], "events": [ { "status": "DRAFT", "description": "Shipment created", "occurredAt": "2026-07-17T12:00:00.000Z" } ], "createdAt": "2026-07-17T12:00:00.000Z" } } ``` ## Compra en una llamada Agrega un objeto `purchase` a `POST /v1/shipments` para crear el envío **y** comprar la guía en una sola petición. Elige **una** forma de seleccionar el servicio: `carrierCode` + `serviceCode` para el producto exacto, **o** `strategy: "cheapest"` para la tarifa más barata. Nunca mandes las dos ni omitas ambas. Con una llave de alcance acotado necesitas `labels:write` además de `shipments:write`. ### Parámetros de `purchase` | Prop | Type | Default | Description | | --- | --- | --- | --- | | `carrierCode?` | `string` | - | Paquetería exacta; se usa junto con serviceCode. Excluyente con strategy. | | `serviceCode?` | `string` | - | Código nativo del servicio; se usa junto con carrierCode. | | `strategy?` | `string` | - | cheapest: selecciona automáticamente la tarifa más barata. Excluyente con carrierCode + serviceCode. | | `labelFormat?` | `string` | `PDF` | Formato de la guía generada: PDF \| ZPL. | | `async?` | `boolean` | `false` | true devuelve labelPurchaseAttempt dentro de la respuesta 201. Consulta su statusUrl. | ```bash curl -X POST https://api.sendit.mx/v1/shipments \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "fromAddressId": "clx_direccion_origen", "toAddress": { "contactName": "María López", "contactPhone": "+5213312345678", "street": "Av. López Mateos", "exteriorNumber": "45", "neighborhood": "Jardines del Sol", "city": "Zapopan", "state": "JAL", "postalCode": "45050", "country": "MX" }, "parcel": { "length": 30, "width": 20, "height": 15, "weight": 2.5 }, "purchase": { "strategy": "cheapest", "labelFormat": "PDF" } }' ``` En éxito, la respuesta `201` incluye además `label` (con `trackingNumber`, `labelUrl`, `charged`) y `purchasedRate`: ```json { "success": true, "data": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "LABEL_PURCHASED", "label": { "trackingNumber": "1234567890", "labelUrl": "https://labels.sendit.mx/clxq1w2e3r4t5y6u7i8o9p0a/1234567890.pdf", "charged": "326.82", "currency": "MXN" }, "purchasedRate": { "id": "DHL_standard_a1b2c3", "carrierCode": "DHL", "serviceCode": "EXPRESS_WORLDWIDE", "serviceLevel": "standard", "totalPrice": 326.82 } } } ``` Con `purchase.async: true`, la respuesta externa sigue siendo `201`. Recibes `labelPurchaseAttempt` en lugar de `label`. Consulta su `statusUrl` o usa `label.purchase.completed`. Ver [Operaciones asíncronas](/api-conventions/asynchronous-operations). El envío **siempre se crea primero**: si la compra falla, te queda un `DRAFT` que puedes terminar por el flujo de dos pasos. | Código | Cuándo | Cómo resolverlo | | --- | --- | --- | | `402 INSUFFICIENT_BALANCE` | El monedero no cubre la guía | El envío queda en `DRAFT`; fondea el [monedero](/wallet-and-billing/wallet) y compra con `POST /v1/shipments/:id/label` | | `422 ONE_CALL_BUY_RATES_PENDING` | Las paqueterías tardaron (>8 s) en cotizar | Sondea `ratesPollUrl` y compra con el `rateId` | | `422 ONE_CALL_BUY_NO_MATCHING_RATE` | Ninguna tarifa coincidió con tu selección | Elige una de `details.availableRates` | | `400 INVALID_INPUT` | `purchase` traía ambas formas de selección, o ninguna | Envía `carrierCode` + `serviceCode` **o** `strategy`, no las dos | ## Manda varias cajas juntas Un envío lleva **un** `parcel`. Para despachar varias cajas en la misma operación, crea un envío por caja y agrúpalos en un [lote](/shipping/batches). Compras todas las guías en una llamada y generas un solo manifiesto para la paquetería. ## Las direcciones se congelan en snapshots Al crear el envío, cada dirección se copia a un **snapshot inmutable** (`fromAddressSnapshot`, `toAddressSnapshot`, `returnAddressSnapshot`). Editar o borrar después la dirección guardada **jamás** altera los envíos históricos. Siempre lee la dirección desde el snapshot, no desde el ID: ```js // Correcto: el snapshot es el valor canónico const origin = shipment.fromAddressSnapshot; // Incorrecto: la dirección guardada pudo cambiar o borrarse const origin = await getAddress(shipment.fromAddressId); ``` Dentro del snapshot, `savedAddressId` indica de qué entrada del directorio provino (`null` si fue una dirección de un solo uso). ## Lista y filtra ```text GET /v1/shipments?status=IN_TRANSIT&carrierCode=DHL&limit=50 ``` | Filtro | Coincidencia | | --- | --- | | `status` | Exacta (`DRAFT`, `IN_TRANSIT`, ...) | | `statuses` | Varios estados a la vez (repite el parámetro o sepáralos por coma) | | `carrierCode` | Exacta (`DHL`, `FEDEX`, `ESTAFETA`, ...) | | `trackingNumber` | Parcial (contiene) | | `externalId` | Exacta | | `search` | Texto libre sobre número de rastreo y `externalId` | | `createdFrom` / `createdTo` | Rango de fechas de creación (ISO; `from` inclusivo, `to` exclusivo) | La paginación por cursor y los operadores avanzados están en [Paginación y filtros](/api-conventions/pagination-and-filtering). Para tableros, `GET /v1/shipments/stats` devuelve el conteo por estado en una sola llamada. ## Consulta el detalle `GET /v1/shipments/:id` devuelve el envío completo: snapshots, parcel, resumen de la guía (si existe) y el historial de eventos: ```json { "success": true, "data": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "IN_TRANSIT", "trackingNumber": "TEST-DHL-A1B2C3D4", "publicTrackingToken": "2f68c611-30af-4a86-9cde-793413af5f65", "trackingUrl": "https://app.sendit.mx/track/2f68c611-30af-4a86-9cde-793413af5f65", "events": [ { "status": "IN_TRANSIT", "description": "Package in transit", "occurredAt": "2026-07-17T09:12:00.000Z" }, { "status": "PICKED_UP", "description": "Package picked up", "occurredAt": "2026-07-17T08:03:00.000Z" } ] } } ``` ## Actualiza un borrador Solo los envíos en `DRAFT` se pueden editar; después quedan bloqueados: ```bash curl -X PUT https://api.sendit.mx/v1/shipments/{id} \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "externalId": "ORD-2026-001-v2" }' ``` Un envío que ya avanzó devuelve `409 SHIPMENT_ALREADY_PROCESSED`. ## Cancela un envío ```bash curl -X DELETE https://api.sendit.mx/v1/shipments/{id} \ -H "X-API-Key: sk_test_..." ``` No se puede cancelar en `DELIVERED`, `RETURNED`, `FAILED` ni si ya está `CANCELLED`. Si el envío ya tiene guía comprada y quieres el reembolso, cancela primero la guía. Ver [Cancelaciones y reembolsos](/shipping/refunds). ## Crea un envío de retorno `POST /v1/shipments/:id/return` crea un **nuevo** envío en `DRAFT` con la ruta invertida, ligado al original vía `returnForShipmentId`: - **from** = el destino original (donde está el paquete ahora) - **to** = la dirección de retorno original, o en su defecto el origen - **parcel** = copiado del original (puedes sobreescribirlo si se reempaca) La respuesta es idéntica a la de `POST /v1/shipments`, con `rates[]` inline. La guía de retorno se compra por el flujo normal. Reglas: - El original debe tener guía (los `DRAFT`, `PENDING`, `CANCELLED` y `RETURNED` se rechazan). - **Un retorno activo por envío.** Cancelar el retorno libera el cupo. - No hay retornos de retornos. - En modo de prueba funciona de punta a punta. :::note Retorno no es lo mismo que reembolso. El retorno mueve el paquete de vuelta y se paga como cualquier guía. El reembolso cancela una guía no usada y devuelve su costo. Ver [Cancelaciones y reembolsos](/shipping/refunds). ::: --- # Reglas de envío Source: https://docs.sendit.mx/shipping/shipping-rules Las reglas de envío automatizan decisiones al crear cada envío: elegir paquetería, fijar servicio, asegurar automáticamente, cambiar el formato de guía y más. Se evalúan en orden de prioridad, antes de cotizar. ## Endpoints | Método | Ruta | Descripción | | --- | --- | --- | | `GET` | `/v1/shipping-rules` | Listar reglas (ordenadas por prioridad) | | `POST` | `/v1/shipping-rules` | Crear una regla | | `GET` | `/v1/shipping-rules/:id` | Obtener una regla | | `PUT` | `/v1/shipping-rules/:id` | Actualizar una regla | | `DELETE` | `/v1/shipping-rules/:id` | Eliminar (borrado suave) | | `PATCH` | `/v1/shipping-rules/reorder` | Reordenar prioridades en bloque | | `POST` | `/v1/shipping-rules/preview` | Simulacro: evaluar sin persistir | ### Parámetros del cuerpo (crear y actualizar) | Prop | Type | Default | Description | | --- | --- | --- | --- | | `priority` | `number` | - | Orden de evaluación (1–9999). Única por organización. | | `conditions` | `objeto` | - | Árbol all/any de condiciones { field, op, value }. Ver la tabla de abajo. | | `actions` | `objeto[]` | - | Acciones a aplicar cuando la regla dispara. Ver la tabla de acciones. | | `name?` | `string` | - | Nombre descriptivo de la regla. | | `isActive?` | `boolean` | `true` | Las reglas inactivas no se evalúan. | ```bash curl -X POST https://api.sendit.mx/v1/shipping-rules \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "name": "DHL para envíos pesados a Jalisco", "priority": 10, "conditions": { "all": [ { "field": "parcel.weight", "op": ">=", "value": 5 }, { "field": "to.state", "op": "==", "value": "JAL" } ] }, "actions": [{ "type": "select_carrier", "carrierCode": "DHL" }] }' ``` ```json { "success": true, "data": { "id": "clxrule1", "name": "DHL para envíos pesados a Jalisco", "priority": 10, "isActive": true, "conditions": { "all": [ { "field": "parcel.weight", "op": ">=", "value": 5 }, { "field": "to.state", "op": "==", "value": "JAL" } ] }, "actions": [{ "type": "select_carrier", "carrierCode": "DHL" }], "createdAt": "2026-07-18T10:00:00.000Z" } } ``` Las secciones siguientes detallan la sintaxis de `conditions` y `actions`. ## Escribe condiciones Las condiciones se anidan con `all` (Y) y `any` (O): ```json { "all": [ { "field": "parcel.weight", "op": ">=", "value": 5 }, { "any": [ { "field": "to.state", "op": "in", "value": ["CDMX", "JAL", "NLE"] }, { "field": "to.isResidential", "op": "==", "value": false } ]} ] } ``` ### Campos disponibles | Campo | Tipo | Descripción | | --- | --- | --- | | `parcel.weight` | number | Peso en kg | | `parcel.length` / `width` / `height` | number | Dimensiones en cm | | `parcel.packagingType` | string | Tipo de empaque | | `to.country` | string | País destino (ISO) | | `to.state` | string | Estado destino | | `to.postalCode` | string | Código postal destino | | `to.isResidential` | boolean | Entrega residencial | | `order.totalPrice` | number | Total de la orden (MXN) | | `order.channel` | string | `SHOPIFY`, `WOOCOMMERCE`, ... | | `shipment.declaredValue` | number | Valor declarado | | `shipment.isInternational` | boolean | Envío internacional | ### Operadores `==`, `!=`, `>`, `>=`, `<`, `<=`, `in`, `not_in`, `contains`, `starts_with` ## Define acciones | Acción | Campos | Efecto | | --- | --- | --- | | `select_carrier` | `carrierCode` | Cotizar solo con esta paquetería | | `select_service` | `carrierCode`, `serviceCode` | Fijar un servicio específico | | `exclude_carrier` | `carrierCode` | Excluir una paquetería | | `add_insurance` | `declaredValue` | Asegurar automáticamente | | `set_label_format` | `format` | Cambiar el formato de guía: `PDF`, `ZPL` o `PNG` | | `add_signature_required` | — | Exigir firma de recibido | | `tag` | `tags: string[]` | Etiquetar el envío | ## Prioridad y cascada Las reglas se evalúan en orden de `priority` ascendente. **Todas** las que coinciden se aplican, y las posteriores pueden sobreescribir a las anteriores: ```text Prioridad 1: select_carrier DHL Prioridad 2: select_service DHL EXPRESS ← refina lo que fijó la prioridad 1 ``` ## Prueba antes de activar `preview` evalúa un envío hipotético sin escribir nada: ```bash curl -X POST https://api.sendit.mx/v1/shipping-rules/preview \ -H "X-API-Key: sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "shipment": { "parcel": { "weight": 8, "length": 40, "width": 30, "height": 20 }, "to": { "country": "MX", "state": "JAL", "postalCode": "44100" }, "shipment": { "declaredValue": 5000, "isInternational": false } } }' ``` ```json { "success": true, "data": { "finalActions": { "carrierCode": "DHL", "serviceCode": "EXPRESS" }, "evaluations": [ { "ruleId": "clxrule1", "fired": true, "actionsApplied": [{ "type": "select_carrier", "carrierCode": "DHL" }] }, { "ruleId": "clxrule2", "fired": true, "actionsApplied": [{ "type": "select_service", "carrierCode": "DHL", "serviceCode": "EXPRESS" }] } ] } } ``` `evaluations[]` muestra la cascada exacta: qué regla disparó, qué condiciones evaluó y qué acciones aplicó. La misma información queda registrada en cada envío real para auditoría. ## Límites y utilidades - Máximo **100 reglas activas** por organización. - La prioridad es única por organización (1–9999). - Para depurar, salta todas las reglas en una petición con el encabezado `SendIt-Rules: skip`. --- # Rastreadores Source: https://docs.sendit.mx/shipping/trackers Los rastreadores te dan seguimiento de guías **que no se generaron en SendIt**. Registra el número de rastreo de cualquier paquetería soportada. Obtienes los mismos estados normalizados, el mismo historial de eventos y los mismos webhooks que en un envío de SendIt. :::note **Beta.** El API de rastreadores y las formas de sus webhooks aún pueden evolucionar antes de la versión estable. Los cambios incompatibles se anunciarán con anticipación. ::: Las guías compradas en SendIt se rastrean **automáticamente y sin costo**. Los rastreadores son solo para números externos. :::note Los rastreadores son para **tus** sistemas: autenticados, con webhooks y con cuota. Si lo que quieres es que **tu comprador** consulte su propio paquete sin cuenta, usa [rastreo público](/shipping/public-tracking). ::: ## Endpoints | Método | Ruta | Rol y alcance | Descripción | | --- | --- | --- | --- | | `POST` | `/v1/trackers` | OPERATOR+ · `trackers:write` | Registrar un número externo (puede cobrar excedente) | | `GET` | `/v1/trackers` | VIEWER+ | Listar rastreadores | | `GET` | `/v1/trackers/:id` | VIEWER+ | Un rastreador con su historial completo | | `DELETE` | `/v1/trackers/:id` | OPERATOR+ · `trackers:write` | Dejar de rastrear permanentemente (irreversible) | Filtros del listado: `search` (coincidencia parcial del número de rastreo), `status`, `carrier`, `origin`, `isFinalized`, `createdAfter` y `createdBefore`. También acepta `livemode` y pagina. `isFinalized` acepta `true` o `false`. Manda `isFinalized=false` para ver solo los rastreadores que siguen en consulta activa. ## Registra un número externo ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `trackingNumber` | `string` | - | El número de rastreo de la guía externa. | | `carrier?` | `string` | - | Pista opcional de paquetería (DHL \| FEDEX \| ESTAFETA hoy). | | `metadata?` | `objeto` | - | Pares llave-valor tuyos; se devuelven tal cual. | ```bash curl -X POST https://api.sendit.mx/v1/trackers \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "trackingNumber": "1234567890123456", "carrier": "DHL", "metadata": { "orderId": "ORD-2026-001" } }' ``` ```json { "success": true, "data": { "id": "trk_a1b2c3d4e5f6", "trackingNumber": "1234567890123456", "carrier": "DHL", "status": "UNKNOWN", "events": [], "metadata": { "orderId": "ORD-2026-001" }, "createdAt": "2026-07-18T10:00:00.000Z" } } ``` - `carrier` es opcional. SendIt lo infiere solo cuando el formato identifica una paquetería sin ambigüedad. Si el formato es ambiguo, recibes `400 INVALID_INPUT`: vuelve a enviar la petición con la pista `carrier`. - Una paquetería no soportada devuelve `422 CARRIER_NOT_SUPPORTED`; una que requiere credenciales de cuenta devuelve `422 CARRIER_CREDENTIALS_REQUIRED` hasta que configures esa integración. - La respuesta llega con `status: "UNKNOWN"` y `events` vacío. La primera consulta a la paquetería ocurre en el primer minuto. A partir de ahí, cada cambio dispara un webhook `tracker.updated`. - `metadata` se devuelve tal cual, nunca se interpreta. ### Los duplicados son gratis Registrar un número que ya tiene un rastreador **activo** en tu organización devuelve el rastreador existente con `meta.deduplicated: true`, y **jamás se cobra de nuevo**. Aplica al mismo número y paquetería dentro de los últimos 3 meses. Los reintentos de tu cliente siempre son seguros: este endpoint no necesita `Idempotency-Key`, aunque se respeta si lo envías. ## Estados ```text UNKNOWN → PRE_TRANSIT → IN_TRANSIT → OUT_FOR_DELIVERY → DELIVERED | RETURNED | FAILED ``` Los escaneos de excepción (retenciones aduanales, intentos de entrega fallidos, daños) aparecen en `events[]` con `isException: true`, sin cambiar necesariamente el estado. ## Frecuencia de consulta y finalización La consulta a la paquetería se adapta al estado: ~6 h antes del primer movimiento, ~2 h en tránsito, ~30 min en reparto, y baja a ~12 h tras 5 días sin escaneos nuevos. Un rastreador **finaliza** cuando pasa lo siguiente. Al finalizar, `isFinalized` es `true`, la consulta se detiene y el registro sigue consultable: | Condición | `finalizedReason` | | --- | --- | | Entregado / devuelto / fallido | `DELIVERED` / `RETURNED` / `FAILED` | | 45 días sin salir de pre-tránsito | `TTL_PRE_TRANSIT` (dispara `tracker.expired`) | | 60 días sin ningún evento nuevo | `TTL_NO_UPDATES` (dispara `tracker.expired`) | | `DELETE /v1/trackers/:id` manual | `CANCELLED` | ## Webhooks Suscribe tu endpoint a `tracker.created`, `tracker.updated` o `tracker.expired`. Usan las mismas firmas y reintentos que todos los [webhooks de SendIt](/webhooks-and-events/webhooks). No hay eventos separados de entrega o excepción: lee `data.object.status` dentro de `tracker.updated`. ## Precios | Plan | Rastreadores incluidos / mes | Excedente por rastreador (MXN) | | --- | --- | --- | | Free | 100 | $0.80 | | Growth | 2,000 | $0.50 | | Scale | 10,000 | $0.30 | | Enterprise | Ilimitados | — | - Solo cuentan los registros **externos**; las guías de SendIt nunca consumen cuota. - Al exceder la cuota, el excedente se debita del monedero al registrar (`402 INSUFFICIENT_BALANCE` si no alcanza). No hay tope duro. - El monto cobrado se devuelve como `overageCharged` en el rastreador. ## En modo de prueba Los registros con llave `sk_test_` nunca cobran excedente ni consumen cuota. Los rastreadores de prueba avanzan solos hasta `DELIVERED` con datos simulados, y sus webhooks llevan `livemode: false`. ## Errores | Código | Cuándo ocurre | Cómo resolverlo | | --- | --- | --- | | `400 INVALID_INPUT` | El número no permite inferir una sola paquetería | Envía una pista `carrier` compatible | | `402 INSUFFICIENT_BALANCE` | El registro excede la cuota y no hay saldo suficiente | Fondea el monedero y vuelve a intentar | | `422 CARRIER_NOT_SUPPORTED` | SendIt no puede consultar esa paquetería | Usa una paquetería soportada | | `422 CARRIER_CREDENTIALS_REQUIRED` | La paquetería necesita una cuenta configurada | Configura las credenciales de la paquetería | --- # CFDI y facturación Source: https://docs.sendit.mx/wallet-and-billing/cfdi Configura los datos fiscales de tu organización y consulta los registros de factura disponibles. Esta superficie usa CFDI 4.0 y está en beta. ## Endpoints | Método | Ruta | Descripción | | --- | --- | --- | | `POST` | `/v1/billing/info` | Crear o actualizar el perfil fiscal | | `GET` | `/v1/billing/info` | Consultar el perfil fiscal | | `GET` | `/v1/billing/invoices` | Listar registros de factura | | `GET` | `/v1/billing/invoices/:id` | Consultar un registro de factura | ## Configura el perfil fiscal ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `rfc` | `string` | - | RFC válido de la organización. | | `razonSocial` | `string` | - | Razón social registrada ante el SAT. | | `regimenFiscal` | `string` | - | Código del régimen fiscal. | | `usoCfdi` | `string` | - | Código de uso del CFDI, por ejemplo G03. | | `street` | `string` | - | Calle del domicilio fiscal. | | `exteriorNumber` | `string` | - | Número exterior. | | `interiorNumber?` | `string` | - | Número interior, si aplica. | | `neighborhood` | `string` | - | Colonia. | | `city` | `string` | - | Ciudad o municipio. | | `state` | `string` | - | Estado. | | `postalCode` | `string` | - | Código postal fiscal. | | `billingEmail` | `string` | - | Correo de facturación. | | `billingPhone?` | `string` | - | Teléfono de facturación. | | `requireInvoice?` | `boolean` | - | Indica si la organización requiere factura. | ```bash curl -X POST https://api.sendit.mx/v1/billing/info \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "rfc": "XAXX010101000", "razonSocial": "Ejemplo Comercio SA de CV", "regimenFiscal": "601", "usoCfdi": "G03", "street": "Avenida Reforma", "exteriorNumber": "123", "interiorNumber": "4B", "neighborhood": "Juárez", "city": "Ciudad de México", "state": "CDMX", "postalCode": "06600", "billingEmail": "facturacion@example.com", "billingPhone": "+525512345678", "requireInvoice": true }' ``` La respuesta usa el sobre normal y devuelve el perfil guardado. `GET /v1/billing/info` devuelve el mismo perfil. Si todavía no existe, devuelve `404 RESOURCE_NOT_FOUND`. ## Lista las facturas ```bash curl "https://api.sendit.mx/v1/billing/invoices?page=1&limit=20" \ -H "X-API-Key: sk_live_..." ``` ```json { "success": true, "data": [ { "id": "inv_123", "billingInfoId": "bi_123", "uuid": null, "folioNumber": null, "series": null, "invoiceType": "INGRESO", "paymentMethod": "PUE", "paymentForm": "03", "subtotal": "1000.00", "tax": "160.00", "total": "1160.00", "currency": "MXN", "status": "PENDING", "xmlUrl": null, "pdfUrl": null, "issuedAt": null, "createdAt": "2026-04-20T12:00:00.000Z", "updatedAt": "2026-04-20T12:00:00.000Z" } ], "meta": { "total": 1, "page": 1, "limit": 20 } } ``` `page` inicia en 1. `limit` inicia en 20 y acepta hasta 100. Si no hay perfil fiscal, el listado devuelve `200` con `data: []`. ## Consulta una factura ```bash curl https://api.sendit.mx/v1/billing/invoices/inv_123 \ -H "X-API-Key: sk_live_..." ``` La respuesta devuelve un registro de factura de tu organización con la misma forma del listado. ## Errores | Código | Cuándo ocurre | Cómo resolverlo | | --- | --- | --- | | `400 INVALID_INPUT` | El RFC, correo o algún campo no es válido | Corrige los datos fiscales | | `403 FORBIDDEN` | El usuario no puede modificar el perfil | Usa un miembro ADMIN o superior | | `404 RESOURCE_NOT_FOUND` | El perfil o la factura no existe | Configura el perfil o verifica el `id` | --- # Seguros y reclamaciones Source: https://docs.sendit.mx/wallet-and-billing/insurance-claims Asegura un envío al crearlo (`requestInsurance: true` + `declaredValue`) y, si el paquete se pierde, se daña o es robado, presenta una reclamación con evidencia directamente por el API. ## Requisitos para reclamar 1. El envío debe tener seguro contratado (se solicitó al [crear el envío](/shipping/shipments#crea-un-envío)). 2. La guía **no** debe estar cancelada. Una guía reembolsada no es reclamable. 3. No debe existir otra reclamación abierta para el mismo envío. ## Cómo se asegura un envío El seguro se contrata **al comprar la guía**, no por separado. Crea el envío con `requestInsurance: true` y un `declaredValue` en MXN. Las cotizaciones regresan con la prima incluida: `breakdown.insuranceCost` e `isInsured: true` en cada tarifa. Al comprar una tarifa asegurada, la prima forma parte del cargo de la guía y se crea una póliza `ACTIVE`. Esa es la póliza que reclamas. [Cancelar la guía](/shipping/refunds) reembolsa la prima y anula la póliza. Sin seguro no hay póliza, y la reclamación se rechaza. ## Endpoints | Método | Ruta | Rol y alcance | Descripción | | --- | --- | --- | --- | | `POST` | `/v1/insurance-claims` | OPERATOR+ · `insurance_claims:write` | Presentar una reclamación | | `GET` | `/v1/insurance-claims` | Cualquier miembro | Listar reclamaciones | | `GET` | `/v1/insurance-claims/:id` | Cualquier miembro | Detalle de una reclamación | | `PATCH` | `/v1/insurance-claims/:id/status` | ADMIN+ · `insurance_claims:write` | Actualizar el estado (p. ej. retirar una reclamación) | ## Presenta una reclamación ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `shipmentId` | `string` | - | El envío asegurado con el incidente. | | `reason` | `string` | - | lost \| damaged \| stolen. | | `description` | `string` | - | Qué pasó, con el mayor detalle posible. | | `claimedAmount` | `number` | - | Monto reclamado. No puede exceder el valor declarado asegurado. | | `evidenceUrls?` | `string[]` | - | Fotos del daño y del empaque, factura de compra, acta si aplica. | ```bash curl -X POST https://api.sendit.mx/v1/insurance-claims \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "reason": "damaged", "description": "El paquete llegó con daño visible en el contenido", "claimedAmount": 1500.00, "evidenceUrls": [ "https://cdn.mitienda.mx/evidencia/foto1.jpg", "https://cdn.mitienda.mx/evidencia/foto2.jpg" ] }' ``` La respuesta `201` regresa la reclamación en estado `FILED` con su número de folio: ```json { "success": true, "data": { "id": "clm_a1b2c3d4e5f6g7h8", "shipmentId": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "FILED", "reason": "damaged", "claimedAmount": "1500.00", "claimNumber": "CLM-2026-000123", "createdAt": "2026-07-18T10:00:00.000Z" } } ``` Entre mejor sea la evidencia inicial, más rápida la resolución: fotos del daño **y** del empaque exterior, más el comprobante del valor. ## El ciclo de vida de la reclamación ```text FILED → INVESTIGATING → EVIDENCE_REQUIRED ↓ APPROVED → PAID PARTIALLY_APPROVED → PAID DENIED CANCELLED ``` | Estado | Significado | | --- | --- | | `FILED` | Recibida y enviada a la aseguradora | | `INVESTIGATING` | En revisión | | `EVIDENCE_REQUIRED` | Se necesita documentación adicional — súbela y actualiza la reclamación | | `APPROVED` | Monto completo aprobado | | `PARTIALLY_APPROVED` | Se aprobó un monto menor al reclamado (`approvedAmount`) | | `DENIED` | Rechazada | | `PAID` | Pago dispersado por la aseguradora | | `CANCELLED` | Retirada por ti | Los estados `DENIED`, `PAID` y `CANCELLED` son terminales. ## Reglas cruzadas | Código | Situación | Cómo resolverlo | | --- | --- | --- | | `409 CLAIM_PENDING_NO_REFUND_ALLOWED` | Intentas [cancelar la guía](/shipping/refunds) con una reclamación abierta | Resuelve o retira la reclamación primero | | `409 SHIPMENT_REFUNDED_NO_CLAIMS_ALLOWED` | Intentas reclamar sobre una guía cancelada | El costo ya fue reembolsado; no aplica seguro | | `409 CLAIM_ALREADY_OPEN` | Ya existe una reclamación abierta para ese envío | Da seguimiento a la existente | --- # Planes y cuotas Source: https://docs.sendit.mx/wallet-and-billing/subscriptions-and-quotas Cada plan incluye una cuota mensual de guías y rastreadores. No hay topes duros. Al exceder tu cuota pagas una tarifa de excedente por guía, cobrada al momento de la compra. ## Los planes | Plan | Guías / mes | Excedente por guía | Cuenta propia por guía | Rastreadores / mes | Excedente por rastreador | Peticiones / min (`read`/`write`/`quote`) | Llaves de API | Endpoints de webhook | Miembros | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | Free | 500 | $0.90 MXN | $1.20 MXN + IVA | 100 | $0.80 MXN | 60 / 30 / 20 | 2 | 2 | 3 | | Growth | 3,000 | $0.70 MXN | $0.90 MXN + IVA | 2,000 | $0.50 MXN | 300 / 150 / 60 | 10 | 10 | Ilimitados | | Scale | 15,000 | $0.50 MXN | $0.60 MXN + IVA | 10,000 | $0.30 MXN | 1,000 / 500 / 200 | 50 | 50 | Ilimitados | | Enterprise | Ilimitadas | — | Sin costo | Ilimitados | — | 5,000 / 2,500 / 1,000 | 500 | 500 | Ilimitados | En todos los planes: API completo, [modo de prueba](/getting-started/test-mode), webhooks y todas las paqueterías. Las peticiones por minuto se cuentan por clase de endpoint. El detalle está en [Límites de peticiones](/api-conventions/rate-limits). La columna de cuenta propia aplica cuando compras con tus propias credenciales de paquetería. En ese caso pagas esa tarifa y nada más: la guía **no** consume excedente normal, aunque sí cuenta para tu cuota mensual. Ver [Cuentas de paquetería](/shipping/carrier-accounts). ## Cómo funciona el excedente **No hay tope mensual.** Una organización Free puede comprar 600 guías en un mes: las primeras 500 al precio normal, y las 100 restantes con $0.90 MXN extra cada una. El excedente: - Se cobra **al momento de cada compra**, junto con la guía. Nunca llega como factura sorpresa a fin de mes. - Aparece explícito en la respuesta de compra (`overageCharge`) y en la guía (`overageAmount`); es `"0.00"` dentro de cuota o en planes ilimitados. - Cuenta **solo la operación real**: las compras en modo de prueba jamás consumen cuota ni cobran excedente. Lo mismo aplica a los [rastreadores](/shipping/trackers#precios) externos. Solo los registros externos consumen cuota: los rastreadores que se crean solos con una guía de SendIt son gratuitos y no cuentan. ## Topes duros: llaves, webhooks y miembros Las guías y los rastreadores no tienen tope: pagas excedente. Las **llaves de API** y los **endpoints de webhook** sí tienen un tope duro por plan. Crear uno más allá del límite devuelve `403 PLAN_LIMIT_REACHED`, con `resource`, `limit` y `plan` en `details`. Sube de plan para elevar el tope. El conteo de llaves excluye las que están en su ventana de rotación de 24 h; el de webhooks excluye los endpoints eliminados. ### Miembros en el plan Free El plan Free permite **3 miembros**, contando los miembros actuales **más las invitaciones pendientes**. Invitar por encima del tope devuelve el mismo `403 PLAN_LIMIT_REACHED`. Aceptar una invitación hacia una organización que ya llegó a 3 miembros también: en ese caso la invitación **sigue pendiente** y funciona en cuanto subas de plan. Los planes de pago no limitan miembros. Las organizaciones que ya tenían más de 3 miembros antes de este tope los conservan, pero no pueden invitar a nadie más sin subir de plan. ## Consulta tu uso del mes `GET /v1/organizations/me/usage` devuelve el consumo del **mes calendario** actual contra las cuotas de tu plan: ```json { "success": true, "data": { "month": "2026-07", "plan": "FREE", "labelsCreated": 562, "labelsQuota": 500, "overageLabels": 62, "overagePerLabel": "0.90", "trackersCreated": 30, "trackersQuota": 100, "overageTrackers": 0, "overagePerTracker": "0.80", "totalSpent": "18234.50", "currency": "MXN" } } ``` | Campo | Descripción | | --- | --- | | `labelsQuota` / `trackersQuota` | Cuota del plan (`null` = ilimitado) | | `overageLabels` | Guías por encima de la cuota este mes | | `totalSpent` | Gasto real del mes (solo operación en vivo) | Ideal para mostrar barras de consumo en tu propio panel o para alertarte antes de entrar en excedente. ## Alertas de uso Tu uso **en vivo** dispara un aviso al cruzar por primera vez el **80%** y el **100%** de una cuota, sea de guías o de rastreadores. Avisamos al **dueño** de la organización por correo. Cada umbral avisa **a lo más una vez por mes** calendario y se rearma al mes siguiente. Los planes ilimitados no generan alertas, y el modo de prueba nunca cuenta. ## Consulta tu suscripción `GET /v1/billing/subscription` devuelve el plan activo, su ciclo y los límites completos: ```json { "success": true, "data": { "plan": "GROWTH", "status": "ACTIVE", "currentPeriodStart": "2026-07-01T00:00:00.000Z", "currentPeriodEnd": "2026-08-01T00:00:00.000Z", "cancelledAt": null, "trialEndsAt": null, "isStripeManaged": true, "limits": { "labelsPerMonth": 3000, "overagePerLabel": "0.70", "trackersPerMonth": 2000, "overagePerTracker": "0.50", "rateLimits": { "read": 300, "write": 150, "quote": 60 }, "maxApiKeys": 10, "maxWebhookEndpoints": 10, "maxMembers": null } } } ``` - `status`: `TRIALING`, `ACTIVE`, `PAST_DUE`, `CANCELLED` o `UNPAID`. - `limits.rateLimits` trae el presupuesto por minuto de cada [clase de endpoint](/api-conventions/rate-limits). - `maxMembers` es `null` en los planes de pago (sin tope) y `3` en Free. - Las organizaciones en plan Free, sin suscripción de pago, reportan `isStripeManaged: false` y periodos `null`. El endpoint nunca devuelve 404. ## Organizaciones personales Tu [organización personal](/getting-started/organizations-and-sandbox) inicia en Free y puede subir de plan como cualquier otra. Las cuotas y los excedentes aplican igual. El sandbox es un **modo**, no la organización: nada de lo que hagas en modo de prueba consume cuota. ## Cambia de plan Puedes subir o administrar tu plan tú mismo (rol ADMIN o superior): - `POST /v1/billing/checkout-session` con `{ "plan": "GROWTH" }` devuelve una URL de **Stripe Checkout**: redirige ahí al usuario para completar el pago. Al terminar, tu plan se actualiza automáticamente. Acepta `GROWTH` o `SCALE`. - `POST /v1/billing/portal-session` devuelve una URL del **portal de facturación** de Stripe para cambiar el método de pago, ver recibos o cancelar. Requiere que tu organización ya haya subido de plan al menos una vez. Ambos endpoints usan tu sesión del dashboard (JWT), no llaves de API. `ENTERPRISE` se contrata con ventas: pedirlo por checkout devuelve `503 CHECKOUT_NOT_CONFIGURED`. Al subir de plan, la cuota nueva aplica de inmediato; al bajar, al cierre del periodo en curso. --- # Monedero y fondeo Source: https://docs.sendit.mx/wallet-and-billing/wallet El monedero es tu saldo prepagado en pesos: cada guía se debita de él al precio cotizado. Fondéalo por transferencia SPEI a tu CLABE dedicada, con tarjeta o con PayPal. ## Endpoints | Método | Ruta | Descripción | | --- | --- | --- | | `POST` | `/v1/wallet/funding-instructions` | Provisionar tu CLABE (idempotente) | | `GET` | `/v1/wallet/funding-instructions` | Consultar tu CLABE | | `POST` | `/v1/wallet/fund/card` | Iniciar fondeo con tarjeta | | `POST` | `/v1/wallet/fund/paypal` | Iniciar fondeo con PayPal | | `POST` | `/v1/wallet/fund/oxxo` | Generar un vale de pago en efectivo (OXXO) | | `GET` | `/v1/wallet/fund/:paymentIntentId/status` | Estado de un pago | | `GET` | `/v1/wallet/balance` | Saldo actual | | `GET` | `/v1/wallet/summary` | Resumen de ingresos/egresos/neto por periodo | | `GET` | `/v1/wallet/transactions` | Historial de transacciones | | `PATCH` | `/v1/wallet/settings` | Configurar el umbral de saldo bajo (ADMIN+) | | `POST` | `/v1/wallet/test/reset` | Restablecer el saldo de prueba (solo con sesión del dashboard) | ## Fondea por SPEI (recomendado) :::warning El fondeo y la consulta del estado de pagos funcionan solo en modo LIVE. Una llave `sk_test_` devuelve `400 LIVE_MODE_REQUIRED` en estas rutas. Para reponer el saldo de prueba, usa el control del dashboard. ::: Cada organización recibe una **CLABE dedicada**: cualquier transferencia SPEI a esa cuenta se acredita automáticamente a tu monedero, sin conciliación manual. ```bash curl -X POST https://api.sendit.mx/v1/wallet/funding-instructions \ -H "Authorization: Bearer " ``` ```json { "success": true, "data": { "id": "clxfund123abc", "type": "mx_bank_transfer", "clabe": "646180111812345678", "bankName": "STP", "bankCode": "646", "status": "ACTIVE", "createdAt": "2026-07-17T01:00:00.000Z" } } ``` El endpoint es idempotente: llamarlo de nuevo devuelve la misma CLABE. Comparte la CLABE con tu equipo de finanzas y fondea por SPEI desde cualquier banco. El crédito aparece en minutos como una transacción `CREDIT`. ## Fondea con tarjeta ```bash curl -X POST https://api.sendit.mx/v1/wallet/fund/card \ -H "Authorization: Bearer " \ -H "Idempotency-Key: 9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d" \ -H "Content-Type: application/json" \ -d '{ "amount": 500 }' ``` ```json { "success": true, "data": { "paymentIntentId": "pi_3QyAbc123xyz", "clientSecret": "pi_3QyAbc123xyz_secret_def456", "amount": 500, "currency": "MXN", "publishableKey": "pk_test_xxxxx" } } ``` ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `amount` | `number` | - | Monto a fondear en MXN. Mínimo $20, máximo $50,000 por operación. | | `returnUrl?` | `string` | - | Solo fund/paypal: a dónde regresar al usuario tras la aprobación. | Usa el `clientSecret` con Stripe.js en tu frontend para capturar la tarjeta y completar 3D Secure. Confirmado el pago, el monedero se acredita automáticamente. El fondeo con **PayPal** funciona igual vía `POST /v1/wallet/fund/paypal` (acepta un `returnUrl` opcional para el regreso tras la aprobación). ### Consulta el estado del pago ```text GET /v1/wallet/fund/pi_3QyAbc123xyz/status ``` ```json { "success": true, "data": { "paymentIntentId": "pi_3QyAbc123xyz", "status": "succeeded", "amount": 500, "currency": "MXN", "paymentMethodType": "card", "walletCredited": true } } ``` Cuando `status` es `succeeded` y `walletCredited` es `true`, el saldo ya refleja el fondeo. :::note El encabezado `Idempotency-Key` es opcional pero **recomendado** en los fondeos. Con él, los reintentos reutilizan el mismo intento de pago y nunca hay dos cobros. Ver [Idempotencia](/api-conventions/idempotency). ::: ## Fondea con OXXO (efectivo) Para pagar en efectivo, usa `POST /v1/wallet/fund/oxxo`. Genera un **vale con código de barras**: muéstralo o imprímelo y paga en cualquier tienda OXXO. El vale vence en 3 días. El monedero se acredita cuando OXXO confirma el pago, así que trátalo como pendiente hasta entonces. Consulta el estado del pago con `/v1/wallet/fund/:paymentIntentId/status`. ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `amount` | `number` | - | Monto a fondear en MXN. Mínimo $20, máximo $10,000, que es el tope del vale OXXO. | ```bash curl -X POST https://api.sendit.mx/v1/wallet/fund/oxxo \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "amount": 500 }' ``` ```json { "success": true, "data": { "paymentIntentId": "pi_3Oxxo123xyz", "hostedVoucherUrl": "https://payments.stripe.com/oxxo/voucher/...", "expiresAfter": 1800000000, "amount": 500, "currency": "MXN" } } ``` `hostedVoucherUrl` es el vale imprimible. `expiresAfter` es la fecha de vencimiento, en segundos epoch Unix. El saldo se actualiza cuando el pago en efectivo se libera. Hasta entonces la transacción queda pendiente. ## Consulta tu saldo ```text GET /v1/wallet/balance ``` ```json { "success": true, "data": { "id": "clxwallet123", "balance": "1500.00", "currency": "MXN", "lowBalanceThreshold": "100.00", "lowBalanceAlertSent": false, "hasFundingSource": true } } ``` Los montos son cadenas decimales. Nunca hagas aritmética flotante con dinero. Con una llave `sk_test_` este endpoint devuelve el **saldo de prueba**. Ver [Modo de prueba](/getting-started/test-mode). ### Configura la alerta de saldo bajo `PATCH /v1/wallet/settings` define a partir de qué saldo quieres recibir un aviso. Requiere rol ADMIN o superior, y el alcance `wallet:write` si usas una llave de API. ```bash curl -X PATCH https://api.sendit.mx/v1/wallet/settings \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "lowBalanceThreshold": 500 }' ``` Manda `null` para desactivar la alerta. ## Resumen de movimientos Para tarjetas de "ingresos vs. egresos" sin recorrer todas las transacciones, `GET /v1/wallet/summary` agrega el periodo que pidas. Ambos parámetros de fecha son opcionales (`from` inclusivo, `to` exclusivo): ```text GET /v1/wallet/summary?from=2026-07-01&to=2026-08-01 ``` ```json { "success": true, "data": { "income": "1050.00", "expenses": "730.50", "net": "314.50", "adjustments": "-5.00", "transactionCount": 7, "currency": "MXN", "byType": { "CREDIT": { "count": 2, "amount": "1000.00" }, "REFUND": { "count": 1, "amount": "50.00" }, "DEBIT": { "count": 3, "amount": "-730.50" }, "ADJUSTMENT": { "count": 1, "amount": "-5.00" } } } } ``` `income` suma créditos y reembolsos. `expenses` es la magnitud de los débitos, que se guardan en negativo. Todos los montos son cadenas decimales. El resumen respeta el modo de la petición. Con `?livemode=false` agrega los movimientos de prueba. ## Revisa tus transacciones ```text GET /v1/wallet/transactions?type=CREDIT&page=1&limit=20 ``` ```json { "success": true, "data": [ { "id": "clxtx_spei_456", "type": "CREDIT", "amount": "1000.00", "currency": "MXN", "balanceAfter": "2500.00", "description": "Depósito vía transferencia bancaria SPEI", "referenceType": "stripe_bank_transfer", "status": "COMPLETED", "createdAt": "2026-07-17T10:00:00.000Z" }, { "id": "clxtx_label_789", "type": "DEBIT", "amount": "326.82", "currency": "MXN", "balanceAfter": "1500.00", "description": "Compra de guía DHL Express Nacional", "referenceType": "label_purchase", "referenceId": "clxq1w2e3r4t5y6u7i8o9p0a", "metadata": { "carrierCode": "DHL" }, "status": "COMPLETED", "createdAt": "2026-07-17T09:00:00.000Z" } ], "meta": { "page": 1, "limit": 20, "total": 143, "totalPages": 8 } } ``` | Parámetro | Valores | | --- | --- | | `type` | `CREDIT`, `DEBIT`, `REFUND`, `ADJUSTMENT` | | `referenceType` | `stripe_bank_transfer` (SPEI), `stripe_payment_intent` (tarjeta, PayPal u OXXO), `label_purchase`, `label_purchase_refund`, `label_void_refund` | | `page` | Número de página. Por defecto 1 | | `limit` | Elementos por página. Por defecto 20, máximo 100 | | `sortOrder` | `asc` o `desc`. Por defecto `desc` | Los fondeos con tarjeta, PayPal y OXXO comparten el `referenceType` `stripe_payment_intent`. Para distinguirlos, lee `metadata.funding_method`: vale `card`, `paypal` u `oxxo`. Cada transacción trae `balanceAfter`. El historial es un estado de cuenta completo y auditable. ## Errores | Código | Cuándo ocurre | Cómo resolverlo | | --- | --- | --- | | `400 INVALID_INPUT` | El monto o los parámetros no son válidos | Corrige la petición y vuelve a intentar | | `400 LIVE_MODE_REQUIRED` | Intentaste fondear o consultar un pago en modo TEST | Cambia a una sesión o llave LIVE | | `402 INSUFFICIENT_BALANCE` | Una operación requiere más saldo del disponible | Fondea el monedero | --- # Notificaciones al destinatario Source: https://docs.sendit.mx/webhooks-and-events/notifications SendIt puede avisarle **a tu comprador**, la persona que recibe el paquete, cuando su pedido avanza. Los avisos salen por correo y por WhatsApp, en español, y tú no construyes nada. Tú controlas qué eventos notifican y por qué canal. ## Qué se notifica | Evento | Cuándo se dispara | Correo | WhatsApp | | --- | --- | --- | --- | | `order_confirmed` | La orden pasa a `CONFIRMED` | Sí | Sí | | `label_purchased` | Se compra la guía de un envío | Sí | Sí | | `shipment_delivered` | El rastreo llega a `DELIVERED` | Sí | Sí | | `claim_filed` | Se presenta una reclamación de seguro | Sí | Sí | La entrega ocurre en segundo plano. Nunca retrasa la compra de la guía ni la respuesta del API. ## Reglas de consentimiento - **WhatsApp requiere opt-in explícito.** La LFPDPPP exige consentimiento antes de cualquier mensaje. WhatsApp se envía **solo** cuando la [orden](/orders/orders) vinculada al envío tiene `customerOptInToWhatsapp: true`. Un envío sin orden vinculada jamás recibe WhatsApp. - **El correo es transaccional.** Son mensajes de servicio sobre el propio envío del destinatario, así que no requieren opt-in individual. El control es el interruptor por organización que se describe abajo. El correo se toma del `customerEmail` de la orden, o del correo de contacto de la dirección destino. - **El modo de prueba nunca notifica.** Toda operación con `livemode: false` se registra como `SKIPPED` y no envía nada real. Los mensajes de WhatsApp usan plantillas preaprobadas por Meta, en español (es-MX). Por ejemplo, la de `label_purchased` incluye el número de rastreo, la paquetería y la fecha estimada de entrega. ## Controla qué se envía Los interruptores son **por organización, por evento y por canal**. Un interruptor ausente significa **habilitado** (la función viene encendida): solo creas filas para apagar cosas. ### Consulta la matriz completa ```bash curl https://api.sendit.mx/v1/notification-settings \ -H "X-API-Key: sk_live_..." ``` ```json { "success": true, "data": [ { "eventType": "order_confirmed", "channel": "EMAIL", "isEnabled": true, "subjectOverride": null }, { "eventType": "order_confirmed", "channel": "WHATSAPP", "isEnabled": true }, { "eventType": "label_purchased", "channel": "EMAIL", "isEnabled": true, "subjectOverride": "Tu guía {{trackingNumber}} está lista" }, { "eventType": "shipment_delivered", "channel": "WHATSAPP", "isEnabled": false } ] } ``` 4 eventos × 2 canales = 8 filas con su valor efectivo. Las filas de correo traen además `subjectOverride`, que es `null` cuando usas el asunto que trae SendIt. Las de WhatsApp no lo traen. Cualquier miembro autenticado puede leer la configuración; el alcance `notification_settings:read` solo aplica a las llaves de API. ### Apaga o enciende interruptores ```bash curl -X PUT https://api.sendit.mx/v1/notification-settings \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "settings": [ { "eventType": "shipment_delivered", "channel": "WHATSAPP", "isEnabled": false } ] }' ``` Requiere rol `OPERATOR` o superior y el alcance `notification_settings:write`. Es una actualización parcial: solo tocas las filas que mandas. #### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `settings` | `objeto[]` | - | Interruptores a actualizar. Cada elemento trae eventType (order_confirmed \| label_purchased \| shipment_delivered \| claim_filed), channel (EMAIL \| WHATSAPP) e isEnabled (boolean). En filas EMAIL acepta además subjectOverride. | Apagar un canal detiene esa notificación en toda la organización. Apagar WhatsApp no tiene efecto sobre mensajes que ya se suprimían por falta de opt-in. ### Personaliza el asunto del correo En una fila `EMAIL`, `subjectOverride` reemplaza el asunto que trae SendIt. Omítelo para dejar el asunto como está, mándalo con un texto de **hasta 150 caracteres** para reemplazarlo, o mándalo en `null` para volver al asunto original. ```bash curl -X PUT https://api.sendit.mx/v1/notification-settings \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "settings": [ { "eventType": "label_purchased", "channel": "EMAIL", "isEnabled": true, "subjectOverride": "Tu guía {{trackingNumber}} está lista" } ] }' ``` Cada evento acepta solo sus propias variables: | Evento | Variables permitidas en el asunto | | --- | --- | | `order_confirmed` | `customerName`, `orderNumber`, `totalPrice` | | `label_purchased` | `customerName`, `carrier`, `trackingNumber`, `estimatedDelivery` | | `shipment_delivered` | `customerName`, `trackingNumber`, `deliveredAt` | | `claim_filed` | `customerName`, `claimNumber`, `shipmentId` | | Código | Cuándo | Cómo resolverlo | | --- | --- | --- | | `400 INVALID_INPUT` | Mandaste `subjectOverride` en una fila `WHATSAPP` | El copy de WhatsApp lo aprueba Meta y no es editable — quita el campo | | `400 INVALID_INPUT` | El asunto usa una variable que ese evento no publica | Usa solo las variables de la tabla de arriba | | `400 INVALID_INPUT` | El asunto trae un salto de línea o pasa de 150 caracteres | Manda una sola línea de máximo 150 caracteres | ### Pon tu marca en los correos Los correos al destinatario pueden llevar tu logo, tu color y tu pie de página. Con `publicTrackingPage` activo, esa misma marca aparece en la [página de rastreo público](/shipping/public-tracking#muestra-tu-marca-cuando-la-habilites). | Método | Ruta | Acceso | | --- | --- | --- | | `GET` | `/v1/notification-settings/branding` | Cualquier miembro, o llave con `notification_settings:read` | | `PUT` | `/v1/notification-settings/branding` | Rol `ADMIN` o superior + `notification_settings:write` | #### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `logoUrl?` | `string` | - | URL https del logo, máximo 500 caracteres. Aparece en el encabezado del correo. | | `accentColor?` | `string` | - | Color de acento en hexadecimal de seis dígitos (por ejemplo #4361EE). | | `replyTo?` | `string` | - | Correo al que responde tu comprador, máximo 320 caracteres. | | `footerText?` | `string` | - | Texto plano del pie de página, máximo 500 caracteres. En la página pública llega como footer. | | `publicTrackingPage?` | `boolean` | `false` | true = muestra tu marca en la página de rastreo público. Solo aplica ahí. | `replyTo` aplica **solo al correo** y nunca sale en la respuesta pública. El `logoUrl` y el `accentColor` aplican a los dos. ```bash curl -X PUT https://api.sendit.mx/v1/notification-settings/branding \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "logoUrl": "https://cdn.tutienda.mx/logo.png", "accentColor": "#4361EE", "replyTo": "hola@tutienda.mx", "footerText": "Gracias por comprar en Tu Tienda.", "publicTrackingPage": true }' ``` ```json { "success": true, "data": { "logoUrl": "https://cdn.tutienda.mx/logo.png", "accentColor": "#4361EE", "replyTo": "hola@tutienda.mx", "footerText": "Gracias por comprar en Tu Tienda.", "publicTrackingPage": true } } ``` Todos los campos son opcionales, y `GET` omite los que nunca configuraste. Una organización nueva devuelve `{ "success": true, "data": {} }`. :::warning `PUT` **reemplaza** la marca completa: no es un parche. El campo que omitas se borra, y mandar `{}` regresa todo a los valores por defecto de SendIt. Un `PUT` que solo manda `logoUrl` deja `publicTrackingPage` en `false` y apaga la marca de la página pública para todos tus destinatarios. Haz `GET` primero, aplica tu cambio sobre lo que ya está y manda el objeto completo. ::: Las plantillas de WhatsApp no cambian nunca. | Código | Cuándo | Cómo resolverlo | | --- | --- | --- | | `400 INVALID_INPUT` | `logoUrl` no es `https` o pasa de 500 caracteres | Publica el logo en una URL https propia | | `400 INVALID_INPUT` | `accentColor` no es un hexadecimal de seis dígitos | Usa el formato `#RRGGBB` | | `400 INVALID_INPUT` | `replyTo` no es un correo válido, o `footerText` pasa de 500 caracteres | Corrige el valor y reenvía | | `403 INSUFFICIENT_SCOPE` | La llave no tiene `notification_settings:write` | Emite una llave con ese [alcance](/api-conventions/scopes) | ### Consulta las plantillas `GET /v1/notification-templates` regresa el catálogo de plantillas, una por evento y canal (4 × 2 = 8), para que tu dashboard muestre una galería de vista previa. Es de solo lectura y usa el alcance `notification_settings:read`. Las plantillas **no son editables**: el texto está aprobado por Meta en WhatsApp, o es propio del código en correo. ```bash curl https://api.sendit.mx/v1/notification-templates \ -H "X-API-Key: sk_live_..." ``` ```json { "success": true, "data": [ { "eventType": "label_purchased", "channel": "EMAIL", "variables": ["customerName", "carrier", "trackingNumber", "estimatedDelivery"], "subject": "Tu envío está en camino — FEDMX123456789", "exampleBody": "Hola María,\n\nGeneramos la guía de tu envío con FedEx.\n…" }, { "eventType": "label_purchased", "channel": "WHATSAPP", "variables": ["customerName", "carrier", "trackingNumber", "estimatedDelivery"], "templateName": "label_purchased", "exampleBody": "Hola María, …", "configured": true } ] } ``` Las entradas de correo se renderizan con **tu marca y tu asunto activos**: `subject` refleja tu `subjectOverride` si lo definiste, y `exampleHtml` trae la vista previa con tu logo, color y pie. Las plantillas de WhatsApp están en español (es-MX), preaprobadas por Meta y se muestran sin marca. `configured` indica si la plantilla está disponible. El canal de correo funciona de manera independiente. ## Semántica de entrega - **Sin duplicados.** El reprocesamiento de un mismo evento no envía un segundo mensaje por el mismo canal. - **Los canales son independientes.** Una falla en un canal nunca bloquea al otro ni a tus [webhooks](/webhooks-and-events/webhooks). :::note Estas notificaciones son para el **comprador final**. Para avisarle a *tu sistema*, usa [webhooks](/webhooks-and-events/webhooks). ::: --- # Webhooks Source: https://docs.sendit.mx/webhooks-and-events/webhooks Los webhooks avisan a tu servidor cuando cambia un recurso: por ejemplo, al comprar una guía, actualizar un rastreo o mover saldo. Registra un endpoint HTTPS y verifica la firma de cada entrega. ## Registra un endpoint ### Parámetros del cuerpo | Prop | Type | Default | Description | | --- | --- | --- | --- | | `url` | `string` | - | HTTPS y respuesta directa. Las redirecciones no se siguen, y un 3xx cuenta como fallo. | | `events` | `string[]` | - | Los tipos de evento a los que se suscribe este endpoint (ver la tabla abajo). | | `description?` | `string` | - | Nombre opcional para reconocer el destino. | ```bash curl -X POST https://api.sendit.mx/v1/webhook-endpoints \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://mitienda.mx/webhooks/sendit", "description": "Receptor de producción", "events": ["shipment.label.created", "shipment.tracking.updated"] }' ``` ```json { "success": true, "data": { "id": "whe_a1b2c3d4e5f6", "url": "https://mitienda.mx/webhooks/sendit", "description": "Receptor de producción", "events": ["shipment.label.created", "shipment.tracking.updated"], "status": "ACTIVE", "failureCount": 0, "disabledAt": null, "createdAt": "2026-07-18T10:00:00.000Z", "signingSecret": "whsec_9f8e7d6c5b4a" } } ``` El campo se llama `signingSecret`. SendIt lo genera y lo devuelve solo al crear o rotar el endpoint. Guárdalo en un gestor de secretos: no puedes consultarlo de nuevo. El listado de endpoints nunca lo incluye. Cada plan tiene un tope de endpoints activos. Registrar uno de más devuelve `403 PLAN_LIMIT_REACHED`. Ver [planes y cuotas](/wallet-and-billing/subscriptions-and-quotas). ### Administra tus endpoints | Método | Ruta | Descripción | | --- | --- | --- | | `GET` | `/v1/webhook-endpoints` | Listar tus endpoints | | `GET`/`PATCH`/`DELETE` | `/v1/webhook-endpoints/:id` | Consultar, actualizar o eliminar uno | | `POST` | `/v1/webhook-endpoints/:id/rotate-secret` | Generar un secreto nuevo (se devuelve una sola vez) | | `POST` | `/v1/webhook-endpoints/:id/test` | Enviar un evento `webhook.test` de inmediato | | `GET` | `/v1/webhook-endpoints/:id/events` | Historial de entregas de ese endpoint | | `POST` | `/v1/webhook-endpoints/:id/events/:eventId/redeliver` | Reintentar una entrega | Las lecturas las puede hacer cualquier miembro autenticado. Las escrituras requieren rol ADMIN y el alcance `webhooks:write`. ## Tipos de evento Puedes suscribirte a estos eventos documentados: | Evento | Cuándo se dispara | | --- | --- | | `shipment.created` | Se creó un envío | | `shipment.updated` | Se actualizó un envío en `DRAFT` | | `shipment.label.created` | Se compró una guía | | `label.purchase.completed` | Una compra durable terminó: con éxito, con falla compensada o en `action_required` | | `shipment.label.voided` | Se canceló una guía y se acreditó el reembolso | | `shipment.tracking.updated` | El rastreo de la paquetería provocó un cambio de estado | | `wallet.credited` | Se confirmó un crédito del monedero | | `wallet.debited` | Se confirmó un débito por compra de guía | | `wallet.low_balance` | Un débito LIVE cruzó el umbral configurado | | `tracker.created` | Se registró un rastreador externo | | `tracker.updated` | Un rastreador externo cambió de estado | | `tracker.expired` | Un rastreador externo llegó a su TTL sin estado terminal | Suscribe cada endpoint solo a los eventos que le interesan. Tu handler debe ignorar los tipos que no reconozca. :::warning **No existen `shipment.delivered`, `shipment.exception` ni `shipment.cancelled`.** La entrega y las excepciones llegan como `shipment.tracking.updated`: lee `data.object.status`. Lo mismo con rastreadores, donde no hay `tracker.delivered`: lee `data.object.status` desde `tracker.updated`. ::: ## El payload ```json { "id": "evt_1a2b3c4d5e", "type": "shipment.tracking.updated", "created": "2026-07-17T12:00:00.000Z", "livemode": true, "data": { "object": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "IN_TRANSIT", "trackingNumber": "1234567890", "carrierCode": "DHL", "organizationId": "clxorg123" } } } ``` - `created` es una marca de tiempo **ISO-8601**, no segundos epoch. - `data.object` es la proyección completa del recurso, no un subconjunto plano. Los eventos de envío traen dentro sus proyecciones de `fromAddress`, `toAddress` y `label`. - **No hay `apiVersion` ni `organizationId` en el nivel superior.** El `data.object` de un envío sí trae su `organizationId`; el de un rastreador no. - `livemode: false` marca los eventos de [modo de prueba](/getting-started/test-mode). Enrútalos a tu staging. - `id` es único por evento. Úsalo para deduplicar si recibes una entrega repetida. :::note `shipment.tracking.updated` **no** trae `previousStatus`. Para detectar una transición concreta, compara contra el estado que ya tenías guardado. Los eventos `tracker.updated` sí traen el estado anterior. ::: ## Verifica la firma Cada entrega llega firmada con HMAC-SHA256 en el encabezado `X-SendIt-Signature`, con el formato `t=,v1=`: ```http POST /webhooks/sendit HTTP/1.1 X-SendIt-Signature: t=1752750000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd Content-Type: application/json ``` Verifica **siempre** antes de procesar. La firma es la única prueba de que el evento viene de SendIt: ```js Node.js import crypto from "node:crypto"; function verifySenditSignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) { const parts = Object.fromEntries( signatureHeader.split(",").map((kv) => kv.split("=")) ); const { t, v1 } = parts; // 1. Rechaza timestamps viejos (protección contra replay) if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false; // 2. Recalcula la firma sobre `${t}.${cuerpoCrudo}` const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex"); // 3. Compara en tiempo constante return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)); } ``` :::warning Calcula el HMAC sobre el **cuerpo crudo** de la petición, con los bytes exactos, no sobre el JSON re-serializado. `JSON.stringify(JSON.parse(body))` puede producir bytes distintos y firmas que no coinciden. ::: ## Responde rápido, procesa después Responde `2xx` en cuanto persistas el evento, de preferencia en menos de un segundo. Procesa en segundo plano. Cualquier respuesta que no sea `2xx` cuenta como fallo, incluidas las redirecciones. ## Política de reintentos | Intento | Espera | | --- | --- | | 1 | Inmediato | | 2 | 2 min | | 3 | 4 min | | 4 | 8 min | | 5 | 16 min | Después del quinto intento el evento queda en `EXHAUSTED` y ya no se reintenta. Un endpoint que falla continuamente durante **24 horas se deshabilita automáticamente**. Cuando eso pasa, avisamos por correo a los administradores de la organización. Para reactivarlo, arregla tu servidor y manda una entrega de prueba con `POST /v1/webhook-endpoints/:id/test`: una prueba exitosa lo vuelve a habilitar. Como las entregas pueden repetirse, haz tu procesamiento idempotente usando el `id` del evento. ## Pruébalo sin riesgo En [modo de prueba](/getting-started/test-mode), cada avance de estado dispara los webhooks reales con `livemode: false`. Compra una guía de prueba, avanza su estado y observa las entregas llegar a tu endpoint. ## Errores | Código | Cuándo ocurre | Cómo resolverlo | | --- | --- | --- | | `400 INVALID_INPUT` | La URL o un tipo de evento no es válido | Usa HTTPS y un evento documentado | | `403 INSUFFICIENT_SCOPE` | La llave no tiene `webhooks:write` para una escritura | Emite una llave con el alcance requerido | | `403 PLAN_LIMIT_REACHED` | La organización alcanzó su límite de endpoints | Elimina uno que no uses o cambia de plan | | `404 RESOURCE_NOT_FOUND` | El endpoint o evento no existe | Verifica los identificadores |