Conecta tu tienda a Bolti
Una API REST para que tu e-commerce, POS o ERP haga en Bolti lo mismo que hace una persona en el panel: sincronizar catálogo, controlar inventario, facturar electrónicamente ante la DIAN y dejar la contabilidad cuadrada —asiento, kardex, cartera y recibo de caja— sin que nadie toque nada a mano.
Empieza aquí
Lo mínimo para entender de qué se trata antes de escribir la primera línea.
| URL base | https://bolti.co/api/public/v1 |
|---|---|
| Autenticación | Header x-api-key: sk_live_… en todas las peticiones |
| Formato | JSON en petición y respuesta (Content-Type: application/json) |
| Moneda | COP. Los montos van en pesos, sin separadores de miles |
| Fechas | YYYY-MM-DD · las marcas de tiempo salen en ISO 8601 UTC |
| Spec OpenAPI | openapi.yaml · visor interactivo |
Cómo consigues una API key
El administrador de la empresa entra a Bolti → API & Integraciones → Nueva API Key, elige el preset E-commerce y copia el secreto. Se muestra una sola vez: en la base de datos solo queda su hash SHA-256.
La key es del backend, no del navegador. Quien la tenga puede emitir facturas a nombre de la empresa. Guárdala como variable de entorno de tu servidor y nunca la incluyas en JavaScript del cliente ni en una app móvil.
Verifica que todo funciona
# ¿La key sirve y qué permisos tiene? curl https://bolti.co/api/public/v1/ping \ -H "x-api-key: $BOLTI_API_KEY"
{
"success": true,
"tenantId": 12,
"keyName": "Tienda online",
"scopes": ["products:read", "invoices:write", "…"],
"serverTime": "2026-08-04T14:22:10.512Z"
}Después de eso, GET /company te dice si la empresa ya está habilitada ante la DIAN, y GET /meta
te devuelve todos los valores válidos (tipos de documento, métodos de pago, bodegas, unidades) para que no tengas que adivinar strings.
Autenticación y permisos
Cada llave pertenece a una empresa y lleva una lista de permisos (scopes). Si una llamada necesita un scope que la llave no tiene, la respuesta es 403 y te dice cuál falta.
x-api-key: sk_live_3f9a2b7c… # forma recomendada Authorization: Bearer sk_live_3f9a2b7c… # también se acepta
Presets
Al crear la llave puedes pedir un preset en vez de listar permisos uno por uno:
| Preset | Para qué | Incluye |
|---|---|---|
ecommerce | Tienda online completa | catálogo, inventario, clientes, facturas, pagos, devoluciones, contabilidad (lectura) y webhooks |
pos | Punto de venta de terceros | lectura de catálogo e inventario, emisión de facturas, pagos, alta de clientes |
readonly | Dashboards, BI | solo lectura de facturas, catálogo, inventario, clientes y contabilidad |
Scopes disponibles
| Scope | Habilita |
|---|---|
products:read | Leer el catálogo y las categorías |
products:write | Crear y actualizar productos (incluida la carga masiva) |
inventory:read | Consultar existencias, bodegas y kardex |
inventory:write | Registrar entradas, salidas y conteos físicos |
customers:read / customers:write | Consultar y dar de alta clientes (terceros) |
invoices:read | Consultar pedidos, facturas, su estado DIAN y sus pagos |
invoices:write | Crear pedidos y emitir facturas |
payments:write | Registrar cobros contra una factura |
creditnotes:write | Emitir notas crédito (devoluciones y anulaciones) |
accounting:read | Leer asientos del libro diario y la cartera |
webhooks:manage | Registrar y probar webhooks |
ai:chat | Consumir el asistente de IA del negocio |
tenants:read / tenants:write / dian:onboard | Crear y habilitar empresas hijas (modo empresa madre) |
Quickstart: tu e-commerce vendiendo en 6 pasos
Del catálogo vacío a una factura electrónica aprobada por la DIAN, con su asiento contable. Todo con curl; al final está el mismo flujo en Node.
-
Sube tu catálogo
El SKU es la identidad del producto: si ya existe, se actualiza; si no, se crea. Puedes mandar hasta 100 por llamada.
curl -X POST $BASE/products/bulk \ -H "x-api-key: $KEY" -H "Content-Type: application/json" \ -d '{ "products": [ { "sku": "CAM-AZUL-M", "name": "Camisa azul talla M", "price": 89900, "taxRate": 19, "priceIncludesTax": true, "cost": 42000, "stock": 25, "categoryName": "Camisas", "barcode": "7701234567890", "unit": "und" } ] }'
stockno se escribe a mano en la tabla: entra por el kardex como ajuste y genera su asiento de inventario. -
Muestra existencias reales en tu tienda
Consulta puntual antes de dejar comprar, o barrido incremental con
updatedSincepara tu cron de sincronización.curl "$BASE/stock?sku=CAM-AZUL-M" -H "x-api-key: $KEY" curl "$BASE/stock?updatedSince=2026-08-04T00:00:00Z&pageSize=200" -H "x-api-key: $KEY"
-
Crea el pedido cuando el comprador paga
Un solo POST hace todo: cliente, factura, DIAN y contabilidad. Manda siempre
externalId(el id del pedido en tu tienda) y unIdempotency-Key.curl -X POST $BASE/orders \ -H "x-api-key: $KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1042-attempt-1" \ -d '{ "externalId": "SHOP-1042", "customer": { "name": "María Restrepo", "documentType": "CC", "documentNumber": "1017234567", "email": "maria@ejemplo.com", "phone": "3001112233", "address": { "address": "Cra 43A # 5-15", "city": "Medellín", "department": "Antioquia" } }, "items": [ { "sku": "CAM-AZUL-M", "quantity": 2 } ], "shipping": { "amount": 12000, "taxRate": 0 }, "payment": { "status": "PAID", "method": "TARJETA", "reference": "wompi_01HX…" }, "notes": "Pedido web #1042" }'
Si no mandas
unitPrice, se usa el precio del catálogo (descomponiendo el IVA cuando el precio lo incluye). Si el inventario no alcanza, responde409con el detalle por SKU antes de gastar un consecutivo de la resolución DIAN. -
Lee la respuesta: ahí está todo
{ "success": true, "order": { "externalId": "SHOP-1042", "invoiceId": 842, "invoiceNumber": "FV-128", "total": 191800, "paid": true }, "dian": { "sent": true, "dianStatus": "APROBADA", "cufe": "a1b2c3…" }, "accounting": { "journalEntry": { "number": "CI-0043", "balanced": true, "lines": [ { "accountCode": "130505", "debit": 191800 }, { "accountCode": "413536", "credit": 163193 }, { "accountCode": "240805", "credit": 28607 } ] }, "receivable": { "total": 191800, "balance": 0, "status": "PAGADA" } } }Guarda
invoiceIdyinvoiceNumberjunto al pedido. Si la DIAN quedó en trámite, consultaGET /invoices/{id}/statuso —mejor— escucha el webhook. -
Cobra lo que quedó a crédito
Si el pedido salió con
payment.status: "PENDING", la venta quedó en cartera. Al recibir el dinero:curl -X POST $BASE/invoices/842/payments \ -H "x-api-key: $KEY" -H "Content-Type: application/json" \ -d '{ "method": "TRANSFERENCIA", "amount": 191800, "reference": "PSE-99213" }'
Sin
amountse aplica el saldo pendiente completo. Genera recibo de caja, mueve bancos contra la cuenta por cobrar y cierra la cartera. -
Devoluciones
Nota crédito electrónica: reingresa el inventario, reversa el costo de venta y baja la cuenta por cobrar. Sin
itemsdevuelve la factura completa.curl -X POST $BASE/invoices/842/returns \ -H "x-api-key: $KEY" -H "Content-Type: application/json" \ -d '{ "reason": "Devolución", "items": [ { "sku": "CAM-AZUL-M", "quantity": 1 } ] }'
El mismo flujo en Node
const BASE = 'https://bolti.co/api/public/v1'; const KEY = process.env.BOLTI_API_KEY; async function bolti(path, { method = 'GET', body, idempotencyKey } = {}) { const res = await fetch(BASE + path, { method, headers: { 'x-api-key': KEY, 'Content-Type': 'application/json', ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), }, body: body ? JSON.stringify(body) : undefined, }); const data = await res.json(); if (!res.ok) throw Object.assign(new Error(data.error), { status: res.status, data }); return data; } // 1) ¿hay inventario? const { stock } = await bolti('/stock?sku=CAM-AZUL-M'); if (stock[0].available < 2) throw new Error('Sin stock'); // 2) facturar el pedido (reintentar es seguro: misma Idempotency-Key) const order = await bolti('/orders', { method: 'POST', idempotencyKey: `order-${orderId}`, body: { externalId: `SHOP-${orderId}`, customer: { name, documentType: 'CC', documentNumber, email }, items: cart.map(l => ({ sku: l.sku, quantity: l.qty })), payment: { status: 'PAID', method: 'TARJETA', reference: paymentId }, }, }); console.log(order.order.invoiceNumber, order.dian?.dianStatus);
Qué pasa en la contabilidad
La razón de conectar la tienda a Bolti y no a un simple emisor de facturas: cada operación de la API deja los mismos registros contables que si se hubiera hecho a mano en el panel.
| Operación | Qué registra |
|---|---|
| POST /orders (pagado) |
Tercero: crea o actualiza el cliente en el catálogo. Libro diario: D clientes / C ingresos + C IVA generado. Kardex: salida por cada producto inventariable, al costo promedio, con su asiento D costo de ventas / C inventario. Cartera: abre la cuenta por cobrar. Caja: recibo de caja automático que la cancela (D caja o bancos / C clientes). DIAN: envía la factura electrónica y guarda el CUFE. |
| POST /orders (a crédito) |
Igual, pero sin recibo de caja: la cuenta por cobrar queda abierta con su vencimiento según creditTermDays. |
| POST /invoices/:id/payments | Recibo de caja: D caja o bancos (según el método) / C clientes. Descuenta el saldo de la cartera y, si la cubre, marca la factura como pagada. Acepta retenciones practicadas por el cliente. |
| POST /invoices/:id/returns | Nota crédito electrónica: D ingresos + D IVA / C clientes, entrada de kardex por lo devuelto y reverso del costo de venta (D inventario / C costo). |
| POST /inventory/adjustments | IN: D inventario / C proveedores. OUT: D costo / C inventario. SET (conteo físico): ajuste contra ingresos o pérdidas por sobrante o faltante. |
| POST /products (con stock) |
El stock nunca se sobrescribe: se calcula el delta y entra como ajuste de kardex con su asiento. |
Cómo lo verificas sin entrar al panel. GET /invoices/{id}/accounting te devuelve el asiento generado con
todas sus líneas, si está balanceado y cómo quedó la cartera. GET /accounting/journal lista los asientos del
periodo y GET /accounting/receivables la cartera abierta.
Cuentas del PUC
Bolti usa la configuración contable de la empresa (Configuración → Contabilidad). Si un producto tiene cuentas propias
—accounts.revenue, accounts.inventory, accounts.cost— esas mandan sobre las de la
configuración general. Puedes fijarlas desde la API al crear el producto.
Periodos cerrados. Si el contador ya cerró el mes, una operación con fecha dentro de ese periodo se rechaza. Factura con la fecha del día o pide que reabran el periodo.
Idempotencia
Una tienda reintenta: se cae la red al confirmar el pago, la cola reencola, el comprador da doble clic. Sin protección, cada reintento sería otra factura electrónica con otro consecutivo DIAN, otro asiento y otra salida de inventario.
Manda el header Idempotency-Key con un valor único por operación (un UUID o el id del pedido) en todos los POST:
Idempotency-Key: order-1042
| Situación | Respuesta |
|---|---|
| Primera vez | Se ejecuta normal y se guarda el resultado |
| Reintento con el mismo cuerpo | La misma respuesta original, con el header Idempotent-Replay: true |
| Reintento mientras la original sigue corriendo | 409 — espera unos segundos y vuelve a intentar |
| Misma key con un cuerpo distinto | 422 — esa key ya identifica otra operación |
| La original falló con 5xx | La key se libera: el reintento se ejecuta de verdad |
Las claves se conservan 30 días. Además, externalId en un pedido es una segunda red de seguridad permanente:
si vuelves a mandar el mismo pedido meses después, la API responde duplicate: true con la factura que ya existía en vez de emitir otra.
Referencia de endpoints
Todas las rutas cuelgan de https://bolti.co/api/public/v1. Haz clic en cualquiera para ver parámetros y ejemplos.
Webhooks
La DIAN no siempre responde al instante. En vez de preguntar cada minuto, registra una URL y Bolti te avisa.
curl -X POST $BASE/webhooks \ -H "x-api-key: $KEY" -H "Content-Type: application/json" \ -d '{ "url": "https://mitienda.com/hooks/bolti", "events": ["invoice.dian.accepted", "invoice.dian.rejected", "stock.low"] }'
La respuesta trae el secret una sola vez. Guárdalo: sirve para verificar la firma.
Eventos
| Evento | Cuándo se dispara |
|---|---|
invoice.created | Se emitió una factura por la API |
invoice.dian.accepted | La DIAN aprobó la factura (aunque se haya emitido desde el panel) |
invoice.dian.rejected | La DIAN la rechazó o quedó en error |
payment.created | Se registró un cobro contra una factura |
creditnote.created | Se generó una nota crédito |
stock.updated | Cambió el saldo de un producto por una operación de la API |
stock.low | El saldo quedó en el mínimo o por debajo |
Cuerpo de la entrega
POST https://mitienda.com/hooks/bolti
x-bolti-event: invoice.dian.accepted
x-bolti-delivery: 5512
x-bolti-signature: t=1785859330,v1=9f86d081884c7d659a2f…
{
"event": "invoice.dian.accepted",
"createdAt": "2026-08-04T14:42:10.512Z",
"tenantId": 12,
"data": { "id": 842, "invoiceNumber": "FV-128",
"dianStatus": "APROBADA", "cufe": "a1b2c3…", "total": 191800 }
}Verificar la firma
La firma es HMAC-SHA256 del texto "<t>.<cuerpo crudo>" con tu secreto. Compárala en tiempo constante y rechaza lo que tenga más de 5 minutos.
const crypto = require('crypto'); app.post('/hooks/bolti', express.raw({ type: 'application/json' }), (req, res) => { const [tPart, vPart] = req.get('x-bolti-signature').split(','); const t = tPart.split('=')[1], v1 = vPart.split('=')[1]; const expected = crypto.createHmac('sha256', process.env.BOLTI_WEBHOOK_SECRET) .update(`${t}.${req.body}`).digest('hex'); const ok = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected)) && Math.abs(Date.now() / 1000 - Number(t)) < 300; if (!ok) return res.sendStatus(401); const evento = JSON.parse(req.body); // … procesa y responde rápido res.sendStatus(200); });
Reintentos. Cualquier respuesta fuera de 2xx (o un timeout de 15 s) se reintenta a 1, 5, 15, 60 y 360 minutos.
Tras 5 intentos la entrega queda en FAILED; puedes verla en GET /webhooks/deliveries.
Responde 200 primero y procesa después: si tardas, cuenta como fallo.
Errores y límites
Todos los errores traen la misma forma, con un mensaje pensado para leerse tal cual en tus logs.
{ "success": false, "error": "No hay inventario suficiente para el pedido.",
"stockIssues": [ { "sku": "CAM-AZUL-M", "requested": 2, "available": 1 } ] }| Código | Significa | Qué hacer |
|---|---|---|
400 | Petición inválida: falta un campo o el valor no sirve | Corrige el cuerpo. El mensaje dice exactamente qué falta |
401 | API key ausente, inválida, revocada o expirada | Revisa el header; genera una llave nueva si hace falta |
403 | La llave no tiene el scope requerido | La respuesta trae los scopes que sí tiene. Crea una llave con el preset correcto |
404 | No existe el producto, cliente o factura | Verifica el SKU, documento o id |
409 | Conflicto: sin inventario, pago mayor al saldo, u operación idéntica en curso | No reintentes ciegamente: revisa el detalle |
422 | Idempotency-Key reutilizada con otro cuerpo | Usa una clave nueva para una operación nueva |
429 | Más de 300 peticiones por minuto con la misma llave | Espera lo que diga Retry-After y agrupa con los endpoints masivos |
500 | Error interno | Reintenta con la misma Idempotency-Key: no duplica |
Límites
- 300 peticiones por minuto por API key.
- 100 productos por llamada a
/products/bulk. - 100 registros por página en los listados (
pageSize), 500 en/stock. - Cuerpo máximo de 100 KB por petición.
La factura no depende de tu total. Los totales se recalculan siempre en el servidor a partir de las líneas.
Si tu carrito y Bolti no coinciden, manda unitPrice y taxRate explícitos por línea y compara
contra order.total antes de confirmarle al comprador.
Antes de salir a producción
Repasa esto y tu integración no te va a despertar de madrugada.
Empresa habilitada
GET /company debe traer electronicInvoicing.enabled: true y una resolución vigente. Si no, usa invoiceClass: "INTERNA" mientras tanto.
Idempotency-Key en todo POST
Derivada del id del pedido, no aleatoria por intento. Es lo que hace que un reintento sea inofensivo.
externalId siempre
Amarra el pedido de tu tienda a la factura y te deja resincronizar sin miedo.
Guarda invoiceId y CUFE
Es lo que vas a necesitar para soporte, devoluciones y para responderle al comprador.
Webhooks con firma verificada
Y responde 200 rápido, procesando en segundo plano.
Stock antes de cobrar
Consulta /stock en el checkout: es más barato que revertir un cobro.
Key rotable
En variable de entorno, nunca en el repositorio. Revocar y crear otra debe tomar un despliegue.
Alertas de 4xx
Un 403 o 409 repetido es una integración rota, no ruido.
¿Necesitas ayuda? Escribe a soporte@bolti.co con el invoiceNumber
o el externalId de la operación: con eso rastreamos la factura, su asiento y la respuesta de la DIAN.