Public API
Authentication, endpoints, request formats, and conventions.
OlivERP API v1
An API designed to let AI agents and automation tools (n8n, Make, Zapier, Custom GPTs) operate the ERP without using the web interface.
The complete, always up-to-date contract is available at:
GET /api/v1/openapi.json
This endpoint is intentionally public: a client must be able to read the contract before it has credentials. It does not expose any data.
Getting started
1. Configure the Convex bridge
Requests authenticated with an ERP API key are resolved by the Next.js Worker and authorized in Convex. Configure the same random value in Convex and the Worker deployment:
NEXT_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
CONVEX_BRIDGE_SECRET=long-random-server-secret
CONVEX_BRIDGE_SECRET is server-only. Do not prefix it with PUBLIC_ or put it
in a browser bundle. Browser sessions are issued by Convex Auth and presented to
Convex as a verified JWT.
2. Create an API key
From the app, in Settings → your project → Manage API keys: name the key, choose whether it may write, optionally set an expiry date, and copy the secret from the confirmation — it is shown once and never again. The same screen lists every key of the project and revokes them. Only project admins can reach it.
For unattended setups there is an equivalent CLI, which authenticates with
CONVEX_BRIDGE_SECRET instead of a user session:
pnpm api:key --nombre "n8n stock" --proyecto 1 --scopes read,write
Options:
| Flag | Description |
|---|---|
--nombre |
Required. Used to identify the key later. |
--proyecto |
Required. ID of the project the key is bound to. |
--scopes |
read, write, or read,write. Defaults to read. |
--expira |
Expiration date (YYYY-MM-DD). Does not expire by default. |
The key is shown only once, by either route: only its SHA-256 hash is stored in Convex. A lost key cannot be recovered, only revoked and replaced.
Authentication
curl -H "Authorization: Bearer erp_sk_..." https://your-erp/api/v1/proyectos
X-API-Key: erp_sk_... is also accepted and is the default header sent by
several automation tools.
The web interface uses the Convex Auth cookie session stored in Convex; the same endpoints support both browser sessions and API keys.
A browser session slides forward on every visit and ends after 30 days without
use, with a hard cap of 90 days before GitHub sign-in is required again. All
three clocks — inactivity, cap, and cookie lifetime — are set in
convex/lib/session.ts.
Permissions
read→GETmethods.write→POST,PATCH, andDELETEmethods.
Every key is bound to one project
A key is always pinned to a single project and cannot read or write data in any
other, including projects belonging to other users. proyecto_id is optional in
requests; if it is provided and does not match, the request is rejected instead
of being silently rewritten.
Binding used to be optional, and an unbound key was treated as a wildcard over every project in the deployment. With open sign-up that would have meant every project of every user, so a key without a project is now refused outright.
Ids are unique per project
id values for sales, purchases, products and transactions are unique within
a project, not across the deployment. The /{id} routes resolve the id inside
the project of the calling key, so two projects can each have a sale with
id: 1 and neither can reach the other's.
A browser session, which is not pinned to a project, must pass ?proyecto_id=
on those routes.
Endpoints
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/proyectos |
Accessible projects. Start here. |
GET |
/api/v1/productos |
Product catalog. Supports the buscar filter. |
POST |
/api/v1/productos |
Creates a product. |
PATCH |
/api/v1/productos/{id} |
Assigns a Wallapop listing title to a product. |
GET |
/api/v1/clientes |
Known customers for a project. |
GET |
/api/v1/ventas |
Sales. Supports date, status, and channel filters. |
POST |
/api/v1/ventas |
Records a sale transactionally. |
GET |
/api/v1/ventas/{id} |
Returns sale details. |
PATCH |
/api/v1/ventas/{id} |
Updates the header and/or line items. |
GET |
/api/v1/compras |
Purchases. |
POST |
/api/v1/compras |
Records a purchase transactionally. |
GET |
/api/v1/compras/{id} |
Returns purchase details. |
PATCH |
/api/v1/compras/{id} |
Updates the header and/or line items. |
GET |
/api/v1/transacciones |
Other income and expenses. |
POST |
/api/v1/transacciones |
Records income or an expense. |
GET |
/api/v1/transacciones/{id} |
Returns transaction details. |
PATCH |
/api/v1/transacciones/{id} |
Updates a transaction. |
DELETE |
/api/v1/transacciones/{id} |
Deletes a transaction. |
GET |
/api/v1/stock |
Stock and days of inventory coverage. |
POST |
/api/v1/stock/ajustes |
Applies a manual stock adjustment. |
GET |
/api/v1/finanzas |
Income, expenses, profit, and VAT balance. |
POST |
/api/v1/importaciones/marketplace |
Imports a confirmed Wallapop or Vinted sale from Gmail. |
POST |
/api/v1/importaciones/wallapop |
Imports a confirmed Wallapop sale from Gmail. |
List endpoints always return the same envelope:
{
"data": [ ... ],
"pagination": { "page": 1, "page_size": 20, "total": 132, "total_pages": 7, "has_more": true }
}
Important conventions
Prices include VAT. This is how the schema stores them. Responses break out
total_base, total_iva, and total, so clients do not have to derive them.
Every operation counts. A recorded sale is income and a recorded purchase
is an expense, both from the moment they are written; there are no workflow
statuses that keep an operation out of the books. Undoing one is a DELETE.
Stock moves automatically. Convex domain mutations generate movements for
sales and purchases. POST /api/v1/stock/ajustes is only for manual corrections
(breakage, stock counts, and giveaways).
Marketplace titles are exact mappings. The Gmail workflow sends the
complete listing title and channel. It must first be assigned to the matching
product through PATCH /api/v1/productos/{id} using titulo_wallapop or
titulo_vinted; an unknown title is rejected instead of being silently
attached to the wrong product.
Dates accept YYYY-MM-DD or ISO 8601. A date without a time is interpreted
as midnight.
importe is always positive in transactions; tipo determines its sign.
Safe retries (Idempotency-Key)
When a request times out, the caller does not know whether the sale was recorded. Retrying blindly could duplicate it. To avoid this, send a unique key for each operation:
curl -X POST https://your-erp/api/v1/ventas \
-H "Authorization: Bearer erp_sk_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: venta-shopify-10432" \
-d '{
"fecha": "2026-01-31",
"canal": "Web",
"items": [{ "producto_id": 1, "unidades": 2, "precio_unitario": 24.99 }]
}'
Repeating this call returns the original response with the
Idempotency-Replayed: true header without creating a second sale.
- Same key with a different body →
422 idempotency_mismatch. - Same key while the first request is still in progress →
409 conflict. - If the request fails, the key is released and can be reused.
A good value is the order ID from the source system, which is naturally unique and stable across retries.
The Convex idempotency ledger can be inspected and cleaned from the Convex dashboard; the migration intentionally starts it empty.
Errors
All errors use the same structure:
{
"error": {
"code": "validation_error",
"message": "El cuerpo de la peticion no es valido.",
"details": [
{
"field": "items.0.unidades",
"message": "Las unidades deben ser un numero entero."
},
{
"field": "canal",
"message": "Invalid option: expected one of \"Wallapop\"|\"Vinted\"",
"expected": "\"Wallapop\" | \"Vinted\""
}
],
"hint": "Revisa los campos listados en 'details'."
}
}
field uses dot notation, so it points directly to the relevant part of the
submitted JSON. expected lists the accepted values for a constrained field,
allowing a model to correct the call instead of retrying it unchanged.
| Code | HTTP | Meaning |
|---|---|---|
validation_error |
400 | Invalid request body or query. |
unauthorized |
401 | The key is missing, invalid, revoked, or expired. |
forbidden |
403 | Missing permission or project not allowed. |
not_found |
404 | The resource does not exist or is not visible. |
conflict |
409 | A request with the same key is still in progress. |
idempotency_mismatch |
422 | The key was reused with a different body. |
demo_mode |
403 | The deployment is running in demo mode. |
not_configured |
503 | NEXT_PUBLIC_CONVEX_URL or CONVEX_BRIDGE_SECRET is missing. |
internal_error |
500 | Server failure. |
Examples
Restock items running out this week
curl -H "Authorization: Bearer erp_sk_..." \
"https://your-erp/api/v1/stock?max_dias_stock=7"
Monthly financial summary
curl -H "Authorization: Bearer erp_sk_..." \
"https://your-erp/api/v1/finanzas?desde=2026-01-01&hasta=2026-01-31&detalle=resumen"
Correct the channel of a sale
curl -X PATCH https://your-erp/api/v1/ventas/42 \
-H "Authorization: Bearer erp_sk_..." \
-H "Content-Type: application/json" \
-d '{"canal": "Vinted"}'
Import a Wallapop sale
The n8n workflow in automations/n8n/wallapop-gmail-to-erp.json searches for
confirmation emails, parses both plain-text and HTML bodies, and calls the
import endpoint. It sends the Gmail message id as origen_id and as
Idempotency-Key, so polling and delivery retries do not create duplicate
sales.
curl -X POST https://your-erp/api/v1/importaciones/wallapop \
-H "Authorization: Bearer erp_sk_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: gmail-message-id" \
-d '{
"origen_id": "gmail-message-id",
"fecha": "2026-08-03",
"comprador_nombre": "Alice B.",
"titulo_wallapop": "Mando Xiaomi XMRM-006 a Estrenar",
"importe_total": 3.49,
"unidades": 1
}'
The import creates or reuses a customer by normalized name and records the
sale as Wallapop. The exact title is matched to the product before the sale
and its stock movement are written.
Import a marketplace sale
The n8n workflow also supports Vinted confirmation emails through
/api/v1/importaciones/marketplace. Send canal: "Wallapop" or
canal: "Vinted" and the exact title in titulo_producto:
{
"origen_id": "gmail-message-id",
"canal": "Vinted",
"fecha": "2026-08-08",
"comprador_nombre": "sample_buyer_01",
"titulo_producto": "Mando Samsung BN59-01358D a Estrenar",
"importe_total": 3.50,
"unidades": 1
}
The import records the marketplace as the sale channel and uses the marketplace-specific exact title mapping before writing the sale.
Connect a Custom GPT or agent
Give it the spec URL and the key:
https://your-erp/api/v1/openapi.json
It can then discover the operations, required fields, and accepted enum values on its own; there is no need to describe the API manually.