ApiQL v3 realtime-first API platform
ApiQL v3 Documentation

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.

Runtime model
REST

Read and write records with normal HTTP requests.

WebSocket

Subscribe to table events generated by ApiQL CRUD writes.

Browser
  -> ApiQL JS SDK
    -> REST / WebSocket
      -> ApiQL Engine
        -> MySQL

What changed in v3

StandaloneIndependent v3 service

v3 lives as its own compiled Go service and does not depend on the v2 runtime.

RealtimeInternal event bus

Insert, update and delete operations publish events to matching WebSocket subscriptions.

SDKSix JS methods

get, insert, update, delete, on and off.

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
After every config change, restart the service. The config is external, so you do not rebuild Go, but the running process must reload the JSON file.

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"
}
KeyMeaning
listenLocal address where the Go service listens. Default v3 port is 8098.
base_urlPublic URL used in generated runtime documentation and SDK examples.
allow_allWhen true, every non-system MySQL table/view becomes an endpoint with all actions enabled.
allowed_actionsTable-by-table action list when allow_all is false.
disabled_columnsColumns removed from reads and blocked from writes unless allow_all is enabled.
permissionsOptional 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.

Fixed token
{
  "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 table
{
  "token": "first_admin_token",
  "tokens_table": "_apiql_tokens"
}

ApiQL creates the table automatically and validates rows where status = "valid".

Automatic token table schema

ColumnPurpose
idToken row id.
tokenUnique API token.
user_idUser claim available as {CURRENT_USER_ID}.
roleRole claim available as {CURRENT_ROLE}.
statusvalid or invalid.
created_atCreation timestamp.
last_used_atUpdated automatically on successful auth.

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

FeatureRESTJS SDK
Fieldsfield[users.id]=userId{ fields: { 'users.id': 'userId' } }
Searchsearch[users.full_name]=mih{ search: { 'users.full_name': 'mih' } }
Filterfilter[users.status]=active{ filter: { 'users.status': 'active' } }
Sortsort[users.id]=DESC{ sort: { 'users.id': 'DESC' } }
Paginationlimit=20&offset=0{ limit: 20, offset: 0 }
Merge/JOINmerge[profiles.id]=profile_id{ merge: { 'profiles.id': 'profile_id' } }
Add related rowadd[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 like users, and action events like users.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 cascade are 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_tables is explicitly enabled.
  • Use allow_all: true only for trusted internal systems.