GSG Corp · Integraciones

Manual de integración — Webhooks de estado de pedidos

Versión 1.2 · Actualizado el 2026-09-03 · Evento: route.status_changed

Este manual está dirigido al equipo técnico del integrador (plataforma multi-tienda, CRM, e-commerce, ERP o sistema propio del negocio) que recibe notificaciones de GSG cuando un pedido registrado vía la API de integración cambia de estado logístico. Aquí está todo lo necesario para recibir, verificar y procesar esos eventos correctamente.

1.Novedades de esta versión (1.2)

El payload incorpora cuatro campos aditivos dentro de data: state_id, route_type_code, size_code y service. Con ellos tu sistema sabe, sin consultar el pedido, qué estado numérico se aplicó y qué tipo de envío y servicio (courier o GSG XL) era. event_version se mantiene en 1.0: la política del contrato es aditiva.

Además, el manual cubre el nuevo escenario de integración directa: el negocio con sistema propio configura su propio webhook desde el panel GSG (sección 3).

Revisión del mismo día (2026-09-03): una respuesta 3xx de tu endpoint ya no se reintenta (el evento queda retenido de inmediato, sección 9); los estados DRIVER_ASIGNADO y DRIVER_DESASIGNADO ahora también generan evento; previous_status es null (no la cadena "null") cuando no hay estado previo; route_state_id viaja en todos los caminos de cambio de estado; y los eventos generados mientras el vínculo de la tienda no estaba activo quedan retenidos en GSG y pueden liberarse al reactivarlo (sección 9).

Acción requerida: si tu endpoint valida el payload contra un esquema estricto (por ejemplo JSON Schema con additionalProperties: false), actualízalo antes de la fecha de despliegue que te comunique GSG, o los eventos serán rechazados por tu propio validador. Si tu endpoint ignora campos desconocidos — el comportamiento recomendado — no necesitas hacer nada para seguir operando.

2.Cómo funciona

Cada vez que un pedido tuyo (creado vía POST /api/v1/orders/integration) cambia de estado — asignado, en ruta, entregado, cancelado, etc. — GSG envía un POST JSON a tu URL de webhook. La entrega es asíncrona, con reintentos automáticos, y cada evento va firmado con HMAC-SHA256 usando el webhook_secret que obtuviste al configurar el webhook.

TransportePOST con cuerpo JSON a tu webhook_url (HTTPS)
Tipo de eventoroute.status_changed (versión de evento 1.0)
FirmaHMAC-SHA256 del cuerpo exacto, cabecera X-GSG-Signature
Respuesta esperadaCualquier código 2xx en menos de 10 segundos

3.Dónde se configura el webhook

EscenarioQuién configuraAlcance
Plataforma multi-tienda Un administrador de GSG, a pedido del integrador (URL y activación). El webhook_secret se entrega por canal seguro al crear o rotar. Una URL por plataforma. Recibes los eventos de todas las tiendas vinculadas; distingue la tienda con binding_id, external_store_id o business_id.
Integración directav1.2 El propio negocio, desde su panel GSG (Integraciones → Integración directa → Webhook): URL, activar/desactivar y rotar el secret. Puede indicar la URL ya en la solicitud: el webhook_secret se muestra una sola vez en la respuesta de la solicitud; después solo al rotarlo (sección Webhook → «Rotar el secret», o rotate_webhook_secret: true al regenerar la API key). Una URL por negocio. Todos los eventos corresponden a ese negocio.

4.Requisitos de tu endpoint

5.Cabeceras HTTP

CabeceraValor
Content-Typeapplication/json
X-GSG-Signaturesha256=<hex> — HMAC-SHA256 del cuerpo UTF-8 exacto, con clave webhook_secret.
X-GSG-Event-IdMismo UUID que event_id del cuerpo. Úsalo para deduplicar.
X-GSG-Event-Typeroute.status_changed
X-GSG-Event-Version1.0
User-AgentGSG-Webhook/1.0

6.Verificación de la firma

Lee el cuerpo como texto crudo (sin re-serializar el JSON), calcula el HMAC-SHA256 con tu secret y compara en tiempo constante con el valor que sigue a sha256=. Si no coincide, responde 401 y descarta.

Node.js (Express)

import { createHmac, timingSafeEqual } from "node:crypto";

app.post("/webhooks/gsg", express.raw({ type: "application/json" }), (req, res) => {
  const firma = req.get("X-GSG-Signature") || "";
  const esperada = "sha256=" +
    createHmac("sha256", process.env.GSG_WEBHOOK_SECRET)
      .update(req.body)            // Buffer del cuerpo EXACTO recibido
      .digest("hex");

  const a = Buffer.from(firma), b = Buffer.from(esperada);
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return res.status(401).end();
  }

  const evento = JSON.parse(req.body.toString("utf8"));
  res.status(200).end();           // responde rápido…
  procesarEvento(evento);          // …y procesa después
});

