The public API
Blupinta has a public API for other apps, like a home dashboard that shows the last version saved of a home. It lives at api.blupinta.app, and its address alone says what it is:
GET https://api.blupinta.app/{ "name": "Blupinta API", "version": "v1", "resource": "https://api.blupinta.app/v1", "issuer": "https://auth.blupinta.app", "discovery": "https://auth.blupinta.app/.well-known/openid-configuration", "scopes": { "projects:read": "the saved versions of the one project the person chose" }, "endpoints": ["GET /v1/projects/{projectId}/home", "GET /v1/projects/{projectId}/revisions", "GET /v1/projects/{projectId}/revisions/{number}"], "docs": "https://docs.blupinta.app/reference/public-api/"}Your app
Section titled “Your app”An app needs a client id. A device can start at once with client_id=device, without registering: it uses
the device flow, and the person names it on the consent page (Kitchen tablet), which is how it shows among their
connected apps. An app never holds a secret of ours: a device or a page is a public client and proves itself with
PKCE, a server signs with a key of its own.
Registering it
Section titled “Registering it”Any other app is registered from Settings › Developers, a section shown to the accounts allowed to make apps (for now on request). Register an app asks for:
- its name, the one people read when they connect it;
- how it signs in: a web page (in the browser, PKCE, back to an
httpsaddress), an installed app (PKCE, back through its own scheme,com.example.app:/callback, or tohttp://127.0.0.1), a device (the device flow, no address) or a server (it signs with its private key,private_key_jwt); - its return addresses, one a line;
httponly tolocalhostwhile you build it; - a server’s public keys, as a JWKS (
{ "keys": [ … ] }): the private key never leaves your server; - what it may ask for, the scopes it may request.
What can’t work is refused with its reason, as the API says it. Each app gets its client_id, to Copy; an account registers up to ten. A new app is unverified: only you can connect it, and its consent page says so, until Blupinta checks who makes it (write to us). Edit changes it for the next sign-in; Delete asks first, then stops its client_id at once and disconnects whoever connected it.
Blupinta’s libraries (TypeScript, React, the command line, an MCP server, Python) do the sign-in and the calls for you.
Getting a token
Section titled “Getting a token”Tokens come from auth.blupinta.app, an OpenID Connect provider: its discovery document lists every endpoint. The person signs in there and allows your app, choosing the one project it may read (Connecting an app).
- A device (no keyboard, like a wall tablet) uses the device flow:
POST /oidc/device/authwith yourclient_idandscope=openid offline_access projects:read; show the person theuser_codeandverification_uri; askPOST /oidc/tokenwith thedevice_codeeveryintervalseconds until it answers. - A page or an app with a browser uses the authorization code with PKCE (
S256), sending the person to/oidc/authorizeand exchanging the code at/oidc/token.
Ask offline_access to stay connected: you get a refresh token too, which changes at every use and lasts 90 days from
the last one. An access token lasts an hour. The token is for the resource https://api.blupinta.app/v1.
Reading
Section titled “Reading”Every request carries the token: Authorization: Bearer …. The projects are the ones the person chose; GET /v1/projects lists them.
| request | answer |
|---|---|
GET /v1/projects |
the projects the app may read, by name, each with its last version’s number and date |
GET /v1/projects/{projectId} |
one of them |
GET /v1/projects/{projectId}/home |
the last version saved: its number, message, author, date and the home itself (Blupinta’s home format, see The home file) |
GET /v1/projects/{projectId}/revisions |
every saved version, newest first, without the home, in rows; next is where the next page starts, null on the last (today every version fits on one) |
GET /v1/projects/{projectId}/revisions/{number} |
one version, with its home |
GET /v1/projects/{projectId}/files/{sha256} |
a file a version names by its sha (a drawing, a floor’s image): a redirect to it, signed for a few minutes; files never change, keep them |
GET /v1/projects/{projectId}/catalog/models/{sha256}, …/thumbnails/{sha256} |
a product’s 3D model or thumbnail that the home’s products name, the same way |
GET /v1/projects/{projectId}/thumbnail |
the last version’s picture, its first plan seen from above (WebP, 640 × 400), the same way as files; 404 when that version has none |
GET /v1/projects/{projectId}/export |
a zip of every version and the files they name |
GET /v1/projects/{projectId}/events |
a stream of server-sent events that stays open: revision.saved with the new version’s number when one is saved; access.revoked (disconnected, or the project taken away) or token.expired before it ends |
With the events, an app doesn’t have to ask again and again: it reads the home when told a version was saved. After
token.expired it refreshes its token and opens the stream again; after access.revoked it stops.
Writing
Section titled “Writing”An app allowed projects:write works on the projects the person chose; one allowed projects:create makes new ones in
their account (and may then read and write them). It writes as the person who connected it, so only on projects they
may edit, never on ones they can only look at (403).
| request | what it does |
|---|---|
POST /v1/projects with { "name": …, "address": … } |
a new project, its home empty; 201 with the project |
PATCH /v1/projects/{projectId} with { "name": …, "address": … } |
renames it |
POST /v1/projects/{projectId}/files?name=pianta.png, the file’s bytes as the body and its type as content-type |
a drawing or an image for the home (PNG, JPEG, WebP, PDF); its sha256 is how the home names it; the same bytes again answer 200 |
POST /v1/projects/{projectId}/revisions with { "home": …, "message": …, "baseRevision": 3 } |
saves the home as the next version, held to the same checks as Create version in the editor; baseRevision is the last version the app had (null when there was none): another one saved since answers 409 conflict, so nobody’s work is overwritten |
A version saved this way leaves the editor’s draft as it is; the project’s history says which app saved it. Send an
Idempotency-Key header with a write and the same request sent again (after a lost answer) gets the same answer,
acted on once.
Webhooks
Section titled “Webhooks”An app allowed webhooks:manage that can’t keep the events’ stream open (a server, an automation) gives Blupinta an
address of its own: POST /v1/webhooks with { "url": "https://…", "events": ["revision.saved", "access.revoked"] }.
The answer holds the webhook’s secret, shown this once. Blupinta then posts each event of the projects the app
reaches to that address, as JSON ({ "id", "type", "createdAt", "data": { "projectId", "number" } }), with three
headers: Blupinta-Event, Blupinta-Delivery (the same id on every retry: an app that saw it already can skip it) and
Blupinta-Signature: t=<seconds>,v1=<hex>. Check the signature before trusting the body:
const [, t, v1] = /^t=(\d+),v1=([0-9a-f]+)$/.exec(signature)const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')const isGenuine = timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex')) && Date.now() / 1000 - t < 300Answer 2xx within 10 seconds. Anything else is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12
hours; after three days of nothing but failures the webhook is turned off, and GET /v1/webhooks says when and why.
GET /v1/webhooks/{webhookId}/deliveries lists the latest ones with what the app answered; DELETE /v1/webhooks/{webhookId} removes it. The address must be HTTPS and on the public internet: Blupinta never posts into a
private network. An app that is disconnected loses its webhooks with it.
Comments
Section titled “Comments”An app allowed comments:read reads the threads pinned on the projects’ plans (GET /v1/projects/{projectId}/threads); one allowed comments:write opens one (POST …/threads with { "planId", "anchor": { "ref": { "kind": "wall", "id": "north" }, "at": [120, 40] }, "body" }), answers one (POST …/threads/{threadId}/comments with { "body" }) and resolves or opens it again (PATCH …/threads/{threadId} with { "isResolved": true }). It writes as the person who connected it, anyone who can look at the project may comment, and
each message says the app wrote it. Mentions (@someone) stay in the editor.
The catalog
Section titled “The catalog”An app allowed catalog:read searches Blupinta’s catalog of products as the editor does: GET /v1/catalog/products?q= libreria&brand=IKEA&mount=floor&sort=name&language=it, in pages (limit, at most 100, and after, the next of the
page before), with how many it found in all. GET /v1/catalog/products/{namespace}/{name} (ikea/kallax) reads a
product whole, as a home holds it among its products; GET /v1/catalog/models/{sha256} and …/thumbnails/{sha256}
its files.
Checking a home file
Section titled “Checking a home file”POST /v1/homes/check with { "home": … }, the content of a .home.json of any version, answers what Blupinta’s own
checks say of it: readAs (the version the file says), checkedAt (the current one, the file brought to it first as an
import would), isValid, and its problems. A file that isn’t a home has format problems, each saying where; a home
has the problems that would keep it from being saved as a version (area-without-rooms, open-room, crossing…),
with the plan and the elements they concern. Nothing is kept.
A home dashboard’s own needs
Section titled “A home dashboard’s own needs”An app allowed hosts:manage (ask it with projects:read; Blupinta’s device client may) tells Blupinta what its own
code relies on, project by project:
| request | what it does |
|---|---|
PUT /v1/projects/{projectId}/host-references with { "refs": [{ "kind": "wall", "id": "north" }] } |
the elements of the home the app names (kinds: plan, level, area, presentation, room, wall, opening, item, light, awning, plate, stair), each one in the last saved version; from then on a version that drops one can’t be saved, and the editor says which app needs it |
PUT /v1/projects/{projectId}/devices with { "devices": [{ "id": "light.soggiorno", "name": "Soggiorno", "kind": "light", "room": "Sala" }] } |
the app’s devices (light, cover, switch, sensor, climate, media), offered in the editor when binding the home’s lights and shutters |
GET on either |
what the app declared |
Each PUT replaces the whole list; a wrong one answers 400 with { "error": "invalid_input", "problems": [...] }.
The person’s name and address aren’t part of /v1: ask profile and email when connecting and read them, as OpenID
Connect does, from the userinfo_endpoint of the discovery document (https://auth.blupinta.app/oidc/userinfo). An app reads as the person who connected it: a project they
leave is closed to it too.
The last version carries its ETag: send it back in If-None-Match and a home that didn’t change answers 304, with
nothing to download. A page of any site may call the API: the token travels in a header, never a cookie, and the
answers let any origin read them.
Every operation, with its parameters, answers and refusals, is in the API reference, generated from
the OpenAPI document the API serves at GET https://api.blupinta.app/v1/openapi.json: give it to a client
generator, or to your tools, to call the API from any language.
When it says no
Section titled “When it says no”- 401: no token, an expired or revoked one, or an app the person disconnected. Refresh the token; if that fails too, the app must be connected again.
- 403: the token hasn’t the scope the request needs.
- 404: a project other than the one allowed, or one that doesn’t exist: the API doesn’t say which.
- 429: too many requests: wait as long as
Retry-Aftersays (seconds). - 500: something failed on Blupinta’s side; quote the
errorIdof the answer when asking for help.
Every refusal is { "error": "<code>" }, the code saying which of these it is.
Every answer says how many requests your app may still make: RateLimit-Limit (600 a minute for each connection of
your app, every token of it counted together), RateLimit-Remaining and RateLimit-Reset (seconds until the minute
starts again). Slow down as RateLimit-Remaining nears 0, instead of waiting for the 429.
Changes
Section titled “Changes”/v1 only grows: a new field, a new operation, a new optional parameter may come, so read what you need and ignore
what you don’t know. A change that would answer differently comes as /v2.
An operation due to go is marked deprecated in the OpenAPI document, and
each of its answers carries Deprecation (since when, RFC 9745), Sunset (the day it stops answering, at least six
months later, RFC 8594) and a Link here. Each is listed below with what takes its place.
None is deprecated today.