Aplicación con backend
Para servidores, automatizaciones y paneles privados capaces de guardar un secreto.
- Recibe
client_idyclient_secret. - El canje del código usa autenticación HTTP Basic.
- El secreto nunca debe enviarse al navegador.
Consultá la operación de un negocio sin depender del frontend: productos, stock, ventas, balances, alertas y reportes. También podés reaccionar a cambios en tiempo real mediante webhooks.
https://www.espinacomercial.com
/api/v1Inicio rápido
El acceso siempre representa a un usuario de Espina. Ese usuario decide qué permisos concede y la API sólo devuelve sus negocios activos.
Registrá el sistema externo y sus URLs de retorno desde tu cuenta.
Redirigí al usuario al login de Espina usando Authorization Code + PKCE.
Canjeá el código temporal por un access token y un refresh token.
Consultá /me o /accounts para obtener el accountId.
Usá el token Bearer para consultar productos, ventas, reportes o webhooks.
Autoservicio
No necesitás solicitar acceso manual. El dueño del negocio registra la aplicación y Espina genera las credenciales.
OAuth exige comparar exactamente la URL enviada con una URL autorizada. Esto impide que otra página reciba el código de acceso. Espina no decide esa URL: la configura el propio usuario.
Para servidores, automatizaciones y paneles privados capaces de guardar un secreto.
client_id y client_secret.Para SPA, aplicaciones de escritorio o móviles donde no es posible ocultar un secreto.
client_id.client_id, sin secret.Creá credenciales para sistemas propios. Las aplicaciones oficiales multiusuario se registran una sola vez y sus usuarios no configuran callbacks.
Autenticación
Los tokens pertenecen siempre al usuario que inicia sesión. Una aplicación oficial utiliza las mismas credenciales globales para todos, pero cada persona recibe tokens separados y sólo puede consultar sus propios negocios.
https://www.espinacomercial.com/oauth2/authorize
Creá un code_verifier aleatorio por intento y enviá su hash SHA-256 como code_challenge.
const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64url(bytes);
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier)
);
const challenge = base64url(new Uint8Array(digest));
function base64url(value) {
return btoa(String.fromCharCode(...value))
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
}
https://www.espinacomercial.com/oauth2/authorize?
response_type=code&
client_id=TU_CLIENT_ID&
redirect_uri=https%3A%2F%2Ftu-app.com%2Foauth%2Fcallback&
scope=openid%20profile%20email%20accounts%3Aread%20products%3Aread&
state=VALOR_ALEATORIO_UNICO&
code_challenge=TU_CODE_CHALLENGE&
code_challenge_method=S256&
prompt=login
state y solicitá la identidad correcta
Guardá y compará siempre state. Usá prompt=login cuando cada conexión deba pedir nuevamente las credenciales de Espina y no reutilizar otra sesión abierta.
curl --request POST 'https://www.espinacomercial.com/oauth2/token' \
--user 'TU_CLIENT_ID:TU_CLIENT_SECRET' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'code=CODIGO_RECIBIDO' \
--data-urlencode 'redirect_uri=https://tu-app.com/oauth/callback' \
--data-urlencode 'code_verifier=TU_CODE_VERIFIER'
curl --request POST 'https://www.espinacomercial.com/oauth2/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'client_id=TU_CLIENT_ID' \
--data-urlencode 'code=CODIGO_RECIBIDO' \
--data-urlencode 'redirect_uri=https://tu-app.com/oauth/callback' \
--data-urlencode 'code_verifier=TU_CODE_VERIFIER'
{
"access_token": "eyJraWQiOiI...",
"refresh_token": "pX7w2c...",
"scope": "openid profile email accounts:read products:read",
"id_token": "eyJraWQiOiI...",
"token_type": "Bearer",
"expires_in": 899
}
El access token dura 15 minutos. El refresh token dura hasta 30 días y rota en cada uso: guardá el nuevo y descartá el anterior.
curl --request POST 'https://www.espinacomercial.com/oauth2/token' \
--user 'TU_CLIENT_ID:TU_CLIENT_SECRET' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=refresh_token' \
--data-urlencode 'refresh_token=TU_REFRESH_TOKEN'
Para revocar un token, enviá el mismo formato a POST /oauth2/revoke con el parámetro token.
Primera consulta
Todos los endpoints protegidos reciben el access token en el header Authorization.
curl 'https://www.espinacomercial.com/api/v1/me' \
--header 'Accept: application/json' \
--header 'Authorization: Bearer TU_ACCESS_TOKEN'
const response = await fetch('https://www.espinacomercial.com/api/v1/me', {
headers: {
Accept: 'application/json',
Authorization: 'Bearer ' + accessToken
}
});
if (!response.ok) throw await response.json();
const user = await response.json();
import requests
response = requests.get(
'https://www.espinacomercial.com/api/v1/me',
headers={
'Accept': 'application/json',
'Authorization': f'Bearer {access_token}',
},
timeout=15,
)
response.raise_for_status()
user = response.json()
{
"id": 42,
"name": "Gabriel Espina",
"email": "gabriel@example.com",
"pictureUrl": "https://...",
"accounts": [
{
"id": 17,
"name": "Kiosco Jardín 2",
"role": "OWNER",
"plan": "PRO",
"subscriptionStatus": "ACTIVE",
"trialEndsAt": null,
"paidUntil": "2026-08-31",
"active": true,
"setupCompleted": true
}
]
}
Modelo de acceso
accountId
Un usuario puede administrar uno o varios negocios. Primero consultá GET /api/v1/accounts,
elegí uno y colocá su id en las rutas que contienen {accountId}.
Aunque una aplicación modifique manualmente el ID, Espina valida que el usuario tenga una membresía activa. Un token nunca permite leer negocios ajenos.
Scopes
| Scope | Permite |
|---|---|
accounts:read | Identidad, listado de negocios, estado y resumen operativo. |
products:read | Productos, precios, categorías y estado de stock. |
inventory:read | Alertas y movimientos de inventario. |
sales:read | Ventas, detalle, totales y balances. |
sales:write | Registrar ventas de productos y ventas manuales. |
reports:read | Bitácora operativa, series, métricas comerciales y reportes analíticos. |
webhooks:manage | Crear, modificar, probar y revisar webhooks. |
Para OpenID Connect también podés solicitar openid profile email.
Convenciones
page comienza en 0. size acepta de 1 a 100 elementos.
Usá ISO YYYY-MM-DD. El rango máximo por consulta es 366 días.
Se devuelven como números decimales y los balances informan ARS.
Estados y tipos se devuelven en mayúsculas, por ejemplo ACTIVE.
{
"items": [],
"page": 0,
"size": 50,
"totalItems": 134,
"totalPages": 3,
"first": true,
"last": false
}
Referencia API
Descubrí al usuario autenticado y los espacios que puede consultar.
/api/v1/meUsuario actual y negocios accesibles.
Ejemplo/api/v1/accountsLista los negocios del usuario.
/api/v1/accounts/{accountId}Datos, plan, suscripción y rol.
/api/v1/accounts/{accountId}/statusEstado operativo, productos y miembros.
/api/v1/accounts/{accountId}/summaryIndicadores del panel de inicio.
{
"accountId": 17,
"operational": true,
"accountStatus": "ACTIVE",
"subscriptionStatus": "ACTIVE",
"plan": "PRO",
"activeProducts": 650,
"lowStockProducts": 20,
"members": 3,
"generatedAt": "2026-07-29T18:25:41-03:00"
}
Referencia API
Consultá el catálogo actual, faltantes y trazabilidad de ingresos.
/api/v1/accounts/{accountId}/productsLista filtrable y paginada.
/api/v1/accounts/{accountId}/products/{productId}Detalle de un producto.
/api/v1/accounts/{accountId}/products/categoriesCategorías existentes.
/api/v1/accounts/{accountId}/inventory/alertsBajo stock y sin stock.
/api/v1/accounts/{accountId}/inventory/movementsHistorial de movimientos.
searchcategoryunitactivetrue o false.stockStatusIN_STOCK, LOW_STOCK u OUT_OF_STOCK.page / sizecurl 'https://www.espinacomercial.com/api/v1/accounts/17/products?stockStatus=LOW_STOCK&active=true&size=25' \
--header 'Authorization: Bearer TU_ACCESS_TOKEN'
{
"id": 982,
"code": "7790036974286",
"description": "Agua saborizada naranja 500 ml",
"category": "BEBIDAS",
"unitCode": "UN",
"unitName": "Unidad",
"customUnit": null,
"price": 1500.00,
"currentStock": 4,
"minimumStock": 6,
"stockStatus": "LOW_STOCK",
"active": true,
"createdAt": "2026-02-12T11:42:19",
"updatedAt": "2026-07-29T15:10:06"
}
Referencia API
Registrá operaciones y consultá detalle de productos, pagos y acumulados del período.
/api/v1/accounts/{accountId}/salesRegistra productos, valida stock y descuenta existencias.
/api/v1/accounts/{accountId}/sales/manualRegistra una venta manual sin productos.
/api/v1/accounts/{accountId}/salesVentas con filtros y paginación.
/api/v1/accounts/{accountId}/sales/{type}/{saleId}Detalle de una venta.
/api/v1/accounts/{accountId}/sales/totalsTotal, ticket promedio y medios de pago.
/api/v1/accounts/{accountId}/balancesBalance operativo completo.*
* /balances requiere simultáneamente sales:read y reports:read.
from / totypeVENTA o MANUAL.statusACTIVA o ANULADA.paymentMethodEFECTIVO, TRANSFERENCIA o TARJETA.sellerminTotal / maxTotalsortdate_desc, date_asc, total_desc o total_asc.{
"from": "2026-07-01",
"to": "2026-07-29",
"salesCount": 428,
"grossSales": 5930240.00,
"averageTicket": 13855.70,
"paymentMethods": {
"EFECTIVO": 2742800.00,
"TRANSFERENCIA": 2079940.00,
"TARJETA": 1107500.00
}
}
{
"id": 845,
"type": "VENTA",
"status": "ACTIVA",
"occurredAt": "2026-07-29T14:36:12",
"seller": "Caja principal",
"total": 4500.00,
"observation": null,
"payments": [
{ "method": "EFECTIVO", "amount": 3000.00 },
{ "method": "TRANSFERENCIA", "amount": 1500.00 }
],
"lines": [
{
"productId": 982,
"productCode": "7790036974286",
"productDescription": "Agua saborizada naranja 500 ml",
"quantity": 3,
"unitPrice": 1500.00,
"subtotal": 4500.00
}
]
}
Referencia API
La misma información analítica que utiliza el panel, disponible para tu propio BI o tablero.
/api/v1/accounts/{accountId}/activityBitácora unificada con carga incremental.
/api/v1/accounts/{accountId}/reportsReporte completo.
/api/v1/accounts/{accountId}/reports/summaryKPIs generales.
/api/v1/accounts/{accountId}/reports/sales-seriesSerie temporal de ventas.
/api/v1/accounts/{accountId}/reports/productsProductos principales y accionables.
/api/v1/accounts/{accountId}/reports/categoriesComparación por categoría.
/api/v1/accounts/{accountId}/reports/payment-methodsTotales por medio de pago.
/api/v1/accounts/{accountId}/reports/cashCaja y cobros digitales.
/api/v1/accounts/{accountId}/reports/inventoryResumen y alertas de stock.
/api/v1/accounts/{accountId}/reports/sellersRendimiento por vendedor.
/api/v1/accounts/{accountId}/reports/peak-hoursVentas agrupadas por hora.
from / tooffset / sizenextOffset mientras hasMore sea verdadero.groupingcurl 'https://www.espinacomercial.com/api/v1/accounts/17/reports/sales-series?from=2026-07-01&to=2026-07-29&grouping=DAILY' \
--header 'Authorization: Bearer TU_ACCESS_TOKEN'
Referencia API
Configurá destinos HTTPS y auditá cada intento de entrega.
/api/v1/accounts/{accountId}/webhooks/eventsEventos disponibles.
/api/v1/accounts/{accountId}/webhooksCrea una suscripción.
/api/v1/accounts/{accountId}/webhooksLista suscripciones.
/api/v1/accounts/{accountId}/webhooks/{subscriptionId}Actualiza o activa/desactiva.
/api/v1/accounts/{accountId}/webhooks/{subscriptionId}Desactiva el destino.
/api/v1/accounts/{accountId}/webhooks/{subscriptionId}/rotate-secretRota la firma.
/api/v1/accounts/{accountId}/webhooks/{subscriptionId}/testEncola un evento de prueba.
/api/v1/accounts/{accountId}/webhooks/{subscriptionId}/deliveriesHistorial paginado.
/api/v1/accounts/{accountId}/webhooks/{subscriptionId}/deliveries/{deliveryId}/replayReencola una entrega.
curl --request POST 'https://www.espinacomercial.com/api/v1/accounts/17/webhooks' \
--header 'Authorization: Bearer TU_ACCESS_TOKEN' \
--header 'Content-Type: application/json' \
--data '{
"name": "Alertas de stock",
"targetUrl": "https://tu-app.com/webhooks/espina",
"events": ["stock.low", "stock.out"]
}'
{
"id": 24,
"accountId": 17,
"name": "Alertas de stock",
"targetUrl": "https://tu-app.com/webhooks/espina",
"events": ["stock.low", "stock.out"],
"active": true,
"lastSuccessAt": null,
"lastFailureAt": null,
"createdAt": "2026-07-29T18:42:00",
"signingSecret": "whsec_..."
}
Eventos en tiempo real
Espina envía un POST JSON cuando ocurre un evento al que tu destino está suscripto.
sale.createdNueva venta.sale.cancelledVenta anulada.product.createdProducto creado.product.updatedProducto actualizado.product.deactivatedProducto desactivado.product.deletedProducto eliminado.inventory.changedMovimiento de stock.stock.lowStock mínimo alcanzado.stock.outProducto sin stock.cash.closedCierre de caja.subscription.status_changedCambio de suscripción.webhook.testPrueba manual.X-Espina-Event | Tipo de evento. |
|---|---|
X-Espina-Event-Id | ID estable para deduplicación. |
X-Espina-Delivery-Id | ID de este intento. |
X-Espina-Timestamp | Unix timestamp utilizado para firmar. |
X-Espina-Signature | Firma HMAC SHA-256 con prefijo v1=. |
{
"id": "01J4M6R7...",
"type": "stock.low",
"createdAt": "2026-07-29T21:42:00Z",
"accountId": 17,
"data": {
"productId": 982,
"code": "7790036974286",
"description": "Agua saborizada naranja 500 ml",
"currentStock": 4,
"minimumStock": 6
}
}
Seguridad de webhooks
La cadena firmada es timestamp + "." + rawBody. Usá el cuerpo sin parsear y una comparación de tiempo constante.
import crypto from "node:crypto";
export function verifyEspinaWebhook(rawBody, headers, secret) {
const timestamp = headers["x-espina-timestamp"];
const received = headers["x-espina-signature"];
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || !received || age > 300) return false;
const expected = "v1=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return expected.length === received.length &&
crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(received)
);
}
Entregas
2xx después de guardar el evento.X-Espina-Event-Id; un evento puede entregarse más de una vez.DEAD y pueden reencolarse con el endpoint de replay.Contrato HTTP
Los errores usan application/problem+json. Nunca dependas del texto: tomá decisiones con status y code.
| Estado | Code | Significado |
|---|---|---|
400 | invalid_request | Parámetro, fecha, filtro o body inválido. |
401 | unauthorized | Token ausente, vencido o inválido. |
403 | forbidden | Falta un scope. |
403 | account_access_denied | El usuario no pertenece al negocio. |
404 | resource_not_found | Recurso inexistente dentro del negocio. |
429 | rate_limit_exceeded | Se superó el límite temporal. |
500 | internal_error | Error interno; informá el requestId. |
{
"type": "https://www.espinacomercial.com/problems/account_access_denied",
"title": "Acceso al negocio denegado",
"status": 403,
"detail": "No tenés acceso al negocio solicitado.",
"instance": "/api/v1/accounts/999/products",
"code": "account_access_denied",
"requestId": "req_01J4M8F5KD7",
"timestamp": "2026-07-29T18:52:18-03:00"
}
Operación confiable
Por aplicación y usuario. Puede ajustarse según el entorno.
X-Request-IdEspina genera uno o respeta el tuyo. Guardalo con cada log.
X-RateLimit-Limit, Remaining y Reset.
Los frontends HTTPS pueden usar la API con tokens Bearer; Espina no comparte cookies de sesión.
Listo para integrar
Las credenciales pertenecen a tu negocio y podés revocarlas cuando quieras.