PHP

$body   = file_get_contents('php://input');
$firma  = $_SERVER['HTTP_X_GSG_SIGNATURE'] ?? '';
$secret = getenv('GSG_WEBHOOK_SECRET');

$esperada = 'sha256=' . hash_hmac('sha256', $body, $secret);
if (!hash_equals($esperada, $firma)) {
    http_response_code(401);
    exit;
}

$evento = json_decode($body, true);
http_response_code(200);
// procesar $evento (idealmente en una cola)

7.Payload del evento

{
  "event_id": "<uuid-v4>",
  "event_type": "route.status_changed",
  "event_version": "1.0",
  "occurred_at": "<ISO-8601>",
  "data": {
    "integration_client_code": "<tu código de integración>",
    "binding_id": 123,
    "store_token_ref": "st_…xxxxxx",
    "business_id": 456,
    "external_store_id": "tu-id-de-tienda",
    "route_id": 789,
    "tracking_code": "GSG-E-789",
    "order_external_id": "tu-id-de-pedido",
    "previous_status": "EN_RUTA",
    "new_status": "ENTREGADO",
    "status_changed_at": "<ISO-8601>",
    "route_state_id": 888994,
    "reported_at": "<ISO-8601 | null>",
    "received_at": "<ISO-8601>",
    "state_id": 14,
    "route_type_code": "DELIVERY",
    "size_code": "M",
    "service": "COURIER"
  }
}
CampoDescripción
event_idUUID único del evento. Clave de deduplicación.
order_external_idEl identificador del pedido en tu sistema, tal como lo enviaste al crearlo.
route_id / tracking_codeIdentificador y código de seguimiento del envío en GSG.
previous_status / new_statusCódigos del estado (ENTREGADO, EN_RUTA, CANCELADO, INACTIVO, …). Catálogo completo en Seguimiento § 5. previous_status es null (JSON null, nunca la cadena "null") cuando no hay estado previo, por ejemplo en una corrección manual de soporte.
store_token_refReferencia opaca a la tienda (nunca se envía el token completo).
binding_id, external_store_id, business_idIdentifican la tienda/negocio. En una plataforma multi-tienda úsalos para enrutar el evento a la tienda correcta.
route_state_idv1.1 ID interno del cambio de estado, estrictamente creciente por route_id. Es tu llave de ordenamiento (sección 8). Desde esta revisión viaja en todos los caminos de cambio de estado (asignación masiva, optimización, reportes del motorizado, correcciones); sigue tolerando null en tu validador por compatibilidad. Coincide con current_state.route_state_id del endpoint de consulta.
reported_atv1.1 Hora en que el motorizado registró el estado en su teléfono — la hora de negocio real de una entrega hecha sin señal. null en reportes online.
received_atv1.1 Hora en que el servidor de GSG registró el evento. La diferencia con reported_at es el tiempo que el reporte pasó sin conexión.
state_idv1.2 Id numérico del nuevo estado (el mismo id del catálogo states; p. ej. 14 = ENTREGADO). Úsalo si prefieres mapear por número en vez de por new_status. Puede ser null en flujos antiguos.
route_type_codev1.2 Tipo de envío: DELIVERY, AGENCY, EXCHANGE, GSG_OFFICE, FLEX_DELIVERY. null si no se pudo resolver.
size_codev1.2 Tamaño canónico del bulto (S, M, L, XL_GRANDE, XL_PLUS, XL_MAX; XL_STD solo en pedidos históricos). Si registraste con size: "XL" aquí llega la categoría que GSG asignó. null en pedidos antiguos sin tamaño canónico.
servicev1.2 COURIER o XL.

8.Orden de eventos y reportes offline

Los motorizados pueden operar en zonas sin cobertura: sus reportes se guardan en el teléfono y se sincronizan en ráfaga al recuperar señal. Sumado a los reintentos con backoff, esto significa que puedes recibir los webhooks de un mismo envío fuera de orden (por ejemplo: EN_RUTA, luego ENTREGADO, y al final un EN_EL_PUNTO rezagado).

Regla de oro: guarda por cada route_id el máximo route_state_id procesado. Si llega un evento con un route_state_id menor o igual al guardado, es un evento viejo o un duplicado: ignóralo (respondiendo 2xx igualmente). Nunca uses las horas para ordenar — usa siempre route_state_id.
// pseudocódigo
ultimo = estadoPorRuta[evento.data.route_id] ?? 0

si (evento.data.route_state_id != null && evento.data.route_state_id <= ultimo) {
    // evento rezagado o duplicado → confirmar con 2xx y no aplicar
    return 200
}

aplicarCambioDeEstado(evento.data)
estadoPorRuta[evento.data.route_id] = evento.data.route_state_id
return 200

Si te interesa mostrar la hora real de la entrega (y no la hora de sincronización), usa reported_at cuando no sea null.

9.Reintentos y reenvíos

