# Connect an app

> Send a connect request, let the venue owner approve it in FloorPlanCraft, then exchange the code with PKCE for a connection id and secret.

Connecting takes one request from your server, one visit by the venue owner and one more request from your server. At the end, your server holds a connection id and a secret.

## Before you start

- Give each install an `installId`: a UUID v4 that you generate once and keep.
- For each attempt, make a PKCE code verifier of 43 to 128 characters from `A-Z`, `a-z`, `0-9` and `- . _ ~`, and its challenge: the base64url SHA-256 of the verifier without padding, 43 characters.
- Make a random `state` of 16 to 128 visible ASCII characters and keep it with the attempt.
- Know where the owner's browser should come back to (`returnUrl`) and which origins will frame the embeds (`embedOrigins`).

To test your PKCE code, use the example from RFC 7636: the verifier `dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk` has the challenge `E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM`. Never use it for a real connection.

## Step 1: send a connect request

Your server sends `POST https://floorplancraft.com/api/connect/requests`. It needs no signature: the request only describes the venue and opens nothing by itself.

```json
{
  "app": "restaurant",
  "installId": "6f1c2a9e-3b7d-4c55-9a1e-2f8b7c6d5e40",
  "appVersion": "2.3.0",
  "venueName": "Harbour Café",
  "timeZone": "Europe/London",
  "returnUrl": "http://192.168.1.20:3000/settings/floorplancraft/callback",
  "state": "c4V9qL2xT8mN1pR6wZ0yB3",
  "codeChallenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
  "codeChallengeMethod": "S256",
  "embedOrigins": ["http://192.168.1.20:3000", "https://pos.harbourcafe.example"],
  "tables": [
    { "label": "T1", "capacity": 4, "room": "Main dining" },
    { "label": "T2", "capacity": 2, "room": "Main dining" },
    { "label": "T10", "capacity": 6, "room": "Terrace" }
  ],
  "stations": [{ "name": "Grill" }, { "name": "Bar" }]
}
```

The answer is `201 Created`. Send the owner to `authorizeUrl` before `expiresAt`, which is 10 minutes away:

```json
{
  "requestId": "cr_q1W2e3R4t5Y6u7I8o9P0aS",
  "authorizeUrl": "https://floorplancraft.com/connect?request=cr_q1W2e3R4t5Y6u7I8o9P0aS",
  "expiresAt": "2026-11-07T12:10:00.000Z"
}
```

## Request fields

| Field | Rule |
| --- | --- |
| `app` | `"restaurant"`, `"booking"` or `"play"` |
| `installId` | A UUID v4, the same for every connect from this install. Compared ignoring case. |
| `appVersion` | Your app's version in semver, such as `2.3.0`. At most 32 characters. |
| `venueName` | 1 to 80 characters, no control characters. The approval page quotes it as what the app says the venue is. |
| `timeZone` | The venue's IANA time zone, such as `Europe/London`. Service days and the times in the live view use it. |
| `returnUrl` | Where the owner's browser comes back: `http` or `https`, at most 2,000 characters, no fragment, no user name or password. Kept byte for byte: send the same string to the code exchange. |
| `state` | 16 to 128 visible ASCII characters (`!` to `~`). It comes back unchanged. |
| `codeChallenge` | The PKCE challenge, 43 characters |
| `codeChallengeMethod` | `"S256"` |
| `embedOrigins` | Optional. Up to 10 origins your app frames the embeds from, each written as scheme, host and optional port: no path, no trailing slash, no wildcard, no repeats. |
| `tables` | Optional. Up to 500 rows, described below. |
| `stations` | Optional, restaurant only. Up to 50 kitchen or bar stations, such as `{ "name": "Grill" }`. A station's name follows the label rule below and may not repeat a table's label. |

`returnUrl` and every embed origin may use `http:` only for a host on the venue's own network, listed below. Everything else must use `https:`.

## Tables, areas and zones

`tables` lists what your pushes will report, so FloorPlanCraft can match it with the plan. Fields other than these are ignored, so you can send your feed rows as they are.

