← Volver a la web

Desarrolladores

La API de TRAZZA.

Lee tu catálogo, tus existencias y el histórico de movimientos, registra entradas y salidas desde tu propio programa y recibe un aviso firmado en tu servidor cada vez que algo cambia. Todo en JSON, con una clave por integración.

Dirección base
https://trazza.newtex.es/api/v1
Autenticación
Authorization: Bearer trz_…
Plan
Enterprise (API y webhooks salientes)
Límite
120 peticiones por minuto y clave

Autenticación y plan

Cada petición lleva una clave de API en la cabecera Authorization: Bearer trz_…. Las claves se crean en Ajustes → Conexiones para desarrolladores; el secreto se enseña una sola vez al crearla (guardamos solo su huella, no la clave) y se puede revocar en cualquier momento. Usa una clave por integración: así puedes cortar una sin tocar las demás.

bash
curl "https://trazza.newtex.es/api/v1/products?limit=20" \
  -H "Authorization: Bearer trz_TU_CLAVE"

Todo lo que devuelve una clave es de su cuenta y solo de su cuenta: no hay forma de leer ni escribir datos de otra, y un identificador ajeno responde como si no existiera. Lo que registras por la API no se atribuye a ninguna persona: el operario queda vacío y el registro de actividad dice qué clave lo hizo.

La API y los webhooks salientes son del plan Enterprise. Una clave válida de una cuenta en otro plan recibe un 403 con details.code: PLAN_REQUIRED; es un 403 a propósito, para que tu programa pare en lugar de reintentar. Al bajar de plan las claves se revocan.

json
{
  "error": "La función claves de API y webhooks es exclusiva del plan Enterprise. Contacta con NEWTEX para ampliar tu plan.",
  "code": "FORBIDDEN",
  "details": { "code": "PLAN_REQUIRED", "plan": "PRO", "feature": "apiAccess", "requiredPlan": "ENTERPRISE" },
  "requestId": "…"
}

Límites, errores y CORS

Límite de peticiones

120 peticiones por minuto por clave. Cada respuesta correcta lleva X-RateLimit-Limit y X-RateLimit-Remaining; al pasarte recibes un 429 con Retry-After (segundos que faltan). Para traer mucho histórico, pagina con limit=100 en lugar de lanzar peticiones en paralelo.

Formato de error

Todos los errores son JSON con el mensaje en castellano, un code estable para tu código, un details opcional y el requestId, que también va en la cabecera X-Request-Id de toda respuesta. Si nos escribes por un fallo, ese identificador nos lleva directos a tu petición. Puedes mandar tu propio X-Request-Id y lo respetamos.

json
{
  "error": "Datos de entrada inválidos.",
  "code": "VALIDATION_ERROR",
  "details": {
    "issues": [
      { "path": "limit", "message": "No puede pasar de 100.", "code": "too_big" }
    ]
  },
  "requestId": "6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6b"
}
  • 400
    code
    VALIDATION_ERROR
    Cuándo
    Parámetros o cuerpo que no cumplen el esquema (details.issues dice qué campo) o una regla de negocio (details.code: INSUFFICIENT_STOCK, LOT_REQUIRED, WORK_ORDER_FALTA_TIPO…).
  • 400
    code
    BAD_REQUEST
    Cuándo
    El cuerpo no es un JSON válido.
  • 401
    code
    UNAUTHORIZED
    Cuándo
    Sin clave, clave inventada o revocada.
  • 402
    code
    PAYMENT_REQUIRED
    Cuándo
    details.code TRIAL_EXPIRED: el periodo de prueba de la cuenta ha terminado.
  • 403
    code
    FORBIDDEN
    Cuándo
    details.code PLAN_REQUIRED (la cuenta no está en Enterprise), TENANT_INACTIVE (cuenta desactivada) o MODULE_DISABLED (el módulo que pides no está activado).
  • 404
    code
    NOT_FOUND
    Cuándo
    El producto, almacén u orden no existe en tu cuenta, o la ruta no existe en la API v1.
  • 409
    code
    CONFLICT
    Cuándo
    Almacén o producto archivado.
  • 429
    code
    RATE_LIMITED
    Cuándo
    Más de 120 peticiones por minuto con la misma clave. Respeta Retry-After.
  • 500
    code
    INTERNAL_ERROR
    Cuándo
    Fallo nuestro. Reintenta con espera y, si se repite, mándanos el requestId.