ParámetroValor
Timeout por intento10 segundos
Intentos automáticos8, con backoff exponencial (delay inicial 5 s)
ÉxitoCualquier respuesta 2xx
Respuesta 3xxRetenido de inmediato, sin reintentos (GSG no sigue redirecciones). Corrige la URL registrada y pide el reenvío
Agotados los intentosEl evento queda retenido en GSG y puede reenviarse manualmente más adelante
Vínculo de la tienda no activoSi la tienda estaba desvinculada, bloqueada o pendiente cuando ocurrió el cambio de estado, el evento se guarda retenido (sin intentar la entrega). Al reactivarse el vínculo, GSG puede liberarlo
Webhook desactivadoNo se generan eventos nuevos; los ya encolados se retienen sin reintentar. GSG lee la URL y el secret vigentes de su base de datos en cada entrega (nunca viajan dentro de la cola)

Ningún evento se pierde: si tu servidor estuvo caído, el equipo de GSG puede liberar los eventos retenidos cuando vuelvas a estar operativo (los reenvíos usan la URL y el secret actuales de tu webhook). Esos reenvíos llegan con el mismo event_id original — otra razón por la que la deduplicación es obligatoria. Mientras tanto, puedes reconciliar con la consulta por API.

10.Buenas prácticas obligatorias

  1. Responde 2xx rápido y procesa en segundo plano. Un timeout cuenta como fallo y consume reintentos.
  2. Deduplica por event_id: reintentos y reenvíos entregan el mismo evento más de una vez.
  3. Ordena por route_state_id, nunca por hora de llegada ni por status_changed_at.
  4. Ignora campos que no conozcas: el contrato crece de forma aditiva. No valides con additionalProperties: false.
  5. Guarda el webhook_secret como credencial (vault/variables de entorno). Si sospechas que se filtró, rótalo (integración directa: desde tu panel; plataforma: pídelo a GSG).
  6. Responde 2xx también a los eventos que decidas ignorar (viejos, duplicados, tipos que no te interesan). Un 4xx/5xx provoca reintentos inútiles.
  7. Usa el webhook como fuente principal y el polling como respaldo (cada 15 min sobre pedidos no finales) para cubrir ventanas en que no pudiste recibir eventos.

11.Errores frecuentes

SíntomaCausa probable
La firma nunca coincide Estás re-serializando el JSON antes de firmar (cambia espacios/orden de claves). Calcula el HMAC sobre el cuerpo crudo recibido. Verifica también que usas el secret vigente: cambia solo si lo rotaste (sección Webhook o al regenerar la API key marcando rotar el secret); regenerar la API key sin esa opción no cambia el secret.
Dejaste de recibir eventos Tu endpoint respondió errores hasta agotar reintentos (o un 3xx) y los eventos quedaron retenidos, el webhook fue desactivado, o el vínculo de esa tienda dejó de estar activo (desvinculada, bloqueada o pendiente). Revisa GET /me → webhook y tu panel (integración directa) o contacta a GSG (plataforma) para reactivar y liberar los retenidos.
Estados “retroceden” en tu CRM Estás aplicando eventos en orden de llegada. Aplica la regla de route_state_id (sección 8).
Pedidos duplicados o inconsistentes Falta deduplicación por event_id.
Recibes eventos de tiendas que no reconoces Plataforma multi-tienda: un negocio se vinculó con un external_store_id que tu sistema no conoce. Enrútalo por external_store_id/binding_id y responde 2xx igualmente.
GSG rechaza tu URL de webhook Debe ser HTTPS, pública (no localhost ni IP privada), sin usuario:contraseña y ≤ 500 caracteres.

12.Historial de cambios

VersiónFechaCambios
1.22026-09-03 Campos aditivos en data: state_id, route_type_code, size_code, service. Nueva sección "Dónde se configura el webhook" con el escenario de integración directa (el negocio gestiona su webhook desde el panel GSG). Referencias al endpoint de consulta y a GET /me. Sin cambios en firma, cabeceras ni event_version. Revisión del mismo día: una respuesta 3xx deja el evento retenido de inmediato (sin reintentos; motivo Redirección … GSG no sigue redirecciones; registre la URL final); eventos generados con el vínculo de la tienda no activo quedan retenidos y son recuperables al reactivarlo; DRIVER_ASIGNADO/DRIVER_DESASIGNADO ahora generan evento; previous_status es null (no "null") sin estado previo; route_state_id presente en todos los caminos; el webhook_secret de integración directa se muestra al enviar la solicitud y solo cambia al rotarlo; GET /me → webhook es null para plataformas.
1.12026-08-05 Campos aditivos en data: route_state_id, reported_at, received_at. Nueva sección de ordenamiento para ráfagas offline. Sin cambios en firma, cabeceras ni event_version.
1.02026-07-14 Versión inicial: evento route.status_changed, firma HMAC-SHA256, reintentos con backoff y reenvío de eventos retenidos.