Skip to content

Editor embedding

Full integration examples

Complete single-file apps for Node.js Express, PHP Laravel, Python Flask and ASP.NET Core: JWT signing, file serving, callbacks, key rotation.

On this page

The four examples below do the same job, each in one file: (1) serve the source file for the gate to download, (2) render the editor page with a JWT-signed config, (3) receive callbacks, verify the HMAC signature, download the new version over the source file, and rotate document.key on closed.

Common setup#

VariableMeaningDEV value
O3O_URLO3O address the BROWSER uses to load api.js.http://localhost:8080
APP_URL_FOR_GATEYour app address the GATE uses to download files and send callbacks.http://host.docker.internal:3000
O3O_EMBED_JWT_SECRETSame as the gate variable. Once the gate has this secret, every config must carry a token. Empty means unsigned configs (only accepted when the gate has O3O_EMBED_ALLOW_UNSIGNED=1).string ≥ 32 characters
O3O_EMBED_CALLBACK_SECRETSame as the gate variable; used to verify callback signatures.random string
BashEnvironment variables and sample file
# ── online/.env (DEV machine) ─────────────────────────────────────────
O3O_FETCH_ALLOW_HOSTS=host.docker.internal                     # the gate may download from and call back your machine
O3O_EMBED_JWT_SECRET=O3O_DEMO_KEY_jwt_0123456789abcdef0123456789
O3O_EMBED_CALLBACK_SECRET=O3O_DEMO_KEY_callback_0123456789abcdef
# cd online && docker compose --env-file .env -f docker/compose.dev.yml up -d o3o-gate

# ── the machine running your app: the SAME two secrets ──────
export O3O_URL=http://localhost:8080
export APP_URL_FOR_GATE=http://host.docker.internal:3000
export O3O_EMBED_JWT_SECRET=O3O_DEMO_KEY_jwt_0123456789abcdef0123456789
export O3O_EMBED_CALLBACK_SECRET=O3O_DEMO_KEY_callback_0123456789abcdef
mkdir -p files && cp ~/Documents/hop-dong.docx files/

Node.js + Express#

JavaScriptserver.js
// server.js — full integration: JWT-signed editor page, source file serving, callback handling and file update
// npm install express jsonwebtoken   ·   node server.js   ·   http://localhost:3000
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const express = require("express");
const jwt = require("jsonwebtoken");

const PORT = Number(process.env.PORT || 3000);
const O3O_URL = process.env.O3O_URL || "http://localhost:8080";                                // used by the browser
const APP_URL_FOR_GATE = process.env.APP_URL_FOR_GATE || `http://host.docker.internal:${PORT}`;  // used by the gate
const JWT_SECRET = process.env.O3O_EMBED_JWT_SECRET || "";
const CALLBACK_SECRET = process.env.O3O_EMBED_CALLBACK_SECRET || "";
const FILES = path.join(__dirname, "files");
const DOC = "hop-dong.docx";
const STATE = path.join(FILES, "state.json");   // { rev, savedVersion }: rev grows after every "closed" callback

const loadState = () => { try { return JSON.parse(fs.readFileSync(STATE, "utf8")); } catch { return { rev: 1, savedVersion: 0 }; } };
const saveState = (s) => fs.writeFileSync(STATE, JSON.stringify(s));