Regla para reintentar: un 4xx dice que la petición está mal y repetirla igual no la arregla; un 429 o un 5xx sí se reintentan, con espera.

CORS

Las respuestas llevan Access-Control-Allow-Origin: * y cada ruta contesta la consulta previa OPTIONS admitiendo Authorization, Content-Type y X-Request-Id. Aun así, no pongas la clave en código que se descarga en un navegador público: quien la lea puede usarla. Llama a la API desde tu servidor o desde una aplicación interna.

Endpoints

Identificadores en formato cuid (cm1p3x7a2…), fechas en ISO 8601 UTC y cantidades enteras. Los listados paginan con page y limit y devuelven el mismo bloque pagination: page, limit, total, totalPages, hasNext y hasPrev.

GET/api/v1/products

Catálogo de productos, paginado.

  • page
    Qué hace
    Página, desde 1. Por defecto 1.
  • limit
    Qué hace
    Filas por página, de 1 a 500. Por defecto 20.
  • search
    Qué hace
    Texto en SKU, nombre o código de barras.
  • code
    Qué hace
    SKU o código de barras exacto (sin distinguir mayúsculas).
  • category, tag
    Qué hace
    Solo los de esa categoría o etiqueta.
  • lowStock
    Qué hace
    true: solo los que están en su mínimo o por debajo.
  • includeArchived, includeVariants
    Qué hace
    true para incluir archivados o variantes.
  • sort, order
    Qué hace
    Ordenar por name, sku, price, threshold o createdAt; asc o desc.
bash
curl "https://trazza.newtex.es/api/v1/products?search=filtro&page=1&limit=50" \
  -H "Authorization: Bearer trz_TU_CLAVE"

Respuesta (cada producto trae además otros campos de su ficha):

json
{
  "items": [
    {
      "id": "cm1p3x7a20004qw81kd9e2f1b",
      "sku": "FIL-0412",
      "name": "Filtro de aceite",
      "category": "Filtros",
      "unit": "u",
      "barcode": "8412345678905",
      "price": 9.9,
      "threshold": 5,
      "hasLots": false,
      "tags": ["mantenimiento"],
      "totalStock": 14,
      "isLowStock": false
    }
  ],
  "pagination": { "page": 1, "limit": 50, "total": 1, "totalPages": 1, "hasNext": false, "hasPrev": false }
}
GET/api/v1/stock

Existencias por producto y almacén.

Una fila por producto y almacén. warehouseId (opcional) limita a un almacén. valorTotal es la cantidad por el coste medio de las entradas del producto (o su precio si nunca se registró un coste).

bash
curl "https://trazza.newtex.es/api/v1/stock" \
  -H "Authorization: Bearer trz_TU_CLAVE"
json
{
  "items": [
    {
      "sku": "FIL-0412",
      "productName": "Filtro de aceite",
      "warehouseCode": "MAIN",
      "warehouseName": "Almacén principal",
      "warehouseArchived": false,
      "quantity": 14,
      "threshold": 5,
      "isLow": false,
      "valorTotal": 89.6
    }
  ]
}
GET/api/v1/movements

Histórico de movimientos con filtros, en el orden que necesites.

  • from
    Qué hace
    Desde (incluido). YYYY-MM-DD es el día completo en hora de España; también acepta una fecha ISO 8601 con zona.
  • to
    Qué hace
    Hasta (incluido). YYYY-MM-DD llega hasta las 23:59:59 de ese día en España.
  • order
    Qué hace
    Orden de trabajo: 165PE, 165pe o 0165-PE son la misma. Solo el número (165) trae la orden en todos sus tipos.
  • type
    Qué hace
    IN, OUT, TRANSFER o ADJUST.
  • productId, warehouseId
    Qué hace
    Solo los de ese producto o los que entran o salen de ese almacén.
  • sort
    Qué hace
    asc o desc por fecha de registro. Por defecto desc; para sincronizar, asc.
  • page, limit
    Qué hace
    Página desde 1 y filas por página, de 1 a 100 (por defecto 50).
