One Service. REST and Realtime.
ApiQL v3 is a standalone Go binary that turns MySQL tables and views into REST endpoints and WebSocket realtime events using one shared auth, permission, query and CRUD engine.
Read and write records with normal HTTP requests.
Subscribe to table events generated by ApiQL CRUD writes.
Browser
-> ApiQL JS SDK
-> REST / WebSocket
-> ApiQL Engine
-> MySQL
What changed in v3
v3 lives as its own compiled Go service and does not depend on the v2 runtime.
Insert, update and delete operations publish events to matching WebSocket subscriptions.
get, insert, update, delete, on and off.
AI agent guide
ApiQL v3 includes a dedicated AGENTS.md file for AI coding agents, deployment agents and automation tools. It explains how to install, configure, authenticate, query, use WebSocket realtime, work with the JavaScript SDK, validate deployments and troubleshoot common errors.
Give this file to an AI agent before asking it to install or integrate ApiQL v3.
curl -fsSL https://apiql.net/documentation/v3/downloads/AGENTS.md -o AGENTS.md
Use a short instruction that forces the agent to read the operational guide first.
Read https://apiql.net/documentation/v3/downloads/AGENTS.md first.
Install and configure ApiQL v3 exactly as documented.
Do not expose tokens or database passwords.
/_documentation on the installed service as the source of truth for available endpoints.Installation
The installer downloads the latest compiled Linux binary, creates an external JSON config, installs a systemd service and can optionally prepare an Apache reverse proxy.
Basic install
curl -fsSL https://apiql.net/documentation/v3/downloads/install.sh | sudo bash
Edit config
sudo nano /etc/apiql-v3/config.json
Start or restart service
sudo systemctl enable --now apiql-v3
sudo systemctl restart apiql-v3
Install with database values
curl -fsSL https://apiql.net/documentation/v3/downloads/install.sh | sudo env \
APIQL_DB_HOST=localhost \
APIQL_DB_NAME=my_database \
APIQL_DB_USER=my_user \
APIQL_DB_PASS='my_password' \
APIQL_TOKEN='change_this_token' \
bash
By default the installer starts from 127.0.0.1:8098. If that port is already used by another service, it automatically selects the next free port and writes it to /etc/apiql-v3/config.json.
Install on a custom port
curl -fsSL https://apiql.net/documentation/v3/downloads/install.sh | sudo env \
APIQL_PORT=8105 \
APIQL_DB_HOST=localhost \
APIQL_DB_NAME=my_database \
APIQL_DB_USER=my_user \
APIQL_DB_PASS='my_password' \
APIQL_TOKEN='change_this_token' \
bash
For a full listen address, use APIQL_LISTEN instead:
curl -fsSL https://apiql.net/documentation/v3/downloads/install.sh | sudo env \
APIQL_LISTEN=127.0.0.1:8105 \
APIQL_DB_NAME=my_database \
APIQL_DB_USER=my_user \
APIQL_DB_PASS='my_password' \
APIQL_TOKEN='change_this_token' \
bash
Install with Apache domain and WebSocket proxy
curl -fsSL https://apiql.net/documentation/v3/downloads/install.sh | sudo env \
APIQL_DOMAIN=api.example.com \
APIQL_EMAIL=admin_at_example_dot_com \
APIQL_DB_NAME=my_database \
APIQL_DB_USER=my_user \
APIQL_DB_PASS='my_password' \
APIQL_TOKEN='change_this_token' \
bash
Replace admin_at_example_dot_com with your real certificate email address before running the command.
Configuration
ApiQL v3 keeps configuration outside the binary. Users receive the compiled binary and edit only the JSON file.
{
"listen": "127.0.0.1:8098",
"app_name": "ApiQL v3 project",
"base_url": "https://api.example.com/",
"token": "change_this_token",
"tokens_table": "_apiql_tokens",
"max_limit_per_page": 100,
"default_per_page": 20,
"allow_all": false,
"allowed_actions": {
"users": ["list", "insert", "update", "delete"]
},
"disabled_columns": ["password", "token", "secret"],
"hostname": "localhost",
"port": 3306,
"username": "dbuser",
"password": "dbpass",
"database": "dbname"
}
| Key | Meaning |
|---|---|
listen | Local address where the Go service listens. Default v3 port is 8098. |
base_url | Public URL used in generated runtime documentation and SDK examples. |
allow_all | When true, every non-system MySQL table/view becomes an endpoint with all actions enabled. |
allowed_actions | Table-by-table action list when allow_all is false. |
disabled_columns | Columns removed from reads and blocked from writes unless allow_all is enabled. |
permissions | Optional row-level read filters and automatic insert/update values based on token claims. |
Token authentication
REST accepts Authorization: Bearer TOKEN, X-APIQL-TOKEN: TOKEN or ?token=TOKEN. WebSocket clients connect first, then send an auth message.
{
"token": "change_this_token",
"tokens_table": null
}
ApiQL compares every request token with the fixed JSON token. The authenticated role is admin and user_id is 0.
{
"token": "first_admin_token",
"tokens_table": "_apiql_tokens"
}
ApiQL creates the table automatically and validates rows where status = "valid".
Automatic token table schema
| Column | Purpose |
|---|---|
id | Token row id. |
token | Unique API token. |
user_id | User claim available as {CURRENT_USER_ID}. |
role | Role claim available as {CURRENT_ROLE}. |
status | valid or invalid. |
created_at | Creation timestamp. |
last_used_at | Updated automatically on successful auth. |
Token-based permissions
Permissions are optional row-level and data-level rules. Use them when every token should see or modify only the rows that belong to its authenticated user_id or role.
allowed_actions decides which CRUD actions exist for each table. permissions decides which rows those actions may read, update or delete, and which values ApiQL should force during insert or update.
{
"tokens_table": "_apiql_tokens",
"allow_all": false,
"allowed_actions": {
"users": ["list", "update"],
"shopping_cart": ["list", "insert", "update", "delete"],
"messages": ["list", "insert", "update", "delete"]
},
"permissions": {
"users": {
"read": {
"id": "{CURRENT_USER_ID}"
},
"update": {
"id": "{CURRENT_USER_ID}"
}
},
"shopping_cart": {
"read": {
"user_id": "{CURRENT_USER_ID}"
},
"insert_values": {
"user_id": "{CURRENT_USER_ID}"
},
"update": {
"user_id": "{CURRENT_USER_ID}"
},
"update_values": {
"user_id": "{CURRENT_USER_ID}"
},
"delete": {
"user_id": "{CURRENT_USER_ID}"
}
},
"messages": {
"read": {
"receiver_id": "{CURRENT_USER_ID}"
},
"insert_values": {
"sender_id": "{CURRENT_USER_ID}"
},
"update": {
"sender_id": "{CURRENT_USER_ID}"
},
"delete": {
"sender_id": "{CURRENT_USER_ID}"
}
}
}
}
| Rule | Behavior |
|---|---|
read | Adds an automatic filter to list/get requests. Example: return only rows where shopping_cart.user_id equals the authenticated user id. |
insert_values | Forces values during insert. Example: user_id is written from the token claim, not trusted from the browser. |
update | Allows update only when the existing row matches the rule. |
update_values | Forces values during update, useful for keeping ownership columns unchanged. |
delete | Allows delete only when the existing row matches the rule. |
Available placeholders
{CURRENT_USER_ID}
{USER_ID}
{CURRENT_ROLE}
{ROLE}
{TOKEN}
{CURRENT_USER_ID} and {CURRENT_ROLE} values, use tokens_table. With a fixed config token and "tokens_table": null, ApiQL authenticates as role admin with user_id equal to 0.Service commands
sudo systemctl status apiql-v3
sudo systemctl restart apiql-v3
sudo journalctl -u apiql-v3 -f
Runtime docs from the service are available at:
http://127.0.0.1:8098/_documentation
https://api.example.com/_documentation
REST transport
Get records
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://api.example.com/users?limit=20&offset=0"
Get one record
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://api.example.com/users/5"
Insert
curl -X POST "https://api.example.com/users" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"full_name":"Mihajlo","email":"mihajlo_at_example.com"}'
Update
curl -X POST "https://api.example.com/users/5" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"full_name":"Mihajlo Siljanoski"}'
Delete
curl -X DELETE "https://api.example.com/users/5" \
-H "Authorization: Bearer YOUR_TOKEN"
WebSocket transport
Connect to wss://api.example.com/ws, authenticate, then send CRUD or subscribe messages.
Authenticate
{"action":"auth","token":"YOUR_TOKEN"}
Subscribe
{"action":"subscribe","event":"users.update"}
Read through WebSocket
{
"id": "req_1",
"action": "get",
"table": "users",
"query": {
"filter": { "users.status": "active" },
"limit": 20
}
}
Event message
{
"type": "event",
"event": "users.update",
"table": "users",
"action": "update",
"data": { "id": "5", "full_name": "Mihajlo" }
}
JavaScript SDK
The SDK chooses WebSocket automatically when possible and falls back to REST for CRUD requests in transport: "auto".
Use the SDK from your own v3 service, or use the global minified SDK link from ApiQL.net.
Service-local SDK
<script src="https://api.example.com/sdk/apiql-v3.js"></script>
<script>
const apiql = new ApiQL({
endpoint: 'https://api.example.com',
token: 'YOUR_TOKEN',
transport: 'auto' // auto | rest | ws
});
</script>
Global SDK link
<script src="https://apiql.net/v3/sdk/apiql-min.js"></script>
<script>
const apiql = new ApiQL({
endpoint: 'https://api.example.com',
token: 'YOUR_TOKEN',
transport: 'auto' // auto | rest | ws
});
</script>
Connection events
apiql.on('connect', function(){
console.log('Connected');
});
apiql.on('disconnect', function(){
console.log('Disconnected');
});
apiql.on('reconnect', function(){
console.log('Reconnected');
});
apiql.on('error', function(error){
console.error(error);
});
CRUD
const users = await apiql.get('users');
const user = await apiql.get('users', 5);
await apiql.insert('users', {
full_name: 'Mihajlo',
email: 'mihajlo_at_example.com'
});
await apiql.update('users', 5, {
full_name: 'Mihajlo Siljanoski'
});
await apiql.delete('users', 5);
Realtime
apiql.on('users', function(event){
console.log(event.action);
console.log(event.data);
});
apiql.on('users.insert', function(user){
console.log(user);
});
const listener = apiql.on('users.update', function(user){
console.log(user);
});
listener.remove();
Realistic JavaScript SDK examples
These examples use practical table names and workflows. Adjust table and column names to match your MySQL schema and keep sensitive columns disabled in the JSON config.
Users admin screen
Load users with fields, search, filters and realtime refresh after any user insert, update or delete.
const apiql = new ApiQL({
endpoint: 'https://api.example.com',
token: sessionStorage.getItem('api_token'),
transport: 'auto'
});
const usersState = {
search: '',
status: 'active'
};
async function loadUsers(){
const users = await apiql.get('users', {
fields: {
'users.id': 'id',
'users.full_name': 'fullName',
'users.email': 'email',
'users.status': 'status',
'users.created_at': 'createdAt'
},
search: usersState.search ? {
'users.full_name': usersState.search
} : {},
filter: usersState.status ? {
'users.status': usersState.status
} : {},
sort: {
'users.id': 'DESC'
},
limit: 25,
offset: 0
});
renderUsersTable(users);
}
async function createUser(form){
await apiql.insert('users', {
full_name: form.fullName.value,
email: form.email.value,
status: 'active'
});
await loadUsers();
}
async function disableUser(userId){
await apiql.update('users', userId, {
status: 'disabled'
});
}
document.querySelector('#user-search').addEventListener('input', function(){
usersState.search = this.value.trim();
loadUsers();
});
await loadUsers();
apiql.on('users', loadUsers);
Shopping cart
Keep a cart synchronized across browser tabs and devices. Every insert, update or delete in cart_items refreshes the visible cart.
const cartId = getCurrentCartId();
async function loadCart(){
const items = await apiql.get('cart_items', {
fields: {
'cart_items.id': 'id',
'cart_items.product_id': 'productId',
'cart_items.quantity': 'quantity',
'cart_items.price': 'price'
},
filter: {
'cart_items.cart_id': cartId
},
add: {
'products.id': 'product_id'
},
sort: {
'cart_items.id': 'ASC'
}
});
renderCart(items);
renderCartTotal(items.reduce(function(total, item){
return total + Number(item.price) * Number(item.quantity);
}, 0));
}
async function addToCart(productId, quantity){
await apiql.insert('cart_items', {
cart_id: cartId,
product_id: productId,
quantity: quantity || 1
});
}
async function changeQuantity(itemId, quantity){
if (quantity <= 0) {
await apiql.delete('cart_items', itemId);
return;
}
await apiql.update('cart_items', itemId, {
quantity: quantity
});
}
await loadCart();
apiql.on('cart_items', loadCart, {
filter: {
'cart_items.cart_id': cartId
}
});
Order status page
Show a customer live order progress without polling. When the backend updates the order through ApiQL, the browser receives the change.
const orderId = getOrderIdFromUrl();
async function loadOrder(){
const order = await apiql.get('orders', orderId);
renderOrderStatus(order.status);
renderOrderSummary(order);
}
await loadOrder();
apiql.on('orders.update', function(order){
if (String(order.id) !== String(orderId)) {
return;
}
renderOrderStatus(order.status);
showMessage('Order status updated to ' + order.status);
}, {
filter: {
'orders.id': orderId
}
});
Live notifications
Use token permissions to return only notifications for the authenticated user, then subscribe to new rows.
async function loadNotifications(){
const notifications = await apiql.get('notifications', {
filter: {
'notifications.is_read': 0
},
sort: {
'notifications.id': 'DESC'
},
limit: 15
});
renderNotifications(notifications);
}
await loadNotifications();
apiql.on('notifications.insert', function(notification){
showToast(notification.title, notification.body);
loadNotifications();
});
async function markNotificationRead(notificationId){
await apiql.update('notifications', notificationId, {
is_read: 1
});
await loadNotifications();
}
Realtime dashboard counters
Create a MySQL view such as dashboard_stats, expose it as a read-only endpoint, and refresh it when related tables change.
async function loadDashboardStats(){
const rows = await apiql.get('dashboard_stats', {
limit: 1
});
const stats = rows[0] || {};
document.querySelector('#active-users').textContent = stats.active_users || 0;
document.querySelector('#pending-orders').textContent = stats.pending_orders || 0;
document.querySelector('#open-tickets').textContent = stats.open_tickets || 0;
}
await loadDashboardStats();
apiql.on('users', loadDashboardStats);
apiql.on('orders', loadDashboardStats);
apiql.on('support_tickets', loadDashboardStats);
PHP examples
v3 examples use plain PHP HTTP requests. There is no PHP SDK section for v3.
GET
<?php
$ch = curl_init('https://api.example.com/users?limit=20');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer YOUR_TOKEN'
],
]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
POST
<?php
$payload = json_encode([
'full_name' => 'Mihajlo',
'email' => 'mihajlo_at_example.com',
]);
$ch = curl_init('https://api.example.com/users');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer YOUR_TOKEN',
'Content-Type: application/json',
],
]);
$response = curl_exec($ch);
curl_close($ch);
Query options
| Feature | REST | JS SDK |
|---|---|---|
| Fields | field[users.id]=userId | { fields: { 'users.id': 'userId' } } |
| Search | search[users.full_name]=mih | { search: { 'users.full_name': 'mih' } } |
| Filter | filter[users.status]=active | { filter: { 'users.status': 'active' } } |
| Sort | sort[users.id]=DESC | { sort: { 'users.id': 'DESC' } } |
| Pagination | limit=20&offset=0 | { limit: 20, offset: 0 } |
| Merge/JOIN | merge[profiles.id]=profile_id | { merge: { 'profiles.id': 'profile_id' } } |
| Add related row | add[countries.id]=country_id | { add: { 'countries.id': 'country_id' } } |
Complex SDK query
const users = await apiql.get('users', {
fields: {
'users.id': 'userId',
'users.full_name': 'fullName',
'profiles.avatar': 'avatar'
},
merge: {
'profiles.id': 'profile_id'
},
search: {
'users.full_name': 'mih'
},
filter: {
'users.status': 'active'
},
sort: {
'users.id': 'DESC'
},
limit: 20,
offset: 0
});
Realtime rules
- Realtime events are generated only by ApiQL v3 CRUD operations.
- Direct MySQL changes do not emit events because they do not pass through ApiQL.
- REST writes and WebSocket writes both publish to the same internal event bus.
- Subscriptions support
*, table events likeusers, and action events likeusers.update. - When WebSocket reconnects, the JS SDK reauthenticates and restores subscriptions automatically.
Security and SQL safety
- All table and column identifiers must match
[A-Za-z0-9_]. - Generated SQL wraps identifiers in backticks, so MySQL reserved words such as
cascadeare handled as`cascade`. - Values are passed as SQL parameters, not string-concatenated.
- The configured token table is treated as an internal system table unless
expose_system_tablesis explicitly enabled. - Use
allow_all: trueonly for trusted internal systems.
