Propura / API
API de Propura
Documentación para desarrolladores
La API de Propura permite conectar tus sistemas (ERP, planillas, automatizaciones) con tu organización en Propura. Se autentica con una API key por organización y hoy cubre la gestión del catálogo de productos y de proveedores. Iremos sumando más recursos con el tiempo.
¿Sos un agente o preferís una especificación legible por máquina? Está publicada en /openapi.json (OpenAPI 3.1).
Autenticación
Todas las peticiones viajan sobre HTTPS y llevan tu API key en el header x-api-key. La key representa a tu organización: solo puede leer y escribir datos de tu propia organización.
Cómo obtenerla: en Propura, entrá a Configuración → Integraciones → API de integración y generá la key (necesitás rol de admin o comprador). Desde ahí también podés regenerarla: al hacerlo, la key anterior deja de funcionar de inmediato.
La URL base de la API es:
https://api.propura.appEjemplo de una petición autenticada:
curl https://api.propura.app/api/v1/catalog-items \
-H "x-api-key: TU_API_KEY"Sin el header, o con una key inválida o ya regenerada, la API responde 401 Unauthorized.
Catálogo de productos
Un producto del catálogo tiene un code único dentro de tu organización, un name y, opcionalmente, una unidad de medida. Las operaciones de edición y borrado usan el code (no un id interno), porque es la referencia que tu sistema ya conoce.
Campos
{
// ── Obligatorios ──────────────
"code": "P001", // string · único por org · inmutable en PUT
"name": "Cemento", // string
// ── Opcional ──────────────────
"unit": "KGR" // UNI | KGR | LTR | M3 | MTS · si se omite queda sin unidad · valor inválido → 400
}GET /api/v1/catalog-items
Devuelve una página de productos. Parámetros de query opcionales: search (filtra por código o nombre), page (default 1), limit (default 20).
curl "https://api.propura.app/api/v1/catalog-items?search=cemento&page=1&limit=20" \
-H "x-api-key: TU_API_KEY"{
"items": [
{ "id": "…", "code": "P001", "name": "Cemento", "unit": "KGR", "orgId": "…", "createdAt": "…" }
],
"total": 1,
"page": 1,
"limit": 20
}POST /api/v1/catalog-items
Crea un producto. Si el code ya existe en tu organización, responde 409 Conflict.
curl -X POST https://api.propura.app/api/v1/catalog-items \
-H "x-api-key: TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "P001", "name": "Cemento", "unit": "KGR" }'PUT /api/v1/catalog-items/:code
Edita el producto con ese code. Si no existe, responde 404 Not Found.
curl -X PUT https://api.propura.app/api/v1/catalog-items/P001 \
-H "x-api-key: TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "P001", "name": "Cemento Portland", "unit": "UNI" }'DELETE /api/v1/catalog-items/:code
Borra el producto con ese code. Responde 204 No Content; si no existe, 404.
curl -X DELETE https://api.propura.app/api/v1/catalog-items/P001 \
-H "x-api-key: TU_API_KEY"Proveedores
Un proveedor se direcciona por un code único dentro de tu organización, el identificador que tu ERP ya usa, no por el id interno de Propura. Debe tener al menos un método de contacto (email o phone) y al menos una categoría (tags). No hay borrado: para dar de baja un proveedor, actualizalo con status: "INACTIVE".
code) por email, phone o taxId, Propura le asigna tu code a ese registro existente en vez de duplicarlo, y le aplica los datos que mandaste. Si tu payload coincide con más de un proveedor sin código, no adivina: responde 409 Conflict para que resuelvas la ambigüedad a mano.Campos
En el POST y en el PUT hay que enviar al menos un contacto (email o phone) y al menos una categoría (tags); si no, 400. Cualquier campo que no esté en esta lista se ignora.
{
// ── Obligatorios ──────────────────────────
"code": "PROV-001", // string · único por org · inmutable en PUT
"name": "Acme S.A.", // string
"tags": ["hierro"], // string[] · ≥1 · se guardan en minúscula
"email": "[email protected]", // email o phone (al menos uno) · único + validado
"phone": "+5491100000000", // email o phone (al menos uno)
// ── Opcionales ────────────────────────────
"legalName": "Acme Sociedad Anónima",
"taxId": "30-12345678-9", // CUIT
"taxCondition": "REGISTERED", // REGISTERED | SMALL_TAXPAYER | EXEMPT | NON_TAXPAYER | END_CONSUMER
"afipVoucherCode": "006", // código de comprobante AFIP · 3 dígitos · catálogo completo en el enum de openapi.json
"afipOperationTypeCode": "0", // 0 gravadas | A no alcanzado | C canje/devol. IVA turistas | E exentas | N no gravado | T reintegro Dto. 1043/2016 | X importaciones del exterior | Z zona franca
"financeEmail": "[email protected]",
"financePhone": "+5491122223333",
"fiscalAddress": "Av. Corrientes 1234, CABA",
"commercialAddress": "Ruta 8 Km 50, Pilar",
"paymentMethod": "transferencia",
"description": "Proveedor de hierro y cemento",
"status": "ACTIVE" // ACTIVE (default) | INACTIVE
}GET /api/v1/suppliers
Devuelve una página de proveedores. Parámetros de query opcionales: search (filtra por nombre, código o CUIT), page (default 1), limit (default 20).
curl "https://api.propura.app/api/v1/suppliers?search=acme&page=1&limit=20" \
-H "x-api-key: TU_API_KEY"{
"items": [
{ "id": "…", "code": "PROV-001", "name": "Acme", "email": "[email protected]", "tags": ["hierro"], "status": "ACTIVE", "orgId": "…" }
],
"total": 1,
"page": 1,
"limit": 20
}GET /api/v1/suppliers/:code
Devuelve el proveedor con ese code. Si no existe, responde 404 Not Found.
curl https://api.propura.app/api/v1/suppliers/PROV-001 \
-H "x-api-key: TU_API_KEY"POST /api/v1/suppliers
Crea un proveedor, o lo vincula a uno existente sin código (ver adopción arriba). Si el code ya existe, o si el email pertenece a otro proveedor, responde 409 Conflict. Sin contacto o sin categorías, 400.
curl -X POST https://api.propura.app/api/v1/suppliers \
-H "x-api-key: TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "PROV-001", "name": "Acme", "email": "[email protected]", "tags": ["hierro"] }'PUT /api/v1/suppliers/:code
Edita el proveedor con ese code (el código no se puede cambiar). Si no existe, responde 404 Not Found. Rige la misma validación de contacto y categorías.
curl -X PUT https://api.propura.app/api/v1/suppliers/PROV-001 \
-H "x-api-key: TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Acme S.A.", "email": "[email protected]", "tags": ["hierro", "cemento"] }'Webhooks
Además de la API de lectura/escritura, Propura puede enviarte un POST cada vez que ocurre un evento. Configurás un endpoint por organización en Configuración → Integraciones → Webhook saliente.
Te mandamos todos los eventos — no hay suscripción que elegir. Ramificá por el campo event del body (o por el header X-Propura-Event) e ignorá los que no uses. Hacelo con un switch que tenga rama por defecto: cuando agreguemos un evento nuevo tu endpoint va a empezar a recibirlo sin aviso, y tiene que responder 2xx igual — si devolvés error, reintentamos y te llenamos el log de envíos fallidos.
Eventos
El envelope es siempre el mismo (id, event, timestamp, data); lo que cambia es data: data.order en los eventos de orden y data.supplier en los de proveedor. Abajo está el cuerpo completo de cada uno de los cuatro eventos.
Lo que vos escribís no te vuelve. Un POST o PUT a /api/v1/suppliers no dispara supplier.*: ya recibiste el registro completo en la respuesta HTTP, y devolvértelo por webhook te haría procesar tu propia escritura. Solo emiten las altas y ediciones hechas desde Propura. Incluye la adopción: si tu POST adopta un proveedor cargado a mano, no vas a recibir aviso de ese merge.
EVT order.created
Nace la orden al adjudicarse la licitación. Todavía no hay factura cargada, así que invoiceNumber e invoiceDate vienen en null.
{
"id": "d3f1a2b0-...", // id del envío (header X-Propura-Delivery)
"event": "order.created",
"timestamp": "2026-08-20T14:03:00.000Z",
"data": {
"order": {
"id": "8c1e...",
"orderNumber": 42,
"title": "Materiales obra Norte",
"status": "INVOICE_PENDING",
"currency": "ARS",
"invoiceAmount": 152340.5,
"invoiceNumber": null,
"invoiceDate": null,
"notes": "Entregar por la mañana",
"tenderId": "5a9b...",
"supplierId": "1f2e...",
"createdAt": "2026-08-20T14:03:00.000Z",
"supplier": {
"id": "1f2e...",
"name": "Acería SA",
"legalName": "Acería del Sur S.A.",
"taxId": "30-11122233-4",
"taxCondition": "REGISTERED",
"afipVoucherCode": "006",
"afipOperationTypeCode": "0",
"paymentMethod": "30 días"
},
"requests": [
{ "requestId": "r1...", "requestTitle": "Pedido obra Norte", "project": "Obra Norte", "costCenter": "CC-100" }
],
"items": [
{
"catalogItemCode": "ART-001", // null si el ítem no tiene código de catálogo
"title": "Cemento CPN40 x 50kg",
"description": null,
"quantity": 100,
"unit": "BLS",
"unitPrice": 1200.5,
"totalPrice": 120050,
"requestId": "r1...", // pedido/obra de esta línea (join con requests[])
"costCenter": "CC-100" // centro de costo de esa obra
}
],
"extraItems": [
{ "description": "Flete", "amount": 5000 }
]
}
}
}invoiceAmount siempre reconcilia: es exactamente la suma de items[].totalPrice más extraItems[].amount.
EVT order.updated
Misma forma que order.created, con los valores al momento del cambio. No es un diff: siempre te llega la orden completa, así que podés pisar tu copia sin fusionar nada. Este ejemplo es el de arriba después de cargarle la factura.
No hay evento dedicado de anulación: una orden cancelada llega como order.updated con status: "CANCELLED" — si tu sistema necesita enterarse de las anulaciones, procesá también order.updated, no solo order.created.
{
"id": "7b44c910-...", // id del envío (header X-Propura-Delivery)
"event": "order.updated",
"timestamp": "2026-08-28T09:12:00.000Z",
"data": {
"order": {
"id": "8c1e...",
"orderNumber": 42,
"title": "Materiales obra Norte",
"status": "PAYMENT_PENDING",
"currency": "ARS",
"invoiceAmount": 152340.5,
"invoiceNumber": "0001-00012345",
"invoiceDate": "2026-08-28T00:00:00.000Z",
"notes": "Entregar por la mañana",
"tenderId": "5a9b...",
"supplierId": "1f2e...",
"createdAt": "2026-08-20T14:03:00.000Z",
"supplier": {
"id": "1f2e...",
"name": "Acería SA",
"legalName": "Acería del Sur S.A.",
"taxId": "30-11122233-4",
"taxCondition": "REGISTERED",
"afipVoucherCode": "006",
"afipOperationTypeCode": "0",
"paymentMethod": "30 días"
},
"requests": [
{ "requestId": "r1...", "requestTitle": "Pedido obra Norte", "project": "Obra Norte", "costCenter": "CC-100" }
],
"items": [
{
"catalogItemCode": "ART-001", // null si el ítem no tiene código de catálogo
"title": "Cemento CPN40 x 50kg",
"description": null,
"quantity": 100,
"unit": "BLS",
"unitPrice": 1200.5,
"totalPrice": 120050,
"requestId": "r1...", // pedido/obra de esta línea (join con requests[])
"costCenter": "CC-100" // centro de costo de esa obra
}
],
"extraItems": [
{ "description": "Flete", "amount": 5000 }
]
}
}
}EVT supplier.created
El maestro completo del proveedor. code es la clave con la que lo direccionás en la API y viene en null hasta que un POST /api/v1/suppliers tuyo lo adopte — los proveedores que se cargan desde Propura no tienen código de tu sistema.
{
"id": "9b2c5d71-...", // id del envío (header X-Propura-Delivery)
"event": "supplier.created",
"timestamp": "2026-08-01T12:00:00.000Z",
"data": {
"supplier": {
"id": "1f2e...",
"code": null,
"name": "Acería SA",
"legalName": "Acería del Sur S.A.",
"taxId": "30-11122233-4",
"taxCondition": "REGISTERED", // REGISTERED | SMALL_TAXPAYER | EXEMPT | NON_TAXPAYER | END_CONSUMER
"afipVoucherCode": "006",
"afipOperationTypeCode": "0",
"email": "[email protected]",
"phone": "+5491100000000",
"financeEmail": null,
"financePhone": null,
"fiscalAddress": "Av. Corrientes 1234, CABA",
"commercialAddress": null,
"paymentMethod": "30 días",
"tags": ["hierro"],
"status": "ACTIVE", // ACTIVE | INACTIVE (la baja es lógica)
"createdAt": "2026-08-01T12:00:00.000Z",
"updatedAt": "2026-08-01T12:00:00.000Z"
}
}
}EVT supplier.updated
Igual que supplier.created y también completo, no un diff. Acá el proveedor ya fue adoptado, así que code trae tu identificador.
{
"id": "c0e8a133-...", // id del envío (header X-Propura-Delivery)
"event": "supplier.updated",
"timestamp": "2026-09-23T10:15:00.000Z",
"data": {
"supplier": {
"id": "1f2e...",
"code": "PROV-001",
"name": "Acería SA",
"legalName": "Acería del Sur S.A.",
"taxId": "30-11122233-4",
"taxCondition": "REGISTERED", // REGISTERED | SMALL_TAXPAYER | EXEMPT | NON_TAXPAYER | END_CONSUMER
"afipVoucherCode": "006",
"afipOperationTypeCode": "0",
"email": "[email protected]",
"phone": "+5491100000000",
"financeEmail": "[email protected]",
"financePhone": null,
"fiscalAddress": "Av. Corrientes 1234, CABA",
"commercialAddress": null,
"paymentMethod": "30 días",
"tags": ["hierro"],
"status": "ACTIVE", // ACTIVE | INACTIVE (la baja es lógica)
"createdAt": "2026-08-01T12:00:00.000Z",
"updatedAt": "2026-09-23T10:15:00.000Z"
}
}
}Órdenes canceladas: la anulación conserva el invoiceAmount como registro de lo que se había adjudicado. Si tu sistema suma gasto por orden, excluí las que tengan status: "CANCELLED": los ítems liberados pueden re-licitarse y la compra nueva llega como otra orden — sin el filtro contás la misma compra dos veces.
Fechas en UTC: todos los campos de fecha (timestamp, createdAt, invoiceDate) son ISO 8601 en UTC (sufijo Z). Convertí a tu zona horaria si los mostrás (Argentina es UTC-3: 2026-08-20T14:03:00.000Z = 11:03 hora local).
Headers de cada envío:
Probar tu receptor
Desde Configuración → Integraciones → Webhook saliente, el botón Enviar prueba te deja elegir cualquiera de los eventos de arriba y te manda un cuerpo de ejemplo con la misma forma que el real, firmado igual. Va marcado con test: true en la raíz del body: usalo para hacer dry-run y no crear una orden ni un proveedor de verdad en tu sistema. Así podés ejercitar cada rama de tu switch sin esperar a que el evento pase de verdad.
Verificar la firma
Calculá el HMAC-SHA256 del cuerpo crudo (los bytes tal como llegaron, sin re-serializar el JSON — re-serializar cambia el orden/espacios y rompe la firma) con tu secreto y compará contra X-Propura-Signature:
const crypto = require("crypto");
// El cuerpo CRUDO, no el JSON parseado+re-serializado.
// En Express: app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }))
const expected =
"sha256=" + crypto.createHmac("sha256", WEBHOOK_SECRET).update(req.rawBody).digest("hex");
const received = req.headers["x-propura-signature"] || "";
const a = Buffer.from(expected);
const b = Buffer.from(received);
// timingSafeEqual TIRA si los largos difieren → chequear largo primero.
const valid = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!valid) return res.status(401).end();Entrega y reintentos
Respondé con un 2xx apenas recibís el evento. Ante error de red o 5xx reintentamos hasta 3 veces con backoff; cada intento tiene un timeout de 10s. Si igual falla, queda como FAILED en la lista de envíos de Configuración, desde donde podés reintentar manualmente. La URL debe ser https pública: no se aceptan direcciones internas o privadas.
Deduplicá por X-Propura-Delivery: si tu endpoint recibe un evento y luego se corta la conexión antes de que Propura vea la respuesta, reintentamos — pudiste haberlo procesado igual. Guardá el ID y descartá los que ya viste.
Errores
Los errores siguen el formato estándar de Propura: { "statusCode", "message", "error" }. En las validaciones (400), message es un array de strings — uno por campo que falló — y no un string suelto.
Los 409 distinguen tres casos y el message te dice cuál: código repetido (cambiá el code), email de otro proveedor (revisá con cuál chocás vía GET /api/v1/suppliers?search=) y vinculación ambigua, donde tu payload coincide con más de un proveedor sin código — ese no se resuelve reintentando: hay que desambiguar los registros en Propura.
Próximamente
Hoy la API cubre catálogo de productos y proveedores para leer y escribir, más webhooks de órdenes y proveedores para que te enteres de los cambios sin pollear. Las órdenes, por ahora, se leen solo por webhook: no hay un GET /api/v1/orders.
Vamos sumando recursos a medida que los clientes los piden. ¿Necesitás integrar algo puntual? Escribinos.