# Signing requests

> Sign pushes, embed address requests, table sync and disconnect with HMAC-SHA256 over the method, path, timestamp and exact body bytes.

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:

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

```ts
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.

## Related

- [Connect an app](https://floorplancraft.com/docs/connect-an-app.md)
- [Live updates](https://floorplancraft.com/docs/live-updates.md)
- [Disconnect and errors](https://floorplancraft.com/docs/disconnect-and-errors.md)

---

Source: https://floorplancraft.com/docs/signing-requests · Updated 2026-11-07