bash
curl "https://trazza.newtex.es/api/v1/movements?from=2026-09-01&to=2026-09-30&order=165PE&sort=asc&limit=100" \
  -H "Authorization: Bearer trz_TU_CLAVE"
json
{
  "items": [
    {
      "id": "cm1q8z0k40007ab12cd34ef56",
      "type": "OUT",
      "direction": "DECREASE",
      "quantity": 2,
      "product": { "id": "cm1p3x7a20004qw81kd9e2f1b", "sku": "FIL-0412", "name": "Filtro de aceite", "unit": "u" },
      "warehouseFrom": { "id": "cm1p2k0a10001qw81aa0b1c2d", "code": "MAIN", "name": "Almacén principal" },
      "warehouseTo": null,
      "reference": null,
      "orderRef": "165PE",
      "lotCode": null,
      "reason": null,
      "unitCost": null,
      "totalCost": null,
      "createdAt": "2026-09-30T08:12:44.000Z",
      "operator": "Nombre del operario"
    }
  ],
  "pagination": { "page": 1, "limit": 100, "total": 1, "totalPages": 1, "hasNext": false, "hasPrev": false }
}

direction dice si el movimiento sube (INCREASE) o baja (DECREASE) el stock; en un traslado es null. operator es el nombre de quien lo registró, o null si lo registró una clave de API. Una salida no guarda coste propio: unitCost y totalCost salen null; el material de una orden sí la valora (ver el siguiente endpoint). Para sincronizar, pide sort=asc con from igual a la fecha del último movimiento que ya tienes y descarta los id repetidos.

POST/api/v1/movements

Registra una entrada, salida, traslado o ajuste.

  • IN
    Campos obligatorios
    productId, warehouseToId, quantity
    Opcionales
    unitCost, supplierId, lotCode, lotExpiresAt, reference, reason, notes, clientKey
  • OUT
    Campos obligatorios
    productId, warehouseFromId, quantity
    Opcionales
    workOrder (con el módulo Órdenes de trabajo), lotCode, reference, reason, notes, clientKey
  • TRANSFER
    Campos obligatorios
    productId, warehouseFromId, warehouseToId, quantity
    Opcionales
    lotCode, reference, reason, notes, clientKey
  • ADJUST
    Campos obligatorios
    productId, warehouseToId, quantity, direction (INCREASE o DECREASE), reason
    Opcionales
    lotCode, lotExpiresAt, reference, notes, clientKey
bash
curl -X POST "https://trazza.newtex.es/api/v1/movements" \
  -H "Authorization: Bearer trz_TU_CLAVE" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "IN",
    "productId": "cm1p3x7a20004qw81kd9e2f1b",
    "warehouseToId": "cm1p2k0a10001qw81aa0b1c2d",
    "quantity": 10,
    "unitCost": 6.4,
    "reference": "ALB-2026-0412",
    "clientKey": "4b0f2c3e-8d1a-4f5e-9b7c-2a6d1e0f3c48"
  }'

Respuesta 201 (resumida):

json
{
  "movement": {
    "id": "cm1q9a4b50009ab12xy34zt78",
    "type": "IN",
    "productId": "cm1p3x7a20004qw81kd9e2f1b",
    "quantity": 10,
    "warehouseToId": "cm1p2k0a10001qw81aa0b1c2d",
    "unitCost": "6.4",
    "totalCost": "64",
    "reference": "ALB-2026-0412",
    "operatorId": null,
    "createdAt": "2026-09-30T09:01:12.000Z"
  },
  "productTotalStock": 24
}

clientKey (un UUID que generas tú) hace el envío repetible: si la respuesta se pierde y reenvías lo mismo, recibes el movimiento ya registrado en vez de mover el stock dos veces. Sin stock suficiente responde 400 con details.code: INSUFFICIENT_STOCK, available y requested. Cada movimiento dispara el webhook stock.movement y, si deja el producto en su mínimo, stock.low.

GET/api/v1/work-orders/{ref}

