Publishing

Last updated 2026-09-21

This page is the console side: getting an account and a namespace, uploading a version, what the store checks, and how a version reaches boxes.

1. Sign up

Create an account with an email and password. You confirm with a 6-digit code sent to that address, and you accept the Developer Terms at signup — the version you accepted is recorded with your account. Signing up grants the publisher role; nothing else is needed to publish.

2. Your namespace

On your first login the store gives you dev.<handle>., derived from your account handle (lowercased, anything outside a-z0-9- replaced with -, at most 24 characters). If the name is taken, a short suffix from your user id is added. Every app id you publish must be that prefix, or a child of it: with dev.jan. you may publish dev.jan.notes and dev.jan.notes.beta, and nothing else.

im.plum and plum are reserved. The default quota is 25 apps per developer.

A namespace for a domain you own (com.example.) is assigned by an admin today; ask us. The self-service DNS TXT flow described in the design is planned and not implemented.

3. Sign your bundle

Every upload must be signed by a key you registered under Signing keys — the store rejects an unsigned bundle. Run plum-dev keygen once, paste the two public lines into the console, and read Signing and trust for what the keys mean.

4. Upload

Two ways, same endpoint (POST /v1/publisher/versions, multipart plu, optional icon, channel):

# from the CLI, with a publisher token from the console (CLI tokens page)
plum-dev publish . --token plum_pub_… --channel beta

or the console's Upload page. Publisher tokens are created in the console only (a token cannot mint another token), default to 90 days, and are shown once. They carry the publisher role and nothing more: they cannot open key escrow or request a store-assisted rotation.

5. What the store checks, in order

Every one of these rejects the upload — there are no warnings.

Check Rule Error
Upload size ≤ 64 MiB 413 too_large
Manifest root manifest.json, ≤ 32 KiB, valid id/name/version, permissions from the allowed set, entry, icon and server.bin present in the zip 400 invalid_plu, manifest_invalid
Entry paths no absolute paths, no .., no backslashes, no NUL 400 bundle_unsafe
Entry types regular files only — no symlinks, no device nodes 400 bundle_unsafe
Size ≤ 32 MiB per file, ≤ 2000 files, ≤ 200 MiB uncompressed 400 bundle_too_large
server.bin 64-bit ELF, machine 0xB7 (arm64) 400 server_bin_arch
clients[] at most 10, client_id = <app id>:<label>, known platform, 1–8 redirect URIs (never https from a manifest), 1–8 scopes 400 bad_clients
Channel public or beta 400 bad_channel
Publisher signature META/ complete, signature valid, MANIFEST.sha256 covers exactly the non-META files and every hash matches 400 unsigned_bundle, signature_invalid
Key registration the signing key is registered to you and not revoked; a declared recovery key matches the registered one 400 key_not_registered, recovery_key_mismatch
Namespace and quota app id under your namespace, not reserved, quota not reached 403 reserved_namespace, namespace_required, quota_exceeded
Ownership the app id is yours 403 not_owner
Key continuity same signing key as the previous version, or a valid rotation record 409 publisher_changed
Version not already uploaded, and higher than the latest 409 version_exists, version_not_increasing

A passing bundle is countersigned checks immediately. Error bodies are {"detail": {"error": "<code>", "detail": "<text>"}}.

plum-dev validate runs the same manifest and bundle rules on your machine before you upload (with a slightly stricter 50 MiB bundle cap), so most of this list should never fire.

6. Beta

--channel beta publishes without human review, to the boxes you list as testers:

plum-dev testers dev.jan.notes add PB24A1B2C3 --note "my own box"
plum-dev testers dev.jan.notes list

A tester is a box serial — 4 to 64 characters of letters, digits, - and _, at most 200 per app. There is no email invite: a box authenticates to the store as its serial, so the serial is all the store needs. A beta version carries the checks countersignature and appears in the catalog only for those boxes, marked as beta. It replaces the public version in the listing when its version number is higher. No mail is sent for a beta upload.

7. Review and publication

--channel public (the default) puts the version in in_review and mails you. A reviewer approves or rejects it in the console; you get a mail either way, with the reviewer's notes on a rejection.

Version statuses: in_review, published, rejected, superseded, beta (and draft, which is reserved and unused).

On approval the store countersigns reviewed, marks the previous published version superseded (along with any beta version at or below it), and points the app's current version at the new one. Boxes then see it in the catalog.

8. Companion clients

If your app has a native companion, register its OAuth clients under Clients on the app page (or declare them in the manifest's clients[], which the store registers on upload). An app with no .plu at all — an existing app that just wants the box as storage — is registered with Register a compatible app and then gets clients the same way. See Companion apps.

9. SKUs and entitlements

Under SKUs you record what your app sells: a sku id (a-z0-9_.-, up to 64 characters), a kind (one_time or subscription, the latter with period P1M or P1Y), a display name, a price in cents and an ISO 4217 currency. Up to 50 SKUs per app.

No payment provider is connected. SKUs are recorded, not sold. What works today is grants: you can issue a test entitlement for one of your own box serials (up to 50 live per app, with an optional expiry) and revoke it again; an admin can grant, revoke and rebind entitlements for anyone. Receipts are signed by the store, fetched by the box, and handed to your app — so you can build and test the paid path now and switch on selling later without changing the app.

How your app reads an entitlement: plum.entitlement.get() in a panel, the X-Plum-Entitlements header or the control socket in a service, GET /api/apps/{id}/entitlement for a companion app.