Signing requests
Sign pushes, embed address requests, table sync and disconnect with HMAC-SHA256 over the method, path, timestamp and exact body bytes.
Updated · View as Markdown
Four endpoints take signed requests: POST /api/live/ingest, POST /api/live/embed-tokens, POST /api/connections/self/tables and DELETE /api/connections/self. Each request carries three headers, made from the connectionId and connectionSecret your server got when it connected.
Headers
| Header | Value |
|---|---|
X-FPC-Connection | The connectionId: lc_ and 22 base64url characters |
X-FPC-Timestamp | The time now in whole Unix seconds, such as 1794054600. It must be within 300 seconds of FloorPlanCraft's clock. |
X-FPC-Signature | v1= and the lowercase hex HMAC-SHA256 of the signed text |
The signed text
Join these five parts with a line feed (\n), with nothing after the body:
v1- The method in upper case:
POSTorDELETE - The path only, with no scheme, host, port or query, such as
/api/live/ingest - The
X-FPC-Timestampvalue - The raw body bytes, exactly as sent. An empty body is zero bytes.
The HMAC key is the connectionSecret text as UTF-8 bytes. Do not base64-decode it first.
Serialise the body once and send exactly the bytes you signed. Because the method and the path are signed, a request signed for one endpoint is refused by every other.
Tip: Keep your server's clock synchronised, for example with NTP. A timestamp more than 300 seconds off is refused.
Example in TypeScript
import { createHmac } from "node:crypto";
const ORIGIN = "https://floorplancraft.com";
/** A signed request. body is the exact JSON text to send, or null to send none. */
export async function signedRequest(method: "POST" | "DELETE", path: string, body: string | null,
connectionId: string, connectionSecret: string): Promise<Response> {
const timestamp = String(Math.floor(Date.now() / 1000));
const signed = ["v1", method, path, timestamp, body ?? ""].join("\n");
const signature = createHmac("sha256", Buffer.from(connectionSecret, "utf8")).update(signed, "utf8").digest("hex");
const headers: Record<string, string> = {
"X-FPC-Connection": connectionId,
"X-FPC-Timestamp": timestamp,
"X-FPC-Signature": "v1=" + signature,
};
if (body !== null) headers["Content-Type"] = "application/json";
return fetch(ORIGIN + path, { method, headers, body: body ?? undefined });
}
// connectionId and connectionSecret come from your server's own store.
const heartbeat = JSON.stringify({ at: new Date().toISOString(), heartbeat: true, sequence: 1288 });
await signedRequest("POST", "/api/live/ingest", heartbeat, connectionId, connectionSecret);
await signedRequest("DELETE", "/api/connections/self", null, connectionId, connectionSecret);Test vectors
With the synthetic secret 0bsCgj_qUCHf4yNLsu7uPYpWYy07SC8jpQzqXHK_9jI, X-FPC-Timestamp: 1760875200 and bodies with no trailing line feed:
| Request | Body | X-FPC-Signature |
|---|---|---|
POST /api/live/ingest | {"at":"2026-10-19T12:00:00.000Z","heartbeat":true,"sequence":1} | v1=27a6976f8787a142f4c1a4a7443a4e81e320499127e9f1db21a960f3d5ab66a8 |
POST /api/live/embed-tokens | {"ttlSeconds":3600} | v1=a08c6c46097e3cdbae8d9f27ecb1281e616c402a8007904d8de7a74e6bcacc2e |
DELETE /api/connections/self | None (zero bytes) | v1=d357517fc7c3fb2f79bee5edfd0f877de88d97c7df691cd0eda1a6d316a8b26a |
Signing errors
| Status | Body | Cause |
|---|---|---|
| 401 | { "error": "unknown_connection" } | X-FPC-Connection is malformed or names no connection. |
| 401 | { "error": "stale_timestamp" } | X-FPC-Timestamp is missing, not whole seconds, or more than 300 seconds off. |
| 401 | { "error": "invalid_signature" } | The signature does not match. Check the method, the path, the key, and that you sent the bytes you signed. |
Each of these failures, and an oversized body, counts against your server's address: 60 at once, then 60 a minute. Past that, every signed request from the address gets 429 before any other check.