El material de una orden de trabajo, con su coste.

Solo con el módulo Órdenes de trabajo activado. Devuelve lo cargado en la orden (salidas), lo comprado para ella (entradas de albarán con la orden puesta), el coste de cada línea, el estado del aviso por correo de cada carga y los totales. {ref} se normaliza: 165pe, 165 PE y 0165-PE son la 165PE.

bash
curl "https://trazza.newtex.es/api/v1/work-orders/165PE" \
  -H "Authorization: Bearer trz_TU_CLAVE"
json
{
  "order": { "ref": "165PE", "number": "165", "type": "PE" },
  "lines": [
    {
      "id": "cm1q8z0k40007ab12cd34ef56",
      "type": "OUT",
      "quantity": 2,
      "product": { "id": "cm1p3x7a20004qw81kd9e2f1b", "sku": "FIL-0412", "name": "Filtro de aceite", "unit": "u" },
      "unitCost": 6.4,
      "totalCost": 12.8,
      "estimatedCost": true,
      "createdAt": "2026-09-30T08:12:44.000Z",
      "operator": "Nombre del operario",
      "loadId": "cm1q8y9z00005ab12gh56ij78",
      "deliveryNote": null
    }
  ],
  "loads": [
    {
      "id": "cm1q8y9z00005ab12gh56ij78",
      "status": "SENT",
      "operator": "Nombre del operario",
      "openedAt": "2026-09-30T08:12:44.000Z",
      "closedAt": "2026-09-30T08:17:46.000Z",
      "closeReason": "INACTIVITY",
      "emailSentAt": "2026-09-30T08:17:48.000Z",
      "attempts": 1,
      "lastError": null,
      "recipients": ["recambios@tu-taller.es"]
    }
  ],
  "totals": { "unitsLoaded": 2, "loadedCost": 12.8, "purchasedCost": 0, "linesWithoutCost": 0 }
}

Una salida se valora a lo que costó la última compra del producto y se marca con estimatedCost: true; una entrada lleva su propio coste. Las compras de un albarán anulado no cuentan. loads[].status: OPEN (cargando), PENDING (cerrada, aviso por salir), SENT, SKIPPED (sin correo de aviso configurado) o FAILED. Una orden sin ningún movimiento en tu cuenta responde 404 NOT_FOUND; con el módulo apagado, 403 y details.code: MODULE_DISABLED.

Webhooks

En Ajustes → Conexiones para desarrolladores das de alta la dirección de tu servidor y eliges los eventos. Cada evento sale como un POST JSON con el sobre { id, event, data, tenantId }.

  • stock.movement
    Cuándo
    Se registra un movimiento (desde la app, un albarán o la API).
    data
    { "id", "type", "productId", "quantity", "productTotalStock"? }
  • stock.low
    Cuándo
    Un movimiento deja un producto en su mínimo o sin stock.
    data
    { "productId", "currentStock", "alertType": "LOW_STOCK" | "OUT_OF_STOCK" }
  • sales_order.created
    Cuándo
    Se crea un pedido de venta.
    data
    { "id", "number" }
  • sales_order.fulfilled
    Cuándo
    Se entrega por completo un pedido de venta.
    data
    { "id", "status" }
  • purchase_order.received
    Cuándo
    Se recibe un pedido de compra (total o parcial).
    data
    { "id", "status" }
  • material.cargado_en_orden
    Cuándo
    Con el módulo Órdenes de trabajo: se cierra una carga de material en una orden. Una vez por carga.
    data
    { "loadId", "order", "orderNumber", "orderType", "operator", "from", "to", "lines": [{ "sku", "name", "quantity" }] }

En stock.movement, productTotalStock llega cuando el movimiento se registra de uno en uno (no en las entradas en lote ni en los albaranes).

Ejemplo: material cargado en una orden

Cuando un operario termina de cargar material en una orden (pulsa «Terminar carga» o pasan 5 minutos sin lecturas), sale un único evento con todo lo cargado, sumado por producto. operator es el nombre de quien lo cargó.