function verifySignature(rawBody, timestamp, signature) {
  if (!CALLBACK_SECRET) return true;                       // acceptable on DEV machines only
  const ts = Number(timestamp);
  if (!signature || !Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
  const expected = "sha256=" + crypto.createHmac("sha256", CALLBACK_SECRET).update(`${timestamp}.`).update(rawBody).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(String(signature));
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// 1. the gate downloads the source file here
app.get("/files/:name", (req, res) => res.sendFile(path.join(FILES, path.basename(req.params.name))));

// 2. the page with the editor
app.get("/", (req, res) => {
  const state = loadState();
  const config = {
    document: { url: `${APP_URL_FOR_GATE}/files/${DOC}`, title: "Sample contract.docx", fileType: "docx", key: `hopdong-r${state.rev}` },
    editor: { mode: "edit", lang: "en-US", user: { id: "u-1001", name: "Jane Doe" }, callbackUrl: `${APP_URL_FOR_GATE}/o3o/callback` },
    ui: { closeButton: true }
  };
  const browserConfig = JWT_SECRET
    ? { ...config, token: jwt.sign(config, JWT_SECRET, { algorithm: "HS256", expiresIn: "1h" }) }
    : config;
  const json = JSON.stringify(browserConfig).replace(/</g, "\\u003c");
  res.type("html").send(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>O3O Editor</title>
<style>html,body{margin:0;height:100%}#o3o-editor{height:100vh}</style></head>
<body><div id="o3o-editor"></div>
<script src="${O3O_URL}/o3o/api.js"></script>
<script>
  const config = ${json};
  config.events = {
    onSaved: (e) => console.log("saved v" + e.version),
    onLimitReached: (e) => console.warn(e.message),
    onError: (e) => console.error(e.code, e.message)
  };
  new O3O.Editor("o3o-editor", config);
</script></body></html>`);
});

// 3. callback: verify, answer at once, then fetch the new version
app.post("/o3o/callback", express.raw({ type: "*/*", limit: "1mb" }), async (req, res) => {
  if (!verifySignature(req.body, req.get("X-O3O-Timestamp"), req.get("X-O3O-Signature"))) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(204);
  try {
    let state = loadState();
    if (event.url && event.version > state.savedVersion) {
      const r = await fetch(event.url);
      if (!r.ok) throw new Error("download " + r.status);
      const data = Buffer.from(await r.arrayBuffer());
      if (event.sha256 && crypto.createHash("sha256").update(data).digest("hex") !== event.sha256) throw new Error("sha256 mismatch");
      fs.writeFileSync(path.join(FILES, DOC + ".tmp"), data);
      fs.renameSync(path.join(FILES, DOC + ".tmp"), path.join(FILES, DOC));
      state.savedVersion = event.version;
    }
    if (event.status === "closed") state = { rev: state.rev + 1, savedVersion: 0 };   // the next open uses a new key
    saveState(state);
  } catch (err) {
    console.error("callback", err);
  }
});

app.listen(PORT, "0.0.0.0", () => console.log(`http://localhost:${PORT}`));

PHP + Laravel#

PHProutes/web.php
<?php
// routes/web.php — Laravel 11+
// composer require firebase/php-jwt   ·   php artisan serve --host=0.0.0.0 --port=3000
// Put the source file at storage/app/o3o/hop-dong.docx

use Firebase\JWT\JWT;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;

$o3o = [
    'url' => env('O3O_URL', 'http://localhost:8080'),                        // used by the browser
    'appUrlForGate' => env('APP_URL_FOR_GATE', 'http://host.docker.internal:3000'),  // used by the gate
    'jwtSecret' => (string) env('O3O_EMBED_JWT_SECRET', ''),
    'callbackSecret' => (string) env('O3O_EMBED_CALLBACK_SECRET', ''),
    'dir' => storage_path('app/o3o'),
    'doc' => 'hop-dong.docx',
];
$loadState = function () use ($o3o): array {
    $raw = @file_get_contents($o3o['dir'] . '/state.json');
    return $raw ? json_decode($raw, true) : ['rev' => 1, 'savedVersion' => 0];
};
$saveState = fn (array $s) => file_put_contents($o3o['dir'] . '/state.json', json_encode($s));

// 1. the gate downloads the source file here
Route::get('/files/{name}', fn (string $name) => response()->file($o3o['dir'] . '/' . basename($name)));

// 2. the page with the editor
Route::get('/', function () use ($o3o, $loadState) {
    $state = $loadState();
    $config = [
        'document' => [
            'url' => $o3o['appUrlForGate'] . '/files/' . $o3o['doc'],
            'title' => 'Sample contract.docx',
            'fileType' => 'docx',
            'key' => 'hopdong-r' . $state['rev'],
        ],
        'editor' => [
            'mode' => 'edit',
            'lang' => 'en-US',
            'user' => ['id' => 'u-1001', 'name' => 'Jane Doe'],
            'callbackUrl' => $o3o['appUrlForGate'] . '/o3o/callback',
        ],
        'ui' => ['closeButton' => true],
    ];
    if ($o3o['jwtSecret'] !== '') {
        $config['token'] = JWT::encode($config + ['iat' => time(), 'exp' => time() + 3600], $o3o['jwtSecret'], 'HS256');
    }
    return view('o3o-editor', ['o3oUrl' => $o3o['url'], 'config' => $config]);
});

// 3. callback: verify over the raw body, fetch the new version, rotate the key on "closed"
Route::post('/o3o/callback', function (Request $request) use ($o3o, $loadState, $saveState) {
    $raw = $request->getContent();
    $ts = (string) $request->header('X-O3O-Timestamp', '');
    $sig = (string) $request->header('X-O3O-Signature', '');
    if ($o3o['callbackSecret'] !== '') {
        $expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $o3o['callbackSecret']);
        if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300 || !hash_equals($expected, $sig)) {
            abort(401);
        }
    }
    $event = json_decode($raw, true);
    $state = $loadState();
    if (!empty($event['url']) && $event['version'] > $state['savedVersion']) {
        $data = Http::timeout(60)->get($event['url'])->throw()->body();
        if (empty($event['sha256']) || hash('sha256', $data) === $event['sha256']) {
            file_put_contents($o3o['dir'] . '/' . $o3o['doc'] . '.tmp', $data);
            rename($o3o['dir'] . '/' . $o3o['doc'] . '.tmp', $o3o['dir'] . '/' . $o3o['doc']);
            $state['savedVersion'] = $event['version'];
        }
    }
    if (($event['status'] ?? '') === 'closed') {
        $state = ['rev' => $state['rev'] + 1, 'savedVersion' => 0];   // the next open uses a new key
    }
    $saveState($state);
    return response()->noContent();
})->withoutMiddleware([ValidateCsrfToken::class]);
HTMLresources/views/o3o-editor.blade.php
{{-- resources/views/o3o-editor.blade.php --}}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>O3O Editor</title>
  <style>html, body { margin: 0; height: 100%; } #o3o-editor { height: 100vh; }</style>
</head>
<body>
  <div id="o3o-editor"></div>
  <script src="{{ $o3oUrl }}/o3o/api.js"></script>
  <script>
    const config = @json($config);
    config.events = {
      onSaved: (e) => console.log("saved v" + e.version),
      onLimitReached: (e) => console.warn(e.message),
      onError: (e) => console.error(e.code, e.message)
    };
    new O3O.Editor("o3o-editor", config);
  </script>
</body>
</html>

Python + Flask#

Pythonapp.py
# app.py — full integration with Flask
# pip install flask pyjwt requests   ·   python app.py   ·   http://localhost:3000
import hashlib
import hmac
import json
import os
import threading
import time

import jwt
import requests
from flask import Flask, Response, abort, request, send_from_directory

PORT = int(os.environ.get("PORT", "3000"))
O3O_URL = os.environ.get("O3O_URL", "http://localhost:8080")                                  # used by the browser
APP_URL_FOR_GATE = os.environ.get("APP_URL_FOR_GATE", f"http://host.docker.internal:{PORT}")  # used by the gate
JWT_SECRET = os.environ.get("O3O_EMBED_JWT_SECRET", "")
CALLBACK_SECRET = os.environ.get("O3O_EMBED_CALLBACK_SECRET", "")
FILES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "files")
DOC = "hop-dong.docx"
STATE = os.path.join(FILES, "state.json")
LOCK = threading.Lock()

PAGE = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>O3O Editor</title>
<style>html,body{margin:0;height:100%}#o3o-editor{height:100vh}</style></head>
<body><div id="o3o-editor"></div>
<script src="__O3O_URL__/o3o/api.js"></script>
<script>
  const config = __CONFIG__;
  config.events = {
    onSaved: (e) => console.log("saved v" + e.version),
    onLimitReached: (e) => console.warn(e.message),
    onError: (e) => console.error(e.code, e.message)
  };
  new O3O.Editor("o3o-editor", config);
</script></body></html>"""

app = Flask(__name__)


def load_state():
    try:
        with open(STATE, encoding="utf-8") as f:
            return json.load(f)
    except (OSError, ValueError):
        return {"rev": 1, "savedVersion": 0}


def save_state(state):
    with open(STATE, "w", encoding="utf-8") as f:
        json.dump(state, f)


def verify(raw, timestamp, signature):
    if not CALLBACK_SECRET:
        return True                                   # acceptable on DEV machines only
    if not signature or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    expected = "sha256=" + hmac.new(CALLBACK_SECRET.encode(), timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


# 1. the gate downloads the source file here
@app.get("/files/<name>")
def files(name):
    return send_from_directory(FILES, os.path.basename(name))


# 2. the page with the editor
@app.get("/")
def index():
    state = load_state()
    config = {
        "document": {"url": f"{APP_URL_FOR_GATE}/files/{DOC}", "title": "Sample contract.docx",
                     "fileType": "docx", "key": f"hopdong-r{state['rev']}"},
        "editor": {"mode": "edit", "lang": "en-US", "user": {"id": "u-1001", "name": "Jane Doe"},
                   "callbackUrl": f"{APP_URL_FOR_GATE}/o3o/callback"},
        "ui": {"closeButton": True},
    }
    browser_config = dict(config)
    if JWT_SECRET:
        now = int(time.time())
        browser_config["token"] = jwt.encode({**config, "iat": now, "exp": now + 3600}, JWT_SECRET, algorithm="HS256")
    data = json.dumps(browser_config, ensure_ascii=False).replace("<", "\\u003c")
    return Response(PAGE.replace("__O3O_URL__", O3O_URL).replace("__CONFIG__", data), mimetype="text/html")


def handle(event):
    try:
        apply_event(event)
    except Exception:                                 # log it; never let the background thread die silently
        app.logger.exception("O3O callback")


def apply_event(event):
    with LOCK:
        state = load_state()
        if event.get("url") and event["version"] > state["savedVersion"]:
            r = requests.get(event["url"], timeout=60)
            r.raise_for_status()
            if event.get("sha256") and hashlib.sha256(r.content).hexdigest() != event["sha256"]:
                raise ValueError("sha256 mismatch")
            tmp = os.path.join(FILES, DOC + ".tmp")
            with open(tmp, "wb") as f:
                f.write(r.content)
            os.replace(tmp, os.path.join(FILES, DOC))
            state["savedVersion"] = event["version"]
        if event["status"] == "closed":
            state = {"rev": state["rev"] + 1, "savedVersion": 0}   # the next open uses a new key
        save_state(state)


# 3. callback: verify, answer at once, fetch the new version in the background
@app.post("/o3o/callback")
def callback():
    raw = request.get_data()
    if not verify(raw, request.headers.get("X-O3O-Timestamp", ""), request.headers.get("X-O3O-Signature", "")):
        abort(401)
    threading.Thread(target=handle, args=(json.loads(raw),), daemon=True).start()
    return "", 204


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=PORT)

ASP.NET Core#

C#Program.cs
// Program.cs — ASP.NET Core minimal API (.NET 8)
// dotnet new web -n O3OEmbed && cd O3OEmbed
// dotnet add package Microsoft.IdentityModel.JsonWebTokens
// dotnet run --urls http://0.0.0.0:3000
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient();
var app = builder.Build();

string Env(string name, string fallback) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v : fallback;
var o3oUrl = Env("O3O_URL", "http://localhost:8080");                             // used by the browser
var appUrlForGate = Env("APP_URL_FOR_GATE", "http://host.docker.internal:3000");  // used by the gate
var jwtSecret = Env("O3O_EMBED_JWT_SECRET", "");
var callbackSecret = Env("O3O_EMBED_CALLBACK_SECRET", "");
var filesDir = Path.Combine(builder.Environment.ContentRootPath, "files");
const string Doc = "hop-dong.docx";
var statePath = Path.Combine(filesDir, "state.json");
var stateLock = new SemaphoreSlim(1, 1);

JsonObject LoadState() => File.Exists(statePath)
    ? JsonNode.Parse(File.ReadAllText(statePath))!.AsObject()
    : new JsonObject { ["rev"] = 1, ["savedVersion"] = 0 };
void SaveState(JsonObject s) => File.WriteAllText(statePath, s.ToJsonString());

// 1. the gate downloads the source file here
app.MapGet("/files/{name}", (string name) =>
    Results.File(Path.Combine(filesDir, Path.GetFileName(name)), "application/octet-stream"));

// 2. the page with the editor
app.MapGet("/", () =>
{
    var state = LoadState();
    var config = new JsonObject
    {
        ["document"] = new JsonObject
        {
            ["url"] = $"{appUrlForGate}/files/{Doc}",
            ["title"] = "Sample contract.docx",
            ["fileType"] = "docx",
            ["key"] = $"hopdong-r{state["rev"]}"
        },
        ["editor"] = new JsonObject
        {
            ["mode"] = "edit",
            ["lang"] = "en-US",
            ["user"] = new JsonObject { ["id"] = "u-1001", ["name"] = "Jane Doe" },
            ["callbackUrl"] = $"{appUrlForGate}/o3o/callback"
        },
        ["ui"] = new JsonObject { ["closeButton"] = true }
    };
    if (jwtSecret.Length > 0)
    {
        var payload = config.DeepClone().AsObject();
        var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        payload["iat"] = now;
        payload["exp"] = now + 3600;
        var creds = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), SecurityAlgorithms.HmacSha256);
        config["token"] = new JsonWebTokenHandler().CreateToken(payload.ToJsonString(), creds);
    }
    var json = config.ToJsonString();   // the default encoder already escapes <, >, & as <…
    var html = $$"""
        <!doctype html>
        <html lang="en"><head><meta charset="utf-8"><title>O3O Editor</title>
        <style>html,body{margin:0;height:100%}#o3o-editor{height:100vh}</style></head>
        <body><div id="o3o-editor"></div>
        <script src="{{o3oUrl}}/o3o/api.js"></script>
        <script>
          const config = {{json}};
          config.events = {
            onSaved: (e) => console.log("saved v" + e.version),
            onLimitReached: (e) => console.warn(e.message),
            onError: (e) => console.error(e.code, e.message)
          };
          new O3O.Editor("o3o-editor", config);
        </script></body></html>
        """;
    return Results.Content(html, "text/html; charset=utf-8");
});

// 3. callback: verify over the raw body, fetch the new version, rotate the key on "closed"
app.MapPost("/o3o/callback", async (HttpRequest req, IHttpClientFactory http) =>
{
    using var buffer = new MemoryStream();
    await req.Body.CopyToAsync(buffer);
    var raw = buffer.ToArray();
    if (callbackSecret.Length > 0 &&
        !VerifySignature(callbackSecret, req.Headers["X-O3O-Timestamp"].ToString(), raw, req.Headers["X-O3O-Signature"].ToString()))
        return Results.Unauthorized();

    var ev = JsonNode.Parse(raw)!.AsObject();
    await stateLock.WaitAsync();
    try
    {
        var state = LoadState();
        var version = (int)ev["version"]!;
        var url = (string?)ev["url"];
        if (url is not null && version > (int)state["savedVersion"]!)
        {
            var data = await http.CreateClient().GetByteArrayAsync(url);
            var sha = Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
            if (ev["sha256"] is null || sha == (string?)ev["sha256"])
            {
                var tmp = Path.Combine(filesDir, Doc + ".tmp");
                await File.WriteAllBytesAsync(tmp, data);
                File.Move(tmp, Path.Combine(filesDir, Doc), overwrite: true);
                state["savedVersion"] = version;
            }
        }
        if ((string?)ev["status"] == "closed")
            state = new JsonObject { ["rev"] = (int)state["rev"]! + 1, ["savedVersion"] = 0 };   // the next open uses a new key
        SaveState(state);
    }
    finally
    {
        stateLock.Release();
    }
    return Results.NoContent();
});

app.Run();

static bool VerifySignature(string secret, string timestamp, byte[] rawBody, string signature)
{
    if (!long.TryParse(timestamp, out var ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) return false;
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
    hmac.TransformBlock(prefix, 0, prefix.Length, null, 0);
    hmac.TransformFinalBlock(rawBody, 0, rawBody.Length);
    var expected = "sha256=" + Convert.ToHexString(hmac.Hash!).ToLowerInvariant();
    return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature));
}

Check each example#

  1. Open http://localhost:3000: the document shows and the console has no onError.
  2. Type something and press Ctrl+S: the console prints saved v1; files/hop-dong.docx is rewritten a few seconds later; files/state.json shows savedVersion: 1.
  3. Close the tab and wait about a minute: the closed callback arrives and rev in state.json becomes 2.
  4. Reopen the page: new key hopdong-r2; the gate downloads the file from your app again and shows the edited content.
  5. Change one character of X-O3O-Signature (e.g. replay it with curl): the app answers 401.