Platform
Authentication: API keys and JWT
Call DocBuilder with an API key or an HS256 JWT, sign the editor embed config, verify tokens in Node, PHP, Python and C#, lifetimes and key rotation.
On this page
O3O uses three kinds of credentials for three different call directions. The table shows which one applies where; the sections below cover each in detail.
| Used for | How it is sent | Key on the O3O server | Notes |
|---|---|---|---|
DocBuilder /v1/* | Authorization: Bearer <API key> | O3O_DOCBUILDER_API_KEYS | Several keys may be configured. GET /v1/status needs no authentication. |
DocBuilder /v1/* | Authorization: Bearer <JWT> | O3O_DOCBUILDER_JWT_SECRET | Optional; enabled only when this variable is set. |
Creating an embed session POST /o3o/embed/session | The token field of the config | O3O_EMBED_JWT_SECRET | HS256 JWT that carries the editor config itself. |
| Other embed session requests | Authorization: Bearer <access_token> | Generated by the gate when the session is created | api.js sends it for you; a mismatch returns 401 invalid_session_token. |
| Callbacks O3O sends to you | X-O3O-Signature header (HMAC SHA-256) | O3O_EMBED_CALLBACK_SECRET, O3O_DOCBUILDER_CALLBACK_SECRET | The reverse direction: you verify O3O's signature. See Callbacks and webhooks. |
API keys for DocBuilder#
An API key is the simplest way for your server to call DocBuilder. The administrator lists keys in O3O_DOCBUILDER_API_KEYS: a comma-separated list where each item is name:key or just key. The name part helps tell apart the keys of different calling applications.
- A key must match
^[A-Za-z0-9_-]{24,128}$; starting it witho3o_is recommended. - Items containing
CHANGE_MEare ignored. With an empty list every authenticated endpoint returns401 unauthorizedwithdetail.reason = "no_keys_configured". - Keys are accepted only in the
Authorizationheader, never in the query string. The server compares keys in constant time. - Rate limits apply to the whole instance, not per key. See Plan limits.
# Generate a random 52-character key
echo "o3o_$(openssl rand -hex 24)"
# online/.env: several keys, one per application
O3O_DOCBUILDER_API_KEYS=crm:o3o_9f2c61d04b7e3a58c1d2e9f0a7b6c5d4e3f2a1b0c9d8e7f6,ketoan:o3o_1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7081
# Reload the configuration (run inside online/)
docker compose --env-file .env -f docker/compose.dev.yml up -d o3o-docbuilder/v1/limitsThe quickest way to test a key: returns the effective plan limits when the key is valid.
curl -s http://localhost:8080/v1/limits \
-H "Authorization: Bearer O3O_DEMO_KEY"// Node.js 18+ (a .mjs file or "type": "module")
const res = await fetch("http://localhost:8080/v1/limits", {
headers: { Authorization: "Bearer O3O_DEMO_KEY" }
});
console.log(res.status, await res.json());# pip install requests
import requests
r = requests.get(
"http://localhost:8080/v1/limits",
headers={"Authorization": "Bearer O3O_DEMO_KEY"},
timeout=30,
)
print(r.status_code, r.json())<?php
$ch = curl_init('http://localhost:8080/v1/limits');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer O3O_DEMO_KEY'],
CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
echo $status, ' ', $body, PHP_EOL;using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");
var res = await http.GetAsync("http://localhost:8080/v1/limits");
Console.WriteLine($"{(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");detail.reason is missing, invalid or no_keys_configured.{
"error": {
"code": "unauthorized",
"message": "Thiếu hoặc sai khoá API.",
"detail": {
"reason": "missing"
},
"request_id": "req_0123456789abcdef"
}
}JWT for DocBuilder#
When the administrator sets O3O_DOCBUILDER_JWT_SECRET (at least 32 characters), the Authorization: Bearer header may carry an HS256 JWT instead of an API key. A value with exactly two dots is treated as a JWT. Use a JWT when you want tokens that expire on their own, or to label the calling application in the logs through sub.
Claims of a DocBuilder JWT
expnumberrequiredUnix seconds. Required. Remaining lifetime at most 24 hours; 60 seconds of clock skew allowed.substringoptionalLabel written to the logs, for example the calling application's name.iatnumberoptionalIssued-at time. JWT libraries usually add it automatically.
# Sign with any of the neighbouring tabs, then send it in the header
TOKEN="$(python3 sign_docbuilder_jwt.py)"
curl -s http://localhost:8080/v1/limits \
-H "Authorization: Bearer $TOKEN"// npm install jsonwebtoken (a .mjs file or "type": "module")
import jwt from "jsonwebtoken";
const token = jwt.sign({ sub: "crm" }, process.env.O3O_DOCBUILDER_JWT_SECRET, {
algorithm: "HS256",
expiresIn: "15m" // enough for one batch of calls; 24 hours at most
});
const res = await fetch("http://localhost:8080/v1/limits", {
headers: { Authorization: `Bearer ${token}` }
});
console.log(res.status, await res.json());# pip install PyJWT requests
import os
import time
import jwt
import requests
now = int(time.time())
token = jwt.encode(
{"sub": "crm", "iat": now, "exp": now + 15 * 60}, # enough for one batch of calls; 24 hours at most
os.environ["O3O_DOCBUILDER_JWT_SECRET"],
algorithm="HS256",
)
r = requests.get(
"http://localhost:8080/v1/limits",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
print(r.status_code, r.json())<?php
// composer require firebase/php-jwt
require __DIR__ . '/vendor/autoload.php';
use Firebase\JWT\JWT;
$now = time();
$token = JWT::encode(
['sub' => 'crm', 'iat' => $now, 'exp' => $now + 15 * 60], // enough for one batch of calls; 24 hours at most
getenv('O3O_DOCBUILDER_JWT_SECRET'),
'HS256'
);
$ch = curl_init('http://localhost:8080/v1/limits');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($ch), PHP_EOL;using System.Net.Http.Headers;
// O3OJwt class from "Checking a token yourself" below; no external package needed (.NET 6+)
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
string token = O3OJwt.Sign(new { sub = "crm", iat = now, exp = now + 15 * 60 },
Environment.GetEnvironmentVariable("O3O_DOCBUILDER_JWT_SECRET")!);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
Console.WriteLine(await http.GetStringAsync("http://localhost:8080/v1/limits"));JWT for the editor embed config#
When the server sets O3O_EMBED_JWT_SECRET, every embed session request must carry a token. The token payload IS the config: the document, editor and ui keys exactly as in the O3O.Editor config, plus exp and iat. When the token is valid the gate uses the config inside it and ignores same-named fields sent alongside, so the page only needs to send {token}. YOUR server signs the token; the key never reaches the browser.
Claims of an embed config JWT
documentobjectrequiredDocument to open:url,title,fileType,key.editorobjectrequiredmode,lang,user,callbackUrl.uiobjectoptionalInterface options implemented in v1.expnumberrequiredUnix seconds. At most 24 hours afteriat(or after now wheniatis absent); 60 seconds of clock skew allowed.iatnumberoptionalRecommended.
| Server configuration | Valid token | No token | Bad or expired token |
|---|---|---|---|
O3O_EMBED_JWT_SECRET set | accepted | 401 token_required | 401 invalid_token / 401 token_expired |
No key, O3O_EMBED_ALLOW_UNSIGNED=1 (DEV only) | token ignored, plain config used | accepted | token ignored, plain config used |
No key, O3O_EMBED_ALLOW_UNSIGNED=0 | 401 embed_auth_not_configured | 401 embed_auth_not_configured | 401 embed_auth_not_configured |
/o3o/embed/sessionapi.js calls this endpoint when you create an O3O.Editor; the token travels in the token field of the request body.
// Your server — npm install jsonwebtoken (a .mjs file or "type": "module")
import jwt from "jsonwebtoken";
const config = {
document: {
url: "https://example.com/files/hop-dong.docx",
title: "Hợp đồng mẫu.docx",
fileType: "docx",
key: "hopdong-42-v7"
},
editor: {
mode: "edit",
lang: "vi",
user: { id: "u-1001", name: "Nguyễn Văn A" },
callbackUrl: "https://example.com/o3o/callback"
},
ui: { closeButton: true }
};
// Key comes from YOUR server's environment and equals O3O's O3O_EMBED_JWT_SECRET
const token = jwt.sign(config, process.env.O3O_EMBED_JWT_SECRET, {
algorithm: "HS256",
expiresIn: "10m" // only needs to cover page load
});
// Browser: only the token returned by your server is needed
// const editor = new O3O.Editor("o3o-editor", {
// token,
// events: { onError: (e) => console.error(e.code, e.message) }
// });# pip install PyJWT
import os
import time
import jwt
now = int(time.time())
payload = {
"document": {
"url": "https://example.com/files/hop-dong.docx",
"title": "Hợp đồng mẫu.docx",
"fileType": "docx",
"key": "hopdong-42-v7",
},
"editor": {
"mode": "edit",
"lang": "vi",
"user": {"id": "u-1001", "name": "Nguyễn Văn A"},
"callbackUrl": "https://example.com/o3o/callback",
},
"ui": {"closeButton": True},
"iat": now,
"exp": now + 10 * 60, # only needs to cover page load
}
# Key comes from YOUR server's environment and equals O3O's O3O_EMBED_JWT_SECRET
token = jwt.encode(payload, os.environ["O3O_EMBED_JWT_SECRET"], algorithm="HS256")<?php
// composer require firebase/php-jwt
require __DIR__ . '/vendor/autoload.php';
use Firebase\JWT\JWT;
$now = time();
$payload = [
'document' => [
'url' => 'https://example.com/files/hop-dong.docx',
'title' => 'Hợp đồng mẫu.docx',
'fileType' => 'docx',
'key' => 'hopdong-42-v7',
],
'editor' => [
'mode' => 'edit',
'lang' => 'vi',
'user' => ['id' => 'u-1001', 'name' => 'Nguyễn Văn A'],
'callbackUrl' => 'https://example.com/o3o/callback',
],
'ui' => ['closeButton' => true],
'iat' => $now,
'exp' => $now + 10 * 60, // only needs to cover page load
];
// Key comes from YOUR server's environment and equals O3O's O3O_EMBED_JWT_SECRET
$token = JWT::encode($payload, getenv('O3O_EMBED_JWT_SECRET'), 'HS256');// O3OJwt class from "Checking a token yourself" below; no external package needed (.NET 6+)
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var payload = new
{
document = new
{
url = "https://example.com/files/hop-dong.docx",
title = "Hợp đồng mẫu.docx",
fileType = "docx",
key = "hopdong-42-v7"
},
editor = new
{
mode = "edit",
lang = "vi",
user = new { id = "u-1001", name = "Nguyễn Văn A" },
callbackUrl = "https://example.com/o3o/callback"
},
ui = new { closeButton = true },
iat = now,
exp = now + 10 * 60 // only needs to cover page load
};
// Key comes from YOUR server's environment and equals O3O's O3O_EMBED_JWT_SECRET
string token = O3OJwt.Sign(payload, Environment.GetEnvironmentVariable("O3O_EMBED_JWT_SECRET")!);Checking a token yourself#
The O3O server is the one that decides whether a token is valid. The functions below apply the same rules as O3O (HS256, mandatory exp, 60 seconds of skew, at most 24 hours) so you can check tokens in automated tests before sending them.
// npm install jsonwebtoken (a .mjs file or "type": "module")
import jwt from "jsonwebtoken";
export function checkToken(token, secret) {
// HS256 only, 60 seconds of clock skew
const payload = jwt.verify(token, secret, { algorithms: ["HS256"], clockTolerance: 60 });
if (typeof payload.exp !== "number") throw new Error("missing exp");
if (payload.exp - Date.now() / 1000 > 24 * 3600) throw new Error("exp is more than 24 hours away");
return payload;
}# pip install PyJWT
import time
import jwt
def check_token(token: str, secret: str) -> dict:
# HS256 only, exp required, 60 seconds of clock skew
payload = jwt.decode(token, secret, algorithms=["HS256"], leeway=60,
options={"require": ["exp"]})
if payload["exp"] - time.time() > 24 * 3600:
raise ValueError("exp is more than 24 hours away")
return payload<?php
// composer require firebase/php-jwt
require __DIR__ . '/vendor/autoload.php';
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
function check_token(string $token, string $secret): object
{
// HS256 only, 60 seconds of clock skew
JWT::$leeway = 60;
$payload = JWT::decode($token, new Key($secret, 'HS256'));
if (!isset($payload->exp) || $payload->exp - time() > 24 * 3600) {
throw new UnexpectedValueException('exp missing or more than 24 hours away');
}
return $payload;
}using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
// Usage: dotnet run -- <token>
string token = args.Length > 0 ? args[0] : "";
JsonElement? payload = O3OJwt.Verify(token, Environment.GetEnvironmentVariable("O3O_EMBED_JWT_SECRET")!);
Console.WriteLine(payload is null ? "invalid token" : payload.Value.GetProperty("exp").ToString());
// Sign and verify HS256 JWTs without external packages (.NET 6+)
public static class O3OJwt
{
public static string Sign(object payload, string secret)
{
string header = B64Url(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" }));
string body = B64Url(JsonSerializer.SerializeToUtf8Bytes(payload));
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
byte[] sig = hmac.ComputeHash(Encoding.ASCII.GetBytes(header + "." + body));
return header + "." + body + "." + B64Url(sig);
}
// Returns the payload when valid, otherwise null
public static JsonElement? Verify(string token, string secret, int leewaySeconds = 60)
{
try
{
string[] parts = token.Split('.');
if (parts.Length != 3) return null;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
byte[] expected = hmac.ComputeHash(Encoding.ASCII.GetBytes(parts[0] + "." + parts[1]));
if (!CryptographicOperations.FixedTimeEquals(expected, B64UrlDecode(parts[2]))) return null;
using var header = JsonDocument.Parse(B64UrlDecode(parts[0]));
if (!header.RootElement.TryGetProperty("alg", out JsonElement alg) || alg.GetString() != "HS256") return null;
using var doc = JsonDocument.Parse(B64UrlDecode(parts[1]));
JsonElement payload = doc.RootElement.Clone();
if (!payload.TryGetProperty("exp", out JsonElement exp)) return null;
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (now > exp.GetInt64() + leewaySeconds) return null;
if (exp.GetInt64() - now > 24 * 3600) return null;
return payload;
}
catch (FormatException) { return null; }
catch (JsonException) { return null; }
}
static string B64Url(byte[] data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
static byte[] B64UrlDecode(string s)
{
s = s.Replace('-', '+').Replace('_', '/');
return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
}
}Lifetimes#
| Credential | Lifetime | When it expires |
|---|---|---|
| DocBuilder JWT | exp required, at most 24 hours remaining | 401 token_expired |
| Embed config JWT | exp required, at most 24 hours | 401 token_expired when creating a session; open sessions are not affected |
Embed session access_token | O3O_EMBED_SESSION_TTL_MINUTES, 720 minutes by default | Session becomes expired; the final save is still accepted |
| Download URL of an embedded document version | O3O_EMBED_RETAIN_HOURS, 24 hours by default | 410 link_expired |
| DocBuilder result files | result_ttl_minutes of the plan: 15 minutes (community), 1,440 minutes (enterprise) | 410 gone |
Rotating keys#
Environment variables take effect only when the container is recreated. After editing .env, run docker compose ... up -d: Compose recreates exactly the services whose configuration changed.
DocBuilder API keys: no downtime
Add the new key toO3O_DOCBUILDER_API_KEYSwhile KEEPING the old one, and reloado3o-docbuilder. Move each calling application to the new key. When the logs show no more requests with the old key, remove it from the list and reload again.JWT keys (
O3O_DOCBUILDER_JWT_SECRET,O3O_EMBED_JWT_SECRET)v1 accepts ONE key of each kind, so tokens signed with the old key are rejected as soon as the new key is active. Pick a quiet hour and change the key in the signing application and in.envtogether. For DocBuilder you can call with an API key during the switch. For embedding, only CREATING new sessions is affected; open sessions continue with their ownaccess_token.Callback signing keys (
O3O_EMBED_CALLBACK_SECRET,O3O_DOCBUILDER_CALLBACK_SECRET)Let your callback receiver accept both the old and the new key, then change the key on the O3O server, and finally drop the old key from the receiver. No callback is lost this way.