Skip to content

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/"
}

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.

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 https address), an installed app (PKCE, back through its own scheme, com.example.app:/callback, or to http://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; http only to localhost while 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.

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/auth with your client_id and scope=openid offline_access projects:read; show the person the user_code and verification_uri; ask POST /oidc/token with the device_code every interval seconds until it answers.
  • A page or an app with a browser uses the authorization code with PKCE (S256), sending the person to /oidc/authorize and 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.

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.

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.

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 < 300

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

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.

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.

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.

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.

  • 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-After says (seconds).
  • 500: something failed on Blupinta’s side; quote the errorId of 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.

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