Webhooks
Drop hace un POST a tu URL cuando cambia el estado de una orden tuya. Estados, firma, reintentos y reconciliación.
En vez de consultar en bucle, nos das una URL y Drop te avisa cuando algo cambia. La registrás en la tarjeta «Notificaciones» de Configuraciones → Integraciones, y ahí mismo aparece el signing secret, distinto del secreto de tu credencial, con el que verificás cada entrega.
Qué vas a recibir
| Evento | Cuándo se dispara |
|---|---|
order.status_changed | Cambió el estado de una orden tuya |
Es un único evento a propósito: cubre tanto los cambios que se originan en Drop como los que reporta la paquetería, con el vocabulario de estados de abajo. No hay un evento aparte por guía.
Solo recibís eventos de las órdenes que vos originaste. Las que el dropshipper crea por su cuenta, o las de otro origen, no te generan notificaciones.
Los eventos describen estado, no transición: no traen el estado anterior. Aplicar el mismo evento dos veces te deja igual que aplicarlo una.
Estados de la orden
Esta es la tabla que necesitás para interpretar un webhook. Los mismos valores
aparecen en el campo status de las respuestas de GET /orders y en su filtro
?status=, así que sirve para las dos cosas.
Cada orden trae dos campos: status, el identificador estable contra el que
tenés que programar, y statusLabel, su traducción al español lista para
mostrar. statusLabel puede cambiar de redacción sin aviso; status no.
El ciclo de vida tiene dos tramos. Primero la orden se gestiona dentro de Drop: se valida, se prepara y sale de bodega. A partir de ahí la mueve la paquetería y los cambios los reporta ella. Ambos tramos usan el mismo vocabulario: no tenés que distinguir de dónde viene el cambio.
Tramo 1: la orden dentro de Drop
status | statusLabel | Qué significa |
|---|---|---|
registered | Orden registrada | Estado inicial normal. La orden quedó completa y en la cola del proveedor, que es quien la despacha. No requiere nada de tu parte |
pending_correction | Verificar | La orden está detenida y no avanza sola. Ocurre al crearla si faltan datos de entrega (departamento o municipio, o las coordenadas en un país que las exige) o si la paquetería asignada no tiene cobertura para esa ruta. También es donde cae una orden que el proveedor devolvió con un motivo de rechazo. Alguien tiene que corregirla |
rejected | Orden rechazada | Final. La orden estuvo en pending_correction durante 72 horas sin corregirse y se descartó automáticamente. Ya no se va a despachar |
cancelled | Orden cancelada | Final. Se canceló antes de despacharse, desde la app del dropshipper o desde tu integración con PATCH /orders/{id}/cancel. Solo es posible mientras la orden no haya salido |
fulfilled | Orden despachada | La orden se despachó y se generó su guía. Es la frontera entre los dos tramos: acá aparecen shipmentNumber y trackingUrl, y de ahora en adelante los cambios los reporta la paquetería |
Tramo 2: el envío en manos de la paquetería
status | statusLabel | Qué significa |
|---|---|---|
created | Envío creado | La guía existe en el sistema de la paquetería, que todavía no la tomó |
registered_in_courier | Registrado en courier | La paquetería registró el envío en su operación |
recollected | Recolectado | El paquete ya está físicamente en poder de la paquetería |
inroute | En ruta a destino | En ruta hacia la dirección de entrega. Es donde un envío pasa la mayor parte del tiempo |
delivered | Entregado | Entregado al cliente final. Si la orden era contra entrega, es cuando se cobró. Único desenlace exitoso |
not_delivered | No entregado | Se intentó entregar y no se pudo. No es final: suele haber otro intento y el envío vuelve a inroute. Si no prospera, termina en returned_to_warehouse |
not_recollected | No recolectado | La paquetería no logró recolectar el paquete en el origen. El envío todavía no arrancó |
in_locker | En locker | El paquete está en un locker esperando que el cliente lo retire. Cuenta como tránsito |
in_agency | En agencia | El paquete está en una agencia esperando que el cliente lo retire. Cuenta como tránsito |
issue | Problemas en gestión | Incidencia levantada por la paquetería que no encaja en los estados anteriores. Requiere seguimiento: llega con su motivo. Ver Motivos de una novedad |
cancelled_in_boxful | Envío cancelado | El envío se anuló en la operación logística |
returned_to_warehouse | Devuelto a warehouse | Final en la práctica. El paquete volvió a bodega sin entregarse. Si era contra entrega, no se cobró |
Cómo tratarlos en tu código
Si necesitás agrupar, esta es la clasificación que usamos internamente:
| Grupo | Estados |
|---|---|
| Sin movimiento | registered, created, registered_in_courier, fulfilled |
| En tránsito | recollected, inroute, in_locker, in_agency |
| Requiere atención | pending_correction, issue, not_delivered, not_recollected |
| Cerrado sin entrega | cancelled, rejected, cancelled_in_boxful, returned_to_warehouse |
| Entregado | delivered |
- El avance no es lineal. Un envío puede ir
inroute→not_delivered→inroute→delivered. No asumas una secuencia fija ni que un estado no se repite. - Solo
deliveredcierra bien el ciclo.returned_to_warehouse,cancelled_in_boxful,cancelledyrejectedlo cierran sin entrega; todo lo demás va a seguir cambiando. - No infieras el cobro del estado del envío, más allá de que
deliveredsignifica que se cobró yreturned_to_warehouseque no. La liquidación es un proceso aparte.
Tolerá valores que no conozcas. Si la paquetería agrega un estado nuevo, lo vas a recibir tal como nos llega en lugar de perderlo; tu cliente no debería romperse por eso.
Forma de la petición
Drop hace un POST a tu URL con Content-Type: application/json:
POST /tu-endpoint HTTP/1.1
Content-Type: application/json
X-Drop-Event-Id: evt_9f1c2a04-91be-4d33-9d20-2b6e0c7a51ff
X-Drop-Event-Type: order.status_changed
X-Drop-Signature: t=1786462395,v1=4f3c…
{
"id": "evt_9f1c2a04-91be-4d33-9d20-2b6e0c7a51ff",
"type": "order.status_changed",
"createdAt": "2026-08-11T18:22:04.118Z",
"data": {
"orderId": "65b000000000000000000301",
"orderNumber": "1786733712601",
"sellerId": "65b0000000000000000000f1",
"status": "inroute",
"statusLabel": "En ruta a destino",
"shipmentNumber": "BX-99120",
"trackingUrl": "https://…/BX-99120",
"shippingCost": 3.5,
"codCommissionAmount": 2,
"codCommissionPercentage": 0.04,
"estimatedCodCommissionAmount": 2,
"reasons": [],
"occurredAt": "2026-08-11T18:22:04.118Z"
}
}| Campo | Notas |
|---|---|
id | Identificador del evento. Repite el header X-Drop-Event-Id |
type | Tipo de evento. Hoy siempre order.status_changed |
createdAt | Cuándo se generó el evento. No cambia entre reintentos |
data.orderId | Orden afectada. Es el id que va en GET /orders/:id |
data.orderNumber | Número visible de la orden |
data.sellerId | Tu identificador de dropshipper |
data.status | Estado nuevo, en el vocabulario de arriba: un cambio de la paquetería llega como "inroute", no como un código numérico |
data.statusLabel | Traducción al español del estado |
data.shipmentNumber | Número de guía. null mientras la orden no se haya despachado |
data.trackingUrl | Enlace de seguimiento. null mientras la orden no se haya despachado |
data.shippingCost | Costo de envío de la orden. null mientras la orden no se haya despachado |
data.codCommissionAmount | Comisión por cobro contra entrega aplicada al despachar. null mientras la orden no se haya despachado |
data.codCommissionPercentage | Porcentaje de esa comisión, como fracción (0.04 = 4 %). null mientras la orden no se haya despachado |
data.estimatedCodCommissionAmount | Comisión estimada al crear la orden |
data.reasons | Por qué la orden quedó en pending_correction o en issue. Vacío en cualquier otro estado. Ver Motivos de "Verificar" y Motivos de una novedad |
data.occurredAt | Cuándo ocurrió el cambio. Usalo para descartar eventos atrasados, no el orden de llegada |
test | Solo aparece, con valor true, en el evento de prueba. Un evento real no trae la clave. Ver más abajo |
Las cabeceras que acompañan el POST:
| Header | Notas |
|---|---|
X-Drop-Event-Id | Llave de deduplicación. Estable entre reintentos |
X-Drop-Event-Type | El mismo valor de type, para enrutar sin parsear el cuerpo |
X-Drop-Signature | t=<timestamp>,v1=<hmac>. Ver Verificá la firma |
El payload no lleva datos del cliente final: ni nombre, ni teléfono, ni
correo, ni dirección. Si los necesitás, pedilos con GET /orders/:id usando tu
credencial: así queda registrado quién accedió a esos datos y cuándo.
Motivos de "Verificar"
Cuando status es pending_correction, reasons te dice qué hay que corregir.
Te llega tanto si la orden nace en ese estado al crearla como si pasa a él
después, porque el proveedor o la bodega la devolvió.
"reasons": [
{
"code": "NO_COVERAGE",
"label": "Sin cobertura",
"detail": "El courier Forza no tiene cobertura para la ruta de la orden"
}
]| Campo | Notas |
|---|---|
code | Identificador estable. Programá contra este, no contra label |
label | Traducción al español; puede cambiar de redacción |
detail | Explicación que dejó quien la mandó a verificar. null si no dejó ninguna |
Puede venir más de un motivo: al crear la orden, por ejemplo, puede faltar la ubicación y además no haber cobertura. Los códigos posibles:
code | Cuándo |
|---|---|
NO_COVERAGE | La paquetería no cubre la ruta de la orden |
MISSING_COORDINATES | El país exige la ubicación del cliente y la orden no la trae |
MISSING_STATE_OR_CITY | Falta el departamento o el municipio |
INCOMPLETE_ADDRESS | El proveedor la devolvió: dirección incompleta |
CUSTOMER_REQUEST | El proveedor la devolvió: lo pidió el cliente |
OUT_OF_STOCK | El proveedor la devolvió: no hay stock |
Tratá un code desconocido como un motivo más, no como un error: la lista
puede crecer sin cambiar de versión. En ese caso mostrá label.
Motivos de una novedad
Cuando status es issue, reasons te dice qué reportó la paquetería, con la
misma forma que los motivos de "Verificar". detail trae el comentario del
repartidor, que es lo que conviene contarle al cliente final.
"reasons": [
{
"code": "COURIER_MANAGEMENT",
"label": "Gestión del courier",
"detail": "No se encuentra ella en el lugar"
}
]| Campo | Notas |
|---|---|
code | Tipo de incidencia. Identificador estable: programá contra este |
label | Traducción al español; puede cambiar de redacción |
detail | Comentario de la paquetería, en texto libre. null si no dejó ninguno |
Los códigos que vas a ver con más frecuencia:
code | Cuándo |
|---|---|
COURIER_MANAGEMENT | La paquetería necesita una gestión: el detalle dice cuál |
FAILED_DELIVERY | No se pudo entregar |
DELIVERY_DELAY | La entrega se retrasó |
SHIPMENT_NO_MOVEMENT | La guía no registra movimiento |
PACKAGE_DAMAGE | El paquete llegó dañado |
PACKAGE_LOST | El paquete se extravió |
COURIER_INCIDENT | La incidencia no tiene tipo; label trae el estado del courier |
Igual que en "Verificar", tratá un code desconocido como un motivo más y
mostrá label.
Dos cosas a tener en cuenta:
- Llega con unos segundos de atraso. La paquetería registra el detalle
después del cambio de estado, así que el evento
issuesale entre 30 segundos y un minuto y medio después. Si mientras tanto la orden cambió de estado, ese evento te llega antes: ordená poroccurredAt. - Siempre trae al menos un motivo. Si la paquetería no registra el detalle
en ese tiempo, el evento
issueno se envía. Vas a recibir el siguiente cambio de estado de la orden, y siempre podés consultarla conGET /orders/:id.
El evento de prueba
En la tarjeta «Notificaciones», una vez guardada tu URL, aparece el botón
«Enviar evento de prueba»: sirve para comprobar que tu endpoint responde
antes de que pase nada real. Llega por el mismo camino que uno de verdad, misma
firma, mismas cabeceras y el mismo sobre, así que podés validarlo con tu código
de producción. Lo que lo distingue es la clave test:
{
"id": "evt_test_9f1c2a04-91be-4d33-9d20-2b6e0c7a51ff",
"type": "order.status_changed",
"createdAt": "2026-08-19T18:22:04.118Z",
"test": true,
"data": {
"orderId": "000000000000000000000000",
"orderNumber": "TEST-0001",
"status": "registered",
"shipmentNumber": null,
"trackingUrl": null,
"shippingCost": null,
"codCommissionAmount": null,
"codCommissionPercentage": null,
"estimatedCodCommissionAmount": null,
"reasons": []
}
}Descartalo apenas lo identifiques
Antes de tocar tu base de datos:
if (event.test) return res.sendStatus(200);La orden 000000000000000000000000 no existe: es un relleno para que el
sobre tenga la misma forma que uno real. Si tu código no filtra por test y
va a buscarla, no la va a encontrar, y según cómo esté escrito puede terminar
creando un registro de una orden que nunca existió.
Verificá la firma antes de procesar
Sin esto, cualquiera que conozca tu URL puede inventarte órdenes.
X-Drop-Signature tiene la forma t=<timestamp>,v1=<hmac>, donde el HMAC es:
HMAC_SHA256( tu_signing_secret, "<timestamp>.<cuerpo crudo>" )En hex minúscula. El cuerpo es el que llegó, byte a byte: no lo re-serialices antes de verificar.
const crypto = require('crypto');
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map(kv => kv.split('=')));
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (age > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
// Comparación en tiempo constante.
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}El timestamp va dentro del HMAC para que una entrega capturada no se pueda reenviar indefinidamente: rechazá las que estén fuera de tu ventana.
Entrega y reintentos
La entrega es at-least-once: podés recibir el mismo evento más de una vez.
X-Drop-Event-Id es tu llave de deduplicación: es estable entre
reintentos, guardalo y descartá los repetidos.
Contamos como éxito cualquier respuesta 2xx. Todo lo demás, incluidos 3xx, timeouts y errores de conexión, es un fallo y se reintenta.
Respondé rápido: el timeout es de 10 segundos. Si tu procesamiento es lento, encolá el evento y respondé 200 de inmediato.
| Intento | Espera desde el anterior |
|---|---|
| 2.º | 1 minuto |
| 3.º | 5 minutos |
| 4.º | 15 minutos |
| 5.º | 1 hora |
| 6.º | 6 horas |
| 7.º en adelante | 24 horas |
Son 6 intentos por defecto. Agotados, la entrega queda marcada como fallida y no se vuelve a intentar.
Tu suscripción puede desactivarse sola tras 20 fallas consecutivas, para no insistir contra un endpoint muerto. Reactivarla es manual: escribinos. Una entrega exitosa reinicia el contador.
Reconciliación
Los webhooks se pueden perder: tu endpoint estuvo caído más allá de los reintentos, o la suscripción se desactivó. La contraparte es consultar por fecha de modificación:
GET /integrations/v1/orders?updatedSince=2026-08-11T00:00:00.000ZDevuelve solo tus órdenes, paginadas. Corriéndolo periódicamente cerrás cualquier hueco. No lo trates como opcional: es lo que hace que tu sistema converja aunque una entrega se pierda.
Referencia: GET /orders.
Recomendaciones
- Respondé 200 antes de procesar. Encolá y procesá aparte.
- Deduplicá por
X-Drop-Event-Id, no por el contenido. - No asumas orden de llegada. Un reintento puede llegar después de un evento
más nuevo; usá
occurredAtpara descartar los atrasados. - Tolerá campos nuevos. Agregarlos al payload no es un cambio incompatible.
- Guardá el signing secret como una credencial, no en el repositorio.
Cancelar una orden PATCH
Solo las órdenes que este partner originó, y solo mientras están registradas o pendientes de corrección y ningún proveedor las despachó: las mismas reglas que la app del dropshipper. Cancelar una orden ya cancelada la devuelve sin cambios. La cancelación se notifica por el webhook order.status_changed.
Puesta en marcha
El orden en que conviene integrar, la checklist antes de producción y qué mandar a soporte.