Skip to content
FloorPlanCraftSign in
Explore the planner
Browse the docs

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

HeaderValue
X-FPC-ConnectionThe connectionId: lc_ and 22 base64url characters
X-FPC-TimestampThe time now in whole Unix seconds, such as 1794054600. It must be within 300 seconds of FloorPlanCraft's clock.
X-FPC-Signaturev1= 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:

  1. v1
  2. The method in upper case: POST or DELETE
  3. The path only, with no scheme, host, port or query, such as /api/live/ingest
  4. The X-FPC-Timestamp value
  5. 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:

RequestBodyX-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/selfNone (zero bytes)v1=d357517fc7c3fb2f79bee5edfd0f877de88d97c7df691cd0eda1a6d316a8b26a

Signing errors

StatusBodyCause
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.