# Bienvenido (/docs) Verifika responde una sola pregunta, y la responde bien: **¿ese pago entró de verdad?** Cruza el comprobante que le muestran a un negocio contra el aviso que el banco manda —por correo o por SMS— y devuelve un veredicto. Esta documentación es para conectar eso a lo que ya tienes: tu bot de WhatsApp, tu ERP, tu software contable, tu panel interno. Todo vive bajo una sola URL: ``` https://api.verifika.tech/v1 ``` ## Empieza aquí [#empieza-aquí] ## Qué puedes hacer [#qué-puedes-hacer] | Quiero… | Con esto | | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | Saber si un comprobante es real | [`POST /v1/verifications`](/docs/referencia/verificar/createVerification) | | Anotar un pago que ya sé que entró | [`POST /v1/incomes`](/docs/referencia/ingresos/createIncome) | | Leer el libro de un negocio | [`GET /v1/incomes`](/docs/referencia/ingresos/listIncomes) y [`/v1/expenses`](/docs/referencia/egresos/listExpenses) | | Registrar gastos y sus categorías | [`POST /v1/expenses`](/docs/referencia/egresos/createExpense) y [categorías](/docs/referencia/egresos/listExpenseCategories) | | Enterarme cuando algo pasa, sin preguntar | [Webhooks](/docs/guias/webhooks) | | Ver los comprobantes repetidos o falsos | [`GET /v1/suspicious-attempts`](/docs/referencia/pagos-sospechosos/listSuspiciousAttempts) | | Preguntarle a mis datos en lenguaje natural | [Servidor MCP](/docs/mcp) | | Conectar mi asistente sin copiar ningún secreto | [Conectar con un clic](/docs/mcp/autorizacion) | ## De dónde sale la llave [#de-dónde-sale-la-llave] Toda petición se autentica con una **API key del negocio**, y solo se crea desde el panel: Claude y los clientes que hablan el estándar de autorización de MCP se conectan pulsando «Conectar»: te llevan a una pantalla de Verifika, eliges qué conceder y ya está. Sin secretos. Ver [Conectar con un clic](/docs/mcp/autorizacion). Para todo lo demás —un servidor, un bot, un proceso sin persona detrás— sigue siendo una llave, y estos son los pasos. ### Entra al panel [#entra-al-panel] [app.verifika.tech](https://app.verifika.tech) → **Integraciones** → pestaña **API pública y webhooks**. ### Crea la llave con los permisos justos [#crea-la-llave-con-los-permisos-justos] Marca solo lo que tu sistema necesita. Una llave de un bot que registra pagos no necesita poder borrar egresos, y si algún día se filtra, esa diferencia es todo. ### Cópiala en ese momento [#cópiala-en-ese-momento] El secreto se muestra **una sola vez**. Después solo queda el nombre y los últimos cuatro caracteres. Si lo pierdes, se rota; no se recupera. ### Úsala en la cabecera [#úsala-en-la-cabecera] ```bash curl https://api.verifika.tech/v1/me \ -H "Authorization: Bearer $VERIFIKA_API_KEY" ``` `/v1/me` es el request con el que conviene empezar siempre: dice si la llave sirve, a qué negocios entra y qué permisos tiene, antes de que un error de permisos te salga a mitad de un flujo. ## Convenciones que aplican a todo [#convenciones-que-aplican-a-todo] * **Montos en pesos enteros.** `45000` son $45.000. Nunca hay decimales. * **Fechas en ISO 8601, UTC.** `2026-09-04T15:04:05.000Z`. * **camelCase** en campos y parámetros. * **Listas paginadas por cursor:** `limit` y `startingAfter`, con `hasMore` y `nextCursor` en la respuesta. Ver [Paginación](/docs/guias/paginacion). * **Escrituras idempotentes.** Manda `Idempotency-Key` y un reintento no cobra dos veces. Ver [Idempotencia](/docs/guias/idempotencia). * **Toda respuesta trae `X-Request-Id`.** Si algo falla, ese id es lo que soporte necesita. # Autenticación y permisos (/docs/guias/autenticacion) Toda petición a `/v1` va con la llave en la cabecera: ```http Authorization: Bearer vk_live_a1b2c3... ``` Sin ella, o con una inválida, la respuesta es `401`. **No decimos cuál de las razones fue** —inválida, revocada, vencida o desde una IP no permitida— porque decirlo le regala información a quien esté probando llaves a ciegas. ## Formato del token [#formato-del-token] | Parte | Ejemplo | Qué es | | ------- | ------------- | --------------------------------------------------------- | | Prefijo | `vk_live_` | `vk_test_` fuera de producción, para no mezclar ambientes | | Secreto | 43 caracteres | 256 bits de entropía | En nuestra base **solo guardamos el SHA-256** del token. Nunca el secreto, nunca en logs. ## Los tres candados [#los-tres-candados] Lo que una llave puede hacer es la intersección de tres cosas: ``` permiso efectivo = permisos de la llave ∩ tu plan ∩ los negocios de la llave ``` 1. **Permisos de la llave (scopes).** Los marcas al crearla. Sin el permiso, `403 SCOPE_REQUIRED` — y el error dice **cuál** falta, no un genérico: ```json { "error": { "code": "SCOPE_REQUIRED", "details": { "scope": "expenses.read" } } } ``` 2. **Tu plan.** Marcar `expenses.read` no sirve si tu plan no incluye el módulo de egresos. En el panel esos permisos salen en gris con candado. Y si bajas de plan, tus llaves **no se revocan**: se acotan. Si vuelves a subir, tu integración revive sola. 3. **Los negocios de la llave.** Una llave nunca ve un negocio que no esté en su lista, aunque se sepa el id. Pedir uno ajeno responde exactamente lo mismo que pedir uno inexistente. ### Catálogo de permisos [#catálogo-de-permisos] | Recurso | Permisos | | ----------------- | ---------------------------------------------------------------------------------------------------- | | Ingresos | `incomes.read` · `incomes.write` · `incomes.delete` | | Verificación | `verifications.write` | | Egresos | `expenses.read` · `expenses.write` · `expenses.delete` | | Categorías | `categories.read` · `categories.write` | | Pagos sospechosos | `suspicious.read` · `suspicious.resolve` | | Negocio | `companies.read` · `branches.read` · `branches.write` · `payment_accounts.read` · `connections.read` | | Equipo | `team.read` · `team.write` | | Reportes | `metrics.read` · `plan.read` | | Webhooks | `webhooks.read` · `webhooks.write` | Una llave de solo lectura que se filtre es un susto; una con `incomes.delete` es un incidente. Si tu integración solo consulta, no marques nada de escritura — siempre puedes ampliar los permisos de la llave después, sin cambiar el secreto. ## Dónde se administran [#dónde-se-administran] Todo esto vive en el panel del negocio: **Integraciones → API pública y webhooks → Llaves**. Hace falta el permiso **«Gestionar integraciones»** (`integration.manage`), que por defecto tienen el Dueño y el Administrador. Desde ahí se crean, se editan, se rotan y se revocan; y la tabla muestra el prefijo, el alias, el resumen de permisos, quién la creó y **cuándo y desde qué IP se usó por última vez** — que es lo que permite darse cuenta de una llave que nadie usa hace tres meses, o de una que se está usando desde un sitio raro. `/v1` no expone ningún endpoint para acuñar, rotar o revocar llaves, y es a propósito: una llave que pudiera emitir llaves convertiría la fuga de una de solo lectura en una fuga total —bastaría usarla para emitir otra con todos los permisos—. Eso exige una sesión de una persona con permiso, y queda con su nombre encima. ## Rotar una llave [#rotar-una-llave] Rotar genera un secreto nuevo **sin dejarte abajo**: la llave vieja sigue funcionando durante un periodo de gracia que tú eliges (por defecto 24 horas), el tiempo de desplegar. ``` llave vieja ──────────── 24 h ────────────┤ deja de funcionar llave nueva ├──────────────────────────────────────────────────▶ ``` Rota cuando: se te fue alguien del equipo que la tenía, la llave lleva más de un año viva, o por higiene periódica. ## Si se te filtró [#si-se-te-filtró] **Rota con gracia cero.** La vieja muere en el acto. Es preferible que tu integración se caiga diez minutos a que alguien más escriba en el libro del negocio. Después revisa la columna **último uso** de la llave en el panel: te dice desde qué IP se usó por última vez. ## Endurecerla más [#endurecerla-más] * **Lista de IPs permitidas.** Si tu integración corre en un servidor fijo, deja solo esa IP o su rango (`190.85.0.0/16`). Una llave robada deja de servir fuera de ahí. * **Fecha de vencimiento.** Para una integración temporal —una migración, una consultoría— ponle fecha desde el principio en lugar de confiar en que alguien se acuerde de borrarla. * **Una llave por sistema.** No compartas la misma entre tu ERP y tu bot: cuando toque revocar una, no quieres tumbar los dos. * **Nunca en el navegador.** La llave es de servidor. Si va en el frontend, cualquiera la lee en la pestaña de red. # Verifica desde tu propio bot (/docs/guias/bots) Si ya tienes un bot que recibe comprobantes, lo que te falta no es el bot: es saber si ese comprobante es real. Eso cabe en una llamada. cURL JavaScript Python PHP Go C# Rust ```bash curl -X POST https://api.verifika.tech/v1/verifications \ -H "Authorization: Bearer $VERIFIKA_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "phone": "+573001112233", "amount": 45000, "reference": "M1A2B3C4", "occurredAt": "2026-09-04T15:04:05-05:00", "customerName": "Juan Pérez", "bankLabel": "Nequi", "imageUrl": "https://tu-cdn.com/comprobante.jpg" }' ``` ```js const res = await fetch('https://api.verifika.tech/v1/verifications', { method: 'POST', headers: { Authorization: `Bearer ${process.env.VERIFIKA_API_KEY}`, // La misma llave en todos los reintentos de ESTE comprobante. 'Idempotency-Key': idempotencyKey, 'Content-Type': 'application/json', }, body: JSON.stringify({ phone: '+573001112233', amount: 45000, reference: 'M1A2B3C4', occurredAt: '2026-09-04T15:04:05-05:00', customerName: 'Juan Pérez', bankLabel: 'Nequi', imageUrl: 'https://tu-cdn.com/comprobante.jpg', }), }); const { outcome, income } = await res.json(); ``` ```python import os, uuid, requests res = requests.post( "https://api.verifika.tech/v1/verifications", headers={ "Authorization": f"Bearer {os.environ['VERIFIKA_API_KEY']}", "Idempotency-Key": idempotency_key, }, json={ "phone": "+573001112233", "amount": 45000, "reference": "M1A2B3C4", "occurredAt": "2026-09-04T15:04:05-05:00", "customerName": "Juan Pérez", "bankLabel": "Nequi", "imageUrl": "https://tu-cdn.com/comprobante.jpg", }, timeout=20, ) resultado = res.json() ``` ```php $ch = curl_init('https://api.verifika.tech/v1/verifications'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('VERIFIKA_API_KEY'), 'Idempotency-Key: ' . $idempotencyKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'phone' => '+573001112233', 'amount' => 45000, 'reference' => 'M1A2B3C4', 'occurredAt' => '2026-09-04T15:04:05-05:00', 'customerName' => 'Juan Pérez', 'bankLabel' => 'Nequi', 'imageUrl' => 'https://tu-cdn.com/comprobante.jpg', ]), ]); $resultado = json_decode(curl_exec($ch), true); ``` ```go body, _ := json.Marshal(map[string]any{ "phone": "+573001112233", "amount": 45000, "reference": "M1A2B3C4", "occurredAt": "2026-09-04T15:04:05-05:00", "customerName": "Juan Pérez", "bankLabel": "Nequi", "imageUrl": "https://tu-cdn.com/comprobante.jpg", }) req, _ := http.NewRequest("POST", "https://api.verifika.tech/v1/verifications", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("VERIFIKA_API_KEY")) req.Header.Set("Idempotency-Key", idempotencyKey) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) ``` ```csharp var req = new HttpRequestMessage(HttpMethod.Post, "https://api.verifika.tech/v1/verifications") { Content = JsonContent.Create(new { phone = "+573001112233", amount = 45000, reference = "M1A2B3C4", occurredAt = "2026-09-04T15:04:05-05:00", customerName = "Juan Pérez", bankLabel = "Nequi", imageUrl = "https://tu-cdn.com/comprobante.jpg", }), }; req.Headers.Add("Idempotency-Key", idempotencyKey); var res = await http.SendAsync(req); ``` ```rust let res = reqwest::Client::new() .post("https://api.verifika.tech/v1/verifications") .bearer_auth(&key) .header("Idempotency-Key", &idempotency_key) .json(&serde_json::json!({ "phone": "+573001112233", "amount": 45_000, "reference": "M1A2B3C4", "occurredAt": "2026-09-04T15:04:05-05:00", "customerName": "Juan Pérez", "bankLabel": "Nequi", "imageUrl": "https://tu-cdn.com/comprobante.jpg", })) .send() .await?; ``` ```json title="Respuesta" { "outcome": "REGISTERED", "business": { "companyId": "cmt7…", "companyName": "Panadería Delipan" }, "income": { "id": "cmtj…", "status": "PENDING", "amount": 45000, "reference": "M1A2B3C4" }, "attachment": { "stored": true } } ``` ## Qué manda cada campo [#qué-manda-cada-campo] `phone` y `amount`. Todo lo demás mejora el resultado —sobre todo `reference`, que es lo único que permite detectar un comprobante repetido— pero no impide registrar el pago. | Campo | Tipo | ¿Obligatorio? | Qué significa y para qué sirve | | -------------- | ----------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `phone` | `string` E.164 | **Sí** | Quién reporta el pago. **Tiene que ser un teléfono del equipo del negocio** (dueño, supervisor o cajero): es lo que ata el movimiento a una persona y a una sucursal. Se tolera con o sin `+` y con separadores — `573001112233` y `+57 300 111 2233` resuelven al mismo número. Si no está en el equipo: `404 PHONE_NOT_REGISTERED`. | | `amount` | `integer` | **Sí** | Cuánto dice el comprobante, en **pesos enteros y sin decimales**: `45000` son $45.000. Es uno de los tres datos con los que se cruza contra el aviso del banco, así que un valor mal leído impide la verificación. | | `reference` | `string` | No, pero mándalo | La referencia del comprobante bancario. **Es la llave anti-duplicados.** Con ella detectamos que el mismo comprobante se está usando dos veces; sin ella no podemos, porque dos ventas de $20.000 el mismo día son perfectamente legítimas. Si tu OCR la extrae, mándala siempre. | | `occurredAt` | `string` ISO 8601 | No | Cuándo dice el comprobante que se hizo el pago, con zona horaria (`-05:00` para Colombia). Si falta, se usa el momento de la llamada — y el cruce con el aviso del banco pierde precisión, porque la ventana de tiempo deja de coincidir. | | `customerName` | `string` | No | Quién pagó, según el comprobante. Solo para que el dueño lo reconozca en su libro; no se valida contra nada. | | `bankLabel` | `string` | No | De dónde salió la plata: «Nequi», «Bancolombia», «Daviplata». Ayuda al cruce y sale en el reporte. | | `accountLabel` | `string` | No | A qué cuenta del negocio entró: «Nequi \*4821». Alimenta el corte por cuenta de recaudo. | | `imageUrl` | `string` URL | No | URL **pública** de la foto del comprobante. La descargamos y la copiamos a nuestro almacenamiento, así que no dependemos de que tu enlace siga vivo. Si la descarga falla, **el pago se registra igual** y te lo decimos en `attachment.stored`. | Y una cabecera que no es opcional en la práctica: | Cabecera | ¿Obligatoria? | Para qué | | ----------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Idempotency-Key` | No, pero úsala | Una llave única **por comprobante**, no por intento HTTP. Si la red se corta después de que procesamos y tu bot reintenta, te devolvemos la respuesta original en vez de registrar la venta dos veces. Ver [Idempotencia](/docs/guias/idempotencia). | ## Qué ramificar [#qué-ramificar] `outcome` es lo único que tu flujo necesita mirar: | `outcome` | Estado del ingreso | Qué decirle al cliente | | ------------ | ------------------ | -------------------------------------------------- | | `REGISTERED` | `PENDING` | «Recibido, estamos confirmando con el banco» | | `REGISTERED` | `VERIFIED` | El banco ya había avisado: **entrega el producto** | | `DUPLICATE` | — | «Ese comprobante ya se usó» | Cuando el banco ya había reportado el pago, el cruce ocurre **en esa misma llamada** y el ingreso vuelve verificado. Cuando no, queda `PENDING` y se confirma en cuanto entre el aviso — para enterarte en ese momento, suscribe [`income.verified`](/docs/guias/webhooks). ## Lo que hay que saber antes de construirlo [#lo-que-hay-que-saber-antes-de-construirlo] Solo el cruce contra el aviso real del banco puede confirmarlo. Marcar como verificado algo que solo dijo un bot destruiría el único dato que hace útil a Verifika — y con él, la razón por la que tu cliente te está pagando a ti. * **La referencia es la llave anti-duplicados.** Sin `reference` no podemos saber que el mismo comprobante se está usando dos veces: dos ventas de $20.000 el mismo día son legítimas. Extráela siempre que puedas. * **El teléfono tiene que ser del equipo.** `phone` identifica a quien reporta y tiene que estar en el equipo del negocio (dueño, supervisor o cajero). Si no, `404 PHONE_NOT_REGISTERED`. * **El comprobante lo guardamos nosotros.** Nos pasas una URL, la descargamos y la copiamos a nuestro almacenamiento. Si tu enlace caduca, la evidencia sigue existiendo. * **Un duplicado no consume cupo.** Solo se descuenta una verificación cuando de verdad se registra algo. * **Usa `Idempotency-Key`.** Tu bot va a reintentar cuando la red falle, y no quieres duplicarle una venta a nadie. Ver [Idempotencia](/docs/guias/idempotencia). ## Antes de la primera llamada [#antes-de-la-primera-llamada] El negocio tiene que haber **conectado su banco** en Verifika: sin eso no hay contra qué cruzar. Es un asistente guiado en el panel, no un trámite técnico. Puedes comprobarlo desde tu lado antes de prometer nada: ```bash curl https://api.verifika.tech/v1/connections \ -H "Authorization: Bearer vk_live_..." ``` ```json { "object": "connectionStatus", "healthy": true, "channels": [ /* … */ ] } ``` Si `healthy` es `false`, el negocio dejó de tener con qué verificar. Vale la pena revisarlo a diario: enterarte tú antes que tu cliente es media razón para integrar. ## Si manejas muchos negocios [#si-manejas-muchos-negocios] Hoy cada negocio te da **su propia llave**, creada desde su panel. Si atiendes cuarenta negocios, guardas cuarenta llaves. Estamos construyendo el modelo de **partner**: una sola credencial tuya que actúa sobre los negocios que te hayan dado permiso explícito, con una pantalla de aprobación que ellos firman. Si estás en ese caso, escríbenos — podemos habilitarlo antes de la apertura general. La razón de que no exista todavía no es técnica: conocer el teléfono de un negocio no puede ser autorización para escribir en su libro. El consentimiento tiene que ser del dueño, explícito y revocable, y esa pantalla es lo que estamos terminando. # Empezar (/docs/guias/empezar) ## 1. Necesitas un plan que la incluya [#1-necesitas-un-plan-que-la-incluya] La API pública es una función del plan (`public_api`). Hoy viene en **Crecimiento** y **Multi**. Si tu cuenta no la tiene, cualquier llamada responde `403 PLAN_FEATURE_REQUIRED` diciéndote **qué plan** la incluye: ```json { "error": { "code": "PLAN_FEATURE_REQUIRED", "message": "Esta función no está incluida en tu plan.", "details": { "feature": "public_api", "requiredPlanKey": "GROWTH", "requiredPlanName": "Crecimiento" } } } ``` ## 2. Saca la llave [#2-saca-la-llave] En el panel del negocio: **Integraciones → API pública → Crear llave**. Le pones un alias (te vas a arrepentir si no: con tres llaves sin nombre no vas a saber cuál revocar), marcas los permisos que necesita y confirmas. Cópiala en ese momento y guárdala donde guardas tus otros secretos. Después solo vas a ver `vk_live_a1b2…7f3a`. No es una molestia de diseño: si pudiéramos volver a mostrártela, tendríamos que guardarla de forma reversible, y una filtración de nuestra base sería la llave de tu sistema. Si la pierdes, **rótala** — no hace falta rehacer la integración. ## 3. Tu primer request [#3-tu-primer-request] `GET /v1/me` es el request que conviene hacer **siempre** primero: valida la llave, te dice a qué negocios entra y qué permisos tiene. Si algo va a fallar por configuración, falla aquí y no a mitad de tu flujo. cURL JavaScript Python PHP Go C# Rust ```bash curl https://api.verifika.tech/v1/me \ -H "Authorization: Bearer $VERIFIKA_API_KEY" ``` ```js const res = await fetch('https://api.verifika.tech/v1/me', { headers: { Authorization: `Bearer ${process.env.VERIFIKA_API_KEY}` }, }); if (!res.ok) { const { error } = await res.json(); // `requestId` es lo que soporte necesita para encontrar tu petición exacta. throw new Error(`${error.code}: ${error.message} (requestId ${error.requestId})`); } const me = await res.json(); ``` ```python import os, requests res = requests.get( "https://api.verifika.tech/v1/me", headers={"Authorization": f"Bearer {os.environ['VERIFIKA_API_KEY']}"}, timeout=15, ) res.raise_for_status() me = res.json() ``` ```php $ch = curl_init('https://api.verifika.tech/v1/me'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('VERIFIKA_API_KEY')], ]); $me = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```go req, _ := http.NewRequest("GET", "https://api.verifika.tech/v1/me", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("VERIFIKA_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { return err } defer res.Body.Close() var me map[string]any json.NewDecoder(res.Body).Decode(&me) ``` ```csharp using var http = new HttpClient(); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("VERIFIKA_API_KEY")); var me = await http.GetFromJsonAsync("https://api.verifika.tech/v1/me"); ``` ```rust let key = std::env::var("VERIFIKA_API_KEY")?; let me: serde_json::Value = reqwest::Client::new() .get("https://api.verifika.tech/v1/me") .bearer_auth(key) .send() .await? .json() .await?; ``` ```json title="Respuesta" { "object": "apiKey", "id": "cmtnj6kmc00000bw16bx3gkkm", "name": "ERP del contador", "type": "BUSINESS", "scopes": ["incomes.read", "expenses.read", "plan.read"], "companies": [{ "id": "cmt7xbfrg0001q3w1itst7uwo", "name": "Panadería Delipan" }], "branchId": null, "plan": { "key": "GROWTH", "name": "Crecimiento", "status": "ACTIVE" } } ``` ## 4. Lee el libro [#4-lee-el-libro] cURL JavaScript Python PHP Go C# Rust ```bash curl "https://api.verifika.tech/v1/incomes?status=VERIFIED&limit=10" \ -H "Authorization: Bearer $VERIFIKA_API_KEY" ``` ```js const url = new URL('https://api.verifika.tech/v1/incomes'); url.searchParams.set('status', 'VERIFIED'); url.searchParams.set('limit', '10'); const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.VERIFIKA_API_KEY}` }, }); const { data, hasMore, nextCursor } = await res.json(); ``` ```python res = requests.get( "https://api.verifika.tech/v1/incomes", params={"status": "VERIFIED", "limit": 10}, headers={"Authorization": f"Bearer {os.environ['VERIFIKA_API_KEY']}"}, timeout=15, ) page = res.json() ``` ```php $url = 'https://api.verifika.tech/v1/incomes?' . http_build_query([ 'status' => 'VERIFIED', 'limit' => 10, ]); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('VERIFIKA_API_KEY')], ]); $page = json_decode(curl_exec($ch), true); ``` ```go req, _ := http.NewRequest("GET", "https://api.verifika.tech/v1/incomes?status=VERIFIED&limit=10", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("VERIFIKA_API_KEY")) res, _ := http.DefaultClient.Do(req) defer res.Body.Close() var page struct { Data []map[string]any `json:"data"` HasMore bool `json:"hasMore"` NextCursor *string `json:"nextCursor"` } json.NewDecoder(res.Body).Decode(&page) ``` ```csharp var page = await http.GetFromJsonAsync( "https://api.verifika.tech/v1/incomes?status=VERIFIED&limit=10"); ``` ```rust let page: serde_json::Value = reqwest::Client::new() .get("https://api.verifika.tech/v1/incomes") .query(&[("status", "VERIFIED"), ("limit", "10")]) .bearer_auth(&key) .send() .await? .json() .await?; ``` ```json title="Respuesta (recortada)" { "object": "list", "data": [ { "object": "income", "id": "cmtmf1k8m006faqw1lh27ozcr", "amount": 45000, "currency": "COP", "status": "VERIFIED", "origin": "WHATSAPP", "occurredAt": "2026-09-04T20:04:05.000Z", "verifiedAt": "2026-09-04T20:05:41.902Z", "reference": "M1A2B3C4", "customerName": "Juan Pérez", "bankLabel": "Bancolombia", "accountLabel": "Nequi *4821", "branch": { "id": "cmt7xbfrj0002q3w1iq4ysatr", "name": "Principal" }, "attachments": [], "bankChannels": ["EMAIL"] } ], "hasMore": true, "nextCursor": "cmtkc0rif005ysuw1mbeef235" } ``` ## 5. Escribe algo [#5-escribe-algo] Registrar un gasto, para comprobar que la escritura también funciona. Fíjate en la cabecera `Idempotency-Key`: si la red se corta y reintentas, no se duplica. cURL JavaScript Python PHP Go C# Rust ```bash curl -X POST https://api.verifika.tech/v1/expenses \ -H "Authorization: Bearer $VERIFIKA_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "amount": 120000, "paidTo": "Harina La Espiga", "occurredAt": "2026-09-04T14:10:00.000Z", "categoryId": "cmt7xbfrm0003q3w121ldx6wz" }' ``` ```js await fetch('https://api.verifika.tech/v1/expenses', { method: 'POST', headers: { Authorization: `Bearer ${process.env.VERIFIKA_API_KEY}`, 'Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json', }, body: JSON.stringify({ amount: 120000, paidTo: 'Harina La Espiga', occurredAt: '2026-09-04T14:10:00.000Z', categoryId: 'cmt7xbfrm0003q3w121ldx6wz', }), }); ``` ```python import uuid requests.post( "https://api.verifika.tech/v1/expenses", headers={ "Authorization": f"Bearer {os.environ['VERIFIKA_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "amount": 120000, "paidTo": "Harina La Espiga", "occurredAt": "2026-09-04T14:10:00.000Z", "categoryId": "cmt7xbfrm0003q3w121ldx6wz", }, timeout=15, ) ``` ```php $ch = curl_init('https://api.verifika.tech/v1/expenses'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('VERIFIKA_API_KEY'), 'Idempotency-Key: ' . bin2hex(random_bytes(16)), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'amount' => 120000, 'paidTo' => 'Harina La Espiga', 'occurredAt' => '2026-09-04T14:10:00.000Z', 'categoryId' => 'cmt7xbfrm0003q3w121ldx6wz', ]), ]); $expense = json_decode(curl_exec($ch), true); ``` ```go body, _ := json.Marshal(map[string]any{ "amount": 120000, "paidTo": "Harina La Espiga", "occurredAt": "2026-09-04T14:10:00.000Z", "categoryId": "cmt7xbfrm0003q3w121ldx6wz", }) req, _ := http.NewRequest("POST", "https://api.verifika.tech/v1/expenses", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("VERIFIKA_API_KEY")) req.Header.Set("Idempotency-Key", uuid.NewString()) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) ``` ```csharp var req = new HttpRequestMessage(HttpMethod.Post, "https://api.verifika.tech/v1/expenses") { Content = JsonContent.Create(new { amount = 120000, paidTo = "Harina La Espiga", occurredAt = "2026-09-04T14:10:00.000Z", categoryId = "cmt7xbfrm0003q3w121ldx6wz", }), }; req.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); var res = await http.SendAsync(req); ``` ```rust let res = reqwest::Client::new() .post("https://api.verifika.tech/v1/expenses") .bearer_auth(&key) .header("Idempotency-Key", uuid::Uuid::new_v4().to_string()) .json(&serde_json::json!({ "amount": 120_000, "paidTo": "Harina La Espiga", "occurredAt": "2026-09-04T14:10:00.000Z", "categoryId": "cmt7xbfrm0003q3w121ldx6wz", })) .send() .await?; ``` De `GET /v1/expense-categories`. Cada negocio tiene las suyas —nace con once y puede crear más—, así que el id no es fijo ni compartido entre negocios. ## Si tu llave entra a varios negocios [#si-tu-llave-entra-a-varios-negocios] Con un plan Multi, una llave puede cubrir más de un negocio. En ese caso **tienes que** decir sobre cuál actúas: ```bash curl "https://api.verifika.tech/v1/incomes?companyId=cmt7xbfrg0001q3w1itst7uwo" \ -H "Authorization: Bearer $VERIFIKA_API_KEY" ``` Si no lo mandas, la respuesta es `400` con la lista de negocios disponibles. No elegimos por ti a propósito: escribir en el libro equivocado no se nota hasta que el contador cuadra el mes. ## Siguiente paso [#siguiente-paso] # Errores (/docs/guias/errores) Todos los errores tienen la misma forma: ```json { "error": { "code": "SCOPE_REQUIRED", "message": "Esta API key no tiene permiso para esta operación.", "details": { "scope": "expenses.read" }, "requestId": "req_b0b926d7fbe34e53be33d869981cc9b9", "docUrl": "https://docs.verifika.tech/guias/errores" } } ``` **Ramifica por `code`, nunca por `message`.** Los códigos son parte del contrato; los mensajes están en español para humanos y pueden cambiar de redacción. ## Catálogo [#catálogo] | HTTP | `code` | Qué pasó | ¿Reintentar? | | ---- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | | 400 | `BAD_REQUEST` | Cuerpo o parámetros inválidos. `details` trae el detalle. También sale si tu llave entra a varios negocios y no mandaste `companyId`. | No, hasta corregir | | 401 | `UNAUTHORIZED` | Falta la llave, o es inválida, revocada, vencida, o viene de una IP no permitida. | No | | 403 | `SCOPE_REQUIRED` | La llave no tiene ese permiso. `details.scope` dice cuál. | No | | 403 | `PLAN_FEATURE_REQUIRED` | El plan no incluye la función. `details.requiredPlanName` dice cuál la trae. | No | | 403 | `FORBIDDEN` | El `companyId` no es de esta llave, o la llave no sirve para esta API. | No | | 404 | `NOT_FOUND` | El recurso no existe **o no es tuyo**. | No | | 409 | `IDEMPOTENCY_IN_PROGRESS` | Esa `Idempotency-Key` se está procesando. | Sí, en unos segundos | | 409 | `IDEMPOTENCY_KEY_REUSED` | Misma llave con cuerpo distinto. | No, usa una llave nueva | | 429 | `RATE_LIMITED` | Te pasaste del límite. | Sí, respetando `Retry-After` | | 500 | `INTERNAL_ERROR` | Nos falló algo. | Sí, con espera creciente | Pedir un ingreso de otro negocio devuelve `404`, no `403`. Si respondiéramos "no tienes permiso", estaríamos confirmando que ese id existe — y eso convierte el endpoint en una forma de descubrir datos ajenos probando ids. ## Qué reintentar [#qué-reintentar] Regla corta: **`429` y `5xx` se reintentan con espera creciente; el resto, no.** Un `403` no se arregla insistiendo, y un `400` tampoco. ```js const REINTENTABLE = (status) => status === 429 || status >= 500; ``` ## El `requestId` [#el-requestid] Va en el cuerpo de todo error y en la cabecera `X-Request-Id` de **toda** respuesta, incluidas las exitosas. **Guárdalo en tus logs.** Con ese id, soporte encuentra tu petición exacta; sin él, un reporte de "a veces me da error" no se puede investigar. Si tu sistema ya genera ids de traza, mándalo tú en `X-Request-Id` y lo respetamos, así tu traza no se parte al entrar a Verifika. # Idempotencia (/docs/guias/idempotencia) La cabecera ya está implementada y aceptada en `/v1`. Los endpoints de escritura (`POST /v1/incomes`, `POST /v1/expenses`, …) se publican en la siguiente fase; esta página existe para que la construyas bien desde el primer día. ## El problema [#el-problema] Tu bot manda un pago. La red se corta **después** de que lo procesamos pero antes de que te llegue la respuesta. Tu bot reintenta. Sin protección, el negocio termina con la misma venta dos veces y culpando a Verifika. ## La solución [#la-solución] Manda una cabecera `Idempotency-Key` única por operación en cada `POST`: ```bash curl -X POST https://api.verifika.tech/v1/incomes \ -H "Authorization: Bearer vk_live_..." \ -H "Idempotency-Key: 8f14e45f-ea28-4b4c-9f2a-0b1d3c5e7a90" \ -H "Content-Type: application/json" \ -d '{ "amount": 45000, "reference": "M1A2B3C4" }' ``` Si repites la petición con la **misma llave y el mismo cuerpo**, no ejecutamos nada: te devolvemos la respuesta original, con la cabecera `Idempotent-Replay: true`. | Situación | Qué pasa | | ------------------------------------------------ | ---------------------------------------------------------- | | Llave nueva | Se ejecuta y se guarda la respuesta | | Llave repetida, mismo cuerpo, ya respondida | Respuesta **original** + `Idempotent-Replay: true` | | Llave repetida, mismo cuerpo, todavía ejecutando | `409 IDEMPOTENCY_IN_PROGRESS` — reintenta en unos segundos | | Llave repetida, **cuerpo distinto** | `409 IDEMPOTENCY_KEY_REUSED` | ## Cómo generar la llave [#cómo-generar-la-llave] Un UUID v4 por **operación de negocio**, no por intento HTTP. Es decir: se genera una vez, antes del primer envío, y se reusa en todos los reintentos de esa misma operación. ```js const idempotencyKey = crypto.randomUUID(); // una vez, fuera del bucle de reintentos for (let intento = 1; intento <= 3; intento++) { const res = await fetch('https://api.verifika.tech/v1/incomes', { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Idempotency-Key': idempotencyKey, 'Content-Type': 'application/json', }, body: JSON.stringify(pago), }); if (res.ok) return res.json(); if (res.status < 500 && res.status !== 429) throw new Error(await res.text()); await new Promise((r) => setTimeout(r, 2 ** intento * 500)); } ``` Solo cacheamos respuestas exitosas. Si te devolvimos un `400`, puedes corregir el cuerpo y reintentar **con la misma llave**: la reserva se libera. Si guardáramos los errores, un problema pasajero se volvería permanente. Las llaves se recuerdan **24 horas**. Después de eso, la misma llave se trata como nueva. # Paginación (/docs/guias/paginacion) Las listas se paginan **por cursor**, no por número de página: ```bash curl "https://api.verifika.tech/v1/incomes?limit=50" \ -H "Authorization: Bearer vk_live_..." ``` ```json { "object": "list", "data": [ /* … 50 ingresos … */ ], "hasMore": true, "nextCursor": "cmtkc0rif005ysuw1mbeef235" } ``` Para la siguiente página, pasas ese cursor: ```bash curl "https://api.verifika.tech/v1/incomes?limit=50&startingAfter=cmtkc0rif005ysuw1mbeef235" \ -H "Authorization: Bearer vk_live_..." ``` Cuando `hasMore` es `false`, terminaste. | Parámetro | Default | Máximo | | --------------- | ------- | ------------------------------------------ | | `limit` | 50 | 100 | | `startingAfter` | — | id de la última fila de la página anterior | ## Por qué no hay `page=2` [#por-qué-no-hay-page2] Porque el libro de un negocio **crece mientras lo recorres**. Con páginas numeradas, cada venta nueva empuja las filas una posición: la página 2 te devuelve algo que ya viste en la 1, o se salta una fila que nunca vas a ver. Un proceso que sincroniza de noche termina con movimientos duplicados y sin forma de darse cuenta. El cursor apunta a una fila concreta. Lo que ya pasó, ya pasó. ## Recorrer todo [#recorrer-todo] ```js async function* todosLosIngresos(params = {}) { let cursor = null; do { const url = new URL('https://api.verifika.tech/v1/incomes'); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('startingAfter', cursor); const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.VERIFIKA_API_KEY}` }, }); if (!res.ok) throw new Error(`Verifika ${res.status}`); const page = await res.json(); yield* page.data; cursor = page.hasMore ? page.nextCursor : null; } while (cursor); } for await (const ingreso of todosLosIngresos({ from: '2026-09-01' })) { console.log(ingreso.id, ingreso.amount); } ``` Para mantener una copia al día, no recorras el histórico completo cada vez: guarda el `occurredAt` del último movimiento que procesaste y arranca con `?from=`. Deja un solape de unas horas — un aviso del banco puede llegar tarde y verificar un pago de ayer. ## Filtros [#filtros] Los mismos que ves en el panel, para que un reporte y tu integración nunca digan cosas distintas del mismo mes: | Parámetro | Ejemplo | Nota | | ------------------------- | ------------- | ------------------------------------------------- | | `from` / `to` | `2026-09-01` | Una fecha pelada es un día **colombiano**, no UTC | | `status` | `VERIFIED` | Repetible: `?status=VERIFIED&status=PENDING` | | `origin` | `WHATSAPP` | `WHATSAPP`, `BANK`, `API`, `MANUAL` | | `branchId` | `cmt7…` | Repetible | | `minAmount` / `maxAmount` | `50000` | Enteros en pesos | | `accountLabel` | `Nequi *4821` | Repetible | | `q` | `Juan` | Busca en nombre del cliente y referencia | | `sort` | `recent` | `recent`, `oldest`, `amount_desc`, `amount_asc` | # Límites de uso (/docs/guias/rate-limits) El límite se cuenta **por llave**, no por IP: si tu servidor comparte salida con otros —cualquier nube lo hace— nadie te gasta tu cuota. | Plan | Peticiones por minuto | | ----------- | --------------------- | | Crecimiento | 120 | | Multi | 300 | Toda respuesta trae el estado actual: ```http RateLimit-Limit: 120 RateLimit-Remaining: 117 RateLimit-Reset: 43 ``` ## Cuando te pasas [#cuando-te-pasas] ```http HTTP/1.1 429 Too Many Requests Retry-After: 43 ``` ```json { "error": { "code": "RATE_LIMITED", "message": "Demasiadas peticiones. Espera unos segundos y reintenta.", "requestId": "req_914fc7cb6c4d4192b6616aace5c10c62" } } ``` **Respeta `Retry-After`.** Reintentar de inmediato solo consume tu propia cuota. ```js async function conReintentos(fn, maxIntentos = 4) { for (let i = 1; i <= maxIntentos; i++) { const res = await fn(); if (res.status !== 429) return res; const espera = Number(res.headers.get('Retry-After') ?? 2 ** i); // Un poco de aleatoriedad para que N procesos no reintenten todos a la vez. await new Promise((r) => setTimeout(r, (espera + Math.random()) * 1000)); } throw new Error('Verifika: límite de peticiones excedido tras varios reintentos'); } ``` ## Cómo no llegar al límite [#cómo-no-llegar-al-límite] * **Pide páginas de 100**, no de 10. Diez veces menos peticiones por el mismo dato. * **Filtra por fecha** en lugar de traer todo y descartar en tu lado. * **No hagas polling cada segundo.** Para enterarte de un pago en el momento, lo que corresponde son los [webhooks](/docs/guias/webhooks) — están en camino. * **Cachea lo que casi no cambia**, como `/v1/plan` o la lista de sucursales. # Webhooks (/docs/guias/webhooks) En vez de preguntar cada minuto si entró un pago, nos das una URL y te avisamos cuando pasa. ## 1. Crea el endpoint [#1-crea-el-endpoint] Desde el panel (**Integraciones → API pública y webhooks → Webhooks**) o por API: cURL JavaScript Python PHP Go C# Rust ```bash curl -X POST https://api.verifika.tech/v1/webhook-endpoints \ -H "Authorization: Bearer $VERIFIKA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://tusistema.com/verifika", "description": "Producción", "events": ["income.verified", "suspicious.created"] }' ``` ```js const res = await fetch('https://api.verifika.tech/v1/webhook-endpoints', { method: 'POST', headers: { Authorization: `Bearer ${process.env.VERIFIKA_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://tusistema.com/verifika', description: 'Producción', events: ['income.verified', 'suspicious.created'], }), }); const { id, secret } = await res.json(); // `secret` solo viene aquí y al rotarlo: guárdalo donde guardas tus secretos. ``` ```python res = requests.post( "https://api.verifika.tech/v1/webhook-endpoints", headers={"Authorization": f"Bearer {os.environ['VERIFIKA_API_KEY']}"}, json={ "url": "https://tusistema.com/verifika", "description": "Producción", "events": ["income.verified", "suspicious.created"], }, timeout=15, ) endpoint = res.json() ``` ```php $ch = curl_init('https://api.verifika.tech/v1/webhook-endpoints'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('VERIFIKA_API_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'url' => 'https://tusistema.com/verifika', 'description' => 'Producción', 'events' => ['income.verified', 'suspicious.created'], ]), ]); $endpoint = json_decode(curl_exec($ch), true); ``` ```go body, _ := json.Marshal(map[string]any{ "url": "https://tusistema.com/verifika", "description": "Producción", "events": []string{"income.verified", "suspicious.created"}, }) req, _ := http.NewRequest("POST", "https://api.verifika.tech/v1/webhook-endpoints", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("VERIFIKA_API_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) ``` ```csharp var res = await http.PostAsJsonAsync("https://api.verifika.tech/v1/webhook-endpoints", new { url = "https://tusistema.com/verifika", description = "Producción", events = new[] { "income.verified", "suspicious.created" }, }); ``` ```rust let endpoint: serde_json::Value = reqwest::Client::new() .post("https://api.verifika.tech/v1/webhook-endpoints") .bearer_auth(&key) .json(&serde_json::json!({ "url": "https://tusistema.com/verifika", "description": "Producción", "events": ["income.verified", "suspicious.created"], })) .send() .await? .json() .await?; ``` ```json title="Respuesta" { "object": "webhookEndpoint", "id": "cmtnoypsk00076kw1lggjtcjx", "url": "https://tusistema.com/verifika", "events": ["income.verified", "suspicious.created"], "status": "ENABLED", "secret": "whsec_f0CLZeUj..." } ``` ### Qué manda cada campo [#qué-manda-cada-campo] | Campo | Tipo | ¿Obligatorio? | Qué significa | | ------------- | ------------ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `url` | `string` URL | **Sí** | A dónde mandamos los eventos. **Tiene que ser `https`** y resolver a una dirección pública: por http los datos de dinero de un negocio viajarían en claro. | | `events` | `string[]` | **Sí** | Qué quieres recibir. Claves del catálogo (`GET /v1/webhook-events`). Al menos una — un webhook sin eventos no recibiría nada, y esa es una trampa silenciosa. | | `description` | `string` | No | Para ti: «producción», «staging», «el ERP del contador». Sale en la lista del panel para que sepas cuál es cuál. | Con él verificas que cada entrega vino de nosotros. A diferencia de una API key, este **sí** se puede volver a consultar desde el panel: lo necesitamos nosotros para firmar, así que ya está guardado de forma reversible y ocultarlo no protegería nada. La URL tiene que ser **https** y resolver a una dirección pública. Se valida al guardarla y **otra vez en cada entrega** — un dominio que hoy apunta a un servidor público puede apuntar mañana a una red interna, y no vamos a ser nosotros quienes toquen esa puerta. ## 2. Los eventos [#2-los-eventos] Consulta el catálogo con `GET /v1/webhook-events`: devuelve todos, con `allowed` según tu plan. | Evento | Cuándo | | --------------------------------------------------------- | -------------------------------------------------------- | | `income.created` | Se registró un pago, venga de donde venga | | `income.verified` | **El banco confirmó el pago.** El que casi todos quieren | | `income.status_changed` | Cambió de estado. Trae `previousStatus` | | `income.updated` · `income.deleted` | Cambió o se borró un pago declarado | | `expense.created` · `expense.updated` · `expense.deleted` | Movimientos de egreso | | `suspicious.created` | Comprobante repetido, o que el banco nunca respaldó | | `suspicious.resolved` | Alguien lo resolvió desde el panel | | `connection.status_changed` | Se cayó (o volvió) un canal del banco | | `team.member_added` · `team.member_removed` | Cambios de equipo | | `plan.limit_reached` | Se agotó un límite del plan | | `webhook.test` | Lo dispara el botón «Probar» | Cuando un pago pasa a verificado emitimos **ambos**. Suscribe solo `income.verified` si eso es lo que te importa: te ahorras filtrar dentro de tu handler y no te llega ruido por cada duplicado y cada no-encontrado del negocio. ## 3. La entrega [#3-la-entrega] ```http POST https://tusistema.com/verifika Content-Type: application/json Verifika-Event-Id: evt_cms9qvef700013jw1qawn586g Verifika-Event-Type: income.verified Verifika-Delivery-Id: dlv_cms9qvef700013jw1qawn586h Verifika-Attempt: 1 Verifika-Signature: t=1789012345,v1=5257a869e7... ``` ```json { "id": "evt_cms9qvef700013jw1qawn586g", "type": "income.verified", "created": "2026-09-04T15:04:05.000Z", "apiVersion": "v1", "companyId": "cmt7xbfrg0001q3w1itst7uwo", "data": { "object": { "object": "income", "id": "cmtmf1k8m006faqw1lh27ozcr", "amount": 45000, "currency": "COP", "status": "VERIFIED", "previousStatus": "PENDING", "reference": "M1A2B3C4", "verifiedAt": "2026-09-04T15:04:05.000Z" } } } ``` `data.object` es **exactamente** el mismo objeto que devuelve el `GET` de ese recurso. No hay dos formas del mismo dato que aprender. ## 4. Verifica la firma [#4-verifica-la-firma] HMAC-SHA256 sobre `${timestamp}.${cuerpo}` con tu secreto. Calcula el HMAC sobre los bytes tal como llegaron, **antes** de parsear el JSON. Si lo haces sobre el objeto re-serializado, un espacio de diferencia rompe la firma. En Express: `express.raw({ type: 'application/json' })` en esa ruta. Node.js Python PHP ```js import crypto from 'node:crypto'; export function verificarFirma(cuerpoCrudo, cabecera, secreto, toleranciaSeg = 300) { const partes = cabecera.split(',').map((p) => p.trim()); const t = partes.find((p) => p.startsWith('t='))?.slice(2); if (!t) return false; // Rechaza eventos viejos: sin esto, quien capture una entrega puede // reenviártela mañana y tu sistema la aceptaría como buena. const edad = Math.abs(Date.now() / 1000 - Number(t)); if (!Number.isFinite(edad) || edad > toleranciaSeg) return false; const esperada = crypto.createHmac('sha256', secreto).update(`${t}.${cuerpoCrudo}`).digest('hex'); const buf = Buffer.from(esperada, 'hex'); // Puede venir más de una v1 durante una rotación: basta con que una cuadre. return partes .filter((p) => p.startsWith('v1=')) .some((p) => { const got = Buffer.from(p.slice(3), 'hex'); // Comparación en tiempo constante: un `===` filtra información por lo que tarda. return got.length === buf.length && crypto.timingSafeEqual(got, buf); }); } ``` ```python import hmac, hashlib, time def verificar_firma(cuerpo_crudo: bytes, cabecera: str, secreto: str, tolerancia: int = 300) -> bool: items = [p.strip().split("=", 1) for p in cabecera.split(",") if "=" in p] t = next((v for k, v in items if k == "t"), None) # Rechaza eventos viejos: sin esto, quien capture una entrega puede # reenviártela mañana y tu sistema la aceptaría como buena. if t is None or abs(time.time() - int(t)) > tolerancia: return False esperada = hmac.new( secreto.encode(), f"{t}.".encode() + cuerpo_crudo, hashlib.sha256 ).hexdigest() # Puede venir más de una v1 durante una rotación: basta con que una cuadre. return any(hmac.compare_digest(v, esperada) for k, v in items if k == "v1") ``` ```php function verificarFirma(string $cuerpoCrudo, string $cabecera, string $secreto, int $tolerancia = 300): bool { $items = []; foreach (explode(',', $cabecera) as $parte) { [$k, $v] = array_pad(explode('=', trim($parte), 2), 2, null); $items[] = [$k, $v]; } $t = null; foreach ($items as [$k, $v]) { if ($k === 't') $t = $v; } if ($t === null || abs(time() - (int) $t) > $tolerancia) return false; $esperada = hash_hmac('sha256', $t . '.' . $cuerpoCrudo, $secreto); foreach ($items as [$k, $v]) { if ($k === 'v1' && hash_equals($esperada, (string) $v)) return true; } return false; } ``` ## 5. Reintentos [#5-reintentos] Éxito es cualquier `2xx` en menos de **5 segundos**. Si no, reintentamos con espera creciente: ``` 1 min → 5 min → 30 min → 2 h → 6 h → 12 h → 24 h ``` Ocho intentos en unas 48 horas. Después la entrega queda `FAILED` y puedes reenviarla desde el panel o con `POST /v1/webhook-deliveries/{id}/retry`. Si tu endpoint acumula **20 fallos seguidos**, lo desactivamos (`status: DISABLED_AUTO`) y se lo avisamos al dueño de la cuenta. Se reactiva con `PATCH { "enabled": true }`. Encola el trabajo y devuelve `200` de inmediato. Si procesas dentro del handler y tardas 6 segundos, para nosotros es un fallo y vas a recibir el mismo evento otra vez. ## 6. Dos reglas que evitan el 90 % de los bugs [#6-dos-reglas-que-evitan-el-90--de-los-bugs] Puedes recibir `income.verified` antes que `income.created`. Ordena por el campo `created`, nunca por el orden de llegada. Un reintento nuestro tras un timeout de tu lado entrega algo que ya procesaste. Guarda los `id` de evento que ya viste y descarta los repetidos. ## 7. Rotar el secreto [#7-rotar-el-secreto] ```bash curl -X POST https://api.verifika.tech/v1/webhook-endpoints/{id}/rotate-secret \ -H "Authorization: Bearer vk_live_..." ``` Durante **24 horas** firmamos cada entrega con los dos secretos (verás dos `v1=` en la cabecera), así despliegas el nuevo cuando quieras sin perder un solo evento. ## 8. Depurar [#8-depurar] `GET /v1/webhook-deliveries` te dice qué mandamos, qué respondió tu servidor y en qué intento: ```json { "object": "webhookDelivery", "status": "PENDING", "attempt": 1, "responseStatus": 405, "responseSnippet": "...", "durationMs": 563, "error": "El endpoint respondió 405.", "nextRetryAt": "2026-09-05T01:17:58.472Z" } ``` Ese `responseSnippet` es tu propia respuesta: casi siempre dice exactamente qué salió mal sin que tengas que ir a mirar tus logs. # Conectar con un clic (/docs/mcp/autorizacion) Hay **dos formas** de conectar un asistente a Verifika, y hacen exactamente lo mismo: | | Con un clic | Con una llave | | ------------------------- | -------------------------------- | -------------------------------------------------- | | Qué pegas | Nada, solo la URL | La URL y un secreto `vk_live_…` | | Quién decide los permisos | Tú, en una pantalla de Verifika | Tú, al crear la llave en el panel | | Dónde se quita | Integraciones → **Aplicaciones** | Integraciones → **Llaves** | | Para qué sirve mejor | Tu asistente personal | Un servidor, un bot, un proceso sin persona detrás | Si tu cliente sabe hablar OAuth —Claude lo sabe— usa el de un clic. Es más seguro por una razón simple: **nunca existe un secreto que puedas pegar en el sitio equivocado**. ## Cómo se ve [#cómo-se-ve] <Steps> <Step> **Añades el servidor** en tu asistente con la URL `https://api.verifika.tech/mcp` y sin ninguna cabecera. En Claude: *Ajustes → Conectores → Añadir conector personalizado*. </Step> <Step> **El asistente descubre solo** que el servidor pide autorización y abre tu navegador. Si no tenías sesión en Verifika, primero entras como siempre. </Step> <Step> **Verifika te pregunta.** Ves quién pide, a qué dominio vuelve, en qué negocios y qué podrá hacer. Los permisos de lectura vienen marcados; los de escritura, no. </Step> <Step> **Aceptas y ya.** El asistente recibe un permiso acotado a lo que marcaste. No hay ningún secreto que guardar. </Step> </Steps> ## Qué te va a preguntar la pantalla [#qué-te-va-a-preguntar-la-pantalla] **En qué negocios.** Solo salen aquellos donde tú administras integraciones. Si administras varios, se marcan a mano: una aplicación autorizada sobre un negocio **no ve** los demás, aunque adivine su identificador. **Qué podrá hacer.** Los mismos permisos que una llave, con los mismos nombres. Lo que tu plan no incluye sale en gris con candado. Lo que escribe en el libro va marcado. <Callout type="warn" title="Mira el dominio, no el nombre"> Cualquiera puede registrar una aplicación y ponerle el nombre que quiera —«Verifika Oficial» incluido—. Lo que **no** puede elegir libremente es el dominio al que vuelve, porque tiene que coincidir con el que registró. La pantalla te lo enseña siempre. Si no lo reconoces, no continúes. </Callout> ## Cuánto dura [#cuánto-dura] | | Cuánto | Qué pasa al vencer | | ------------------------- | ------------------ | --------------------------------------------------- | | El permiso de acceso | 1 hora | Se renueva solo, sin preguntarte nada | | Los permisos de escritura | 15 minutos | Igual, pero la ventana es más corta a propósito | | La renovación | 30 días sin usarse | La conexión muere sola y hay que autorizar de nuevo | | La autorización | No vence | Vive hasta que la quites | En la práctica: **un asistente que usas a diario no vuelve a pedirte permiso nunca**, y uno que abandonaste un mes se apaga solo sin que tengas que acordarte de él. ## Cómo se quita [#cómo-se-quita] **Integraciones → API pública y webhooks → Aplicaciones → Quitar acceso.** Corta **en la siguiente petición**, no cuando venza el permiso de acceso. Es la diferencia entre cerrar la puerta y esperar una hora a que se cierre sola: si quitas el acceso porque algo va mal, quieres que deje de entrar ya. También caduca solo si la cuenta pierde el plan que incluye la API pública, o si quien la autorizó pierde el permiso de administrar integraciones. ## Para quien construye el cliente [#para-quien-construye-el-cliente] El servidor implementa el perfil de autorización de MCP sobre OAuth 2.1. Lo que necesitas: | Pieza | Dónde | | --------------------------------- | --------------------------------------------------------------------------- | | Recurso protegido | `https://api.verifika.tech/mcp` | | Metadatos del recurso (RFC 9728) | `https://api.verifika.tech/.well-known/oauth-protected-resource/mcp` | | Servidor de autorización | `https://api.verifika.tech/api/auth` | | Metadatos del servidor (RFC 8414) | `https://api.verifika.tech/.well-known/oauth-authorization-server/api/auth` | No hace falta que los memorices: un `POST` a `/mcp` sin token responde `401` con la cabecera `WWW-Authenticate` que apunta al primero, y desde ahí se descubre todo lo demás. ```http HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://api.verifika.tech/.well-known/oauth-protected-resource/mcp", scope="incomes.read companies.read metrics.read …" ``` Lo que el servidor espera: * **PKCE con `S256`.** Obligatorio, también para clientes confidenciales. * **`resource=https://api.verifika.tech/mcp`** (RFC 8707) en la petición de autorización y en la de token. El token queda atado a ese recurso: fuera de él no vale. * **Registro del cliente**: Client ID Metadata Documents (lo recomendado hoy) o registro dinámico en `POST /api/auth/oauth2/register`, que sigue abierto porque varios clientes todavía lo usan. * **`offline_access`** si quieres poder renovar sin volver a pedirle permiso a la persona. Las respuestas de autorización llevan `iss` (RFC 9207): compáralo con el emisor que descubriste antes de mandar el código al endpoint de token. <Callout title="El scope que pides no es el que recibes"> La persona puede conceder **menos** de lo que pediste — es el sentido de la pantalla. El `scope` de la respuesta del token dice qué se concedió de verdad, y `tools/list` te devolverá solo las herramientas que caben ahí. No asumas que tienes lo que pediste: mira lo que te dieron. </Callout> ## Preguntas que salen siempre [#preguntas-que-salen-siempre] **¿Necesito una llave además de esto?** No. Son dos caminos al mismo sitio. Usa llave cuando no haya una persona que pueda autorizar —un servidor, un cron, un bot—. **¿La aplicación ve mi contraseña?** No, y no puede: entras en Verifika, no en ella. **¿Puedo autorizar la misma aplicación otra vez con más permisos?** Sí. Vuelve a conectarla y marca lo que falte; la pantalla llega con lo que ya habías concedido para que veas qué cambias. La autorización se reemplaza, no se acumula. **¿Y si alguien de mi equipo autoriza algo que no me gusta?** Aparece en Integraciones → Aplicaciones con su nombre, la fecha y el último uso, y cualquiera que administre integraciones puede quitarlo. # Cómo se conecta (/docs/mcp/conectar) ## Antes de empezar [#antes-de-empezar] <Callout title="Si tu cliente habla OAuth, no necesitas nada de esto"> En Claude (y en cualquier cliente que siga el estándar de autorización de MCP) basta con añadir `https://api.verifika.tech/mcp` **sin cabeceras**: te lleva a una pantalla de Verifika, autorizas y listo. Ninguna llave que copiar. Ver [Conectar con un clic](/docs/mcp/autorizacion). Esta página es para el otro camino: **con llave**, que es el que necesitas cuando no hay una persona que pueda autorizar —un servidor, un cron, un bot— o cuando tu cliente todavía no sabe hacer OAuth. </Callout> Con llave necesitas **una llave de API** con los permisos que quieras darle al asistente. Sácala en el panel: **Integraciones → API pública → Crear llave**. <Callout title="Empieza con una llave de solo lectura"> Marca solo los permisos `.read` la primera vez. Vas a querer ver qué hace el asistente antes de dejarlo escribir en el libro — y ampliar los permisos después **no** te obliga a cambiar el secreto ni a reconfigurar nada. </Callout> ## Claude Desktop [#claude-desktop] Edita el archivo de configuración: * **macOS** — `~/Library/Application Support/Claude/claude_desktop_config.json` * **Windows** — `%APPDATA%\Claude\claude_desktop_config.json` ```json title="claude_desktop_config.json" { "mcpServers": { "verifika": { "type": "http", "url": "https://api.verifika.tech/mcp", "headers": { "Authorization": "Bearer vk_live_TU_LLAVE" } } } } ``` Reinicia Claude Desktop. Deberías ver «verifika» en la lista de herramientas conectadas. ## Claude Code [#claude-code] Desde la terminal, en cualquier carpeta: ```bash claude mcp add --transport http verifika https://api.verifika.tech/mcp \ --header "Authorization: Bearer vk_live_TU_LLAVE" ``` Compruébalo con `claude mcp list`. ## Cursor [#cursor] `~/.cursor/mcp.json` (global) o `.cursor/mcp.json` en el proyecto: ```json title=".cursor/mcp.json" { "mcpServers": { "verifika": { "url": "https://api.verifika.tech/mcp", "headers": { "Authorization": "Bearer vk_live_TU_LLAVE" } } } } ``` ## VS Code [#vs-code] `.vscode/mcp.json` en el proyecto. Con `inputs` la llave se pide al arrancar y **no queda escrita en un archivo que puedas subir a git por accidente**: ```json title=".vscode/mcp.json" { "inputs": [ { "id": "verifika-key", "type": "promptString", "description": "API key de Verifika", "password": true } ], "servers": { "verifika": { "type": "http", "url": "https://api.verifika.tech/mcp", "headers": { "Authorization": "Bearer ${input:verifika-key}" } } } } ``` ## ChatGPT [#chatgpt] En **Configuración → Conectores → Agregar conector personalizado**, pega la URL del servidor y la cabecera de autorización. Requiere un plan que habilite conectores personalizados. ## Un cliente propio [#un-cliente-propio] Cualquier SDK de MCP sirve. Con el de TypeScript: ```ts import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; const transport = new StreamableHTTPClientTransport( new URL('https://api.verifika.tech/mcp'), { requestInit: { headers: { Authorization: `Bearer ${process.env.VERIFIKA_API_KEY}` }, }, }, ); const client = new Client({ name: 'mi-app', version: '1.0.0' }); await client.connect(transport); const { tools } = await client.listTools(); console.log(tools.map((t) => t.name)); const res = await client.callTool({ name: 'list_incomes', arguments: { status: 'VERIFIED', limit: 10 }, }); ``` ## Si tu llave entra a varios negocios [#si-tu-llave-entra-a-varios-negocios] Con un plan Multi, pasa el negocio en la cabecera para no tener que repetirlo en cada pregunta: ```json "headers": { "Authorization": "Bearer vk_live_TU_LLAVE", "Verifika-Company-Id": "cmt7xbfrg0001q3w1itst7uwo" } ``` Sin ella, las herramientas piden `companyId` como argumento y el asistente tendrá que preguntártelo. ## Comprobar que quedó [#comprobar-que-quedó] Pregúntale al asistente: > «¿Qué permisos tiene mi llave de Verifika?» Debería llamar a `whoami` y responderte con el alias de la llave, sus permisos, el negocio y el plan. Si eso funciona, todo lo demás también. ## Si algo falla [#si-algo-falla] | Síntoma | Causa casi siempre | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------ | | El asistente no ve las herramientas | La configuración no se recargó. Reinicia el cliente. | | `401` | La llave está mal copiada, revocada o vencida. Míralo en el panel. | | `403 PLAN_FEATURE_REQUIRED` | Tu plan no incluye la API pública. | | `403 SCOPE_REQUIRED` | La llave no tiene ese permiso. `details.scope` dice cuál falta: edítala en el panel, sin cambiar el secreto. | | `400` pidiendo `companyId` | Tu llave entra a varios negocios y no mandaste la cabecera de arriba. | # Herramientas (/docs/mcp/herramientas) Hay **una herramienta por cada operación de la API**, con el mismo nombre en `snake_case` y exactamente el mismo permiso. No hay herramientas «de alto nivel» que junten varias consultas: si existiera un `resumen_del_mes` con su propia forma de sumar, tarde o temprano el asistente diría 4,2 millones y el panel diría 4,1, y nadie sabría cuál tiene razón. <Callout title="Lo que tu llave no puede hacer, no aparece"> El catálogo se arma con los permisos de **tu** llave. Con una de solo lectura, el asistente ni siquiera sabe que `create_expense` existe: no es que se le niegue al intentarlo, es que no está en la lista. Un modelo que ve una herramienta asume que puede usarla, y media conversación se va en intentarlo, fallar y explicar un permiso que nunca tuvo. </Callout> ## Identidad [#identidad] | Herramienta | Permiso | Qué hace | | ----------- | ----------- | --------------------------------------------------------------------------------------------- | | `whoami` | — | Alias de la llave, permisos, negocios a los que entra y plan. La primera que conviene llamar. | | `get_plan` | `plan.read` | Plan, funciones incluidas, límites y **cuánto cupo de verificaciones queda este mes**. | ## Verificación [#verificación] | Herramienta | Permiso | Qué hace | | ---------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `verify_receipt` | `verifications.write` | Registra un comprobante y devuelve el veredicto: `REGISTERED`, `ALREADY_VERIFIED` o `DUPLICATE`. **Consume cupo.** | ## Ingresos [#ingresos] | Herramienta | Permiso | Qué hace | | -------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------- | | `list_incomes` | `incomes.read` | Pagos por fecha, estado, origen, sucursal, monto, cuenta o texto libre. Responde «¿cuánto entró ayer?». | | `get_income` | `incomes.read` | El detalle de un pago, con sus comprobantes y por qué canal lo respaldó el banco. | | `get_income_receipt` | `incomes.read` | **Muestra el comprobante como imagen**, dentro del propio chat. Si el pago tiene varios, enumera el resto. | | `declare_income` | `incomes.write` | Anota un pago en el libro. Nace `DECLARED` y **no consume cupo**. | | `update_income` | `incomes.write` | Corrige un pago declarado a mano. Los demás son inmutables. | | `delete_income` | `incomes.delete` | Borra un pago declarado a mano. | <Callout title="El comprobante viaja como imagen, no como enlace"> `get_income_receipt` devuelve la foto ya reducida y el asistente la pinta en la conversación. No se firma ninguna URL de descarga: una dirección así serviría a cualquiera que la tuviera, desde cualquier máquina, hasta que venciera. Aquí el permiso se comprueba en cada llamada y, si quitas la conexión desde el panel, deja de funcionar en la siguiente. Dos consecuencias prácticas: un comprobante en **PDF no se puede pintar** (te responde con los datos del archivo y dónde abrirlo), y la imagen **queda dentro de la conversación** — trátala como lo que es, el comprobante en el chat. </Callout> ## Egresos [#egresos] | Herramienta | Permiso | Qué hace | | ------------------------- | ------------------ | -------------------------------------------------------------------------------------------- | | `list_expenses` | `expenses.read` | Gastos por fecha, categoría, sucursal, monto, forma de pago o beneficiario. | | `get_expense` | `expenses.read` | El detalle de un gasto. | | `create_expense` | `expenses.write` | Registra un gasto. Pide `categoryId`, así que suele ir después de `list_expense_categories`. | | `update_expense` | `expenses.write` | Corrige un gasto. | | `delete_expense` | `expenses.delete` | Borra un gasto. | | `list_expense_categories` | `categories.read` | Las categorías del negocio, con su emoji y si están activas. | | `create_expense_category` | `categories.write` | Crea una categoría propia. Nombre y emoji obligatorios; descripción opcional. | | `update_expense_category` | `categories.write` | Renombra, cambia el emoji o **desactiva** una categoría. | | `delete_expense_category` | `categories.write` | Borra una categoría. Falla si ya tiene gastos: en ese caso se desactiva. | ## Negocio [#negocio] | Herramienta | Permiso | Qué hace | | -------------------------- | ----------------------- | ---------------------------------------------------------------------------- | | `get_company` | `companies.read` | Nombre, categoría (con etiqueta legible), ciudad y cuántas sucursales tiene. | | `list_branches` | `branches.read` | Las sucursales, con cuál es la principal. | | `list_business_categories` | `companies.read` | El catálogo de «a qué se dedica un negocio». | | `list_payment_accounts` | `payment_accounts.read` | Cuentas de recaudo, por alias y últimos cuatro. Nunca el número completo. | ## Equipo [#equipo] | Herramienta | Permiso | Qué hace | | ------------------- | ----------- | ------------------------------------------------------------------------------------------------- | | `list_team_members` | `team.read` | Quién trabaja aquí, con qué rol y en qué sucursal, más el cupo de usuarios del plan. Sin correos. | | `list_roles` | `team.read` | Los roles que se pueden asignar. | ## Pagos sospechosos [#pagos-sospechosos] | Herramienta | Permiso | Qué hace | | -------------------------- | ----------------- | ---------------------------------------------------------------------------------------------- | | `list_suspicious_attempts` | `suspicious.read` | Comprobantes repetidos (`DUPLICATE`) y comprobantes que el banco nunca respaldó (`NOT_FOUND`). | | `get_suspicious_attempt` | `suspicious.read` | El detalle de un intento. | ## Bancos y reportes [#bancos-y-reportes] | Herramienta | Permiso | Qué hace | | ----------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------- | | `get_connections` | `connections.read` | Si los avisos del banco siguen entrando. Responde «¿se cayó algo?». | | `get_metrics` | `metrics.read` | Totales del periodo y del anterior, serie por día, perfil por hora y ranking por persona y por cuenta. Acepta turnos. | ## Webhooks [#webhooks] | Herramienta | Permiso | Qué hace | | -------------------------- | ---------------- | ----------------------------------------------------------------------------------- | | `list_webhook_event_types` | `webhooks.read` | Los eventos a los que se puede suscribir, y cuáles permite el plan. | | `list_webhook_endpoints` | `webhooks.read` | Los webhooks configurados y su salud reciente. | | `list_webhook_deliveries` | `webhooks.read` | El historial: qué se mandó y **qué respondió tu servidor**. Depurar sin abrir logs. | | `create_webhook_endpoint` | `webhooks.write` | Da de alta una URL y devuelve el secreto de firma. | | `update_webhook_endpoint` | `webhooks.write` | Cambia URL, eventos o descripción; lo apaga y lo enciende. | | `delete_webhook_endpoint` | `webhooks.write` | Lo borra, con todo su historial. | | `rotate_webhook_secret` | `webhooks.write` | Secreto nuevo, con ventana de gracia en la que firmamos con los dos. | | `test_webhook_endpoint` | `webhooks.write` | Dispara un `webhook.test` para comprobar que responde. | | `retry_webhook_delivery` | `webhooks.write` | Reencola una entrega fallida sin esperar al reintento automático. | ## Guiones listos [#guiones-listos] Además de las herramientas, el servidor trae **prompts**: tareas de varios pasos ya escritas. En Claude Desktop salen como comandos con `/`; en otros clientes, en un menú aparte. | Guion | Qué hace | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `cierre-de-caja` | Totales del día, pagos que quedaron pendientes, gastos y sospechosos. Acepta una fecha. | | `revisar-sospechosos` | Separa los repetidos de los que el banco nunca respaldó — y comprueba antes que el canal del banco no se hubiera caído, porque si se cayó, esos «falsos» pueden ser pagos buenos. | | `diagnosticar-webhook` | Por qué no llegan los eventos, y de qué lado está el problema. | ## Cosas que ninguna herramienta puede hacer [#cosas-que-ninguna-herramienta-puede-hacer] No es que estén restringidas: **no existen**, por las mismas razones que en la API. * **Marcar un pago como verificado.** Solo el cruce con el aviso real del banco cambia ese estado. Si un asistente pudiera decir «esto está verificado», el dato dejaría de significar algo — y ese dato es el producto. * **Editar un pago que nació de un comprobante o de un aviso del banco.** Su fuente de verdad está fuera del libro. * **Crear, rotar o revocar API keys.** Eso exige una sesión de una persona en el panel: una llave que puede emitir llaves convierte cualquier fuga en una fuga total. * **Borrar una categoría con gastos.** Se desactiva; el histórico se conserva. * **Invitar gente al equipo o cambiarle el rol a nadie.** Se lee el equipo, no se administra. # Qué es el MCP de Verifika (/docs/mcp) ## Qué es [#qué-es] **MCP** (Model Context Protocol) es el estándar abierto con el que un asistente de IA se conecta a un sistema externo: en vez de que tú copies y pegues datos en el chat, el asistente los pide él mismo, con tus permisos. Con el MCP de Verifika conectado, esto funciona: > «¿Cuánto entró ayer en la sede norte?» > > «Muéstrame los comprobantes rechazados de esta semana y quién los reportó.» > > «¿Se cayó algún canal del banco en los últimos días?» > > «Registra un gasto de 120.000 a Harina La Espiga en la categoría de proveedores.» ## Cómo se conecta, en una línea [#cómo-se-conecta-en-una-línea] Si tu asistente habla el estándar de autorización de MCP —Claude lo habla—, pegas `https://api.verifika.tech/mcp`, te sale una pantalla de Verifika preguntando qué le concedes, aceptas, y ya: [Conectar con un clic](/docs/mcp/autorizacion). Si no, va con una [llave de API](/docs/mcp/conectar), que abre exactamente lo mismo. ## Por qué no es lo mismo que darle tu API key a un chat [#por-qué-no-es-lo-mismo-que-darle-tu-api-key-a-un-chat] Un asistente con el MCP conectado **no ve tu llave** —cuando la hay— y no puede salirse de lo que se le concedió. Las tres barreras de la API pública siguen ahí, intactas: ``` permiso efectivo = permisos de la llave ∩ tu plan ∩ los negocios de la llave ``` Si le das una llave de solo lectura, no hay forma de que el asistente escriba nada — ni equivocándose, ni si alguien le pide que lo haga. ## Endpoint [#endpoint] ``` https://api.verifika.tech/mcp ``` Transporte **Streamable HTTP**, autenticado con la misma llave `vk_live_…` que ya usas para la API. No hay una credencial aparte que administrar ni un segundo sitio donde revocar. ## Y la documentación, también [#y-la-documentación-también] Aparte del servidor, esta documentación está pensada para que un modelo la lea sin ayuda: <Cards> <Card title="/llms.txt" href="/llms.txt" description="El índice del sitio, en el formato que los agentes esperan." /> <Card title="/llms-full.txt" href="/llms-full.txt" description="Toda la documentación en un archivo, para pegársela a un modelo de una vez." /> </Cards> Y cada página tiene arriba dos botones: **Copiar como Markdown** —se lleva la página limpia, sin la interfaz— y **Abrir en ChatGPT / Claude**, que arranca una conversación con esa página ya cargada. Cualquier URL de la documentación devuelve Markdown si le agregas `.md` o si la pides con `Accept: text/markdown`. <Cards> <Card title="Cómo se conecta" href="/docs/mcp/conectar" description="Claude, ChatGPT, Cursor, VS Code y clientes propios." /> <Card title="Herramientas" href="/docs/mcp/herramientas" description="Qué puede hacer el asistente, una por una." /> <Card title="Seguridad" href="/docs/mcp/seguridad" description="Qué ve, qué no, y cómo acotarlo." /> </Cards> # Seguridad (/docs/mcp/seguridad) ## El MCP no abre una puerta nueva [#el-mcp-no-abre-una-puerta-nueva] Es una capa delgada sobre la API pública: **la misma llave, los mismos permisos, el mismo rate limit**. Todo lo que el asistente puede hacer, podría hacerlo un `curl` con esa llave — ni más ni menos. ``` permiso efectivo = permisos de la llave ∩ tu plan ∩ los negocios de la llave ``` ## Cómo acotarlo [#cómo-acotarlo] <Cards> <Card title="Autorízalo con un clic, no con una llave" description="Si tu cliente habla OAuth, no hay ningún secreto que se pueda pegar donde no debe. Ver Conectar con un clic." href="/docs/mcp/autorizacion" /> <Card title="Una llave aparte para el asistente" description="Si vas con llave, no reuses la de tu ERP. Cuando quieras cortarle el acceso, revocas una sola cosa y no tumbas la integración que sí funciona." /> <Card title="Solo lectura al principio" description="Marca únicamente los permisos .read. Ampliarlos después no te obliga a cambiar el secreto." /> <Card title="Acotada a una sucursal" description="Si el asistente lo usa el encargado de un local, la llave puede ver solo esa sucursal." /> <Card title="Con fecha de vencimiento" description="Para una prueba o una consultoría, ponle caducidad desde el principio en vez de confiar en que alguien se acuerde de borrarla." /> </Cards> ## Lo que el asistente nunca ve [#lo-que-el-asistente-nunca-ve] * **Tu llave** — y si conectaste con un clic, directamente no existe ninguna. * **Tu contraseña**: entras en Verifika, no en la aplicación. * **Correos ni fotos de perfil** del equipo: `list_team_members` devuelve nombre, teléfono, rol y sucursal, nada más. * **Números de cuenta completos**: las cuentas de recaudo se identifican por su alias y los últimos cuatro dígitos. * **Otros negocios**: fuera de los de la llave no existe nada, aunque el asistente adivine un id. ## Lo que sí conviene tener presente [#lo-que-sí-conviene-tener-presente] <Callout type="warn" title="Un asistente puede equivocarse de argumento"> Si le das permisos de escritura, puede crear un gasto con el valor mal o borrar una categoría que no era. No es malicia, es que interpreta lenguaje natural. Por eso: escritura solo cuando la necesites, y en un negocio donde un movimiento de más se pueda corregir. </Callout> Lo que **no** puede pasar, por diseño: * Marcar un pago como verificado. Ninguna llave puede: solo el cruce con el aviso real del banco cambia ese estado. * Editar un ingreso que nació de un comprobante o de un aviso del banco: son inmutables. * Crear otra API key. La administración de llaves exige una sesión de una persona en el panel, precisamente para que una llave filtrada no pueda emitir otra con más permisos. * Borrar una categoría con gastos: se desactiva, y el histórico se conserva. ## Si algo sale mal [#si-algo-sale-mal] Cada llamada queda con su `requestId`, y la llave registra **cuándo y desde qué IP** se usó por última vez. En el panel, la columna «último uso» te dice si esa llave se está usando desde donde esperabas. Para cortar el acceso: **revocar** la llave en el panel, o **quitar el acceso** a la aplicación en `Integraciones → Aplicaciones` si la conectaste con un clic. Las dos cosas son inmediatas — no hay caché ni ventana de gracia salvo que rotes una llave con periodo de gracia a propósito. Con una aplicación autorizada, «inmediato» quiere decir **en la siguiente petición**, aunque su permiso de acceso todavía no hubiera vencido: lo que manda es lo que sigue concedido, no lo que diga un token emitido hace media hora. # Referencia de la API (/docs/referencia) Todo cuelga de `https://api.verifika.tech/v1` y va con la llave en la cabecera: ```http Authorization: Bearer vk_live_... ``` <Callout title="Puedes probar desde aquí"> Cada endpoint trae un playground. Pega tu llave y ejecútalo contra tus propios datos — con una llave de solo lectura no hay nada que se pueda romper. </Callout> <Cards> <Card title="Identidad" href="/docs/referencia/identidad/getMe" description="Quién es la llave, qué plan y cuánto cupo queda." /> <Card title="Verificar" href="/docs/referencia/verificar/createVerification" description="¿Este comprobante es real? El endpoint central." /> <Card title="Ingresos" href="/docs/referencia/ingresos/listIncomes" description="Leer, declarar, corregir y eliminar pagos." /> <Card title="Egresos" href="/docs/referencia/egresos/listExpenses" description="Gastos y sus categorías." /> <Card title="Negocio" href="/docs/referencia/negocio/getCompany" description="Datos del negocio, sucursales y cuentas de recaudo." /> <Card title="Equipo" href="/docs/referencia/equipo/getTeam" description="Quién trabaja aquí, con qué rol y en qué sucursal." /> <Card title="Pagos sospechosos" href="/docs/referencia/pagos-sospechosos/listSuspiciousAttempts" description="Comprobantes repetidos y no respaldados." /> <Card title="Bancos" href="/docs/referencia/bancos/getConnections" description="Si los avisos del banco siguen entrando." /> <Card title="Reportes" href="/docs/referencia/reportes/getMetrics" description="Totales, series, turnos y rankings." /> <Card title="Webhooks" href="/docs/referencia/webhooks/listWebhookEndpoints" description="Suscribirte a eventos y ver el historial de entregas." /> </Cards> ## Los dos endpoints que se confunden [#los-dos-endpoints-que-se-confunden] Es la distinción más importante de toda la API, y equivocarse cuesta cupo o credibilidad del dato: | | `POST /v1/verifications` | `POST /v1/incomes` | | ------------------------- | ------------------------------------------- | -------------------------------- | | Qué le estás pidiendo | «¿este pago entró de verdad?» | «anota que entró esta plata» | | Estado en que nace | `PENDING` → `VERIFIED` si el banco confirma | `DECLARED` | | ¿Consume cupo? | **Sí**, una verificación | No | | ¿Se puede editar después? | No, es inmutable | Sí | | Para qué sirve | Un bot que recibe comprobantes | Un ERP que sincroniza sus ventas | ## Lo que aplica a todos [#lo-que-aplica-a-todos] | | | | ------------- | ------------------------------------------------------------------------------------- | | Autenticación | `Authorization: Bearer vk_live_…` | | Negocio | `?companyId=` — obligatorio solo si la llave entra a varios | | Paginación | `?limit=` y `?startingAfter=` — [ver guía](/docs/guias/paginacion) | | Escrituras | `Idempotency-Key` — [ver guía](/docs/guias/idempotencia) | | Montos | Enteros en pesos: `45000` = $45.000 | | Fechas | ISO 8601 en UTC | | Errores | `{ error: { code, message, details?, requestId } }` — [catálogo](/docs/guias/errores) | # Estado de la conexión (/docs/referencia/bancos/getConnections) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Registrar un egreso (/docs/referencia/egresos/createExpense) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Crear una categoría de egreso (/docs/referencia/egresos/createExpenseCategory) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Eliminar un egreso (/docs/referencia/egresos/deleteExpense) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Eliminar una categoría de egreso (/docs/referencia/egresos/deleteExpenseCategory) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Ver un egreso (/docs/referencia/egresos/getExpense) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Listar categorías de egreso (/docs/referencia/egresos/listExpenseCategories) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Listar egresos (/docs/referencia/egresos/listExpenses) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Editar un egreso (/docs/referencia/egresos/updateExpense) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Editar una categoría de egreso (/docs/referencia/egresos/updateExpenseCategory) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Ver el equipo (/docs/referencia/equipo/getTeam) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Listar roles (/docs/referencia/equipo/listRoles) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Quién soy (/docs/referencia/identidad/getMe) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Plan y consumo (/docs/referencia/identidad/getPlan) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Declarar un ingreso (/docs/referencia/ingresos/createIncome) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Eliminar un ingreso declarado (/docs/referencia/ingresos/deleteIncome) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Ver un ingreso (/docs/referencia/ingresos/getIncome) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Listar ingresos (/docs/referencia/ingresos/listIncomes) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Corregir un ingreso declarado (/docs/referencia/ingresos/updateIncome) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Ver el negocio (/docs/referencia/negocio/getCompany) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Listar sucursales (/docs/referencia/negocio/listBranches) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Catálogo de categorías de negocio (/docs/referencia/negocio/listBusinessCategories) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Listar cuentas de recaudo (/docs/referencia/negocio/listPaymentAccounts) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Ver un intento sospechoso (/docs/referencia/pagos-sospechosos/getSuspiciousAttempt) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Listar intentos sospechosos (/docs/referencia/pagos-sospechosos/listSuspiciousAttempts) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Métricas del periodo (/docs/referencia/reportes/getMetrics) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Verificar un comprobante (/docs/referencia/verificar/createVerification) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Crear un webhook (/docs/referencia/webhooks/createWebhookEndpoint) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Eliminar un webhook (/docs/referencia/webhooks/deleteWebhookEndpoint) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Historial de entregas (/docs/referencia/webhooks/listWebhookDeliveries) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Listar webhooks (/docs/referencia/webhooks/listWebhookEndpoints) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Catálogo de eventos (/docs/referencia/webhooks/listWebhookEventTypes) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Reenviar una entrega (/docs/referencia/webhooks/retryWebhookDelivery) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Rotar el secreto de firma (/docs/referencia/webhooks/rotateWebhookSecret) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Probar un webhook (/docs/referencia/webhooks/testWebhookEndpoint) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} # Editar un webhook (/docs/referencia/webhooks/updateWebhookEndpoint) {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}