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.

REST · JSON Autenticación por API key Facturación electrónica DIAN Contabilidad automática Webhooks firmados Idempotencia

Empieza aquí

Lo mínimo para entender de qué se trata antes de escribir la primera línea.

URL basehttps://bolti.co/api/public/v1
AutenticaciónHeader x-api-key: sk_live_… en todas las peticiones
FormatoJSON en petición y respuesta (Content-Type: application/json)
MonedaCOP. Los montos van en pesos, sin separadores de miles
FechasYYYY-MM-DD · las marcas de tiempo salen en ISO 8601 UTC
Spec OpenAPIopenapi.yaml · visor interactivo

Cómo consigues una API key

El administrador de la empresa entra a Bolti → API & IntegracionesNueva 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:

PresetPara quéIncluye
ecommerceTienda online completacatálogo, inventario, clientes, facturas, pagos, devoluciones, contabilidad (lectura) y webhooks
posPunto de venta de terceroslectura de catálogo e inventario, emisión de facturas, pagos, alta de clientes
readonlyDashboards, BIsolo lectura de facturas, catálogo, inventario, clientes y contabilidad

Scopes disponibles

ScopeHabilita
products:readLeer el catálogo y las categorías
products:writeCrear y actualizar productos (incluida la carga masiva)
inventory:readConsultar existencias, bodegas y kardex
inventory:writeRegistrar entradas, salidas y conteos físicos
customers:read / customers:writeConsultar y dar de alta clientes (terceros)
invoices:readConsultar pedidos, facturas, su estado DIAN y sus pagos
invoices:writeCrear pedidos y emitir facturas
payments:writeRegistrar cobros contra una factura
creditnotes:writeEmitir notas crédito (devoluciones y anulaciones)
accounting:readLeer asientos del libro diario y la cartera
webhooks:manageRegistrar y probar webhooks
ai:chatConsumir el asistente de IA del negocio
tenants:read / tenants:write / dian:onboardCrear 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.

  1. 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" }
        ]
      }'

    stock no se escribe a mano en la tabla: entra por el kardex como ajuste y genera su asiento de inventario.

  2. Muestra existencias reales en tu tienda

    Consulta puntual antes de dejar comprar, o barrido incremental con updatedSince para 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"
  3. 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 un Idempotency-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, responde 409 con el detalle por SKU antes de gastar un consecutivo de la resolución DIAN.

  4. 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 invoiceId y invoiceNumber junto al pedido. Si la DIAN quedó en trámite, consulta GET /invoices/{id}/status o —mejor— escucha el webhook.

  5. 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 amount se aplica el saldo pendiente completo. Genera recibo de caja, mueve bancos contra la cuenta por cobrar y cierra la cartera.

  6. Devoluciones

    Nota crédito electrónica: reingresa el inventario, reversa el costo de venta y baja la cuenta por cobrar. Sin items devuelve 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ónQué 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ónRespuesta
Primera vezSe ejecuta normal y se guarda el resultado
Reintento con el mismo cuerpoLa misma respuesta original, con el header Idempotent-Replay: true
Reintento mientras la original sigue corriendo409 — espera unos segundos y vuelve a intentar
Misma key con un cuerpo distinto422 — esa key ya identifica otra operación
La original falló con 5xxLa 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

EventoCuándo se dispara
invoice.createdSe emitió una factura por la API
invoice.dian.acceptedLa DIAN aprobó la factura (aunque se haya emitido desde el panel)
invoice.dian.rejectedLa DIAN la rechazó o quedó en error
payment.createdSe registró un cobro contra una factura
creditnote.createdSe generó una nota crédito
stock.updatedCambió el saldo de un producto por una operación de la API
stock.lowEl 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ódigoSignificaQué hacer
400Petición inválida: falta un campo o el valor no sirveCorrige el cuerpo. El mensaje dice exactamente qué falta
401API key ausente, inválida, revocada o expiradaRevisa el header; genera una llave nueva si hace falta
403La llave no tiene el scope requeridoLa respuesta trae los scopes que sí tiene. Crea una llave con el preset correcto
404No existe el producto, cliente o facturaVerifica el SKU, documento o id
409Conflicto: sin inventario, pago mayor al saldo, u operación idéntica en cursoNo reintentes ciegamente: revisa el detalle
422Idempotency-Key reutilizada con otro cuerpoUsa una clave nueva para una operación nueva
429Más de 300 peticiones por minuto con la misma llaveEspera lo que diga Retry-After y agrupa con los endpoints masivos
500Error internoReintenta 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.