Introducción
La Digital Hub API permite a sistemas externos enviar embarques hacia Digital Hub (push) y consultar embarques existentes (pull), con soporte de webhooks salientes para notificar eventos en tiempo real.
Todos los endpoints filtran resultados por esas restricciones, el server nunca devuelve datos fuera del alcance configurado.
Obtener token de acceso
/oauth/tokenImplementa OAuth2 Client Credentials Grant (RFC 6749). El JWT devuelto debe incluirse en todas las requests como Authorization: Bearer <token>.
Expira a los 3600 segundos
| Campo | Descripción | |
|---|---|---|
| grant_type | requerido | Debe ser client_credentials |
| client_id | requerido | ID de cliente provisto por Digital Hub |
| client_secret | requerido | Secret provisto por Digital Hub |
curl -X POST https://api.fulter.net/oauth/token \ -H "Content-Type: application/json" \ -d '{"grant_type":"client_credentials","client_id":"mi-client-id","client_secret":"mi-secret"}'
const res = await fetch('https://api.fulter.net/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'client_credentials', client_id: 'mi-client-id', client_secret: 'mi-secret' }), }); const { access_token } = await res.json();
data = requests.post('https://api.fulter.net/oauth/token', json={'grant_type':'client_credentials','client_id':'mi-client-id','client_secret':'mi-secret'}).json() access_token = data['access_token']
const { data } = await axios.post('https://api.fulter.net/oauth/token', { grant_type: 'client_credentials', client_id: 'mi-client-id', client_secret: 'mi-secret', }); const accessToken: string = data.access_token;
var req = HttpRequest.newBuilder() .uri(URI.create("https://api.fulter.net/oauth/token")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString( "{\"grant_type\":\"client_credentials\",\"client_id\":\"mi-client-id\",\"client_secret\":\"mi-secret\"}")) .build(); var response = client.send(req, HttpResponse.BodyHandlers.ofString());
var res = await http.PostAsJsonAsync("https://api.fulter.net/oauth/token", new { grant_type = "client_credentials", client_id = "mi-client-id", client_secret = "mi-secret" }); var json = await res.Content.ReadFromJsonAsync<JsonElement>(); var accessToken = json.GetProperty("access_token").GetString();
{ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600 }
Listar embarques
/shipmentsLista paginada de embarques accesibles para el cliente. Los resultados se filtran automáticamente por las empresas habilitadas en las credenciales.
Scope: shipments:read
| Parámetro | Descripción | |
|---|---|---|
| page | opcional | Número de página. Default: 1 |
| pageSize | opcional | Resultados por página. Default: 20. Máximo: 100 |
| enterpriseCode | opcional | Filtrar por empresa (debe estar en el enterprise scope del cliente) |
| status | opcional | inCoordination · inProgress · arrived · notAvailable |
| awb | opcional | Filtrar por número de AWB |
| clientReference | opcional | Filtrar por referencia del cliente |
curl "https://api.fulter.net/shipments?status=inProgress&page=1&pageSize=20" \ -H "Authorization: Bearer $TOKEN"
const params = new URLSearchParams({ status: 'inProgress', page: '1', pageSize: '20' }); const { data, pagination } = await fetch(`https://api.fulter.net/shipments?${params}`, { headers: { Authorization: `Bearer ${accessToken}` }, }).then(r => r.json());
result = requests.get('https://api.fulter.net/shipments', params={'status':'inProgress','page':1,'pageSize':20}, headers={'Authorization':f'Bearer {access_token}'}).json()
const { data: result } = await axios.get('https://api.fulter.net/shipments', { params: { status: 'inProgress', page: 1, pageSize: 20 }, headers: { Authorization: `Bearer ${accessToken}` }, });
{
"data": [{ "id": "a1b2c3d4...", "awb": "020-98765432", "bl": null, "enterpriseCode": 999,
"origin": "MIA", "destiny": "EZE", "land": "aerial", "status": "inProgress",
"containers": [], "events": [], "createdAt": "2026-08-01T10:00:00.000Z", "updatedAt": "2026-08-10T14:00:00.000Z" }],
"pagination": { "page": 1, "pageSize": 20, "totalItems": 1999, "totalPages": 8 }
}Obtener embarque por ID
/shipments/id/:idDetalle completo de un embarque (contenedores y eventos). Devuelve 404 si no existe o si el enterpriseCode está fuera del scope del cliente.
Scope: shipments:read
curl "https://api.fulter.net/shipments/id/a1b2c3d4-e5f6-7890-abcd-ef1234567890" \ -H "Authorization: Bearer $TOKEN"
const shipment = await fetch(`https://api.fulter.net/shipments/id/${id}`, { headers: { Authorization: `Bearer ${accessToken}` } }).then(r => r.json());
shipment = requests.get(f'https://api.fulter.net/shipments/id/{id}', headers={'Authorization':f'Bearer {access_token}'}).json()
const { data: shipment } = await axios.get(`https://api.fulter.net/shipments/id/${id}`, { headers: { Authorization: `Bearer ${accessToken}` } });
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"awb": null, "bl": "MSCUXX1234567",
"hawb": null, "clientReference": null,
"enterpriseCode": 999, "origin": "SHA", "destiny": "BUE",
"land": "maritime", "operationType": "importMaritime",
"shipper": "ACME Corp", "status": "inCoordination",
"ETD": "2026-09-01", "ETA": "2026-10-15",
"containers": [{ "containerNumber": "MSCU1234567", "volume": 33.2, "quantity": 1, "weight": 12500, "type": "20DV" }],
"events": [{ "eventDate": "2026-08-01T14:30:00.000Z", "statusCode": "DEPARTED", "eventPlace": "Shanghai", "flightNumber": null }],
"createdAt": "2026-07-28T10:00:00.000Z", "updatedAt": "2026-08-01T14:30:00.000Z"
}Para aéreos/terrestres es al revés.
Buscar por AWB
/shipments/awb/:awbDevuelve el embarque aéreo o terrestre con ese AWB/guía. Si el número corresponde a un embarque marítimo, devuelve 404 para marítimos usá /shipments/bl/:bl.
Scope: shipments:read
curl "https://api.fulter.net/shipments/awb/020-98765432" \ -H "Authorization: Bearer $TOKEN"
const shipment = await fetch(`https://api.fulter.net/shipments/awb/${awb}`, { headers: { Authorization: `Bearer ${accessToken}` } }).then(r => r.json());
shipment = requests.get(f'https://api.fulter.net/shipments/awb/{awb}', headers={'Authorization':f'Bearer {access_token}'}).json()
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"awb": "020-98765432", "bl": null,
"hawb": null, "clientReference": "REF-2026-001",
"enterpriseCode": 999, "origin": "MIA", "destiny": "EZE",
"land": "aerial", "operationType": "importAerial",
"shipper": "ACME Corp", "trackingNumber": null,
"ETD": "2026-09-01", "ETA": "2026-09-02", "status": "inProgress",
"containers": [],
"events": [
{ "eventDate": "2026-09-02T07:15:00.000Z", "statusCode": "ARRIVED", "eventPlace": "Ezeiza", "flightNumber": "AA931" }
],
"createdAt": "2026-08-20T12:00:00.000Z", "updatedAt": "2026-09-02T07:15:00.000Z"
}Buscar por Bill of Lading
/shipments/bl/:blDevuelve el embarque marítimo con ese Bill of Lading. Si existe pero no es marítimo, devuelve 404.
Scope: shipments:read
curl "https://api.fulter.net/shipments/bl/MSCUXX1234567" \ -H "Authorization: Bearer $TOKEN"
const shipment = await fetch(`https://api.fulter.net/shipments/bl/${bl}`, { headers: { Authorization: `Bearer ${accessToken}` } }).then(r => r.json());
shipment = requests.get(f'https://api.fulter.net/shipments/bl/{bl}', headers={'Authorization':f'Bearer {access_token}'}).json()
{
"id": "c3d4e5f6-a7b8-9012-cdef-012345678901",
"awb": null, "bl": "MSCUXX1234567",
"hawb": null, "clientReference": null,
"enterpriseCode": 999, "origin": "SHA", "destiny": "BUE",
"land": "maritime", "operationType": "importMaritime",
"shipper": "ACME Corp", "trackingNumber": null,
"ETD": "2026-09-01", "ETA": "2026-10-15", "status": "inCoordination",
"containers": [
{ "containerNumber": "MSCU1234567", "volume": 33.2, "quantity": 1, "weight": 12500, "type": "20DV" }
],
"events": [],
"createdAt": "2026-07-28T10:00:00.000Z", "updatedAt": "2026-07-28T10:00:00.000Z"
}Buscar por referencia del cliente
/shipments/referenceNumber/:referenceNumberDevuelve un arreglo de embarques que coincidan con la referencia dada. Puede retornar más de uno si la misma referencia fue usada en distintos embarques.
Scope: shipments:read
curl "https://api.fulter.net/shipments/referenceNumber/REF-2026-001" \ -H "Authorization: Bearer $TOKEN"
const shipments = await fetch(`https://api.fulter.net/shipments/referenceNumber/${ref}`, { headers: { Authorization: `Bearer ${accessToken}` } }).then(r => r.json()); // Devuelve un array — puede tener más de un elemento
shipments = requests.get(
f'https://api.fulter.net/shipments/referenceNumber/{ref}',
headers={'Authorization':f'Bearer {access_token}'}).json()
# Devuelve una lista[
{ "id": "a1b2...", "awb": "020-98765432", "bl": null, "clientReference": "REF-2026-001", /* ... */ },
{ "id": "c3d4...", "awb": null, "bl": "MSCUXX9876543", "clientReference": "REF-2026-001", /* ... */ }
]Buscar por número de contenedor
/shipments/containers/:containerNumberDevuelve el embarque marítimo que contiene ese número de contenedor. Devuelve 404 si no existe o si el contenedor no pertenece a un embarque marítimo.
Scope: shipments:read
curl "https://api.fulter.net/shipments/containers/MSCU1234567" \ -H "Authorization: Bearer $TOKEN"
const shipment = await fetch(`https://api.fulter.net/shipments/containers/${containerNumber}`, { headers: { Authorization: `Bearer ${accessToken}` } }).then(r => r.json());
shipment = requests.get(
f'https://api.fulter.net/shipments/containers/{container_number}',
headers={'Authorization':f'Bearer {access_token}'}).json()Devuelve el embarque marítimo que contiene ese contenedor.
{
"id": "c3d4e5f6-a7b8-9012-cdef-012345678901",
"awb": null, "bl": "MSCUXX1234567",
"hawb": null, "clientReference": null,
"enterpriseCode": 999, "origin": "SHA", "destiny": "BUE",
"land": "maritime", "operationType": "importMaritime",
"shipper": "ACME Corp", "trackingNumber": null,
"ETD": "2026-09-01", "ETA": "2026-10-15", "status": "inCoordination",
"containers": [
{ "containerNumber": "MSCU1234567", "volume": 33.2, "quantity": 1, "weight": 12500, "type": "20DV" }
],
"events": [],
"createdAt": "2026-07-28T10:00:00.000Z", "updatedAt": "2026-07-28T10:00:00.000Z"
}Eventos de un embarque
/shipments/:id/eventsDevuelve el historial de eventos de trazabilidad del embarque (salidas, llegadas, escalas, etc.).
Scope: shipments:read
/id/ que usan los otros endpoints.curl "https://api.fulter.net/shipments/a1b2c3d4-e5f6-7890-abcd-ef1234567890/events" \ -H "Authorization: Bearer $TOKEN"
const events = await fetch(`https://api.fulter.net/shipments/${id}/events`, { headers: { Authorization: `Bearer ${accessToken}` } }).then(r => r.json());
events = requests.get(f'https://api.fulter.net/shipments/{id}/events', headers={'Authorization':f'Bearer {access_token}'}).json()
[
{ "eventDate": "2026-08-01T14:30:00.000Z", "statusCode": "DEPARTED", "eventPlace": "Shanghai", "flightNumber": null },
{ "eventDate": "2026-08-20T08:00:00.000Z", "statusCode": "ARRIVED", "eventPlace": "Buenos Aires", "flightNumber": null }
]Crear embarque (push)
/shipmentsRegistra un nuevo embarque. El enterpriseCode debe estar dentro del enterprise scope de las credenciales.
Scope requerido: shipments:write
| Campo | Descripción | |
|---|---|---|
| awb | si es areo o terrestre | Air Waybill o guía terrestre. Requerido cuando land es aerial o land. |
| bl | si es marítimo | Bill of Lading. Requerido cuando land es maritime. |
| hawb | opcional | House Air Waybill |
| clientReference | opcional | Referencia interna del cliente. Máx. 250 caracteres. |
| enterpriseCode | requerido | Código de empresa (entero). Debe estar en el enterprise scope del cliente. |
| origin | requerido | Origen (código de aeropuerto, puerto o texto libre) |
| destiny | requerido | Destino |
| land | requerido | maritime · aerial · land |
| operationType | requerido | importAerial · exportAerial · importMaritime · exportMaritime · importLand · exportLand |
| shipper | requerido | Nombre del shipper |
| trackingNumber | opcional | Número de tracking (entero) |
| ETD | requerido | Fecha estimada de salida. Formato: YYYY-MM-DD |
| ETA | requerido | Fecha estimada de llegada. Formato: YYYY-MM-DD |
| status | opcional | Estado inicial. Default: inCoordination. Valores: inCoordination · inProgress · arrived · notAvailable |
| containers | opcional | Arreglo de contenedores. Puede estar vacío o ausente, incluso para embarques marítimos. |
| Campo | Descripción | |
|---|---|---|
| containerNumber | requerido | Número de contenedor (ej. MSCU1234567) |
| volume | requerido | Volumen en m³ (número) |
| quantity | requerido | Cantidad de bultos (entero) |
| weight | requerido | Peso en kg (número) |
| type | opcional | Tipo de contenedor (ej. 20DV, 40HC) |
curl -X POST https://api.fulter.net/shipments \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "awb": "020-98765432", "enterpriseCode": 999, "origin": "MIA", "destiny": "EZE", "land": "aerial", "operationType": "importAerial", "shipper": "ACME Corp", "ETD": "2026-09-01", "ETA": "2026-09-02" }'
const shipment = await fetch('https://api.fulter.net/shipments', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ awb: '020-98765432', enterpriseCode: 999, origin: 'MIA', destiny: 'EZE', land: 'aerial', operationType: 'importAerial', shipper: 'ACME Corp', ETD: '2026-09-01', ETA: '2026-09-02', }), }).then(r => r.json());
shipment = requests.post('https://api.fulter.net/shipments', json={'awb':'020-98765432','enterpriseCode':999,'origin':'MIA','destiny':'EZE', 'land':'aerial','operationType':'importAerial','shipper':'ACME Corp', 'ETD':'2026-09-01','ETA':'2026-09-02'}, headers={'Authorization':f'Bearer {access_token}'}).json()
const { data: shipment } = await axios.post('https://api.fulter.net/shipments', { awb: '020-98765432', enterpriseCode: 999, origin: 'MIA', destiny: 'EZE', land: 'aerial', operationType: 'importAerial', shipper: 'ACME Corp', ETD: '2026-09-01', ETA: '2026-09-02', }, { headers: { Authorization: `Bearer ${accessToken}` } });
var body = """{"awb":"020-98765432","enterpriseCode":999,"origin":"MIA","destiny":"EZE", "land":"aerial","operationType":"importAerial","shipper":"ACME Corp", "ETD":"2026-09-01","ETA":"2026-09-02"}"""; var req = HttpRequest.newBuilder() .uri(URI.create("https://api.fulter.net/shipments")) .header("Authorization","Bearer "+accessToken).header("Content-Type","application/json") .POST(HttpRequest.BodyPublishers.ofString(body)).build();
var res = await http.PostAsJsonAsync("https://api.fulter.net/shipments", new { awb = "020-98765432", enterpriseCode = 999, origin = "MIA", destiny = "EZE", land = "aerial", operationType = "importAerial", shipper = "ACME Corp", ETD = "2026-09-01", ETA = "2026-09-02" });
{
"bl": "MSCUXX1234567",
"enterpriseCode": 999, "origin": "SHA", "destiny": "BUE",
"land": "maritime", "operationType": "importMaritime",
"shipper": "ACME Corp", "ETD": "2026-09-01", "ETA": "2026-10-15",
"containers": [
{ "containerNumber": "MSCU1234567", "volume": 33.2, "quantity": 1, "weight": 12500, "type": "20DV" }
]
}enterpriseCode + mismo AWB o BL), el servidor responde 409 Conflict. Ver sección de idempotencia.
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"awb": "020-98765432", "bl": null,
"hawb": null, "clientReference": null,
"enterpriseCode": 999, "origin": "MIA", "destiny": "EZE",
"land": "aerial", "operationType": "importAerial",
"shipper": "ACME Corp", "trackingNumber": null,
"ETD": "2026-09-01", "ETA": "2026-09-02", "status": "inCoordination",
"containers": [], "events": [],
"createdAt": "2026-08-14T17:00:00.000Z", "updatedAt": "2026-08-14T17:00:00.000Z"
}Actualizar embarque
/shipments/id/:idActualización parcial de un embarque. Solo se modifican los campos presentes en el body. Al actualizar, se dispara el evento shipment.updated a los webhooks suscriptos de los demás clientes.
Scope requerido: shipments:write
| Campo | Descripción |
|---|---|
| awb | Nuevo AWB (para embarques aéreos/terrestres) |
| bl | Nuevo BL (para embarques marítimos) |
| hawb | House AWB |
| clientReference | Referencia del cliente. Máx. 250 caracteres. |
| origin | Nuevo origen |
| destiny | Nuevo destino |
| operationType | Tipo de operación (ver valores en POST) |
| shipper | Shipper |
| trackingNumber | Número de tracking (entero) |
| ETD | Fecha estimada de salida (YYYY-MM-DD) |
| ETA | Fecha estimada de llegada (YYYY-MM-DD) |
| containers | Reemplaza todos los contenedores. Si se incluye, debe tener al menos 1 contenedor. |
El barco tuvo demora y el ETA cambió. Solo se envía el campo que se quiere modificar.
curl -X PATCH \ https://api.fulter.net/shipments/id/c3d4e5f6-a7b8-9012-cdef-012345678901 \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"ETA": "2026-11-10"}'
const updated = await fetch(`https://api.fulter.net/shipments/id/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ ETA: '2026-11-10' }), }).then(r => r.json());
updated = requests.patch(f'https://api.fulter.net/shipments/id/{id}', json={'ETA':'2026-11-10'}, headers={'Authorization':f'Bearer {access_token}'}).json()
const { data: updated } = await axios.patch( `https://api.fulter.net/shipments/id/${id}`, { ETA: '2026-11-10' }, { headers: { Authorization: `Bearer ${accessToken}` } } );
El embarque se cargó sin contenedores. Cuando la información estuvo disponible se los carga con PATCH. El campo containers reemplaza la lista completa: si ya había contenedores hay que incluirlos también en el array para no perderlos.
{
"containers": [
{ "containerNumber": "MSCU1234567", "volume": 33.2, "quantity": 480, "weight": 12500, "type": "20DV" },
{ "containerNumber": "TCKU9876543", "volume": 67.5, "quantity": 210, "weight": 18200, "type": "40HC" }
]
}Devuelve el embarque con todos los campos actualizados.
{
"id": "c3d4e5f6-a7b8-9012-cdef-012345678901",
"awb": null, "bl": "MSCUXX1234567",
"hawb": null, "clientReference": null,
"enterpriseCode": 999, "origin": "SHA", "destiny": "BUE",
"land": "maritime", "operationType": "importMaritime",
"shipper": "ACME Corp", "trackingNumber": null,
"ETD": "2026-09-01", "ETA": "2026-11-10", "status": "inCoordination",
"containers": [
{ "containerNumber": "MSCU1234567", "volume": 33.2, "quantity": 480, "weight": 12500, "type": "20DV" },
{ "containerNumber": "TCKU9876543", "volume": 67.5, "quantity": 210, "weight": 18200, "type": "40HC" }
],
"events": [],
"createdAt": "2026-07-28T10:00:00.000Z", "updatedAt": "2026-08-14T16:42:00.000Z"
}Listar suscripciones de webhook
/webhook-subscriptionsDevuelve todas las suscripciones activas del cliente autenticado.
Scope: shipments:read
curl https://api.fulter.net/webhook-subscriptions \
-H "Authorization: Bearer $TOKEN"const subs = await fetch('https://api.fulter.net/webhook-subscriptions', { headers: { Authorization: `Bearer ${accessToken}` }, }).then(r => r.json());
[
{
"id": "b9c8d7e6-f5a4-3210-fedc-ba9876543210",
"url": "https://mi-sistema.com/webhooks/dh",
"events": ["shipment.created", "shipment.updated"],
"status": "active",
"createdAt": "2026-08-01T09:00:00.000Z"
}
]Crear suscripción de webhook
/webhook-subscriptionsRegistra una URL para recibir notificaciones de eventos en tiempo real. Digital Hub hace un POST a esa URL cada vez que ocurre uno de los eventos suscriptos.
Scope requerido: shipments:write
No necesitás crear una suscripción por empresa. Si querés recibir eventos de un subconjunto específico, contactá a Digital Hub para ajustar el enterprise scope de tus credenciales.
POST /shipments — no recibirás ese webhook. El sistema excluye al cliente originador automáticamente para evitar loops. Sí recibirás eventos generados por otros sistemas o clientes que operen sobre las mismas empresas.
| Campo | Descripción | |
|---|---|---|
| url | requerido | URL HTTPS a la que Digital Hub enviará los eventos. Debe ser accesible públicamente. |
| events | requerido | Eventos a suscribir: "shipment.created" y/o "shipment.updated" |
| secret | requerido | Secret HMAC que Digital Hub usará para firmar cada entrega (X-DH-Signature). Guardalo de forma segura — no se devuelve en consultas posteriores. |
curl -X POST https://api.fulter.net/webhook-subscriptions \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://mi-sistema.com/webhooks/dh", "events": ["shipment.created", "shipment.updated"], "secret": "mi-secret-hmac-muy-seguro" }'
const sub = await fetch('https://api.fulter.net/webhook-subscriptions', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ url: 'https://mi-sistema.com/webhooks/dh', events: ['shipment.created', 'shipment.updated'], secret: 'mi-secret-hmac-muy-seguro', }), }).then(r => r.json());
sub = requests.post('https://api.fulter.net/webhook-subscriptions', json={'url':'https://mi-sistema.com/webhooks/dh', 'events':['shipment.created','shipment.updated'],'secret':'mi-secret-hmac-muy-seguro'}, headers={'Authorization':f'Bearer {access_token}'}).json()
const { data: sub } = await axios.post('https://api.fulter.net/webhook-subscriptions', { url: 'https://mi-sistema.com/webhooks/dh', events: ['shipment.created', 'shipment.updated'], secret: 'mi-secret-hmac-muy-seguro', }, { headers: { Authorization: `Bearer ${accessToken}` } });
{
"id": "b9c8d7e6-f5a4-3210-fedc-ba9876543210",
"url": "https://mi-sistema.com/webhooks/dh",
"events": ["shipment.created", "shipment.updated"],
"status": "active",
"createdAt": "2026-08-13T12:00:00.000Z"
}Eliminar suscripción de webhook
/webhook-subscriptions/:idElimina una suscripción. Devuelve 204 No Content si se eliminó correctamente, o 404 si no existe o pertenece a otro cliente.
Scope requerido: shipments:write
curl -X DELETE \
https://api.fulter.net/webhook-subscriptions/b9c8d7e6-f5a4-3210-fedc-ba9876543210 \
-H "Authorization: Bearer $TOKEN"await fetch(`https://api.fulter.net/webhook-subscriptions/${subId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${accessToken}` } }); // 204 No Content
Formato de entrega
Digital Hub hace un POST a la URL registrada con Content-Type: application/json. El body tiene esta estructura:
{
"id": "evt-uuid-v4", // ID único del evento
"type": "shipment.created", // o "shipment.updated"
"createdAt": "2026-08-13T12:30:00.000Z", // ISO 8601 UTC
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"awb": "020-98765432", "bl": null,
"enterpriseCode": 999,
"land": "aerial", "status": "inCoordination",
// ... mismo schema que GET /shipments/id/:id
}
}200 inmediatamente y procesá en background.Verificación HMAC
Cada entrega incluye el header X-DH-Signature con un HMAC-SHA256 del body completo, firmado con el secret que configuraste al crear la suscripción. Verificar esta firma es la única forma de confirmar que el request viene de Digital Hub.
import { createHmac, timingSafeEqual } from 'crypto'; function verifySignature(rawBody: string, signature: string, secret: string): boolean { const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); return timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); } // Express — leer el body raw antes de parsear JSON: app.post('/webhooks/dh', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.headers['x-dh-signature'] as string; if (!verifySignature(req.body.toString(), sig, process.env.WEBHOOK_SECRET!)) return res.status(401).send('Invalid signature'); const event = JSON.parse(req.body.toString()); res.sendStatus(200); });
import hmac, hashlib def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool: expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) # Flask: @app.route('/webhooks/dh', methods=['POST']) def webhook(): sig = request.headers.get('X-DH-Signature', '') if not verify_signature(request.get_data(), sig, WEBHOOK_SECRET): abort(401) event = request.get_json() return '', 200
using System.Security.Cryptography; bool VerifySignature(string rawBody, string signature, string secret) { var key = Encoding.UTF8.GetBytes(secret); var expected = Convert.ToHexString(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(rawBody))).ToLower(); return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature)); }
import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; boolean verifySignature(String rawBody, String sig, String secret) throws Exception { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256")); String expected = HexFormat.of().formatHex(mac.doFinal(rawBody.getBytes())); return MessageDigest.isEqual(expected.getBytes(), sig.getBytes()); }
=== no es suficiente.Errores (RFC 7807)
Todos los errores usan Problem Details for HTTP APIs (RFC 7807) con Content-Type: application/problem+json.
{
"type": "https://docs.digitalhub.com/errors/validation-error",
"title": "Error de validación",
"status": 400,
"detail": "El campo ETD es obligatorio\nEl campo ETA es obligatorio"
}| Status | slug | Cuándo ocurre |
|---|---|---|
| 400 | validation-error | Body inválido o campos incorrectos. El detail describe cada error, uno por línea. |
| 401 | unauthorized | Token ausente, expirado o inválido. |
| 403 | forbidden | El token no tiene el scope requerido para este endpoint. |
| 404 | not-found | Recurso inexistente o fuera del enterprise scope del cliente. |
| 409 | conflict | El embarque ya existe (ver idempotencia). |
| 500 | internal-error | Error inesperado. El detail es genérico por seguridad. |
Idempotencia
Para evitar embarques duplicados, POST /shipments aplica idempotencia automática basada en la combinación de enterpriseCode + identificador principal del embarque (awb para aéreos/terrestres, bl para marítimos).
// Embarque aéreo con awb="020-98765432" en la empresa 999: key = "999::020-98765432" // Embarque marítimo con bl="MSCUXX1234567" en la empresa 999: key = "999::MSCUXX1234567"
Si enviás el mismo embarque dos veces (misma clave), el servidor responde 409 Conflict:
{
"type": "https://docs.digitalhub.com/errors/conflict",
"title": "Conflicto",
"status": 409,
"detail": "Este embarque ya se encuentra en nuestro sistema"
}