http
POST /webhooks/trazza HTTP/1.1
Content-Type: application/json
X-Trazza-Event: material.cargado_en_orden
X-Trazza-Delivery-Id: 9c1f4e7a2b8d40c6a5e3f1b2c7d8e9f0
X-Trazza-Timestamp: 1759219068
X-Trazza-Signature: 3f6b…(HMAC-SHA256 del cuerpo, en hex)
X-Trazza-Signature-256: t=1759219068,v1=8a2e…(HMAC-SHA256 de "t.cuerpo", en hex)

{
  "id": "9c1f4e7a2b8d40c6a5e3f1b2c7d8e9f0",
  "event": "material.cargado_en_orden",
  "data": {
    "loadId": "cm1q8y9z00005ab12gh56ij78",
    "order": "165PE",
    "orderNumber": "165",
    "orderType": "PE",
    "operator": "Nombre del operario",
    "from": "2026-09-30T08:12:44.000Z",
    "to": "2026-09-30T08:16:02.000Z",
    "lines": [
      { "sku": "FIL-0412", "name": "Filtro de aceite", "quantity": 2 },
      { "sku": "TOR-M6-20", "name": "Tornillo M6 x 20", "quantity": 8 }
    ]
  },
  "tenantId": "cm1p1a0b20000qw81zz9y8x7w"
}

Cabeceras

  • X-Trazza-Event
    Contenido
    El evento, igual que event en el cuerpo.
  • X-Trazza-Delivery-Id
    Contenido
    Identificador de la entrega, igual que id en el cuerpo. Es el mismo en todos sus reintentos.
  • X-Trazza-Timestamp
    Contenido
    Segundos Unix de este intento. Cambia en cada reintento, y la firma con él.
  • X-Trazza-Signature-256
    Contenido
    t=<timestamp>,v1=<HMAC-SHA256 en hex de «timestamp.cuerpo»>. La que debes comprobar.
  • X-Trazza-Signature
    Contenido
    HMAC-SHA256 en hex del cuerpo solo. Se mantiene por compatibilidad; no protege contra reenvíos.

Entrega y reintentos

Responde con un 2xx en menos de 8 segundos y procesa después: pasado ese tiempo el intento se da por fallido. Si tu servidor no responde, responde 408, 429 o un 5xx, lo volvemos a intentar solos con espera creciente; cualquier otro 4xx se da por rechazado y no se repite (repetirlo no cambiaría la respuesta).

  • 1
    Cuándo
    En el momento del evento.
  • 2
    Cuándo
    1 minuto después del fallo anterior.
  • 3
    Cuándo
    5 minutos después.
  • 4
    Cuándo
    15 minutos después.
  • 5
    Cuándo
    1 hora después.
  • 6
    Cuándo
    6 horas después.
  • 7 (último)
    Cuándo
    24 horas después: en total, unas 31 horas de margen.

Cada reintento lleva el mismo X-Trazza-Delivery-Id y el mismo cuerpo, con un X-Trazza-Timestamp y una firma X-Trazza-Signature-256 nuevos: tu comprobación de los 5 minutos sigue valiendo para un reintento que llega horas después. Si se agotan los intentos, la entrega queda como fallida en Ajustes, con su código y el motivo, y desde ahí se puede reenviar (otra vez con el mismo id). Avisamos a la administración de tu cuenta una vez, no por cada entrega.

Idempotencia

La entrega es al menos una vez: si tu 2xx se pierde por el camino, el mismo evento puede llegar dos veces. Guarda el id de cada evento procesado y, si vuelve a llegar, responde 2xx sin aplicarlo otra vez: es tu garantía de no imputar dos veces lo mismo. Si tu servidor estuvo caído más de lo que cubren los reintentos, recupera lo que falte con GET /movements o GET /work-orders/{ref}. Solo enviamos a direcciones públicas; no se siguen redirecciones.

Verificar la firma

Cada endpoint tiene su secreto (whsec_…), que ves en Ajustes. Para comprobar un envío: separa t y v1 de X-Trazza-Signature-256, rechaza si t se aleja más de 5 minutos de tu reloj (así un envío capturado no se puede repetir más tarde), calcula el HMAC-SHA256 de `${t}.${cuerpo}` con el secreto sobre el cuerpo crudo, tal cual llegó, y compáralo con v1 en tiempo constante.

