Conciliación por API
Sube el extracto del banco, mira qué cuadra con el libro, resuelve lo que falta en lote y descarga el reporte — lo mismo que la pantalla de Conciliación, desde tu ERP o tu bot.
La conciliación compara el extracto del banco contra el libro de Verifika: cada movimiento del banco que ya está en el libro queda conciliado; lo que falta se registra o se ignora; y lo que está en el libro pero el banco no muestra queda a la vista. Es lo mismo que Contabilidad → Conciliación bancaria del panel, con las mismas cifras y las mismas reglas: lo que resuelvas por API se ve allí, y al revés.
1. Subir el extracto ─► 2. Esperar la lectura ─► 3. Ver el resumen ─► 4. Resolver filas ─► 5. Reporte
POST /bank-statements 202 → GET o webhook verdict, progress /lines, /lines/bulk /reportAntes de empezar
- El plan incluye la conciliación bancaria (
features.bank_reconciliationenGET /v1/plan). Si no,403 PLAN_FEATURE_REQUIREDcondetails.feature: "bank_reconciliation". - La llave tiene los permisos:
reconciliation.read(ver),reconciliation.write(subir y resolver) y, si vas a borrar extractos,reconciliation.delete. Para registrar una salida del banco como gasto hace falta ademásexpenses.write. Crear la llave. - Con una llave real. La conciliación no tiene modo prueba: con una
vk_test_responde403 TEST_MODE_UNAVAILABLE.
1. Sube el extracto
Tres formas, una sola por petición, y el archivo hasta 15 MB:
| Forma | Cómo |
|---|---|
| Archivo | multipart/form-data con el campo file; los demás campos van como texto. |
| Base64 | JSON con fileBase64 y fileName (con extensión). |
| URL | JSON con fileUrl: https y pública. La bajamos con un tope de 20 s. |
Leemos PDF (también con clave), Excel (.xlsx, .xls), CSV, TXT, XML,
.prn, .dat, el .fil de Bancolombia y un .zip con uno de esos adentro (como los
entrega la sucursal virtual de Bancolombia).
# Por archivo
curl -X POST https://api.verifika.tech/v1/bank-statements \
-H "Authorization: Bearer $VERIFIKA_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-F "file=@extracto-septiembre.pdf" \
-F "paymentAccountId=cmt7xbfs10005q3w1u8k2c9va"
# Por URL
curl -X POST https://api.verifika.tech/v1/bank-statements \
-H "Authorization: Bearer $VERIFIKA_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "fileUrl": "https://drive.tuerp.com/extractos/2026-09.pdf" }'| Campo | Para qué |
|---|---|
paymentAccountId | La cuenta de recaudo del extracto (GET /v1/payment-accounts). Opcional: si no la mandas, se deduce por el banco y los últimos 4 dígitos. |
password | La clave del PDF. Antes de pedirla probamos las que el negocio pidió recordar. |
rememberPassword | true para guardarla (cifrada) para los próximos extractos de esa cuenta. |
async | true para que conteste 202 de una vez, sin esperar la lectura. |
2. Espera la lectura: 201 o 202
Un CSV se lee en milisegundos; un PDF escaneado puede tomar un minuto. La petición espera hasta 20 segundos:
201: el extracto ya está leído y cruzado. Sigue al paso 3.202: se sigue leyendo. Llegastatus: "processing", la cabeceraLocationcon su URL yRetry-After: 10. Eliddel202es el id del extracto de punta a punta.
Para saber cuándo termina tienes dos caminos:
while (statement.status === 'processing') {
await new Promise((r) => setTimeout(r, 10_000)); // respeta Retry-After
statement = await (await fetch(`${API}/bank-statements/${statement.id}`, { headers })).json();
}
if (statement.status === 'failed') throw new Error(`${statement.error.code}: ${statement.error.message}`);O, mejor, suscribe los webhooks bank_statement.processed
(trae el extracto completo) y bank_statement.failed (trae el error). processed sale para
cualquier extracto que termine de leerse, también los que el negocio sube desde el panel.
Si la lectura falla a tiempo, el error sale en la misma respuesta de la subida (y no queda nada
guardado). Si falla después del 202, GET devuelve status: "failed" con el mismo error que
habría dado la subida. Una lectura que lleva más de 15 minutos se cortó: pasa a failed con
PROCESSING_INTERRUPTED («vuelve a subirlo»).
3. Mira el resumen
{
"object": "bankStatement",
"id": "cmuh8q2r70044x8w1b6n3m1kd",
"status": "processed",
"fileName": "extracto-septiembre.pdf",
"bank": { "key": "bancolombia", "name": "Bancolombia" },
"accountLast4": "8300",
"periodStart": "2026-09-01",
"periodEnd": "2026-09-30",
"verdict": "VERIFIED",
"balances": { "opening": 1250000, "closing": 3184520.37 },
"totals": { "credits": 4872300, "debits": 2937779.63, "lineCount": 64 },
"difference": { "closingBalance": 0, "credits": 0, "debits": 0 },
"progress": { "total": 64, "resolved": 51, "pending": 13, "percentResolved": 80 },
"groups": {
"matched": { "count": 38, "amount": 4104300 },
"registered": { "count": 0, "amount": 0 },
"bankFees": { "count": 13, "amount": 26443.62 },
"ignored": { "count": 0, "amount": 0 },
"missingIncomes": { "count": 6, "amount": 768000 },
"missingExpenses": { "count": 7, "amount": 1843200 },
"onlyInVerifika": { "count": 2, "amount": 95000 }
}
}| Campo | Qué dice |
|---|---|
verdict | VERIFIED: lo que leímos cuadra al centavo con el resumen del banco. MISMATCH: no cuadra — no es un error, el extracto se guarda igual; mira difference (lo leído menos lo del banco, en pesos) y checks. UNVERIFIABLE: el archivo no trae saldos contra qué medirlo (algunos CSV y TXT). |
progress | Cuántas filas están resueltas y cuántas faltan. |
groups.missingIncomes / missingExpenses | Entradas y salidas del banco que faltan en Verifika: lo que hay que resolver. |
groups.bankFees | Cobros del banco reconocidos solos: 4×1000, cuota de manejo, comisiones, intereses. |
groups.onlyInVerifika | Ingresos del libro en esa cuenta y período que el banco no muestra. Vale la pena mirarlos. |
4. Resuelve las filas
curl "https://api.verifika.tech/v1/bank-statements/cmuh8q2r70044x8w1b6n3m1kd/lines?status=UNMATCHED&direction=in" \
-H "Authorization: Bearer $VERIFIKA_API_KEY"Cada fila trae amount con signo (+ entró, − salió), direction, status
(UNMATCHED = falta en Verifika) y, si ya quedó resuelta, el income o expense con el que
quedó. Filtros: status (separados por coma), direction, from, to, minAmount,
maxAmount, q y category.
Hay cuatro acciones por fila:
| Acción | Qué hace |
|---|---|
POST …/lines/{lineId}/match con { "incomeId" } o { "expenseId" } | La concilia con algo que ya está en el libro (mismo valor exacto). |
POST …/lines/{lineId}/register | La registra: una entrada como ingreso (PENDING, «lo vio el banco»); una salida como gasto, con categoryId opcional (pide expenses.write). |
POST …/lines/{lineId}/ignore | «No va en el libro»: un traslado entre tus propias cuentas, por ejemplo. |
POST …/lines/{lineId}/reset | Deshace lo resuelto y la vuelve a cruzar. No borra lo que se registró en el libro. |
En lote, con remaining
Para resolver muchas a la vez está POST …/lines/bulk, con lineIds (hasta 1.000) o un
filter (los mismos campos del listado, con status como arreglo; {} = todas). Por filtro se
toman las primeras 1.000 que tu llave puede resolver, y remaining dice cuántas quedan:
repite el pedido hasta que llegue a 0.
// Registrar todas las entradas que faltan en el libro.
let r;
do {
r = await (
await fetch(`${API}/bank-statements/${statement.id}/lines/bulk`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID() },
body: JSON.stringify({ action: 'register_income', filter: { status: ['UNMATCHED'], direction: 'in' } }),
})
).json();
console.log(`Registradas ${r.applied}, saltadas ${r.skipped.length}, faltan ${r.remaining}`);
} while (r.remaining > 0 && r.applied > 0);{
"object": "bankStatementBulkAction",
"action": "register_income",
"selected": 6,
"applied": 5,
"alreadyDone": 0,
"skipped": [
{ "lineId": "cmuh8q2t10046x8w1y7u8i9op", "code": "LINE_NOT_REGISTRABLE", "message": "Un pago a la tarjeta no es un ingreso del negocio." }
],
"remaining": 0,
"created": { "incomes": 5, "expenses": 0 },
"statement": { "id": "cmuh8q2r70044x8w1b6n3m1kd", "progress": { "total": 64, "resolved": 56, "pending": 8, "percentResolved": 88 } }
}Las acciones del lote son register, register_income, register_expense, ignore y reset.
Lo que no aplica a una fila (ya estaba registrada, es de otra dirección…) sale en skipped con
su código y no tumba el resto. reset por filtro admite hasta 1.000 filas; con más, 422 BULK_TOO_LARGE: manda lineIds en tandas.
Reintentar sin duplicar
Cada acción mira el estado de la fila antes de escribir. Repetirla contesta
outcome: "ALREADY_DONE" sin tocar nada: un reintento nunca crea un segundo ingreso. En la
subida, la protección es doble: la Idempotency-Key y que el mismo archivo no se puede subir dos
veces (409 STATEMENT_DUPLICATE con details.statementId, que es el que ya existe).
Después de registrar en el libro movimientos que faltaban por otro lado, POST …/rematch vuelve a
cruzar el extracto. Lo resuelto a mano se respeta.
5. Descarga el reporte
curl -o conciliacion.xlsx \
"https://api.verifika.tech/v1/bank-statements/cmuh8q2r70044x8w1b6n3m1kd/report?format=xlsx" \
-H "Authorization: Bearer $VERIFIKA_API_KEY"Es el mismo «Descargar reporte» del panel: Excel con las hojas Resumen, Movimientos y Solo en
Verifika; también format=pdf o format=csv.
Errores de la subida
| HTTP | code | Qué pasó |
|---|---|---|
409 | STATEMENT_DUPLICATE | Ese archivo ya se subió (details.statementId; con details.status: "processing" si todavía se está leyendo). |
422 | STATEMENT_PASSWORD_REQUIRED · STATEMENT_PASSWORD_INCORRECT | El PDF tiene clave: mándala en password. details.savedTried dice cuántas guardadas se probaron. |
422 | STATEMENT_NOT_A_STATEMENT | No es un extracto (details.documentLabel dice qué parece: una factura, un comprobante…). |
422 | STATEMENT_NO_TABLE · STATEMENT_SCANNED · STATEMENT_UNREADABLE | No hay tabla de movimientos, es un PDF escaneado y con clave, o el PDF está dañado. |
422 | STATEMENT_ZIP_UNREADABLE | El .zip no trae un solo extracto legible (o tiene clave, o trae otro .zip adentro). |
415 | STATEMENT_UNSUPPORTED · STATEMENT_XLS_LEGACY · FILE_TYPE_NOT_ALLOWED | Formato que no leemos. |
413 | FILE_TOO_LARGE · STATEMENT_TOO_LARGE | Más de 15 MB. |
429 | STATEMENT_UPLOADS_IN_PROGRESS | Ya hay 3 extractos leyéndose en este negocio. Espera a que termine alguno. |
503 | SERVICE_BUSY | Estamos leyendo muchos extractos a la vez. Reintenta en un minuto. |
409 | STATEMENT_PROCESSING | Pediste filas, el reporte o una acción de un extracto que todavía se está leyendo. |
Y los de una fila: LINE_ALREADY_REGISTERED, LINE_ALREADY_MATCHED, LINE_IGNORED (409),
LINE_DIRECTION_MISMATCH, LINE_NOT_REGISTRABLE, AMOUNT_MISMATCH (422) y
EXPENSES_NOT_ALLOWED (403). La referencia de cada endpoint está en
Conciliación bancaria.
Borrar un extracto
DELETE /v1/bank-statements/{id} (permiso reconciliation.delete) borra el extracto y sus
filas. Lo que se registró en el libro desde él se queda.
Por MCP
Las mismas operaciones existen como herramientas del servidor MCP:
upload_bank_statement, get_bank_statement, list_bank_statement_lines,
bulk_bank_statement_lines y las demás. Así un asistente puede conciliar el mes conversando.
Cuando el comprobante llega antes que el banco
El caso de todos los días — el cliente manda el comprobante, el banco todavía no avisa — y cómo tu bot le confirma minutos después con un webhook.
Gastos con IA
Manda la foto o el PDF de una factura y recibe el gasto listo para registrar — proveedor, NIT, número de factura, IVA y categoría — con el aviso de si ya estaba registrado.