Integration guide¶
Everything your product needs to offer a customer their own database: a "Move our data to our own server" button. One call from your backend, two things to show the customer, and webhooks to follow the move.
Every example on this page is a file in our repository that our end-to-end test runs against a live service on each change: the curl calls one by one, the Node.js, Python, PHP and Go code as written. Set three variables for the curl ones:
export LOKADB_API=https://api.lokadb.com
export LOKADB_KEY=lss_… # your API key (step 1)
export LOKADB_ACCOUNT=… # your account's id: in the panel's address, or "account" in any move
What you need first¶
- An active account. Ask for access on the LokaDB site; we activate the account and set its plan (how many organizations may be at customers at once).
- LokaDB in front of your database. The open proxy and relay, set up as Install on your database says. Your application connects through the proxy.
- The runner. In the panel, the setup wizard writes its
runner.jsonand a one-time command. Run it next to the proxy, in the network that reaches your database and the relay. The runner does all the database work and keeps your passwords; it connects out to us. See The runner. - LokaDB enabled in your database. In the panel: a check of your database, then enable (the account's owner; you type the database's name). You can also run
lokadb enableyourself.
Check from the API that all is in place: an installation with "enabled": true and a runner with "online": true.
curl -sS "$LOKADB_API/v1/accounts/$LOKADB_ACCOUNT/installations" \
-H "Authorization: Bearer $LOKADB_KEY"
curl -sS "$LOKADB_API/v1/accounts/$LOKADB_ACCOUNT/runners" \
-H "Authorization: Bearer $LOKADB_KEY"
What your customer needs¶
| A PostgreSQL database | Theirs, on their servers or their own cloud account, and its administrator login for the installer. The installer creates two roles of its own (no superuser rights) and the extensions your tables need. The check compares the database with yours and names anything missing. |
| A server with Docker | In their network, able to reach that database. The install command is one docker run; the container then stays running as the agent (--restart unless-stopped), with a volume that keeps its credentials. |
| Outbound connections only | From the agent to the LokaDB API (HTTPS) and to your relay's agent and enrollment ports (the command carries your relay's address). Nothing inbound: no port is opened in their firewall, and their database is not exposed. |
| Someone to run one command | The person who administers their database. The installer shows your runner's key fingerprint: the same as in your panel. |
More in The installer and the agent.
1. An API key¶
In the panel, Account → API keys (the account's owner): name it after the system that uses it. The key (lss_…) is shown once. A key acts as a member of the account; a read-only key can only read, which suits a dashboard.
Only from your backend
The key is your whole account's. Never send it to a browser or a mobile app: your cabinet's page calls your backend, and your backend calls us.
curl -sS "$LOKADB_API/v1/accounts/$LOKADB_ACCOUNT/plan" \
-H "Authorization: Bearer $LOKADB_KEY"
2. Your webhook receiver¶
Every state change of a move is posted to your receiver, signed with your account's secret. Set the receiver once; the secret (whs_…) is in this answer only (a later "rotate": true makes a new one). Set it even if you give each move its own callback_url: its secret signs those deliveries too. The panel has the same under Account → Alerts & webhook.
curl -sS -X PUT "$LOKADB_API/v1/accounts/$LOKADB_ACCOUNT/webhook" \
-H "Authorization: Bearer $LOKADB_KEY" \
-H "Content-Type: application/json" \
-d "{\"url\": \"$URL\", \"events\": [\"move.state\"]}"
3. Ask for the move¶
When the customer presses your button, your backend asks for a move of their organization: its number (org), or tenant when your tenants are uuids. external_ref is yours (a customer id): it comes back in the move and every webhook. Add "installation": "in_…" when the account has several databases, "require_approval": true to approve each move yourself, "customer_can_move_back": true to let the customer move back from their cabinet, "callback_url" for a receiver of this move only.
curl -sS "$LOKADB_API/v1/moves" \
-H "Authorization: Bearer $LOKADB_KEY" \
-H "Content-Type: application/json" \
-d "{\"org\": $ORG, \"external_ref\": \"$REF\"}"
Node.js¶
// Ask for a move of one organization. Call it from your backend only: the key is your account's.
async function createMove(org, externalRef) {
const res = await fetch(`${API}/v1/moves`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LOKADB_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ org, external_ref: externalRef }),
});
const move = await res.json();
if (res.status !== 201) {
// 402 plan_limit, 409 a live move already (move.id), 503 your runner did not answer
throw new Error(`HTTP ${res.status}: ${move.error}${move.hint ? " (" + move.hint + ")" : ""}`);
}
// Show the organization's administrator these two; they are in this answer only.
return { id: move.id, command: move.install.command, cabinet: move.portal_url };
}
Python¶
# Ask for a move of one organization. Call it from your backend only: the key is your account's.
def create_move(org, external_ref=""):
req = urllib.request.Request(
API + "/v1/moves",
method="POST",
data=json.dumps({"org": org, "external_ref": external_ref}).encode(),
headers={
"Authorization": "Bearer " + os.environ["LOKADB_KEY"],
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=30) as res:
move = json.load(res)
except urllib.error.HTTPError as e:
# 402 plan_limit, 409 a live move already (its id), 503 your runner did not answer
err = json.load(e)
raise RuntimeError("HTTP %d: %s %s" % (e.code, err.get("error"), err.get("hint", "")))
# Show the organization's administrator these two; they are in this answer only.
return {"id": move["id"], "command": move["install"]["command"], "cabinet": move["portal_url"]}
PHP¶
// Ask for a move of one organization. Call it from your backend only: the key is your account's.
function createMove(int $org, string $externalRef = ''): array
{
global $API;
$ch = curl_init("$API/v1/moves");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('LOKADB_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['org' => $org, 'external_ref' => $externalRef]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$move = json_decode((string) $body, true);
if ($status !== 201) {
// 402 plan_limit, 409 a live move already (its id), 503 your runner did not answer
throw new RuntimeException("HTTP $status: " . ($move['error'] ?? '') . ' ' . ($move['hint'] ?? ''));
}
// Show the organization's administrator these two; they are in this answer only.
return ['id' => $move['id'], 'command' => $move['install']['command'], 'cabinet' => $move['portal_url']];
}
Go¶
// Move is the part of a move's answer the button needs.
type Move struct {
ID string `json:"id"`
Install struct {
Command string `json:"command"`
} `json:"install"`
PortalURL string `json:"portal_url"`
Error string `json:"error"`
Hint string `json:"hint"`
}
// createMove asks for a move of one organization. Call it from your backend only: the key is
// your account's.
func createMove(org int64, externalRef string) (*Move, error) {
body, _ := json.Marshal(map[string]any{"org": org, "external_ref": externalRef})
req, _ := http.NewRequest("POST", api+"/v1/moves", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOKADB_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var m Move
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
return nil, err
}
if res.StatusCode != http.StatusCreated {
// 402 plan_limit, 409 a live move already (its id), 503 your runner did not answer
return nil, fmt.Errorf("HTTP %d: %s %s", res.StatusCode, m.Error, m.Hint)
}
// Show the organization's administrator m.Install.Command and m.PortalURL; they are in
// this answer only.
return &m, nil
}
The answers¶
201 |
The move: id, state: token_issued, install.command, portal_url, actions. Keep the id with the customer's record. |
409 |
The organization has a live move already: id names it. Show that one (a new command: token, a new link: portal, below). |
402 |
error starts with plan_limit:: all the plan's organizations are in use. Tell the customer you will come back to them; upgrade, or move one back. |
503 |
Your runner did not answer: it is down or not connected (GET /v1/accounts/{a}/runners). Nothing was made; ask again later. |
400, 403 |
A wrong field (say, an organization the runner does not find); the account is pending or suspended. |
4. Show the command and the cabinet¶
Two things from the answer go to the customer's administrator, and both are in this answer only: store them with the move, or ask for new ones.
install.command: onedocker runline with a one-time token (lsi_…), valid for 30 minutes (install.expires_at). Show it with a copy button.install.command_noninteractiveis the same for automation (docker run -dwithLOKADB_PG_*variables instead of questions).portal_url: the customer's cabinet. A link they open without an account: the move's status, what to fix in their database in plain words, a new install command, and moving back if you allow it.
An expired or lost command: token makes a new one (the old token stops working). A lost cabinet link: portal makes a new one (the old link stops working).
curl -sS -X POST "$LOKADB_API/v1/moves/$MOVE/token" \
-H "Authorization: Bearer $LOKADB_KEY"
curl -sS -X POST "$LOKADB_API/v1/moves/$MOVE/portal" \
-H "Authorization: Bearer $LOKADB_KEY"
5. Follow the move¶
Your receiver gets a POST at each state change, at least once (deduplicate by the X-Lokadb-Delivery header), retried for 24 hours until it answers 2xx:
{"type": "move.state", "id": "mv_…", "org": 7, "external_ref": "acme-corp",
"state": "checked", "previous": "checking", "at": "2026-11-02T10:14:03Z"}
Verify each one before you trust it: X-Lokadb-Signature is sha256= and the hex HMAC-SHA256, with your secret, of X-Lokadb-Timestamp, a dot and the raw body. Refuse a timestamp older than 5 minutes.
Node.js¶
// Verify a delivery before you trust it: the raw body (not re-serialized JSON), the two headers.
function verifyWebhook(secret, timestamp, signature, rawBody) {
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false; // older than 5 minutes: a replay
const want = "sha256=" + createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
const a = Buffer.from(want), b = Buffer.from(String(signature));
return a.length === b.length && timingSafeEqual(a, b);
}
// In Express:
// app.post("/hooks/lokadb", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyWebhook(secret, req.get("X-Lokadb-Timestamp"), req.get("X-Lokadb-Signature"), req.body))
// return res.sendStatus(401);
// const event = JSON.parse(req.body); // { type: "move.state", id, org, external_ref, state, ... }
// res.sendStatus(200);
// });
Python¶
# Verify a delivery before you trust it: the raw body (not re-serialized JSON), the two headers.
def verify_webhook(secret, timestamp, signature, raw_body):
try:
if abs(time.time() - int(timestamp)) > 300: # older than 5 minutes: a replay
return False
except (TypeError, ValueError):
return False
mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
return hmac.compare_digest("sha256=" + mac.hexdigest(), signature or "")
# In Flask:
# raw = request.get_data()
# if not verify_webhook(secret, request.headers.get("X-Lokadb-Timestamp"),
# request.headers.get("X-Lokadb-Signature"), raw):
# abort(401)
# event = json.loads(raw) # {"type": "move.state", "id", "org", "external_ref", "state", ...}
PHP¶
// Verify a delivery before you trust it: the raw body (not re-serialized JSON), the two headers.
function verifyWebhook(string $secret, ?string $timestamp, ?string $signature, string $rawBody): bool
{
if ($timestamp === null || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
return false; // older than 5 minutes: a replay
}
$want = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($want, (string) $signature);
}
// In a plain PHP endpoint:
// $raw = file_get_contents('php://input');
// if (!verifyWebhook($secret, $_SERVER['HTTP_X_LOKADB_TIMESTAMP'] ?? null,
// $_SERVER['HTTP_X_LOKADB_SIGNATURE'] ?? null, $raw)) {
// http_response_code(401);
// exit;
// }
// $event = json_decode($raw, true); // ['type' => 'move.state', 'id', 'org', 'external_ref', 'state', ...]
Go¶
// verifyWebhook checks a delivery before you trust it: the raw body (not re-encoded JSON) and
// the two headers.
func verifyWebhook(secret, timestamp, signature string, rawBody []byte) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if age := time.Since(time.Unix(ts, 0)); age > 5*time.Minute || age < -5*time.Minute {
return false // older than 5 minutes: a replay
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
want := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(want), []byte(signature))
}
// In a handler:
//
// raw, _ := io.ReadAll(r.Body)
// if !verifyWebhook(secret, r.Header.Get("X-Lokadb-Timestamp"), r.Header.Get("X-Lokadb-Signature"), raw) {
// http.Error(w, "bad signature", http.StatusUnauthorized)
// return
// }
// var event struct {
// Type, ID, State string
// Org int64
// ExternalRef string `json:"external_ref"`
// }
// json.Unmarshal(raw, &event)
The states¶
| State | |
|---|---|
token_issued |
Waiting for the customer to run the command. |
agent_connected |
The agent is up in their network; the check is queued. |
checking |
The runner checks their database against yours. |
checked |
Nothing blocks the move. It starts by itself, or waits for your approve when you asked for approval. |
blocked |
Something must be fixed first: findings says what, and who says whether the customer (customer) or you (saas) has to act. Then recheck. |
moving |
The rows are being moved; your application keeps working, writes included. |
moved |
The organization lives in the customer's database. |
moving_back, moved_back |
On its way back to your database; back (final). |
failed |
A step failed: phase (check, move, move_back) and error say which. retry or recheck, as actions allows. |
cancelled |
Stopped before the move (final). A new move of the organization may be asked for. |
Reading a move¶
Without webhooks, or to show progress live, read the move. actions in it lists what you may do now; progress is the latest line of the runner's progress.
curl -sS "$LOKADB_API/v1/moves/$MOVE" \
-H "Authorization: Bearer $LOKADB_KEY"
curl -sS "$LOKADB_API/v1/moves/$MOVE/events?after=0" \
-H "Authorization: Bearer $LOKADB_KEY"
The stream is server-sent events: event: status when the move changes, one event per line of progress, ending with the move.
curl -sSN "$LOKADB_API/v1/moves/$MOVE/stream" \
-H "Authorization: Bearer $LOKADB_KEY"
curl -sS "$LOKADB_API/v1/moves?org=$ORG" \
-H "Authorization: Bearer $LOKADB_KEY"
The check's full report, once the check ran:
curl -sS "$LOKADB_API/v1/moves/$MOVE/report" \
-H "Authorization: Bearer $LOKADB_KEY"
6. Approve, fix, recheck¶
With require_approval, a checked move waits for you: look at the report, then approve. A call refused in the move's state answers 409 with the state and the actions it allows.
curl -sS -X POST "$LOKADB_API/v1/moves/$MOVE/approve" \
-H "Authorization: Bearer $LOKADB_KEY"
A blocked move: the customer fixes what findings lists as theirs (their cabinet shows the same in words), you fix yours, then recheck (the same form). retry starts a failed step again.
7. Move back or cancel¶
A moved organization comes back to your database with one call (and from the customer's cabinet too, if the move has customer_can_move_back). Before the move switches, cancel stops it and revokes the agent.
curl -sS -X POST "$LOKADB_API/v1/moves/$MOVE/move-back" \
-H "Authorization: Bearer $LOKADB_KEY"
curl -sS -X POST "$LOKADB_API/v1/moves/$MOVE/cancel" \
-H "Authorization: Bearer $LOKADB_KEY"
The current releases of the runner and the agent, to compare with yours (no key needed):
curl -sS "$LOKADB_API/v1/versions"
Texts for your cabinet¶
Suggestions; change the product name and the tone to yours.
| The button | Move our data to our own server |
| Before they press it | Your data will be moved to a PostgreSQL database that you run. Your team keeps using [Product] as before. You will need a server with Docker in your network that can reach the database, and the database's administrator login. No port needs to be opened. |
| Next to the command | Run this on a server in your network that can reach your PostgreSQL. It asks for the database's address and an administrator login, creates two roles for [Product], and keeps running afterwards: [Product] reaches your data through it. The command works once, within 30 minutes. |
| Next to the link | Follow the move, see anything that needs fixing in your database, and get a new command: [link] |
token_issued |
Waiting for the command to be run in your network. |
checking |
Checking your database. |
blocked |
Something in your database needs a change before the move. Your status page says what. |
checked, awaiting approval |
Your database is ready. We will start the move shortly. |
moving |
Moving your data. [Product] keeps working meanwhile. |
moved |
Your data is in your own database. |
failed |
The move stopped. We are looking into it; nothing is lost. |
Errors and limits¶
Errors are JSON: error in words, and where there is one a stable code (unauthenticated, account_pending, read_only_key…) and a hint. 401: no valid key. 403: a read-only key, an owner's call (enable, removing a runner), or an account that is not active. 404: no such object in your account. 402: error starts with plan_limit:.
Each key may make 600 requests a minute and each address 1,200, with bursts of 10 seconds' worth. Past that a call answers 429 (code: rate_limited) with Retry-After, the seconds to wait. A move's stream is one request however long it stays open: follow it rather than poll, or poll a move at most once every few seconds.
Every call and field: the API reference, or openapi.yaml for your own client generator.