Skip to content

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

  1. 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).
  2. 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.
  3. The runner. In the panel, the setup wizard writes its runner.json and 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.
  4. 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 enable yourself.

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: one docker run line with a one-time token (lsi_…), valid for 30 minutes (install.expires_at). Show it with a copy button. install.command_noninteractive is the same for automation (docker run -d with LOKADB_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.