Node.js (Express)

javascript
import crypto from "node:crypto";
import express from "express";

const SECRETO = process.env.TRAZZA_WEBHOOK_SECRET; // whsec_…
const VENTANA_SEGUNDOS = 300;

function firmaValida(cabecera, cuerpo) {
  const partes = Object.fromEntries(
    String(cabecera ?? "").split(",").map((p) => p.trim().split("=", 2)),
  );
  const t = partes.t;
  if (!/^\d+$/.test(t ?? "") || !partes.v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > VENTANA_SEGUNDOS) return false;
  const esperada = crypto.createHmac("sha256", SECRETO).update(`${t}.${cuerpo}`).digest();
  const recibida = Buffer.from(partes.v1, "hex");
  return recibida.length === esperada.length && crypto.timingSafeEqual(recibida, esperada);
}

const app = express();

// El cuerpo CRUDO: volver a serializar el JSON cambia los bytes y la firma no casa.
app.post("/webhooks/trazza", express.raw({ type: "application/json" }), async (req, res) => {
  const cuerpo = req.body.toString("utf8");
  if (!firmaValida(req.get("X-Trazza-Signature-256"), cuerpo)) return res.sendStatus(401);

  const evento = JSON.parse(cuerpo);
  if (await yaProcesado(evento.id)) return res.sendStatus(200); // idempotencia
  await guardarComoProcesado(evento.id);

  if (evento.event === "material.cargado_en_orden") {
    // Imputar evento.data.lines a la orden evento.data.order en tu programa.
  }
  res.sendStatus(200);
});

PHP

php
<?php
$secreto  = getenv('TRAZZA_WEBHOOK_SECRET'); // whsec_…
$cuerpo   = file_get_contents('php://input'); // cuerpo crudo
$cabecera = $_SERVER['HTTP_X_TRAZZA_SIGNATURE_256'] ?? '';

$partes = [];
foreach (explode(',', $cabecera) as $par) {
    [$clave, $valor] = array_pad(explode('=', trim($par), 2), 2, '');
    $partes[$clave] = $valor;
}
$t  = $partes['t'] ?? '';
$v1 = $partes['v1'] ?? '';

if (!ctype_digit($t) || abs(time() - (int) $t) > 300) {
    http_response_code(401);
    exit;
}
$esperada = hash_hmac('sha256', $t . '.' . $cuerpo, $secreto);
if (!hash_equals($esperada, $v1)) {
    http_response_code(401);
    exit;
}

$evento = json_decode($cuerpo, true);
// Idempotencia: si $evento['id'] ya está procesado, responde 200 y sal.
http_response_code(200);

Python

python
import hashlib
import hmac
import time

def firma_valida(cabecera: str, cuerpo: bytes, secreto: str, ventana: int = 300) -> bool:
    partes = dict(p.strip().split("=", 1) for p in cabecera.split(",") if "=" in p)
    t, v1 = partes.get("t", ""), partes.get("v1", "")
    if not t.isdigit() or abs(time.time() - int(t)) > ventana:
        return False
    esperada = hmac.new(secreto.encode(), t.encode() + b"." + cuerpo, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperada, v1)

Integración con tu programa de gestión

El caso típico de un taller: el material se escanea en el almacén contra la orden de trabajo, y el programa de gestión lo necesita en la orden para facturarlo.

De TRAZZA a tu programa

Suscribe material.cargado_en_orden. Cada carga cerrada llega con la orden y las líneas (SKU, nombre y cantidad): tu programa imputa ese material a la orden. Verifica la firma y guarda el id para no imputarlo dos veces.

De tu programa a TRAZZA

Consulta GET /work-orders/{ref} para ver el material de una orden con su coste antes de cerrarla, o GET /movements?order=165PE para el detalle. Para cuadrar el día, GET /movements?from=…&sort=asc.

Dar de alta órdenes, productos o tarifas en TRAZZA desde tu programa no forma parte de la API estándar: es trabajo a medida y se diseña con cada integrador. Escríbenos a info@newtex.es con lo que necesitas conectar.