Integrate Finanxis into your checkouts
From zero to your first charge in 10 steps. Try everything in sandbox before charging for real.
How it works
Each of your checkouts has a fixed printed QR. When you charge, your system creates a charge; the customer scans the QR with the Finanxis app, sees your logo and the amount, and confirms. The money reaches your wallet instantly and we notify you by webhook so you can print the receipt.
POST /cobros
amount + currency
Charge PENDIENTE
the QR shows the charge
Scans & confirms
PIN · Face ID
Charge PAGADO
money in your wallet
webhook cobro.pagado
print the receipt
1. Get your keys
In the dashboard (API keys) create a test key. You'll get two values:
| Value | What for | Where it goes |
|---|---|---|
| pk_test_… | Identifies your business | X-Finanxis-Key header on every request |
| sk_test_… | Signs your requests. Shown only once | Only on your server (env variable). Never sent |
| Environment | URL | Keys |
|---|---|---|
| Sandbox | https://sandbox.finanxis.net/business/v0 | pk_test_ / sk_test_ |
| Production | https://api.finanxis.net/business/v0 | pk_live_ / sk_live_ |
2. Sign your requests
Every request carries three headers. The signature is an HMAC-SHA256 (hex) with your secret over this text:
${timestamp}.${METODO}.${ruta_con_query}.${body}
Ej: 1760106045.POST./business/v0/cobros.{"caja_id":"caja_8x2k1m","monto":21000.5,"moneda":"VES"}| Header | Value |
|---|---|
| X-Finanxis-Key | pk_test_… |
| X-Finanxis-Timestamp | Current Unix seconds (UTC). ±5 min accepted |
| X-Finanxis-Signature | The signature in hex |
| Idempotency-Key | Only when creating something. Unique per operation |
Copy this helper into your project: it signs and sends any request.
import crypto from "node:crypto";
const KEY = process.env.FINANXIS_KEY; // pk_test_…
const SECRET = process.env.FINANXIS_SECRET; // sk_test_…
const BASE = "https://sandbox.finanxis.net";
export async function finanxis(metodo, ruta, bodyObj, idempotencyKey) {
const body = bodyObj ? JSON.stringify(bodyObj) : "";
const ts = Math.floor(Date.now() / 1000).toString();
const firma = crypto
.createHmac("sha256", SECRET)
.update(`${ts}.${metodo}.${ruta}.${body}`)
.digest("hex");
const res = await fetch(BASE + ruta, {
method: metodo,
headers: {
"Content-Type": "application/json",
"X-Finanxis-Key": KEY,
"X-Finanxis-Timestamp": ts,
"X-Finanxis-Signature": firma,
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: body || undefined,
});
return res.json(); // { success, message, data }
}3. Test your integration
const r = await finanxis("GET", "/business/v0/ping");
console.log(r);{
"success": true,
"message": "Autenticación correcta. Tu integración está lista.",
"data": { "comercio": { "id": "cm9x1…", "nombre_fantasia": "Farmacia Central" }, "ambiente": "sandbox", "hora_servidor": "2026-10-10T14:20:45.000Z" }
}4. Set up your checkouts in the dashboard
Branches, checkouts, keys and webhooks are managed only from the dashboard; the API is only for charging. Create a branch per location and a checkout per point of sale.
- •Copy each checkout id (caja_…) and store it in that POS's configuration: it's the caja_id you send when creating a charge.
- •Download the checkout QR, print it and place it at the checkout. It never changes.
5. Create a charge
When the cashier closes the sale, create the charge on that checkout. The checkout's QR now shows this charge.
const cobro = await finanxis("POST", "/business/v0/cobros", {
caja_id: "caja_8x2k1m",
monto: 21000.5, // número, máx. 2 decimales
moneda: "VES",
referencia_externa: "TICKET-55821",
expira_en_segundos: 180,
}, "TICKET-55821"); // Idempotency-Key = tu número de ticket
if (!cobro.success) console.error(cobro.data.error);- •Use your ticket number as the Idempotency-Key: if the connection drops and you retry, no duplicate charge is created.
- •A checkout has only one pending charge at a time. If there is one, cancel it (POST /cobros/{id}/cancelar) or let it expire.
- •If the customer doesn't pay before expira_en, the charge becomes EXPIRADO and you get cobro.expirado.
6. The customer pays
Nothing to do on your side here. The customer scans the QR with the Finanxis app (or their camera), sees your logo, branch and amount, picks a wallet and confirms with PIN, Face ID or fingerprint. It's debited from their wallet and credited to yours in a single operation.
7. Receive the webhook
Register an https URL in the dashboard (Webhooks) and pick the events. Store the whsec_ secret shown only once. When the customer pays, we send you cobro.pagado:
POST https://tu-servidor.com/webhooks/finanxis
Finanxis-Signature: t=1760106090,v1=9a7f3c…
Finanxis-Event-Id: evt_7d2c…
Finanxis-Event-Type: cobro.pagado
{
"evento_id": "evt_7d2c…",
"evento": "cobro.pagado",
"ambiente": "live",
"creado_en": "2026-10-10T14:21:30Z",
"data": { "cobro": { "id": "cob_7d2c9a", "estado": "PAGADO", "monto": 21000.5, "moneda": "VES", "referencia_externa": "TICKET-55821" } }
}Always verify the signature before marking the sale as paid. The signature is computed over the exact bytes we send, so order matters:
- •1. Receive: Take the body as the original bytes (Buffer / raw text). Don't parse it yet.
- •2. Verify: Compute HMAC-SHA256 of
${t}.${raw_body}with your whsec_ secret and compare it with each v1 in the header. - •3. Parse: Only if the signature is valid, turn the JSON into an object.
- •4. Process: Skip the event if you already processed its evento_id, reply 2xx quickly and run your logic.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRETO = process.env.FINANXIS_WEBHOOK_SECRET; // whsec_…
// 1) RECIBIR: guarda el body crudo ANTES de que express.json() lo parsee.
// (Si express.json() es global, este "verify" lo captura para todas las rutas.)
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); },
}));
app.post("/webhooks/finanxis", (req, res) => {
try {
const rawBody = req.rawBody;
if (typeof rawBody !== "string") return res.status(500).json({ message: "Raw body no disponible" });
// Header: "t=1760106090,v1=<firma>[,v1=<firma_anterior>]"
const partes = (req.get("Finanxis-Signature") ?? "").split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
});
const t = partes.find(([k]) => k === "t")?.[1];
const firmas = partes.filter(([k]) => k === "v1").map(([, v]) => v);
// Anti-reenvío: el timestamp debe tener menos de 5 minutos.
if (!t || !/^\d+$/.test(t)) return res.sendStatus(400);
const timestamp = Number(t);
if (!Number.isSafeInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(400);
// 2) VERIFICAR sobre los bytes exactos recibidos.
const esperada = crypto.createHmac("sha256", SECRETO).update(`${t}.${rawBody}`, "utf8").digest("hex");
const valida = firmas.some((firma) =>
/^[a-f0-9]{64}$/i.test(firma) &&
crypto.timingSafeEqual(Buffer.from(firma, "hex"), Buffer.from(esperada, "hex")),
);
if (!valida) return res.status(401).json({ message: "Firma HMAC inválida" });
// 3) PARSEAR: ya verificado, usa el objeto (express.json() ya lo dejó en req.body).
const evento = req.body;
// 4) PROCESAR: idempotencia por evento.evento_id y responde rápido.
// if (await yaProcesado(evento.evento_id)) return res.sendStatus(200);
// if (evento.evento === "cobro.pagado") { marcar venta pagada + imprimir ticket }
return res.status(200).json({ success: true });
} catch (error) {
console.error("Error verificando webhook:", error);
return res.sendStatus(500);
}
});- •Reply 2xx within 10 s. Otherwise we retry at 1 min, 5 min, 30 min, 2 h and 12 h.
- •You may get the same event more than once: store evento_id and don't process it twice.
- •You can have several URLs, each with its own secret and events.
Common mistakes:
- •Verifying against the already parsed body (req.body from express.json()) re-serialized with JSON.stringify: spacing and order change and the signature never matches.
- •Keeping a single v1: during a secret rotation we send two (new and previous). Accept if any of them matches.
- •Comparing signatures with === instead of a constant-time comparison (timingSafeEqual / hmac.compare_digest / hash_equals).
8. If the webhook doesn't arrive
As a fallback, fetch the charge. Useful if your server was down or to confirm before handing over the purchase.
const r = await finanxis("GET", "/business/v0/cobros/cob_7d2c9a");
if (r.data.estado === "PAGADO") { /* entrega la compra */ }9. Handle errors
Every response has the same shape. On errors, code against data.error.code (stable):
{
"success": false,
"message": "Hay campos inválidos.",
"data": {
"error": {
"code": "VALIDACION",
"detalle": "Revisa 1 campo(s) en body.",
"como_resolver": "Revisa data.error.campos…",
"campos": [{ "campo": "monto", "mensaje": "monto debe ser un número (no texto)", "recibido": "21000", "ejemplo": 21000.5 }],
"docs": "https://business.finanxis.net/docs#errores"
},
"request_id": "f324556f-…"
}
}| Code | What to do |
|---|---|
| AUTH_FIRMA_INVALIDA | Compare your signed text with the one shown in detalle. |
| AUTH_TIMESTAMP_INVALIDO | Use Unix seconds and sync the clock (NTP). |
| VALIDACION | Fix each item in data.error.campos. |
| CONFLICTO_ESTADO | Fetch the resource: its status may have changed. |
| LIMITE_PETICIONES | Wait Retry-After seconds. |
| ERROR_INTERNO | Retry with the same Idempotency-Key; if it persists, send us the request_id. |
10. Go live
- •You tested in sandbox: ping, paid charge, expired charge, canceled charge and retry with the same Idempotency-Key.
- •Your webhook verifies the signature, replies 2xx quickly and ignores repeated events.
- •You created a pk_live_ key and stored it as an env variable (optional: restricted to your server IP).
- •You switched the base URL to https://api.finanxis.net and registered your production webhook.
- •You printed the production QR for each checkout.