| Field | Rule |
| --- | --- |
| `label` | The name your pushes will use. At most 24 characters, starting with a letter or digit; letters, digits, spaces and `. _ # & ' / + -` only. Unique, ignoring case. |
| `capacity` | A whole number from 1 to 99. A play venue sends the zone's kid capacity, from 1 to 999. |
| `room` | Optional. The room's name, with the same rule as `label`. Without it, `"Dining room"`. |

- **Restaurant:** one row per table, plus optional `stations`.
- **Booking:** one row per area, such as a room, a chair or basin, or a staff member's station. `capacity` is 1 for an appointment, or a class's size. Leave `stations` out or send `[]`.
- **Play venue:** one row per play zone, its `label` as the plan names the zone. Leave `stations` out or send `[]`.

## Addresses on the venue's network

`http:` is allowed only when the host is one of these:

- `localhost`, `127.0.0.0/8` or `[::1]`
- A private IPv4 address in `10.0.0.0/8`, `172.16.0.0/12` or `192.168.0.0/16`
- An IPv6 unique local address, `fc00::/7`
- A single-label name such as `pos`, or a name ending in `.local`

Write each origin as a browser does: a lower-case host, no default port and no trailing slash, such as `http://192.168.1.20:3000`. Anything else is refused rather than corrected.

## Connect request errors

| Status | Body | Meaning |
| --- | --- | --- |
| 400 | `{ "error": "invalid_request", "issues": [{ "path": "tables.3.label", "message": "…" }] }` | A field breaks a rule. Up to 50 issues, each naming its field. A body that is not JSON or is over 256 KB is a 400 too. |
| 429 | `{ "error": "rate_limited", "retryAfterMs": 60000 }` | Too many requests: wait `retryAfterMs` milliseconds. |
| 503 | `{ "error": "unavailable" }` | Try again shortly. |

Limits: 20 requests at once, then 60 an hour from one address (an IPv6 address counts by its /64), and 10 at once, then 30 an hour for one install from one address.

## Step 2: the venue owner approves

Check that `authorizeUrl` starts with `https://floorplancraft.com/connect?request=`, then send the owner's browser to it.

On that page the owner signs in or creates an account, chooses a workspace and a plan, checks the venue name, the return origin and the embed origins, and presses **Approve and connect** or **Deny**. The page warns the owner when the return origin is not on the venue's own network. [Connect your venue app](https://floorplancraft.com/docs/connect-your-app.md) shows this page from the owner's side.

- **Restaurant:** the owner picks an existing plan or **Create a plan from your N tables**. That plan has one zone per table, each with a table and chairs for its capacity, and a **Kitchen** room with one zone per station.
- **Booking and play venue:** the owner picks an existing plan. The owner draws the venue in the planner first and names each zone as your `label` values.

When the owner approves, FloorPlanCraft publishes and shares the plan, then sends the browser back to your `returnUrl` with a GET:

```text
Approve: <returnUrl>?code=<43 characters>&state=<your state>
Deny:    <returnUrl>?error=access_denied&state=<your state>
```

Query parameters already on `returnUrl` are kept. Check that `state` matches the attempt before you go on.

> **Note:** An expired or already answered request sends the browser nowhere. If the owner has not come back within 10 minutes, treat the attempt as expired and start again.

## Step 3: exchange the code

Within 5 minutes, your server sends `POST https://floorplancraft.com/api/connect/token`:

```json
{
  "grantType": "authorization_code",
  "code": "<the code from the return address>",
  "codeVerifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
  "installId": "6f1c2a9e-3b7d-4c55-9a1e-2f8b7c6d5e40",
  "returnUrl": "http://192.168.1.20:3000/settings/floorplancraft/callback"
}
```

The answer is `200 OK`:

