Introduction
Floor Plan API runs a U-Net++ wall-segmentation model on the file you upload and returns the binary mask as a PNG. Foreground pixels (255) mark walls; background pixels (0) mark everything else. For a raster input the mask matches its resolution exactly, so it lines up pixel-for-pixel with the image you sent. For a PDF you name the page (page, 1-based, default 1); it is rasterised at 200 DPI (longest edge capped at 8192 px) and the mask matches that raster; the size comes back in X-Mask-Width / X-Mask-Height.
Two ways to call the API, both fully supported and documented end-to-end:
- Python library —
pip install floorplan-api. Typed sync and asyncio clients, automatic retries, image and PDF input. See Python library. - REST API — plain HTTP from any language or HTTP client (curl, Node fetch, Go, Ruby, …). See REST API.
Quickstart
1. Get an API key
Create an account, then generate a key from the dashboard. Test keys (fp_test_…) use the same model but aren't billed.
2. Make your first request
pip install floorplan-api
# in Python:
from floorplan_api import Client
client = Client(api_key="fp_test_...")
mask_png = client.extract("plan.png")
with open("walls.png", "wb") as fh:
fh.write(mask_png)
print(f"{mask_png.width}x{mask_png.height} mask, {len(mask_png)} bytes")3. Inspect the response
On success the response body is a single-channel PNG withContent-Type: image/png. Useful headers:
HTTP/1.1 200 OK
Content-Type: image/png
Content-Length: 18432
X-Mask-Width: 1024
X-Mask-Height: 768
X-Job-Id: job_abc123
X-Request-Id: req_def456Mask output
The output of every successful inference call is a single PNG. Knowing exactly what's in it makes the rest of the docs simpler.
| Property | Value |
|---|---|
| format | PNG |
| channels | 1 (single-channel grayscale) |
| bit depth | 8 |
| pixel values | 0 = background; 255 = wall. No greys — the probability map is thresholded at 0.5 and nothing else is done to it: no morphology, no hole filling, no component filtering. Walls follow the carved convention: door and window openings are not wall, and walls are as thick as the source linework. |
| resolution | Identical to the input image, so the mask aligns pixel-for-pixel with it. For a PDF, identical to the rasterised page (200 DPI, longest edge ≤ 8192 px); read it from X-Mask-Width / X-Mask-Height. |
| colour space | None — single-channel grayscale, no embedded ICC profile. |
See Working with the mask for Python recipes that overlay the mask on the original image, count wall pixels, or convert it to other formats.
What happens to your file
Images and PDFs leave your machine differently. Be aware of the difference before you rely on either.
| You give | What is uploaded |
|---|---|
| PNG / JPEG / WEBP | The file, byte for byte. Neither the Python client nor a raw HTTP call decodes, resizes or re-encodes an image. |
| PDF via the Python client | A new single-page PDF holding only the page you asked for. The client builds it locally with pypdf: the page object is copied with its content stream, resources (fonts, embedded images) and annotations; nothing is rasterised on your side. The other pages, document metadata, bookmarks, attachments and form definitions are never sent. Page errors are raised before any request. |
| PDF via raw HTTP | Whatever you send. Upload the whole document and pass page; the worker renders that page of the stored file. The full document then sits in storage. |
| upload_key | Nothing; the object is already stored. Pass page for a multi-page PDF you stored yourself. |
The API server never touches pixels either: it checks the declared type, size and page, stores the bytes as uploaded, and the GPU worker decodes them once, like this:
- Rasters (PNG, JPEG, WEBP) are decoded with OpenCV in colour mode. Alpha is dropped without compositing, so flatten transparent PNGs onto white before uploading; grayscale is expanded to three channels; 16-bit depth becomes 8-bit; JPEG EXIF orientation is applied, so the mask aligns with the image as displayed; ICC profiles are ignored. The mask is on the decoded image's own pixel grid.
- Very large rasters (longer edge above 8192 px) are processed downscaled to that bound and the mask is resized back to the input size: still pixel-aligned, with less detail.
- PDF pages are rendered at 200 DPI onto white (transparent regions composite onto white), reduced so the longer edge is at most 8192 px. A 24 × 36 in sheet becomes 4800 × 7200 px. Everything on the page is rendered: linework, hatching, text, dimensions. The mask has the rendered size (
X-Mask-Width/X-Mask-Height). To map it back to PDF coordinates usepx = pt × width_px / page_width_pt; the Python client does this for you viaMaskBytes.pdf_scale. - Inference is a two-stage U-Net++: the whole sheet at a shortest side of 1024 px, then a crop refiner at native resolution in 512 px tiles. No test-time augmentation, no ensembling.
- Failures at this stage (undecodable bytes, a password-protected PDF, a page past the end) fail the job with
inference_errorand are not retried: the same file would fail again.
Authentication
Every request must carry an API key in the Authorization header:
Authorization: Bearer fp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxfp_live_. Charged against your account, write usage records.fp_test_. Run the same model, never billed, never logged as usage.Python library
The official client — typed, with retries built in — in a synchronous flavour (Client, on requests) and an asyncio flavour (AsyncClient, on httpx). Works on Python 3.9+.
Install
pip install floorplan-api # Client (requests)
pip install "floorplan-api[async]" # + AsyncClient (httpx)Runtime dependencies are requests and pypdf (pure Python, used to cut the requested page out of a PDF before upload); the async extra adds httpx. PIL / OpenCV / NumPy are optional and only needed if you want to process the returned mask in Python.
Initialise the client
from floorplan_api import Client
client = Client(
api_key="fp_live_...",
timeout=60.0,
max_retries=3,
retry_backoff=0.5,
)extract()
Synchronous wall segmentation. Accepts a path, raw bytes, or a binary file-like object holding a PNG, JPEG, WEBP or PDF. Returns MaskBytes: the PNG mask as bytes, with width, height, job_id, request_id and mode attached, plus page_size_pt / pdf_scale for a PDF.
from pathlib import Path
# any of these works:
mask = client.extract("plans/floor1.png") # path
mask = client.extract(Path("plans/set.pdf"), page=3) # PDF: page 3 (1-based; default 1)
mask = client.extract(image_bytes) # raw bytes (format is sniffed)
with open("plan.jpg", "rb") as fp:
mask = client.extract(fp) # file-like
mask.width, mask.height # from X-Mask-Width / X-Mask-Height
mask.job_id # from X-Job-Id
open("walls.png", "wb").write(mask) # it is still bytes
# PDF input: map the mask back to the page (the client measured it before upload)
mask = client.extract("plans/set.pdf", page=3)
mask.page_size_pt # (1728.0, 2592.0) points, /Rotate honoured
mask.pdf_scale # mask px per PDF pt -> x_px = x_pt * mask.pdf_scale
mask.pdf_dpi # the DPI actually rendered (200, or less past the 8192 px cap)
# Per-call timeout override:
mask = client.extract("plan.png", timeout=120.0)The client identifies the format from the file's leading bytes (PNG, JPEG, WEBP and PDF signatures), falling back to the extension, and raises InvalidRequestError for anything else before making a request. An image is uploaded byte for byte. A PDF is not: the client rewrites it locally into a single-page PDF holding only the requested page (see What happens to your file), so a 40-page drawing set costs one page of upload and the server never holds the rest; a page that does not exist (or a password-protected file) is rejected before any request. page other than 1 is an error for raster input. Max inline upload size 10 MB. The returned bytes are a single-channel PNG (see Mask output).
analyze()
Currently produces the same wall mask as extract(). Costs 2 credits per call. Kept distinct so future capabilities (per-room classification, symbol detection) can attach to analyze without changing extract's contract.
mask_png = client.analyze("plan.png")Large files (presigned uploads)
The inline extract() / analyze() path streams the bytes through the API server, which caps the request at 10 MB. For larger files, the client uploads directly to object storage via a presigned URL the API hands back, then submits the job by storage key — the API server never holds the bytes.
upload_then_extract() is the one-shot helper. Files smaller than threshold_bytes (default 10 MiB) go through the inline path; larger files switch to the presigned flow automatically.
# Same return value as extract() — MaskBytes (the PNG mask).
# For a PDF the size check runs on the selected page, not the whole file.
mask_png = client.upload_then_extract("big_floor_plan.pdf", page=2)
# Force the presigned path even for small files (e.g. CI smoke tests):
mask_png = client.upload_then_extract("plan.png", threshold_bytes=0)If you need finer control — say, you already have the bytes in S3 and just want to extract — use the two-step API:
# 1. Reserve a slot. Returns {upload_url, key, expires_at, content_type}.
# content_type: image/png | image/jpeg | image/webp | application/pdf
slot = client.create_upload(content_type="image/png")
# 2. PUT the bytes (any HTTP client; we use the built-in session here).
import requests
with open("plan.png", "rb") as fp:
res = requests.put(
slot["upload_url"],
data=fp,
headers={"Content-Type": slot["content_type"]},
)
res.raise_for_status()
# 3. Or, with the helper that wraps create_upload + PUT. For a PDF, page=
# cuts that page out first, so the stored object is a single-page PDF.
upload_key = client.upload("plan.png")
# 4. Run extract by storage key (no bytes flow through the API server).
# analyze() and analyze_async() take upload_key= the same way. If the
# stored object is a multi-page PDF you uploaded yourself, add page=N and
# the server renders that page.
mask = client.extract(upload_key=upload_key)AsyncClient (asyncio)
AsyncClient has the same methods as Client, every one a coroutine, on top of httpx. Install with pip install "floorplan-api[async]". Use it as an async context manager so the connection pool is closed, or pass your own httpx.AsyncClient as client= (proxies, HTTP/2) and it is left open.
import asyncio
from floorplan_api import AsyncClient
async def main() -> None:
async with AsyncClient() as client: # FLOORPLAN_API_KEY from env
masks = await asyncio.gather(
client.extract("plans/a.pdf", page=2),
client.extract("plans/b.png"),
client.upload_then_extract("plans/huge.pdf"),
)
for mask in masks:
print(mask.size, mask.job_id)
asyncio.run(main())Retries, error classes, MaskBytes and the job helpers below behave identically in both clients; only close() becomes await aclose().
Background jobs
For batches or longer-running analyses, submit a job and poll for completion, then download the mask. Three calls cover the full lifecycle (available on both clients):
| Step | Method | Returns |
|---|---|---|
| 1. Submit | analyze_async(image) | Job (status="queued") |
| 2. Wait or poll | wait_for_job(job.id) / get_job(id) | Job (terminal) |
| 3. Download | download_mask(job.id) | bytes (PNG) |
Complete walkthrough:
from floorplan_api import Client, FloorPlanError
with Client() as client:
job = client.analyze_async("plan.png")
print(f"Submitted {job.id} (status={job.status})")
final = client.wait_for_job(
job.id,
poll_interval=2.0, # seconds between polls
timeout=300.0, # raise TimeoutError after 5 min
)
print(f"Final status: {final.status}")
if final.status != "completed" or final.result is None:
raise FloorPlanError(final.error["message"] if final.error else "no result")
print(f"Mask is {final.result.width}x{final.result.height}px")
mask_png = client.download_mask(final.id)
with open("walls.png", "wb") as fh:
fh.write(mask_png)Or poll yourself with custom logic:
import time
job = client.analyze_async("plan.png")
while not job.is_terminal:
time.sleep(1.0)
job = client.get_job(job.id)Working with the mask
The bytes you get back are a standard PNG, so any image library works. A few common patterns:
from io import BytesIO
from PIL import Image
mask_png = client.extract("plan.png")
# Decode the PNG and inspect it.
mask_img = Image.open(BytesIO(mask_png))
print(mask_img.size) # (width, height) — same as the input image
print(mask_img.mode) # "L" — single-channel 8-bit grayscale
# Save somewhere else, convert format, etc.
mask_img.save("walls.tiff")
mask_img.convert("1").save("walls_bw.png") # 1-bit bitmapError handling
import time
from floorplan_api import (
Client, FloorPlanError,
AuthenticationError, RateLimitError, InvalidRequestError, NotFoundError,
ServerError, ProcessingError, TimeoutError, ConnectionError,
)
client = Client()
try:
mask = client.extract("plan.png")
except RateLimitError as exc:
# Already retried max_retries times honouring Retry-After.
time.sleep(exc.retry_after or 5.0)
mask = client.extract("plan.png")
except TimeoutError as exc:
# 504: the worker is still on it. Do not resubmit — finish the job.
if exc.job_id is None:
raise
job = client.wait_for_job(exc.job_id)
mask = client.download_mask(job.id)
except ProcessingError as exc:
# The worker failed this file (undecodable, ...). Same input will fail again.
print(f"cannot process: {exc.message} (job {exc.job_id})")
raise
except AuthenticationError:
print("Invalid or revoked API key")
raise
except InvalidRequestError as exc:
# 400 / 409 / 413 / 415, or rejected locally (unsupported bytes) — fix the input
print(f"bad request: {exc.message}")
raise
except FloorPlanError as exc:
# Anything else — keep the request_id for support tickets.
print(f"{exc.type}: {exc.message} (request_id={exc.request_id})")
raiseConnection failures, 429 and transient 5xx responses are retried up to max_retries times with exponential backoff and jitter; a server Retry-After (sent on 429 and on 503 when the queue is at capacity) overrides the backoff, capped at 30 s per attempt. The upload body is rewound before every retry.
Two responses are deliberately not retried because the server has already queued the job: a 504 timeout_error (raised as TimeoutError with job_id set — the job is still running) and a 500 that carries a job_id (raised as ProcessingError — the worker failed that input). Retrying either would create a second job for the same file.
Configuration
| Argument | Default | Description |
|---|---|---|
| api_key | env FLOORPLAN_API_KEY | Your API key. |
| base_url | https://api.floorplanapi.com | Override for self-hosted deployments. Also reads FLOORPLAN_BASE_URL. |
| timeout | 60.0 | Per-request timeout (seconds). Override per call with the keyword arg. |
| max_retries | 3 | Retries for connection errors, 429, and transient 5xx. A 504 or 500 that names a job_id is never retried. |
| retry_backoff | 0.5 | Base delay (s) for exponential backoff with jitter. |
| session / client | new | Reuse a custom requests.Session (Client) or httpx.AsyncClient (AsyncClient, argument client=). |
Method reference
| Method | Returns | REST equivalent |
|---|---|---|
| extract(image=None, *, upload_key=None, page=None, timeout=None) | MaskBytes | POST /v1/extract |
| analyze(image=None, *, upload_key=None, page=None, timeout=None) | MaskBytes | POST /v1/analyze |
| upload_then_extract(image, *, page=None, threshold_bytes=10_485_760, timeout=None) | MaskBytes | POST /v1/uploads + PUT + POST /v1/extract |
| create_upload(*, content_type="image/png", timeout=None) | dict | POST /v1/uploads |
| upload(image, *, page=None, content_type=None, timeout=None) | str (storage key) | POST /v1/uploads + PUT |
| analyze_async(image=None, *, upload_key=None, page=None, timeout=None) | Job | POST /v1/analyze?async=true |
| get_job(job_id, *, timeout=None) | Job | GET /v1/status/{id} |
| wait_for_job(job_id, *, poll_interval=1.0, timeout=300.0) | Job | GET /v1/status/{id} (loop) |
| download_mask(job_id, *, timeout=None) | MaskBytes | GET /v1/jobs/{id}/mask |
| close() / await aclose() | None | Closes the underlying session / connection pool. Context managers do this for you. |
image can be a path (str / pathlib.Path), raw bytes, or any binary file-like object.
Types reference
class MaskBytes(bytes): # returned by extract / analyze / upload_then_extract / download_mask
width: int | None # X-Mask-Width
height: int | None # X-Mask-Height
job_id: str | None # X-Job-Id
request_id: str | None # X-Request-Id
mode: str | None # X-Floorplan-Mode: "live" | "test"
size: tuple[int, int] | None # (width, height) when both known
page_size_pt: tuple[float, float] | None # PDF input only: page size in points
pdf_scale: float | None # mask px per PDF pt (width / page_size_pt[0])
pdf_dpi: float | None # pdf_scale * 72
def pdf_page_size(data: bytes, page: int = 1) -> tuple[float, float]:
"""(width, height) in points of a PDF page as it renders (crop box, /Rotate applied)."""
@dataclass(frozen=True)
class MaskResult:
result_url: str # path on the API host (combine with base_url to GET)
width: int # pixels
height: int # pixels
@dataclass(frozen=True)
class Job:
id: str
status: Literal["queued","processing","completed","failed"]
created_at: datetime
completed_at: datetime | None
result: MaskResult | None
error: dict | None
@property
def is_terminal(self) -> bool: ...
# All exceptions derive from FloorPlanError:
class FloorPlanError(Exception):
type: str | None
message: str
status_code: int | None
request_id: str | None
job_id: str | None # set when the server had already queued a job
response: dict | None
class AuthenticationError(FloorPlanError): ... # 401 / 403
class InvalidRequestError(FloorPlanError): ... # 400 / 409 / 413 / 415 / other 4xx, or rejected locally
class NotFoundError(FloorPlanError): ... # 404
class RateLimitError(FloorPlanError): # 429 (after retries)
retry_after: float | None
class ServerError(FloorPlanError): ... # 5xx (after retries)
class ProcessingError(ServerError): ... # 500 with job_id: worker failed the job; not retried
class TimeoutError(FloorPlanError): ... # client timeout, wait_for_job deadline, or 504 with job_id (not retried)
class ConnectionError(FloorPlanError): ...REST API
All requests use HTTPS. The hosted base URL is https://api.floorplanapi.com. When developing locally, use http://localhost:3000. Each endpoint below shows the same call in cURL, raw Python (requests), and Node.js (fetch).
Base URL
Production:
https://api.floorplanapi.comLocal development:
http://localhost:3000Authorization header
Authorization: Bearer fp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxPOST /v1/uploads
POST/v1/uploadsfree
Reserve a presigned URL the client can PUT bytes to directly. Use this to upload files larger than the inline 10 MB cap, or whenever you want to keep the bytes off the API server. After the PUT succeeds, pass the returned key as upload_key on /v1/extract or /v1/analyze.
Request body
Content-Type: application/json
{
"content_type": "image/png" // optional; defaults to "image/png"
// image/png | image/jpeg | image/webp | application/pdf
// — must match the Content-Type used on PUT
}Sample call
# 1. Reserve a slot.
curl -X POST https://api.floorplanapi.com/v1/uploads \
-H "Authorization: Bearer fp_test_..." \
-H "Content-Type: application/json" \
-d '{"content_type":"image/png"}'
# Response:
# {"upload_url":"https://...","key":"uploads/<rand>.png","expires_at":"...","content_type":"image/png"}
# 2. PUT the bytes to upload_url with the same content_type.
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/png" \
--data-binary @big_plan.png
# 3. Submit the job by storage key.
curl -X POST https://api.floorplanapi.com/v1/extract \
-H "Authorization: Bearer fp_test_..." \
-F "upload_key=$KEY" \
--output walls.pngResponse
HTTP/1.1 200 OK
Content-Type: application/json
{
"upload_url": "https://<bucket>.<account>.r2.cloudflarestorage.com/uploads/...?X-Amz-...",
"key": "uploads/<48-hex>.png",
"expires_at": "2026-04-28T13:15:00.000Z",
"content_type": "image/png"
}Notes
- The presigned URL expires 15 minutes after issuance.
- The PUT must use the same
Content-Typedeclared in the request body, or it will be rejected. - The returned
keyis opaque — pass it back as-is. The API never reuses keys across calls.
POST /v1/extract
POST/v1/extract1 credit
Run wall segmentation on a floor plan. Synchronous; the response body on success is the binary PNG mask. The server waits up to about 30 s for the worker, then answers 504 with the job id (see below).
Request
- Content-Type:
multipart/form-data - One of (mutually exclusive):
image— PNG / JPEG / WEBP / PDF (first page), ≤ 10 MB inline. The part'sContent-Typemust be the file's type.upload_key— storage key from POST /v1/uploads (recommended for files larger than 10 MB).page— optional, PDF only: the 1-based page to process (default 1). Only that page is rasterised and segmented. The server knows the declared kind of the upload (the part'sContent-Type, or theupload_key's extension) and answers 400 ifpageis not a positive integer or is given for anything but a PDF; the job fails withinference_errorif the PDF has fewer pages.
Sample call
# Inline (small files, ≤ 10 MB):
curl -X POST https://api.floorplanapi.com/v1/extract \
-H "Authorization: Bearer fp_test_..." \
-F "image=@plan.png" \
--output walls.png
# By storage key (after POST /v1/uploads + PUT):
curl -X POST https://api.floorplanapi.com/v1/extract \
-H "Authorization: Bearer fp_test_..." \
-F "upload_key=uploads/<key>.png" \
--output walls.png
# Page 3 of a PDF:
curl -X POST https://api.floorplanapi.com/v1/extract \
-H "Authorization: Bearer fp_test_..." \
-F "image=@drawing-set.pdf;type=application/pdf" \
-F "page=3" \
--output walls.pngSample response (success)
HTTP/1.1 200 OK
Content-Type: image/png
Content-Length: 18432
X-Mask-Width: 1024
X-Mask-Height: 768
X-Job-Id: job_abc123
X-Request-Id: req_def456
<binary PNG bytes>Possible status codes
200— success, body is the PNG mask.400— missing/invalidimagefield, or a badpage.401— missing / invalid / revoked / expired key.413— image larger than 10 MB on the inline path; use POST /v1/uploads +upload_keyinstead.415— unsupported MIME type.429— rate limit exceeded.500— the worker failed the job (error.typee.g.inference_error); body carriesjob_id. Resubmitting the same file will fail again.503— queue full; honourRetry-After.504— timed out before the worker finished; body carriesjob_idand the job keeps running. Poll/v1/status/{job_id}thenGET /v1/jobs/{job_id}/maskinstead of resubmitting.
POST /v1/analyze
POST/v1/analyze2 credits
Same wall-segmentation model as /v1/extract for now. Sync by default; pass async=true to submit a job and receive a job_id instead of the mask.
Request fields
- Content-Type:
multipart/form-data - One of
imageorupload_key(required) — same semantics as /v1/extract. page— optional, PDF only: 1-based page to process (default 1), as on /v1/extract.async(defaultfalse) — set totrueto submit asynchronously.
Sync call
curl -X POST https://api.floorplanapi.com/v1/analyze \
-H "Authorization: Bearer fp_test_..." \
-F "image=@plan.png" \
--output walls.pngAsync submit
curl -X POST https://api.floorplanapi.com/v1/analyze \
-H "Authorization: Bearer fp_test_..." \
-F "image=@plan.png" \
-F "async=true"Async submission response (202)
{
"success": true,
"job_id": "job_abc123",
"status": "queued",
"created_at": "2026-04-28T13:00:00.000Z",
"mode": "test"
}GET /v1/status/{job_id}
GET/v1/status/{job_id}free
Poll the status of an async job. When the job completes, the response carries a result_url pointing at the mask download endpoint below.
Sample call
curl https://api.floorplanapi.com/v1/status/job_abc123 \
-H "Authorization: Bearer fp_test_..."Response shape
{
"job_id": "job_abc123",
"status": "completed", // or "queued" | "processing" | "failed"
"created_at": "2026-04-28T13:00:00.000Z",
"completed_at": "2026-04-28T13:00:02.000Z",
"result": {
"result_url": "/v1/jobs/job_abc123/mask",
"width": 1024,
"height": 768
},
"error": null // or { "type": "...", "message": "..." }
}Polling pattern
Suggested cadence: poll every 1–2 s for the first 30 s, then every 5 s, with a hard ceiling of a few minutes (depending on your batch size). The Python client's wait_for_job() does this for you.
import time, requests
def wait(job_id, *, key, base="https://api.floorplanapi.com", timeout=300):
deadline = time.monotonic() + timeout
while True:
res = requests.get(f"{base}/v1/status/{job_id}",
headers={"Authorization": f"Bearer {key}"})
res.raise_for_status()
job = res.json()
if job["status"] in ("completed", "failed"):
return job
if time.monotonic() > deadline:
raise TimeoutError(job_id)
time.sleep(1.5)GET /v1/jobs/{job_id}/mask
GET/v1/jobs/{job_id}/maskfree
Download the PNG mask for a completed job. Returns 409 while the job is still queued or processing, and 404 if the job belongs to another account or has expired.
Sample call
curl https://api.floorplanapi.com/v1/jobs/job_abc123/mask \
-H "Authorization: Bearer fp_test_..." \
--output walls.pngSuccess response
HTTP/1.1 200 OK
Content-Type: image/png
X-Mask-Width: 1024
X-Mask-Height: 768
X-Job-Id: job_abc123
<binary PNG bytes>Response headers
| Header | Description |
|---|---|
| X-Request-Id | Unique ID for the request — include in support tickets. |
| X-Job-Id | Job ID created for the request. Use it to poll /v1/status/{id} after a 504. |
| X-Mask-Width | Pixel width of the returned PNG: the input image's own width, or the rendered page width for a PDF. |
| X-Mask-Height | Pixel height of the returned PNG. |
| X-Floorplan-Mode | live or test. |
| X-RateLimit-Limit | Requests per minute allowed. |
| X-RateLimit-Remaining | Requests left in current window. |
| X-RateLimit-Reset | Unix timestamp when the window resets. |
Errors
Error responses always use Content-Type: application/json with a stable shape, even on endpoints whose success responses are PNG bytes. The Python client maps these onto typed exceptions.
{
"error": {
"type": "authentication_error",
"message": "Invalid API key."
}
}| Status | type | Python exception | When |
|---|---|---|---|
| 400 | invalid_request_error | InvalidRequestError | Missing field, malformed body. |
| 401 | authentication_error | AuthenticationError | Missing/invalid/revoked/expired key. |
| 403 | authentication_error | AuthenticationError | Resource doesn't belong to your account. |
| 404 | not_found_error | NotFoundError | Job ID not found or expired. |
| 409 | invalid_request_error | InvalidRequestError | Mask requested before the job completed. |
| 413 | invalid_request_error | InvalidRequestError | Image larger than 10 MB. |
| 415 | invalid_request_error | InvalidRequestError | Unsupported MIME type. |
| 429 | rate_limit_error | RateLimitError | Per-minute rate limit exceeded. |
| 503 | service_unavailable | ServerError | Queue at capacity; honour Retry-After. |
| 500 | inference_error, storage_error, processing_error | ProcessingError | The worker failed the queued job; body carries job_id. Not retried. |
| 504 | timeout_error | TimeoutError (job_id set) | Sync request did not finish in time; the job keeps running. Poll /v1/status/{job_id}. Not retried. |
| other 5xx | server_error | ServerError | Transient server problem; retried automatically. |
Rate limits & quotas
Rate limits are per API key per minute. Hitting the limit returns 429 with a Retry-After header. Monthly credit budgets apply separately and reset on the first day of the billing period.
| Plan | Requests/minute | Concurrent | Monthly credits |
|---|---|---|---|
| Free | 10 | 1 | 50 |
| Starter | 30 | 3 | 100 |
| Pro | 60 | 10 | 500 |
| Enterprise | Custom | Custom | Unlimited |
Support
Have a question, found a bug, or want to ship something we don't expose yet?
- Contact us — for everything else.
- Include the
X-Request-Idresponse header (or therequest_idfield on theFloorPlanErrorin Python) to speed things up. - Dashboard — manage keys, view usage, change plan.