Managed Tables API
Endpoints for managing company-owned PostgreSQL tables. Each company has a dedicated managed database, provisioned on demand. Supports manual table creation, file upload (CSV/Excel/ZIP) with schema inference, and row-level CRUD.
Difference vs. Data API:
- Data API = consumes saved visualizations as a public endpoint (read-only via API key).
- Managed Tables = the user storage layer (CRUD via console JWT).
Visualizations published via the Data API can read from managed tables, but the two modules are independent.
All endpoints require the standard JWT auth (Authorization: Bearer <token>) and are prefixed with /v1/managed-tables.
Provisioning
Provision the managed database
/v1/managed-tables/provisionCreates a dedicated PostgreSQL database for the company (idempotent — re-calling returns the existing connection).
Required Permission: managed-tables:create
Response: 200 OK
{
"provisioned": true,
"connection_id": "507f1f77bcf86cd799439011"
}
Provisioning status
/v1/managed-tables/statusRequired Permission: managed-tables:list
Response: 200 OK
{
"provisioned": true,
"connection_id": "507f1f77bcf86cd799439011"
}
provisioned: false indicates that POST /provision has not run yet.
Tables CRUD
List tables
/v1/managed-tablesRequired Permission: managed-tables:list
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
per_page | integer | 20 | Items per page (max 100) |
search | string | - | Filter by display_name (case-insensitive) |
Response: 200 OK
{
"total": 12,
"quantity": 12,
"records": [
{
"_id": "507f1f77bcf86cd799439011",
"company_id": "507f1f77bcf86cd799439012",
"connection_id": "507f1f77bcf86cd799439013",
"display_name": "vendas_mensais",
"columns": [
{ "name": "produto", "display_name": "Product", "type": "text", "nullable": false },
{ "name": "valor", "display_name": "Value", "type": "decimal", "nullable": true }
],
"source": "manual",
"status": "active",
"row_count": 1500,
"created_by_user_id": "507f1f77bcf86cd799439014",
"created_at": "2026-01-15T10:30:00.000Z",
"updated_at": "2026-01-15T10:30:00.000Z"
}
]
}
Get table
/v1/managed-tables/:idRequired Permission: managed-tables:list
Response: 200 OK — same shape as one item from the list response.
Errors: 404 { "error": "Tabela não encontrada" }
Create table (manual)
/v1/managed-tablesRequired Permission: managed-tables:create
Prerequisite: database provisioned (POST /provision). Otherwise returns 400 Base de dados não provisionada. Ative "Meus Dados" primeiro.
Body:
| Field | Type | Required | Description |
|---|---|---|---|
display_name | string | Yes | Table name. Must match ^[a-z_][a-z0-9_]*$ (snake_case, no leading digit). |
columns | array | Yes | Column list (>=1). |
columns[].name | string | Yes | PG name (snake_case). |
columns[].display_name | string | Yes | Friendly name. |
columns[].type | enum | Yes | text | integer | decimal | boolean | date | datetime. |
columns[].nullable | boolean | Yes | Whether NULL is allowed. |
{
"display_name": "vendas_mensais",
"columns": [
{ "name": "produto", "display_name": "Product", "type": "text", "nullable": false },
{ "name": "valor", "display_name": "Value", "type": "decimal", "nullable": true }
]
}
Response: 201 Created — same shape as GET /:id.
Errors:
| HTTP | Code | Description |
|---|---|---|
| 400 | MANAGED_TABLE_DUPLICATE_NAME | Active name already exists |
| 400 | - | Database not provisioned |
| 422 | - | Invalid schema (display_name or columns) |
Update table
/v1/managed-tables/:idRequired Permission: managed-tables:update
Body (optional fields):
{
"display_name": "novo_nome",
"columns": [...],
"status": "active"
}
Response: 200 OK
Delete table
/v1/managed-tables/:idRequired Permission: managed-tables:delete
Soft-delete: the table disappears from listings. Restoration is not exposed by the API.
Response: 204 No Content
File upload
3-step flow:
- Upload → creates a job in
queuedstatus. Worker processes the file and infers the schema (statusanalisando→waiting_confirm). - Confirm → user reviews the inferred schema and confirms; job goes to
inserting→done. - Polling → frontend tracks state via
GET /upload-jobs/:jobId.
Accepts .csv, .xlsx, .xls, .zip. Up to 10 files per upload, 100MB total.
Upload (create new table)
/v1/managed-tables/uploadRequired Permission: managed-tables:upload
Content-Type: multipart/form-data
Form fields:
| Field | Type | Description |
|---|---|---|
files | file[] | 1-10 files (repeated field). |
Response: 201 Created
{
"job_id": "507f1f77bcf86cd799439011",
"status": "queued"
}
Upload (append to existing table)
/v1/managed-tables/:id/uploadRequired Permission: managed-tables:upload
Content-Type: multipart/form-data
Form fields:
| Field | Type | Default | Description |
|---|---|---|---|
files | file[] | - | 1-10 files. |
mode | string | append | append (adds rows) or replace (truncate + insert). |
Response: same shape as the new-table upload.
List active jobs
/v1/managed-tables/upload-jobs/activeRequired Permission: managed-tables:list
Returns jobs that are not yet terminal (queued, analisando, waiting_confirm, inserting).
Response: 200 OK
{
"records": [
{
"_id": "...",
"company_id": "...",
"user_table_id": null,
"files": [
{ "original_name": "vendas.csv", "storage_path": "...", "size_bytes": 12345, "mime_type": "text/csv" }
],
"mode": "create",
"status": "waiting_confirm",
"inferred_schema": {
"suggested_table_name": "vendas",
"columns": [{ "name": "produto", "display_name": "Product", "type": "text", "nullable": false }],
"sample_rows": [{ "produto": "X" }],
"total_row_count_estimate": 1500
},
"confirmed_schema": null,
"progress": null,
"error": null,
"created_at": "2026-01-15T10:30:00.000Z",
"updated_at": "2026-01-15T10:30:00.000Z"
}
]
}
Get job
/v1/managed-tables/upload-jobs/:jobIdRequired Permission: managed-tables:list
Response: 200 OK — same shape as the active list item.
Possible statuses: queued, analisando, waiting_confirm, inserting, done, failed.
Errors: 404 { "error": "Job não encontrado" }
Confirm job schema
/v1/managed-tables/upload-jobs/:jobId/confirmRequired Permission: managed-tables:upload
Applies the user-confirmed schema and enqueues the inserting phase.
Body:
{
"display_name": "vendas",
"columns": [
{ "name": "produto", "display_name": "Product", "type": "text", "nullable": false }
]
}
For an upload into an existing table (user_table_id set on the job), display_name reuses the legacy name and is not validated against the regex.
Response: 200 OK
{ "status": "inserting" }
Errors:
| HTTP | Code | Description |
|---|---|---|
| 400 | - | Job is not in waiting_confirm |
| 400 | MANAGED_TABLE_DUPLICATE_NAME | Name already exists (only on creation) |
| 422 | - | Invalid display_name (regex), invalid column (name/type) |
Data CRUD
List rows
/v1/managed-tables/:id/dataRequired Permission: managed-tables:data-read
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
per_page | integer | 50 | Items per page |
sort_by | string | - | field ASC or -field DESC |
Response: 200 OK
{
"total": 1500,
"quantity": 50,
"records": [
{ "id": "uuid", "produto": "X", "valor": 12.5 }
]
}
Insert rows
/v1/managed-tables/:id/dataRequired Permission: managed-tables:data-write
Body:
{
"rows": [
{ "produto": "X", "valor": 12.5 },
{ "produto": "Y", "valor": 30 }
]
}
Response: 201 Created
{ "inserted": 2 }
Errors: 422 if rows is missing or empty.
Update row
/v1/managed-tables/:id/data/:rowIdRequired Permission: managed-tables:data-write
Body: object with the fields to update.
Response: 200 OK — updated row.
Errors: 404 { "error": "Linha não encontrada" }
Delete row
/v1/managed-tables/:id/data/:rowIdRequired Permission: managed-tables:data-write
Response: 204 No Content
Errors: 404 { "error": "Linha não encontrada" }
Column types
| Type | PostgreSQL DDL | Description |
|---|---|---|
text | TEXT | Free-form string |
integer | BIGINT | 64-bit integer |
decimal | NUMERIC | Arbitrary-precision number |
boolean | BOOLEAN | true/false |
date | DATE | Date (no time) |
datetime | TIMESTAMPTZ | Timestamp with timezone |
Limits and constraints
- Upload: max 100MB total, max 10 files per call.
- Table / column name: regex
^[a-z_][a-z0-9_]*$. - Table
display_nameis unique per company among active tables. DELETEis a soft-delete: the table disappears from listings. Restoration is not exposed by the API.
Error codes
| HTTP | Code | Description |
|---|---|---|
| 400 | MANAGED_TABLE_DUPLICATE_NAME | display_name conflict in the company |
| 400 | - | Database not provisioned / job not in waiting_confirm |
| 401 | - | Missing or invalid JWT |
| 403 | - | Missing IAM permission (managed-tables:*) |
| 404 | - | Table, row, or job does not exist |
| 422 | - | Invalid schema (identifier regex / column type) |
| 500 | - | Internal error |