```json
{
  "connectionId": "lc_8sKq2mXvT0bN4yR7wE1uZa",
  "connectionSecret": "<43 base64url characters, returned once>",
  "planId": "…",
  "shareToken": "…",
  "planEmbedUrl": "https://floorplancraft.com/embed/<shareToken>",
  "ingestUrl": "https://floorplancraft.com/api/live/ingest",
  "zoneLabels": ["T1", "T2", "T10", "Grill", "Bar"],
  "liveView": { "entitled": true, "policy": "entitled" },
  "planVersionId": "…"
}
```

| Field | Meaning |
| --- | --- |
| `connectionId` | `lc_` and 22 base64url characters. Send it as `X-FPC-Connection`. |
| `connectionSecret` | Your signing key, 43 base64url characters. It is only in this answer: store it on your server, encrypted. |
| `planId` | The connected plan |
| `shareToken` | The plan's public share |
| `planEmbedUrl` | The view-only plan, for an iframe |
| `ingestUrl` | Where to push status |
| `zoneLabels` | The plan's zone names, in plan order. Compare them with your labels and show the owner any that differ. |
| `liveView` | `entitled` is `false` while the workspace does not have Live 3D: pushes answer 402 until it does. `policy` is `"free"` or `"entitled"`. |
| `planVersionId` | The plan's published version. Every push answer carries the current one. |

The exchange also adds your `embedOrigins` to the origins that may frame `planEmbedUrl`.

If this install already had an active connection in the same workspace, the new one replaces it and the old secret stops working. One install may be connected in several workspaces at once.

## Code exchange errors

| Status | Body | What to do |
| --- | --- | --- |
| 400 | `{ "error": "invalid_grant" }` | The code is used, expired or wrong, or the verifier, `installId` or `returnUrl` does not match. An exchange that reaches the code uses it up, even when it fails: start again. |
| 400 | `{ "error": "invalid_request" }` | A wrong `grantType`, a missing field, or a body that is not JSON or is over 4 KB. The code is not used. |
| 429 | `{ "error": "rate_limited", "retryAfterMs": 1000 }` | Too many failed requests from your address: wait `retryAfterMs` milliseconds. |
| 503 | `{ "error": "unavailable" }` | The code is still unused: retry the same exchange within the 5 minutes. |

## Add tables later

A restaurant app can add tables the plan lacks with a signed `POST https://floorplancraft.com/api/connections/self/tables`. The body follows the connect request's rules for `tables`, which is required, and `stations`, which is optional:

```json
{
  "tables": [
    { "label": "T1", "capacity": 4, "room": "Main dining" },
    { "label": "T11", "capacity": 4, "room": "Terrace" }
  ],
  "stations": [{ "name": "Pastry" }]
}
```

Each table the plan lacks, compared ignoring case, becomes a zone with a table and chairs in a new room, **New tables**, set against the building with its own entrance. Missing stations go into the plan's kitchen when they fit, or else into a new kitchen room. Nothing in the plan moves, is renamed or is deleted; the owner moves the new tables into place in the planner.

```json
{
  "added": ["T11"],
  "existing": ["T1"],
  "addedStations": ["Pastry"],
  "existingStations": [],
  "stationsRoom": "Kitchen",
  "stationsRoomCreated": false,
  "planVersionId": "…"
}
```

- `stationsRoom` and `stationsRoomCreated` are there only when stations were added. `conflicts`, when present, lists stations whose name is already a table's zone: rename those in your app.
- The change is published at once, together with any changes the owner has saved but not yet published.
- A call that adds nothing makes no new version and returns the current `planVersionId`.
- A plan made or grown this way holds at most 1,000 zones, 6,000 items (tables and chairs included) and 4 MB. A sync past a limit adds nothing and answers `422` with `"error": "plan_too_large"` and the `limit`, `max` and `needed` values.
- Limits: a 256 KB body; 6 at once, then 6 a minute per connection. It does not need Live 3D.
- Booking and play venue apps get `422` with `"error": "unsupported_app"`: the owner adds or renames zones in the planner instead.

## Related

- [Signing requests](https://floorplancraft.com/docs/signing-requests.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/connect-an-app · Updated 2026-11-07
