One Worker, one D1 database (binding DB), one R2 bucket (binding BUCKET) and one SQLite-backed Durable Object, P99Hub (binding HUB), per space: a space is the Cloudflare account P99 is deployed to. p99 deploy binds SPACE (the space's slug) and SPACE_NAME (its display name). The Worker is mounted on every host it serves as the route <host>/_p99/*, so a page talks only to its own origin. It may also sit on a custom domain of its own.
The host of a request is the URL's hostname. A host must be listed in the hosts table to be served; an unknown host answers 404 on everything but /_p99/health. The signed API is the exception: it names the host itself (below), so it answers on any address the Worker is mounted on.
| Path | What |
|---|---|
GET /_p99/health |
{"ok":true,"version"}, unsigned |
GET /_p99/p99.js |
the loader |
GET /_p99/panel.js, /_p99/board.js, /_p99/avatar.js, /_p99/p99.css, /_p99/capture.js, /_p99/art/concrete.webp, /_p99/art/concrete-dark.webp (and their -2x twins) |
the lazy parts |
/_p99/api/* |
the browser API, authorised by a personal code |
/_p99/v1/* |
the signed API, for the CLI, the TUI and p99 announce |
Static files are answered from text modules bundled into the Worker (worker/src/web/). The loader is Cache-Control: public, max-age=300; every other file is fetched as <path>?v=<version> and answered public, max-age=31536000, immutable. __P99_VERSION__ in any served file is replaced by the Worker's VERSION variable.
Every error is {"error":"<code>","message":"<English>"} with 400 invalid, 401 unauthorized, 403 forbidden, 404 not_found, 409 conflict, 413 too_large, 428 totp_required, 429 rate_limited, 500 internal. A client_uid seen before answers the original object with 200, never a duplicate. The message is written for the person who reads it: the panel and the CLI show it as it is.
Every request carries X-P99-Code: <code>. A code is 12 characters of Crockford base32 (0123456789abcdefghjkmnpqrstvwxyz); the Worker lowercases it, reads i and l as 1 and o as 0, drops - and spaces, and looks up its keyed hash, HMAC-SHA256 under the Worker secret CODE_PEPPER, so the codes table alone gives no code away (p99 setup makes that secret once and p99 deploy keeps it). A missing, unknown or revoked code, or one whose grant doesn't reach the request's host, answers 401, and the loader then forgets it; a parked code answers 403 on every route (see Spaces, projects and grants) and is kept, since it is not revoked; a revocation takes effect everywhere within ten seconds. Each code is limited to 120 requests a minute (the RATE rate-limiting binding, when the Worker has one) and to the daily limits below. Both answer 429 rate_limited; a daily limit's message starts with today's limit of, and the panel shows it at once, while it retries the per-minute one, a 5xx and a lost connection a few times before saying so. Since the code travels in a header and never in a cookie, there is no CSRF surface.
A code with an authenticator (totp) answers 428 totp_required to every route but POST /_p99/api/verify until the browser holds a device token for it, and the header then carries both, X-P99-Code: <code>.<device>. The TOTP is RFC 6238 with SHA-1, 6 digits and 30-second steps; the Worker keeps its secret encrypted with AES-GCM under a key derived from CODE_PEPPER, accepts one step either side of now, never a step at or before the last one used, and after 10 wrong codes answers 403 until p99 invites totp <id> sets a new secret. Device tokens are 128 random bits, kept as keyed hashes in code_devices.
The actor of a browser request is the code's person: {email, name, role}. A person is an Owner, who runs P99 (triage, statuses, codes, boards; a space may have several), or a User, who signs in with a code to file cards and comment. An Owner is a code minted with "role":"owner", or an e-mail in the Worker's MANAGERS secret (comma separated); every other code is a User. Answers carry "role":"owner"|"user" (/me, /people, a code object, a comment's author); /me and a code object also carry "manager", true for an Owner, which the loader reads. Changed in 1.13.0: a body that sends manager answers 400 manager is gone; send role: owner or user. "Guest" is reserved for someone without a code, which no answer carries today.
| Route | Who | Does |
|---|---|---|
GET /_p99/api/me?path= |
any code | {"name","email","username","role","manager","host","env","version","counts":{"page_open","mine_open","mine_unread","replies","all_open","waiting","needs_you"},"theme":{},"strings":{},"mask":[],"capture","link","license","licensed","licence":{"state","link","stored"},"space":{"slug","name"},"projects":[name],"project","avatar"}; all_open is null for a User; page_open counts the open cards filed on path on this host (a User's own only), and replies counts the person's cards with a comment by someone else newer than their last read (a User never counts an internal note); waiting counts an Owner's cards in Needs you, Stuck and Landed and needs_you those in Needs you alone (both null for a User); the ⋯ button shows needs_you plus replies at its top right and page_open at its top left; theme, strings, mask and capture are the host's config (capture is on or off); projects names the projects the code reaches and project the host's own; avatar is the person's avatar URL or null; licence is the licence's state word and whether the link may hide (hideable or shown), read from a key pasted with PUT /licence first and the deploy's bindings after, and licensed is true for any state but free: the panel then hides its small P99 logo; link is false only when the licence lets the "by PITO.MD" link hide and the host turned it off; license is the same state word (free, studio, agency or ff; from the bindings, where an older owner reads as agency, and free when LICENSE_STATE is missing or unknown), which the panel's head draws as its badge; licence.stored is true when the key comes from the page (PUT /licence), not the deploy |
POST /_p99/api/verify |
a code with an authenticator | {"totp":"<6 digits>"}; 201 {"device":"<32 hex>"}; the panel stores <code>.<device> where the loader keeps the code |
GET /_p99/api/issues?scope=mine|all|page&path=&status=open|closed|any&kind=&q=&cursor=&limit=&label=&source= |
any code; all, label and source Owners only |
{"issues":[summary],"next_cursor","total"}, newest first (total, on the first page only, counts every match), this host's cards only, limit at most 100 (default 50); label keeps the cards whose label it names, case and accents ignored as search ignores them (an exact match, up to ASCII case, wins when there is one), and a 1.8 status key (inbox, doing, review, done) the cards in that key's lane; source=human or agent keeps the cards whose last change was a person's or an agent's; priority and board answer 400 |
POST /_p99/api/issues |
any code (task Owners only) |
{"client_uid","kind","title","body","page","context","marks"}; 201 issue |
GET /_p99/api/issues/{key} |
the person who filed it, or an Owner | a thread, {"issue","comments","attachments","events"}; marks it read |
PATCH /_p99/api/issues/{key} |
as the signed API | 200 issue |
DELETE /_p99/api/issues/{key} |
Owners | as the signed API; the person who filed it gets 403 |
POST /_p99/api/issues/{key}/comments |
the person who filed it, or an Owner (internal Owners only) |
{"client_uid","body","internal"}; 201 comment |
PUT /_p99/api/issues/{key}/attachments/{client_uid}?name=&role=&mark=&comment= |
the person who filed it, or an Owner | raw body, Content-Length required, X-Content-SHA256 optional (the Worker hashes the body when it is absent); 201 attachment |
GET /_p99/api/files/{id} |
the person who filed it, or an Owner | the file, Content-Disposition: attachment |
GET /_p99/api/board, GET /_p99/api/labels, PATCH …/labels/{id}, POST /_p99/api/move |
Owners | as the signed API, over the cards the code reaches; a code for the whole space sees every card, as the key does |
POST /_p99/api/stream-ticket |
Owners | 201 {"ticket":"<48 hex>","expires_in":60}: a ticket for one WebSocket, used once, within 60 seconds |
GET /_p99/api/stream?t=<ticket> |
the ticket | a WebSocket that carries the live events of the cards the code reaches; no X-P99-Code, since a browser can't set headers on a WebSocket; a used, expired or unknown ticket, or one whose code was revoked or relinked since, answers 401; an open stream closes with 4001 the moment its code is revoked or relinked |
POST /_p99/api/merge |
Owners | as the signed API |
GET /_p99/api/cards, GET …/cards/{key}, POST /_p99/api/cards, POST …/cards/{key}/status, …/links, …/comments |
Owners | as the signed API, over the cards the code reaches; a new card is filed on this host, in the project it names, which the code must reach (404 otherwise). On GET /cards, host= only names the host the request is for, and site=1 keeps this host's cards |
GET /_p99/api/projects |
any code | as the signed API, over the projects the code reaches; a User's counts are their own cards |
GET /_p99/api/people |
Owners | {"people":[{"username","role","avatar"}]}, avatar the person's avatar URL or null: the people with a live code reaching this host's project and the e-mails in MANAGERS, by username; never an e-mail, a code, a hint or a host list |
PATCH /_p99/api/me |
any code | {"username"}, nothing else; 200 {"username"}, 409 when it is taken or too close to someone's: changes the code's own person only (usernames) |
GET /_p99/api/space |
any code | {"space":{"slug","name"},"projects":[{"name","hosts":[host],"open","closed"}]}: the projects the code reaches, by name; a User's counts are their own cards |
POST /_p99/api/projects, PATCH …/projects/{name} |
Owners | as the signed API; a code that reaches only some projects can't make one (403) |
PUT /_p99/api/licence, DELETE …/licence |
Owners | as the signed API |
PUT /_p99/api/me/avatar |
any code | the image as the raw body, Content-Type image/png, image/jpeg or image/webp, at most 256 KB (the page crops it square and makes it 256×256 first); 200 {"avatar":"<url>"}; another type, or content that doesn't look like its type, answers 400, a larger body 413. It is kept in the bucket under avatars/<person id>/<version>, and the URL carries ?v=<version>, so a new picture has a new URL and shows at once |
DELETE /_p99/api/me/avatar |
any code | 200 {"avatar":null,"removed"} |
GET /_p99/api/avatars/{username}?v= |
any code | the person's avatar, Cache-Control: private, max-age=86400 at its current v, and private, no-cache without it or at an old one; 404 when they have none, and the page draws the generated disc |
Every person (an e-mail with a code, an e-mail in MANAGERS, an assignee) has a username, unique in the space: 3 to 30 of a-z 0-9 . _ -, starting and ending with a letter or digit, stored lowercase. P99 makes the first one from the person's name (Zoë Ångström is zoe.angstrom, then zoe.angstrom2), and the person changes it with PATCH /me. admin, p99, root, support, manager, system, null, me, everyone, here and channel are reserved, and a username is refused when, with dots, dashes and underscores taken out and look-alikes folded (0 and o; 1, i and l; 3 and e; 4 and a; 5 and s; 7 and t; 8 and b; rn and m; vv and w), it matches someone else's or a reserved word. A card keeps its assignee by e-mail, so a new username shows everywhere at once; every answer shows the assignee as {"username"}, never the e-mail. An agent acts as its person, so it has the person's username. The panel and the board draw each person's disc from the username with /_p99/avatar.js.
Every route under /_p99/v1/ is signed:
| Header | Value |
|---|---|
X-P99-Key-Id |
the key id, p99 |
X-P99-Actor |
base64url, no padding, of the actor JSON; absent only on POST /_p99/v1/builds |
X-Content-SHA256 |
lowercase hex SHA-256 of the body (empty body: the hash of nothing) |
X-P99-Auth |
v1 ts=<unix seconds>,sig=<hex>, sig = HMAC-SHA256(secret, "v1\n" + ts + "\n" + METHOD + "\n" + path_and_query + "\n" + X-P99-Actor + "\n" + X-Content-SHA256); refused when ts is more than 60 s away |
The secret of key id p99 is the Worker secret KEY_P99, with KEY_P99_NEXT accepted beside it during a rotation. Key id test (the secret KEY_TEST) works only on 127.0.0.1 or localhost, for the Worker's own tests. There is no nonce: a captured request could be replayed within that minute, so the signed API travels over HTTPS only.
The actor is {"email","name","agent"?}, agent true when an agent acts as this person, left out otherwise. Cards, comments and events keep it, and every view shows such an actor as the person's name with a robot, Ada Lovelace 🤖. An agent's label, "via", comes back on reporter (the person who filed the card), a comment's author and each event (null for a person's own action). The browser API never carries it.
Key p99 is the space's own key, the one p99 setup makes and the CLI and TUI sign with. Its actor is always an Owner, so the daily limits never apply to it. It names the host with ?host=<host> on any route (host=*, or no host, on the list and counts reads every host), and the environment with env in a build's body. On the list and counts an unlisted host answers 404; on a card's routes host is a filter, so a card of another host answers 404; POST /_p99/v1/issues takes the query's host first, then the body's.
| Route | Who | Does |
|---|---|---|
POST /_p99/v1/builds |
the key | {"sha","branch","version","dirty","env","items":[{"id","rel","sha","subject"}]} replaces that environment's row for that branch; an env that is not an environment answers 400; 204 |
GET /_p99/v1/counts?path= |
actor | {"page_open","mine_open","mine_unread","replies","all_open"} |
GET /_p99/v1/stream |
actor | a WebSocket (Upgrade: websocket) that carries one event per change, as Live events describes; with host=<host>, only that host's |
GET /_p99/v1/issues?scope=&path=&status=&kind=&q=&cursor=&limit=&source= |
actor; all and source Owners only |
as the browser API; with host=*, every host's cards |
POST /_p99/v1/issues |
actor | as the browser API, plus "host"; 201 issue |
GET /_p99/v1/issues/{key}, PATCH, POST …/comments, PUT …/attachments/{uid}, GET /_p99/v1/files/{id} |
as the browser API | |
GET /_p99/v1/labels |
Owners | {"labels":[{"id","name","word","closes","boards":[{"id","name","owner_username"}]}]}: the seven lanes in order, as Boards describes |
PATCH /_p99/v1/labels/{id} |
Owners | {"name"}; 200 {"id","name","closes"}: renames a lane; a name taken in any case answers 409, an id that isn't a lane 404, and an Owner scoped to one host 403 |
POST /_p99/v1/merge |
actor | {"items":[key],"into":key}, at most 50 cards; 200 {"into","merged":[key],"unchanged":[key]}, as Merging duplicates describes |
DELETE /_p99/v1/issues/{key} |
actor | deletes the card for good; 200 {"key","comments","files","retrying","unlinked":[key]}, as Deleting a card describes |
GET /_p99/v1/hosts |
Owners | {"hosts":[{"host","env","config","project","created_at"}]} |
PUT /_p99/v1/hosts/{host} |
the key | {"env","config","project"?}; adds or updates a host; 200 host. A new host needs project, an existing project's name (400 otherwise); an update without it keeps the host's project, and one that names another project moves the host as PATCH does |
PATCH /_p99/v1/hosts/{host} |
the key | {"project"} alone; moves the host to that project, with its cards that were in the host's old project, in one batch; 200 host plus "moved", the number of cards moved. Every code granted the new project reaches the host at once, and those granted only the old one no longer do |
GET /_p99/v1/space |
actor | as the browser's GET /space, every project |
POST /_p99/v1/projects |
Owners, an agent too | {"name"}, 1 to 40 characters; 201 {"project":{"name","hosts":[],"open":0,"closed":0}}; a name another project holds in any case answers 409 |
PATCH /_p99/v1/projects/{name} |
Owners, an agent too | {"name"} alone; renames the project, and the project of the cards that name it with it; the cards filed on its sites stay in it. 200 {"project"}; 409 for a name taken, 404 for an unknown project |
PUT /_p99/v1/licence |
an Owner person, never an agent | {"key"}: a P99 Studio or Agency key, checked offline against the embedded PITO MD public key as p99 license set checks it, and stored in the database's settings; 200 {"licence":{"state","link","stored"},"tier","studio","paid_through"}. A key that doesn't verify answers 400 with the reason, and one whose paid-through date plus its grace ends before the Worker's build date (BUILD_DATE, else today) answers 400. The badge, the link and the small-logo rule follow at once. An agent gets 403 only an Owner, as a person, sets the licence |
DELETE /_p99/v1/licence |
an Owner person, never an agent | removes the stored key; 200 {"licence","removed"}, the licence then read from the deploy's bindings |
GET /_p99/v1/avatars/{username} |
actor | as the browser API |
DELETE /_p99/v1/hosts/{host} |
the key | 204; its cards stay |
GET /_p99/v1/envs |
Owners | {"envs":[{"name","kind","position","notify"}]} |
PUT /_p99/v1/envs/{name} |
the key | {"kind":"development"|"release","position","notify"}; 200 env |
GET /_p99/v1/codes?host= |
the key | {"codes":[code]}, newest first |
POST /_p99/v1/codes |
the key | {"name","email","grant","role","client_uid","totp","mail"}, grant {"space":true} or {"projects":[name,…]} (an unknown name, or no project at all, answers 400); the old "hosts":["…"] is taken in its place and mapped to its hosts' projects (400 with both). role is owner or user (user when left out); manager answers 400. 201 {"code":code,"secret":"<12 characters>","links":["https://<host>/#p99=<secret>"]}, plus "totp_key":"<base32>" when totp is true; the secret and the key are shown this once, and the same client_uid again answers 200 {"code":code} without them. links holds one link per host the grant reaches. Unless "mail":false, the Worker mails the person their links (one button per host, the host with the most cards filed or changed in the last 30 days first, the authenticator key never in it, reply-to the signed actor) and answers "mail":"sent", "off" (no mail set), "failed" or "skipped" (an .invalid address). With totp and the link mail sent, a second message follows with the authenticator setup alone (the QR code as an inline PNG attachment, the key and its otpauth:// link, never the code's link), and the answer adds "totp_mail":"sent" or "failed" |
POST /_p99/v1/codes/{id}/relink |
the key | {"client_uid","new_totp","mail"}, each optional; 201 the same answer as a new code, plus "revoked":code (the old one) and "totp":"carried" or "new" when the code has an authenticator: mints a code for the same person, grant and role and revokes the old one in one batch, so the old code answers 401 from then on. The authenticator's sealed secret is copied to the new code with its last used step, unless new_totp; the old code's device tokens are dropped. With mail and new_totp, the authenticator setup follows the link in a second message, as for a new code, with "totp_mail". 404 for an unknown or revoked code |
POST /_p99/v1/codes/{id}/totp[?mail=1] |
the key | 200 {"code":code,"totp_key":"<base32>"}: adds an authenticator to the code, or replaces it; every browser of that code asks for a 6-digit code again. With mail=1 the Worker mails the person the setup (the QR code, the key and its otpauth:// link, no code link) and adds "mail" as for a new code |
GET /_p99/v1/people?host= |
the key | as the browser's GET /people, for one host or, with no host, every live code |
POST /_p99/v1/people/rename |
the key | {"from","to"}; 200 {"from","to","items","assigned","comments","events","attachments","codes"}: moves everything filed, said, assigned or held under one e-mail to another in the whole space, in one transaction |
POST /_p99/v1/codes/{id}/revoke |
the key | 200 code |
PATCH /_p99/v1/codes/{id} |
a person, never an agent | {"grant"} alone, as POST /codes takes it, or {"projects":[]} to park the code; 200 {"code"}. It never mails anyone. A parked code opens nothing and sees nothing, yet stays unrevoked, so a later grant opens it again; its open streams close. 404 for an unknown or revoked code, 403 only a person changes a grant for an agent |
DELETE /_p99/v1/codes/{id} |
the key | a revoked code only; 200 {"id","deleted":true}: removes the code for good, with its browsers' device tokens and app sessions. An active code answers 409 revoke it first, an unknown one 404 no such code; the cards, comments and username of its person stay |
GET /_p99/v1/hooks?host= |
Owners | {"hooks":[hook]} |
PUT /_p99/v1/hooks/{host} |
the key | {"url","service"?,"events"?}; adds or replaces that service's hook on a listed host; 200 hook |
DELETE /_p99/v1/hooks/{host}?service= |
the key | removes the host's hooks, or only slack or discord; 204, or 404 when there was none |
POST /_p99/v1/hooks/{host}/test |
the key | posts one test message to each of the host's hooks; 200 {"results":[{"service","hint","ok","status"}]} |
GET /_p99/v1/admin/backup-status |
Owners | the last and the running backup |
GET /_p99/v1/board?project=&host=&source=&status= |
Owners | the one board, as Boards describes |
GET, POST /_p99/v1/boards, GET, PUT and DELETE /_p99/v1/boards/{id} |
Owners | 410 boards are one per space; see p99 board |
POST /_p99/v1/move |
Owners | {"key","from","to","before"?,"after"?,"resolution"?}; 200 {"card"}, as Boards describes |
GET /_p99/v1/cards?project=&status=&since=&source=&host=&limit=&cursor= |
Owners | {"cards":[card],"next_cursor"} in lane order, as Cards in a project describes |
GET /_p99/v1/cards/{key} |
Owners | {"card","comments","events"}: the card with its body, comments and events oldest first; a card filed on a site that names no project of its own answers 409 |
POST /_p99/v1/cards |
Owners | {"client_uid","project","title","body"?,"kind"?,"host"?}, a card with no host when host is left out; 201 {"card"} in lane new, at its top; the same client_uid again answers 200 with the same card |
POST /_p99/v1/cards/{key}/status |
Owners | {"status","note"?,"client_uid"?} (client_uid required with a note); 200 {"card"}, plus "comment" with a note |
POST /_p99/v1/cards/{key}/links |
Owners | {"ref","note"?}; 201 {"key","link":{"ref","note","by","at"}} |
POST /_p99/v1/cards/{key}/comments |
Owners | {"client_uid","body","internal"?}; 201 {"key","comment":{"id","author","body","internal","created_at"}} |
GET /_p99/v1/projects |
actor | {"projects":[{"name","hosts":[host],"open","done"}]}, by name: every project in the space, an empty one too, with its hosts and the open and done cards in it, those filed on its sites and those that name it alike; host= doesn't narrow it |
PATCH /_p99/v1/issues/{key} with {"project"} alone |
Owners | a name files the card under that project (200 {"card"}); null takes the name off again, leaving the card in its site's project and the lane it is in (200 issue) |
POST /_p99/v1/import, PUT /_p99/v1/import/attachments/{uid}, POST /_p99/v1/import/finish |
the key | an import that keeps keys and numbers, as import.md describes |
GET /_p99/v1/github |
Owners | {"hosts":[{"host","repo","hook_id","webhook","token","secret","created_at","updated_at"}]}, token and secret true when the Worker holds them |
PUT /_p99/v1/github/{host} |
the key | {"repo":"owner/name","hook_id"}; links the host to the repository; 200 as one entry of the list |
DELETE /_p99/v1/github/{host} |
the key | removes the link and, with the host's token, the repository's webhook; 200 {"host","repo","hook_id","hook_removed","secrets"} |
POST /_p99/v1/issues/{key}/github |
Owners | opens a GitHub issue for the card (its title, a body with its key and link); 201 {"key","existing":false,"kind","rel","repo","number","url","at"}, or 200 with "existing":true when it has one |
A hook object is {"host","service","hint","events","created_at","updated_at","last_status","last_at"}: service slack or discord, hint the URL's last four characters, events from new, comment, status and shipped, last_status ok, the HTTP status, timeout or error. No route ever answers the URL.
A code object is {"id","name","email","hosts","grant","role","manager","hint","created_at","created_by","revoked_at","last_used_at","totp"}, grant {"space":true} or {"projects":[name,…]} ([] when parked), hosts the hosts it reaches, hint the secret's last four characters.
GET /_p99/v1/stream is signed like every other route, with an empty body, and upgrades to a WebSocket held by the Durable Object P99Hub, one per space. Every successful write sends each open socket one text message, a JSON event:
{"type":"comment","key":"BUG-12","host":"shop.example.com","project":"Shop","space":"example","title":"The total does not update","by":{"name":"Ada Lovelace","agent":true,"via":"ci"},"at":"2026-10-15T09:30:00.000Z","internal":false}type |
When | Adds |
|---|---|---|
item |
a card is filed | |
comment |
a comment is added | internal |
change |
kind, assignee, title or body change | what, the fields that changed |
attachment |
a file is stored | internal |
import |
an import writes records | items and comments, the counts of new ones; key and title are null |
label |
a card's label changes | from and to (label names); done (closed or reopened) and resolution when its done-ness changed |
order |
a card is placed in its lane | board (the one board's id), order (its key there); rebalanced when the lane's keys were written afresh, which a reader takes as a reload |
labels |
a lane is renamed | action (renamed) and names, the old and the new; every socket hears it |
delete |
a card is deleted | label, the label it had; a label event back to the first label follows for each closed card that was merged into it |
A move's events carry the card's host. A reader patches its board in place on label, order and delete and reads it again on anything else.
project names the card's project (null when the event has none) and space the space's slug. title is the card's title after the change and by the actor, null for a write without one. Internal comments and files are included, since only the signed API listens. A socket opened with host=<host> hears only that host's events; an unlisted host answers 404. The socket answers the text ping with pong, which keeps it open through idle proxies; the CLI and the TUI send one every 30 seconds and reconnect when it drops. A Worker without the HUB binding (one not deployed since live updates came) answers 404 this Worker has no push yet; run p99 deploy, and its writes skip the event. The event is sent after the response, so a write never waits on it, and a socket that is not open misses it: a client that reconnects reads the list again.
Everything people file is a card, from the panel's New card, p99 card new or an agent. The API keeps its older names for it as identifiers: the routes say issues, a list answers issues, bodies and counts say items, a live event's type is item, and fields such as reporter (the person who filed the card), merged or an import's item keep their names. The objects below are the card's fields.
- Keys are
<PREFIX>-<id>over one sequence per space, the prefix from the kind:bugBUG,featureFEAT,improvementIMP,questionQ,taskTASK; any prefix with the right number, or the bare number, names the same card. - Labels: a card carries exactly one label, its lane on the board (see Boards), and the seven lanes are the labels in use. A new card gets the first lane,
New. The closing lane (Done) closes the card that carries it: it then has aresolution,fixed(the default),abandonedorduplicate(withduplicate_of); an open card's is null. The labels of the boards before 1.11 are deleted (see Boards). - The status keys of P99 1.x and 1.8 still import, and still filter a list (
status=doingkeeps the open cards inIn progress):newandinboxareNew,confirmed,in_progressanddoingareIn progress,waitingandreviewareLanded,resolved,abandonedandduplicateareDonewith that resolution. Status events written before keep their old keys indetail. - Owners change
kind,assignee,duplicate_of,resolution,titleandbodywithPATCH, and the label withPOST /move;labelsandpriorityanswer 400. A 1.8statusstill works:done(withresolution) closes the card,inbox,doingorreviewmoves it toNew,In progressorLanded, reopening it when closed.resolutioncloses a card (it goes toDone) and null reopens it (it goes back toNew); settingduplicate_ofalone closes it asduplicate.assigneeis a username (a leading@is fine),mefor the actor, or null, and the person must hold a live code reaching the card's project or be inMANAGERS; an assignee event'sfromandtoare{"username"}. The person who filed it changestitleandbodywhile the card is inNew, setsabandonedon an open card, and null to reopen afixedone. A label change is an internallabelevent{"from","to"}; a change of what the person who filed it sees is astatusevent{"from","to","resolution"?}in their words; atitleorbodyevent keeps the old and new text as{"from","to"}, and neither mails anyone. stageis what the person who filed the card sees:receivedwhile the card is inNew,in_progressin any other open lane, thenfixed,droppedorduplicateonce closed; the panel words them Received, In progress, Fixed, Dropped and Duplicate.labelis the label's name for Owners and null for anyone else.- Everyone but an Owner sees only the cards they filed, and the cards theirs were merged into; internal comments and their files are for Owners only.
- Attachments: 20 MB (20 MiB) each, 30 per card; roles
screenshot,page,region(withmark),file; the type is the body'sContent-Type, checked against the file's first bytes: PNG, JPEG or WebP for the images; for afilealso GIF, PDF,text/plain,text/csv,text/x-log, JSON, zip, and Word, Excel, PowerPoint and OpenDocument files (p99 card new --attachandp99 card comment --attachpick it from the file's extension). A name ending in an executable or web page extension (.html,.svg,.xml,.js,.exe,.bat,.shand the like) is refused, whatever the type. - Limits per person and day: 30 cards, 200 comments, 100 MB of files. Owners have none.
- Search (
q) matches the title and body with accents and case folded on both sides (cafefindsCafé): the Worker keepssearch_text, the NFD form of title and body without combining marks, lowercased, and foldsqthe same way.qis matched whole, as one piece of text, and takes up to 200 characters, counted as Unicode code points (a single emoji is one, a flag two);%,_and\in it match only themselves. Aqthat is a key also finds that card. Text limits elsewhere (a title's 200 characters, a body's 20,000) count characters the same way.
A summary is {"id","key","kind","stage","label","resolution","project","title","host","env","page":{"url","path","title"},"reporter":{"email","name","agent","via"},"assignee":{"username"},"created_at","updated_at","comments","attachments","unread","landing"}; an issue adds "body","duplicate_of","closed_at","context","marks","commits":[{"sha","subject","rel","envs"}],"github":[{"kind","rel","repo","number","url","at"}] (github empty for anyone but an Owner), and a thread's issue "merged":[{"key","title","host","reporter"}], the cards merged into it (for anyone but an Owner, only their own); a comment is {"id","client_uid","author":{"email","name","role","agent","via"},"body","internal","created_at","attachments":[id]}; an attachment {"id","client_uid","role","mark","name","type","bytes","sha256","comment_id","created_at"}; an event {"actor","actor_name","agent","via","kind","detail","at"}, actor_name the person's name as the thread, or else the newest code for that e-mail, knows it (null for P99 itself), agent true when an agent acting as that person made it; agent is always a boolean, and via the agent's label, or null for a person and for an agent with none. assignee, reporter's email, event actors' e-mails (and the from and to of an assignee event) and a comment author's email show to Owners only: for anyone else they are null, unless they are that person's own. project is the name of the card's project (its host's, for a card filed on a site) for an Owner, and null for anyone else; a card in no project says null. Every assignee also carries "avatar", the person's avatar URL or null.
A space has one board. Its seven lanes are its columns, by position: new, planned, in-progress, needs-you, stuck, landed and done, named New, Planned, In progress, Needs you, Stuck, Landed and Done until an Owner renames them. Every card sits in one lane: a new card in New, a closed one in Done. A card's place in its lane is its priority, one order for the whole space. Owners see the board; a User's code answers 403. An Owner whose code reaches only some projects sees only the cards it reaches.
GET /boardanswers{"board":{"lanes":[{"word","label","closes"}]},"cards":[card]}, in lane order and then in each lane's order. A card here is{"key","kind","project","status","label","lane","title","host","assignee","order","changed_at","changed_by"}:projectis the name of the card's project, its host's for a card filed on a site, and null only for a card in no project,statusthe lane's word,labelits name,lanethe card's place in its lane from 1 (null in Done),assigneea username or null,orderits fractional key (a short base-62 string that sorts as text, null for a card not placed yet, which sits at the top of its lane, newest first), andchanged_bya person as in a card. The filter:project=(repeatable; by the card's project, so a card filed on a site counts in its host's),host=,source=humanoragent(who made the last change), andstatus=(a lane word, repeatable).POST /movetakes the card'skey,from(the lane the mover saw, by word or name) andto(a lane word, or a lane's name matched with case and accents ignored);boardis ignored. Any Owner moves any card they see. When the card isn't infromany more the answer is 409{"error":"moved","message":"this card moved; refreshed"}and nothing changes. A move into another lane puts the card at the top of it;beforeoraftera neighbour's key in that lane places it there. Placing writes one key for the moved card, between its neighbours' keys; the lane's keys are written afresh only when a key would pass 50 characters, or to place a card above cards with no key yet. Done closes the card (resolutionpicksabandonedorduplicateoverfixed); any other lane reopens a closed one. An agent moves a card into Done as its person would. The answer is{"card":{"key","kind","title","label","assignee":{"username"}|null,"order"}}, and the move is one write and one broadcast: alabelevent, and anorderevent when it was placed.GET /labelsanswers the lanes,{"labels":[{"id","name","word","closes","boards"}]}, andPATCH /labels/{id}with{"name"}renames one (1 to 32 letters, digits, spaces or. _ : + -); a name another label holds is 409, an id that isn't a lane 404. The lanes are the space's: an Owner whose code reaches only some hosts can't rename one (403).
Changed in 1.11.0: each Owner's own boards folded into the one board. 0016_one_board.sql does it once, and p99 deploy runs it only with --accept-plan (p99 migrate --plan shows it first). A card already in a lane stays there. Otherwise a closed card goes to Done with its resolution; an open one goes to New from Inbox, to Landed from Review, to In progress from Doing, and from any other label to In progress, so the stage the person who filed it sees stays as it was; an open card never goes to Done. Each moved card gets one internal label event {"from","to","migrated":true} with no actor, so its thread shows the old column's name and nothing counts as a change. Each lane keeps the order the cards had on the oldest Owner's boards, then on the other boards in the order they were made, then newest first. 1.11.0 kept the old boards and labels archived; 0017_delete_archived.sql (below) deletes them. A Cards board with six lanes gets Needs you as its fourth; a Cards board with any other number of columns, or six with one already called Needs you, is set to the seven lanes by name, and a card on a column that isn't one of them moves as any other card does. Changed in 1.13.0: GET /boards and GET /boards/{id} are gone and answer 410 boards are one per space; see p99 board, as POST /boards, PUT /boards/{id} and DELETE /boards/{id} do.
Deleted after the fold: nothing is archived. 0017_delete_archived.sql runs after 0016, in the same deploy for a space still before 1.11, and like it only with --accept-plan. It deletes every old board with its columns and its order, every label that isn't one of the seven lanes, the old tags (rows from before 1.9 that nothing reads), and the archive 1.11.0 kept. What the one board doesn't hold yet is carried onto it first, and p99 migrate --plan lists each as a decision: a card still on an old label moves to In progress, or to Done when it is closed, with one label event {"from","to","migrated":true}; a card with a place only on an old board gets one after its lane's last card; either way in the order of the old boards, oldest first. Each card's history stays. On a space whose Cards board doesn't have seven lanes, the plan says so and deploy stops before any migration, deleting nothing. The plan also counts the cards, comments, attachments, events and each lane's cards before and after; p99 deploy sends its migrations as one step, counts again inside 0017 and, if any count differs, rolls every one of them back (0016 included) and stops, leaving the database as it found it. Running it again changes nothing.
A card filed or named into a project from the CLI or by an agent (or given one with PATCH) carries the project's name, 1 to 40 characters matched without regard to ASCII case. A card filed on a host is in that host's project too, and its JSON says so, though it names no project of its own; the /cards routes below take it as they take any other card. A card names a project that exists and that the code reaches; a code for the whole space (the key, or an Owner's code granted the space) that names a new one makes it with that spelling, as POST /projects does, and any other code answers 404. A card filed from the CLI or an agent needs no host: its host is then null. The project filter (project=, repeatable) on /board, /cards and /issues keeps the cards in those projects, the cards filed on their sites included on /board and /issues. Only Owners use the /cards routes, on the signed API and the browser API alike; the code of a User, who sees only the cards they filed, answers 403 on every one of them.
The Cards board is the space's one board (see Board). Its seven lanes are the labels New, Planned, In progress, Needs you, Stuck, Landed and the closing label (Done), and the status words name them by position: new, planned, in-progress, needs-you, stuck, landed, done. Lanes are renamed as labels are; the words stay. Only the Done lane closes, whatever another lane's closes flag says.
GET /cardslists in lane order, then each card's place in its lane; with nostatusit lists every lane butdone.projectandstatusrepeat;sinceis an RFC 3339 time and keeps the cards changed at or after it;source=humanoragentkeeps the cards whose last change was a person's or an agent's;hostkeeps one host's;limitis 200 by default and at most 500, andnext_cursorreads on.GET /cards/waitinganswers{"count":n,"cards":[card]}: the cards inneeds-you,stuckandlandedthat the reader sees, newest change first, up to 500, withcountthe whole number./mecarries the same number ascounts.waitingfor an Owner, andnullfor a User.POST /cards/{key}/statusmoves the card to that lane, at its top; a card already in it keeps its place.donecloses it (fixed) and any other word reopens it. A note is a comment in the same write and mails no one. Anyone but a User may setneeds-you, an agent included.- A card in
needs-yougoes back by itself when an Owner (a person, not an agent) comments on it, internal or not, byPOST /cards/{key}/commentsorPOST /issues/{key}/comments: in the comment's own write it moves to the top of the lane its last move came from (thefromof its newestlabelevent that the fold didn't write). When that lane is gone by a rename, or it wasneeds-youor the closing lane, the card goes to In progress. An agent's or a User's comment leaves it where it is, and any status set moves it as usual. Cards filed on a site that name no project stay put. - A card's close by any other route (
PATCHstatusorresolution, a merge, GitHub) takes it to the Done lane, and aPATCHstatusofinbox,doingorreviewto New, In progress or Landed. - An agent (
agenttrue in the actor) acts with its person's role and closes a card as they would: asfixed(status: done, a move into Done, or aPATCH),abandoned, or a duplicate of another card (PATCHwithduplicate_of, or a merge); the card'schanged_bythen names the person withagenttrue. PATCH /issues/{key}withprojectalone moves a card to that project;nullanswers 400project must name a project: a card moves to another project, never out of one. The card stays in its lane, open or closed, with its place on the board: a project change is one internalprojectevent{"from","to"}, and it never mails, never reopens and never shows to the person who filed it.- An unknown key answers 404
no card <KEY>, and a bad project name 400a project name has 1 to 40 characters. - A link is a
linkevent,{"ref","note"}:ref1 to 200 characters,noteup to 200 (a blank one is null). A lane change stays alabelevent.
A card is {"key","kind","project","status","label","lane","title","host","reporter","created_at","changed_at","changed_by","comments","links":[{"ref","note","by","at"}]}: status the lane's word, label the lane's current name, lane the card's place in its lane among the cards the reader sees (1 is the top, null when done), changed_at and changed_by the card's latest event. Every person in it is {"name","username","agent","via","avatar"}, avatar the person's avatar URL or null, so a page draws the picture without asking for it; e-mails never appear.
Changed in 1.9.0: a summary, an issue and a card no longer carry status, board, position, priority or labels; they carry stage and label. /statuses, POST /labels, DELETE /labels/{name} and board slugs are gone; boards are named by id.
p99 merge BUG-14 BUG-15 --into BUG-12 folds cards about the same thing into one. Each merged card gets duplicate_of the target and closes with resolution duplicate (a status event and a live label), with a merge event on it ({"into"}) and on the target ({"item"}), and the person who filed it is mailed the status change. Cards already merged into a merged card move to the target too, so a merge stays one level deep; a target that is itself merged answers 409 and names the one to use. An Owner scoped to one host (?host=) merges that host's cards only: a card on another host answers 404. Without host, cards of several hosts merge.
Everyone who filed a merged card follows the target: it shows in their "mine" list and counts, they read its thread and comment on it as the person who filed a card does, and they are mailed its replies, status changes and landings. A follower sees the target's title, status, public comments with their files, events and landings; never its internal notes, the e-mail of the person who filed it, its text, page context, marks or capture files. Only the person who filed the target changes or drops it. p99 show and the TUI's thread list the cards merged into a card, and the panel shows "merged into BUG-12" on each merged one.
p99 delete BUG-12 (or DELETE /issues/{key} on either API, Owners only) removes a card for good: the card, its comments and internal notes, its events, read marks, labels, its place on its board, its GitHub links (GitHub's own issue and pull requests stay as they are), mail opt-outs, landings, and every file in the bucket, screenshots, page captures, marked areas and attachments, on the card and on its comments. There is no undo and no trace in the space but the Worker's log line, deleted BUG-12 by Ada Lovelace 🤖.
The rows go in one D1 batch, which is one transaction: a failure part-way leaves the card whole. The same batch records every file's bucket key in deletions; the files are then removed from R2, up to 1,000 keys a call, and each key is struck off once its object is gone. A key whose removal fails stays in deletions, and the cron trigger retries it every 15 minutes until it goes, so no file is left behind; a file still uploading when the card went is removed once more 15 minutes later. retrying counts the files still waiting.
Cards merged into the deleted one lose their duplicate_of; a closed duplicate goes back to New, open (a status event on it, and a live label), so it is triaged again. Keys are never handed out again, after a restore included: a card's number comes from SQLite's AUTOINCREMENT, which never reuses one, and the backups keep its counter for a restore to write back (see Backups). Chat hooks and GitHub get nothing, and no one is mailed. The fixed feed reads the current rows, so a deleted card drops out of it. The weekly backups in the bucket keep the card until they age out, eight weeks later.
An environment is a name with a kind. A development environment counts the build of branch main; a release environment counts its latest announced build, whatever the branch. p99 announce (or a host's own build) posts a build with the cards its commits name in Fixes: and Refs: trailers; a card lands in an environment when a counted build there carries a Fixes: commit for it, or when an import records a landing there, and the first landing is an event. An environment with notify mails the person who filed a card at its first landing there, when mail is set up.
landing is {"branch":bool,"envs":[{"env","kind","at","sha","version"}]} in position order, branch true when a fix sits only on a branch no counted build carries.
Off by default. p99 feed on <host> sets the host's feed and https://<host>/_p99/feed then serves an Atom feed (?format=json: a JSON Feed 1.1) of the host's cards whose fix reached a release environment, newest first, at most 50: each entry carries the key, the kind, the title and the date of the first landing, and nothing else (no one who filed it, no text, page, comments or commits). Anyone may read it, the response is cached at the edge for five minutes, and a host without feed answers 404. p99 feed off <host> turns it off; p99 feed lists each host's feed.
Off until p99 email set turns it on for the space: the sender and the provider live in the database's mail table, and the provider's key in the Worker's secret RESEND_API_KEY. A Worker set up before that, with a MAIL_FROM binding and no mail row, keeps mailing from that binding until p99 email set, p99 email off or the next p99 deploy writes the row. Resend is the one provider; the Worker sends through one interface, a batch of {to, subject, text, html}.
New cards and comments by anyone but an Owner go to every Owner. The person who filed a card, and everyone who filed a card merged into it, is mailed about an Owner's non-internal reply, the card closing or reopening, and the first landing of a fix in a notify environment ("fixed and shipped"); a merge reads as "merged into BUG-12". A change reads as the person who filed it sees it: Received, Fixed, Dropped or Duplicate. Other label changes mail no one. An Owner's own new card mails nobody, and no one is mailed about their own action. Every mail has a plain-text part and a light HTML part in the host's theme colours (accent, on-accent, ink, muted, rule), so subjects and bodies keep their accents; an agent acting as a person reads as Ada Lovelace 🤖. Sending never fails a request; each send, or skip, is an internal mail event with its outcome.
An Owner's link is https://<card's host><page path>#p99=<key>, which opens the panel on that card. The link of the person who filed it is https://<card's host>/_p99/open?to=<page path>&item=<key>#<their code>: a small page on the host stores the code where the loader looks for it, the way an invite link does, and moves on to the page with the card open in the panel. The code rides in the fragment, which the browser never sends. The Worker keeps an AES-GCM sealed copy of each code for this (its key derived from CODE_PEPPER), written when the code is minted, or for an older code the first time it is used; until then that person's link has no code. No API answer carries the sealed copy.
Every mail to the person who filed a card ends with a link to stop mail about that card, https://<card's host>/_p99/mail?t=<token>, and carries it as List-Unsubscribe with one-click List-Unsubscribe-Post. The token is signed for that card and that e-mail (HMAC-SHA256 under a key derived from CODE_PEPPER), so it works for nothing else and needs no code. Opening it stops the mail and offers Undo; the person still gets mail about their other cards.
Each host may hold one Slack and one Discord incoming webhook (the hooks table). A url is taken only when it is https://hooks.slack.com/services/<a>/<b>/<c> or https://discord.com/api[/v<n>]/webhooks/<id>/<token> (or discordapp.com), with no port, user, query or fragment; anything else answers 400, so a hook can reach no other address. The URL is stored sealed with AES-GCM under a key derived by HKDF from CODE_PEPPER, as the authenticator secrets are. events defaults to all four on a new hook and is kept when a later PUT leaves it out.
After the response, in waitUntil, the Worker turns the request's live events into posts: item is new; a comment that is not internal is comment; a label change that closes or reopens the card is status (other label changes post nothing); a first landing in a release environment is shipped. Each hook gets one message per request listing up to ten cards, Slack as {"text","blocks"} and Discord as {"content","embeds","allowed_mentions":{"parse":[]}} with the host's theme.accent as the embed colour. Each card shows its key, kind, title, label, the actor (an agent as Name*) and its page link (https://<host><path>#p99=<key>); never an e-mail address, a comment body or an attachment. A post has 5 seconds, follows no redirect, and its outcome is kept as last_status; a failure is logged and never fails the request.
POST /_p99/v1/github is GitHub's webhook, not signed with the space's key: the host is the URL's, and the body must verify against X-Hub-Signature-256 (HMAC-SHA256 with the host's webhook secret) or the answer is 401. A delivery id (X-GitHub-Delivery) is acted on once. From the host's linked repository, a pull_request opened, edited or reopened records the cards its title and description name in Fixes: and Refs: trailers; one closed as merged also reads its commits, records them, and moves every open card named in Fixes: to done with the resolution fixed, with the status event, the live event and the mail to the person who filed it. An issues closed as completed (state_reason completed, or none) does the same for the card POST /_p99/v1/issues/{key}/github opened it for. Only cards of that host are read or changed. The answer is 200 {"linked":[key],"changed":[key]} when something was recorded or changed, and 204 for everything else.
The host's secrets are Worker secrets named after the first 16 hex digits of the SHA-256 of the host name, uppercased: GITHUB_TOKEN_<tag> (the fine-grained token) and GITHUB_HOOK_<tag> (the webhook secret). p99 github set writes both. Links are kept in github_links and shown on an issue as github to Owners; github_deliveries keeps delivery ids for 30 days.
The cron trigger (*/15 * * * *) runs a weekly backup of every table into the bucket under backups/<date>/, in steps small enough for one invocation, keeping eight. A deleted card stays in the backups taken before its delete until they age out; P99 never edits a backup.
A backup is one cut, taken when its run starts: rows written after that moment, such as a card filed while the run pages, its comments, files and events, wait for the next run. Tables are paged parents first (labels, boards and their columns, then cards, then what hangs off them, codes before their authenticator devices, the key counters last), so a row deleted while the run pages is either in the backup with what points at it, or gone with it. A row that points at a label or a board made during the run (a card moved into a new column) brings that label or board along as one more page of its table. Two cases are cleared and named in the Worker's log line when the run finishes: a card merged into one with a higher key that was deleted before its page loses its duplicate_of (the delete does the same in D1), and a row whose label or board no longer exists is left out. Rowids of tables without AUTOINCREMENT can be handed out again after a delete, so in those tables a row added during the run can still land in the backup; it never points at a row the backup lacks.
Each page is <table>-<page>.json (run, exported_at, table, page, rows), where run is the run's start; a date's pages are a backup once that date's backup_runs row has finished_at. A second run on the same date replaces the first whole: it starts by replacing the date's backup_runs row, and before it finishes it removes every page the first left. Sign-in tries, app sessions, usage, GitHub delivery ids and the runs themselves stay out of the backup.
P99 has no restore command. To restore by hand into a freshly migrated D1, write each table's rows from its pages, rowid aside (_rowid in a page), and the sqlite_sequence rows too, replacing each row by its name (DELETE FROM sqlite_sequence WHERE name = ? then the INSERT), so a key used before, a deleted card's included, is never handed out again. Writing sqlite_sequence through wrangler d1 execute is built but untested on a real D1; if D1 refuses it, UPDATE sqlite_sequence SET seq = ? WHERE name = ? after the rows are in does the same.
worker/migrations/ holds the schema: 0001_p99.sql, then each change in its own numbered file. Migrations are recorded in d1_migrations (id, name, applied_at), the table wrangler keeps, so a database migrated by either stays in step.
PUT /_p99/v1/hosts/{host}'s config is {"theme":{…},"strings":{…},"corner","offset":{"x","y"},"above","off":[…],"feed","mask":[…],"capture"}, as docs/hosts.md describes. mask lists at most 50 CSS selectors of at most 200 characters each, and a selector that doesn't parse as a selector list answers 400; the panel masks every element they match in a capture, beside those marked data-p99-redact, unless the person filing unchecks "Hide this site's private areas" (password fields stay blanked). capture is on (the default) or off: on an off host the panel files the card without a screenshot, and the Worker refuses a screenshot, page or region attachment on that host's cards with 403, so nothing of the page is stored even by an older panel. The Worker writes the host's corner, offset, above, off and theme.accent into the loader it serves for that host (replacing __P99_HOST__ with that JSON), so the button draws in place before any request; /_p99/api/me answers the whole config.
A space holds projects, each with a name unique in the space (ASCII case ignored), and every host belongs to one project. A card filed on a host lands in that host's project; a card filed into a project names it. Each card keeps its project as project_id, and one that names its project also keeps the name in project, kept in step on every write.
A code grants the whole space ({"space":true}) or some projects ({"projects":[…]}), never hosts. It reaches a host when it grants the host's project or the space, and it reaches a card in a project it grants; a card in no project it reaches through the card's host. A card on a host it reaches but in another project stays out of reach. Only {"space":true} reaches the whole space: a code granted every project today makes no project and reaches none made later. When a code's grant changes, or a host moves to another project, the open live boards it touches close, and the page opens them again with what the code now reaches. The P99 page opens on the space even when no project the code reaches has a host yet: /me then says host and project null, the board and cards answer as usual, and a card from the panel's New card, which needs a site, answers 400. A parked code ({"projects":[]}) reaches nothing: the browser API answers it 403 a parked code opens nothing; …, and since it is not revoked, the browser keeps it. Moving a host to another project, or adding one, reaches everyone granted that project at once. No grant change, park or host move ever mails anyone. Owners are mailed about the cards their grant reaches; an assignee is a person whose live code reaches the card's project.
The licence state is read from a key an Owner pasted (PUT /licence, kept in the settings table) first, and from the deploy's LICENSE_STATE and LICENSE_LINK bindings after. A Studio or Agency key covering the Worker's build date gives licensed: true in /me, and the panel then hides its small P99 logo; an Agency key lets a host hide the "by PITO.MD" link.
Secret test-secret, ts 1760400000, POST /_p99/v1/issues?host=example.com.
- Actor JSON:
{"email":"ada@example.com","name":"Ada Lovelace"} X-P99-Actor:eyJlbWFpbCI6ImFkYUBleGFtcGxlLmNvbSIsIm5hbWUiOiJBZGEgTG92ZWxhY2UifQ- Body:
{"client_uid":"0b9c2f4e-1a2b-4c3d-8e9f-001122334455","kind":"bug","title":"The total does not update","body":"","page":{"url":"https://example.com/orders","path":"/orders","title":"Orders"},"context":{},"marks":[]} X-Content-SHA256:ecf774eccd7e95d4a00046092fe70059b83b5d650c1a1da9e9cc6c22e5ce22bfX-P99-Auth:v1 ts=1760400000,sig=89b1d507574a2e8bb9c58d3ba92a29eab0f03baad0e422c4882f17c8ba33505a