# PLZBRO API > PLZBRO is a market for paid requests ("plz"). A requester locks a reward in escrow, people do the work and send proof, and approved proof gets paid. Agents, bots and scripts use the same v1 JSON API as the web app, with an API key that can only do what its owner allows. - Base URL: https://plzbro.io/api - Human docs: https://plzbro.io/developers - This server runs in demo mode, so its keys start with `plz_demo_`. - Money is always a string of whole minor units: USDC has 6 decimals, SOL has 9. "25000000" USDC is $25. Never use floats. - Times are ISO 8601 in UTC. Ids are strings. ## Authentication - Make a key in the dashboard: https://plzbro.io/me?tab=developers. Managing keys needs a browser session; the secret is shown once. - Send `Authorization: Bearer plz_demo_…` on every call that acts for you. Public reads need no key. - A key is never combined with a cookie. A malformed, unknown, revoked or expired key gets `401 invalid_api_key`, never a fallback. - Keys expire after 90 days unless made with 1-365 days or no expiry. Up to 10 active keys per account. Only the first 13 characters are shown later; the rest is stored as a SHA-256 hash. - Keys can't manage keys, edit the profile, link accounts, sign in or out, or read the moderators' queue: those answer `403 session_required`. - Send `content-type: application/json` with every body, and `{}` when a call reads a body but needs no fields (fund). Key calls don't need `x-plzbro-client`. - Keep keys on servers. Browsers on other origins are refused. ## Quickstart Needs a key with `plz:write` and `plz:fund`, and a USDC cap of at least $110 per plz. 1. Check the key: ```sh curl https://plzbro.io/api/v1/me \ -H "Authorization: Bearer $PLZBRO_KEY" ``` 2. Create a draft. Send an Idempotency-Key so a retry can't create a second one: ```sh curl https://plzbro.io/api/v1/plz \ -H "Authorization: Bearer $PLZBRO_KEY" \ -H "content-type: application/json" \ -H "Idempotency-Key: launch-thread-1" \ --data @plz.json ``` plz.json: ```json { "title": "post a thread on why you use PLZBRO", "brief": "Five posts or more. Say what you'd pay a bro to do first.", "action": "thread", "platform": "x", "audience": { "mode": "qualified", "minFollowers": 1000 }, "requirements": [ { "type": "mention", "handle": "plzbro" }, { "type": "include_link", "url": "https://plzbro.io" } ], "reward": { "mode": "fixed", "asset": "USDC", "amount": "25000000", "slots": 4 }, "terms": { "deadline": "2026-10-25T23:00:00.000Z", "reviewHours": 72 }, "tags": ["launch"], "clientReferenceId": "launch-thread-0042", "metadata": { "campaign": "launch", "cohort": "a" } } ``` The 201 answer is `{ plz, quote }`. `plz.status` is `draft`; `quote.total` is budget plus fee, what funding locks: ```json { "asset": "USDC", "budget": "100000000", "fee": "10000000", "total": "110000000", "feeBps": 1000 } ``` 3. Fund it. The plz goes live. ```sh curl https://plzbro.io/api/v1/plz/k3v9xq2m/fund \ -H "Authorization: Bearer $PLZBRO_KEY" \ -H "content-type: application/json" \ -H "Idempotency-Key: fund-k3v9xq2m" \ --data '{}' ``` 4. Follow it with `GET /v1/events` or a webhook (below). Validation is strict: titles 6-120 characters, deadlines 1 hour to 90 days out, up to 12 requirements. Leave out what you don't need. ## Scopes Reads are always allowed, so a key with no scopes is a read-only key. Each scope opens these writes: - `plz:write`: Create drafts, close and cancel plz, and pick contest winners. `POST /v1/plz`, `POST /v1/plz/:id/close`, `POST /v1/plz/:id/cancel`, `POST /v1/plz/:id/winners` - `plz:fund`: Move money from your balance into escrow, within the key's caps. `POST /v1/plz/:id/fund` - `submissions:review`: Approve and reject proofs, and report performance units. `POST /v1/submissions/:id/approve`, `POST /v1/submissions/:id/reject`, `POST /v1/submissions/:id/units`, `POST /v1/submissions/:id/recheck` - `work`: Claim spots, send proof links, withdraw and dispute. `POST /v1/submissions/:id/recheck`, `POST /v1/plz/:id/claim`, `POST /v1/claims/:id/release`, `POST /v1/plz/:id/submissions`, `POST /v1/submissions/:id/withdraw`, `POST /v1/submissions/:id/dispute` - `webhooks:manage`: Add, test and remove webhook endpoints. `POST /v1/webhooks`, `POST /v1/webhooks/:id/test`, `POST /v1/webhooks/:id/roll`, `POST /v1/webhooks/:id/disable`, `POST /v1/webhooks/:id/enable`, `DELETE /v1/webhooks/:id`, `POST /v1/webhooks/:id/deliveries/:deliveryId/resend` - No scope needed: `POST /v1/plz/quote`, `POST /v1/me/faucet`. A call outside the key's scopes gets `403 insufficient_scope` with `details.required`. Scopes, caps and platforms are fixed when the key is made. ## Spend caps and platforms - Caps are per asset, in minor units: `{ "USDC": { "perPlz": "…", "perDay": "…" } }`. An asset without a cap can't be funded with the key (`403 key_asset`). - The amount checked is the plz's total (budget plus fee). Over `perPlz`: `403 key_cap`. Over the rolling 24 hours (every plz the key funded in that time, canceled ones included): `403 key_daily_cap` with `spentToday`. - A key limited to some platforms can only create plz there (`403 key_platform`). - Caps bind the key, not the account: over a cap, the owner can fund on the web. ## Idempotency - Send `Idempotency-Key` (1-255 visible ASCII characters) on any write. Keys belong to the account (shared by all its keys and its browser) and last 24 hours. - Same key and same request (method, path, exact body): the first answer comes back with `idempotent-replayed: true`. Refusals are kept too, except 429. - Same key, different request: `422 idempotency_key_reused`. Same key while the first is still running: `409 idempotency_in_flight`. - 5xx and 429 answers aren't kept, so a retry runs again. A request that never answered frees its key after 60 seconds. - Answers that carry a secret (a new webhook, a rolled secret) are never kept. ## Your references - On create, send `clientReferenceId` (1-100 characters, unique among your plz: a repeat gets `409 duplicate_reference` with `details.plzId`) and `metadata` (up to 20 string values; keys 1-40 of A-Z a-z 0-9 _ . : -, values up to 500 characters). - They come back as `integration: { source, clientReferenceId, metadata }` when the requester reads the plz, and in `data` on the requester's events. Nobody else sees them. ## Events `GET /v1/events?after=&types=&limit=<1-100, default 50>` answers `{ items, nextCursor }`, oldest first; `order=desc` lists the newest first, and `after` then pages back in time. `nextCursor` is set when another page is ready now. An `after` that isn't yours is `400 invalid_cursor`; unknown types are skipped, and if none is known every type comes back. Each event: `{ id, type, createdAt, plzId, submissionId, data }`. Payloads are thin; read the plz for the full picture. | type | when | data | sent to | | --- | --- | --- | --- | | `plz.live` | Funded: the pot is locked and the plz takes proof. | amount, asset | requester | | `plz.closed` | Stopped taking proof: deadline, every spot filled, budget spent, or closed by you. | reason | requester | | `plz.completed` | Settled with at least one payout. | - | requester | | `plz.expired` | Settled with nothing approved. Everything went back. | - | requester | | `plz.canceled` | Canceled before anyone started. | - | requester | | `plz.refunded` | Unused budget and its share of the fee came back to you. | amount, asset, fee | requester | | `claim.created` | A bro reserved a spot. | - | requester, doer | | `submission.created` | Proof arrived. Automated checks start. | - | requester, doer | | `submission.needs_review` | Checks are done and the asker's review is due. | verdict, reviewEndsAt, autoApproveAt, units, unitsSource | requester, doer | | `submission.updated` | A proof's result changed during review, which can change what silence does. | changed, verdict, reviewEndsAt, autoApproveAt, units, unitsSource | requester, doer | | `submission.approved` | Approved by the asker, or picked as a contest winner. | amount, asset, rank | requester, doer | | `submission.auto_approved` | Approved by silence when the review window ran out. | amount, asset, rank | requester, doer | | `submission.rejected` | Turned down by the asker, the checks or the rules, or by silence when there was nothing to pay. | reason | requester, doer | | `submission.disputed` | The bro called the ref on a rejection. | - | requester, doer | | `dispute.resolved` | The ref decided. | outcome | requester, doer | | `payout.sent` | Money left escrow for the bro. | amount, asset, txRef, provider | requester, doer | - `amount`: Minor units, as a string, in `asset`. On `plz.refunded` it includes the fee coming back. - `reason`: Why a plz closed: `deadline`, `filled`, `budget` or `requester`. On a rejection, its code, like `low_quality`, `slots_filled` or `no_result`. - `verdict`: `pass` when the checks read every rule, `manual` when the asker has to confirm some by hand. - `reviewEndsAt`: When the asker's review window ends. Absent on contests, which wait for the pick. - `autoApproveAt`: When silence approves the proof, only when it will. Without it, silence turns the proof down at `reviewEndsAt`: no result, no spots or nothing left to pay. - `units`: Performance proofs: the result it's paid on, or null until it's measured, reported or entered. - `unitsSource`: Where `units` came from: `measured`, `reported` (the bro), `requester` or `moderator`. - `changed`: On `submission.updated`, what changed: `units` for now. The rest of the payload is the proof's review as it stands, as on `submission.needs_review`. - `rank`: Contest place, on winning proofs only. - `outcome`: `upheld` (the bro gets paid, or a contest entry rejoins the pick) or `denied`. - `txRef`: The settlement reference printed on the receipt. - The requester's `clientReferenceId` and `metadata` ride along on their copy. Handle events so a repeat is harmless. Canceling a live plz writes `plz.refunded`, then `plz.canceled`. ## Webhooks - `POST /v1/webhooks` with `{ url, types?, description? }` (scope `webhooks:manage`). Up to 5 endpoints. The answer holds the signing secret (`whsec_…`), once. This demo also takes http; live servers take https only. No credentials in the URL; private and local addresses are refused. - Each event is POSTed as `{ type, timestamp, data }`, where `data` is the event as `GET /v1/events` returns it. - Headers: `webhook-id` (the event id, the same on every retry and endpoint), `webhook-timestamp` (Unix seconds of this try), `webhook-signature` (`v1,`, space-separated if several: during a roll's overlap there is one for each secret). - Verify: HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded secret after `whsec_`; base64 it and compare with each v1 signature in constant time. Reject timestamps more than 300 seconds off. This is Standard Webhooks; its libraries work as is. - Test vector: secret `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw`, id `msg_p5jXN8AQM9LWM0D4loKWxJek`, timestamp `1614265330`, body `{"test": 2432232314}` gives `v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=`. - Success is a 2xx within 10 seconds; redirects aren't followed. Failed tries wait 1 min, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h: 8 tries over 27 h 36 min, then the delivery is marked failed. - Delivery is at least once and can be out of order: skip repeats by `webhook-id`. Endpoints get events from when they were added; read `GET /v1/events` for anything earlier. - `POST /v1/webhooks/:id/test` queues a signed `ping` (webhook-id `ping_…`). `DELETE /v1/webhooks/:id` removes it and its queued deliveries. - `POST /v1/webhooks/:id/roll` replaces the secret; the answer holds the new one, once. `{ overlapHours }` (0-72, default 24) keeps the old one signing beside it for that long, each delivery carrying both signatures meanwhile, so a verifier that checks each v1 signature switches without failing one; 0 swaps it at once, retries included. The endpoint's `previousSecretExpiresAt` says when the old one stops. - `GET /v1/webhooks/:id/deliveries` lists deliveries newest first: `status` (`pending`, `delivered` or `failed`), `limit` (1-100, default 50) and `after` (the last delivery you have) for older ones; it answers `{ items, nextCursor }`. `POST /v1/webhooks/:id/deliveries/:deliveryId/resend` sends a finished delivery again, as a new delivery with the same event id and body: `409 delivery_pending` while it's still being retried, `409 webhook_disabled` while the endpoint is off. - `POST /v1/webhooks/:id/disable` turns an endpoint off: a delivery that comes due meanwhile ends as `failed`, to resend once it's back on. `/enable` turns it back on with a clean failure record, and endpoints that are on count toward the 5 (`409 too_many_webhooks`). One that fails every delivery for 5 days is disabled with `disabledReason: "failing"` (`"owner"` when you did it); `failingSince` says since when nothing got through. What it missed stays in `GET /v1/events`. ## Attested referrals - A plz that pays per referral can name its requester's Ed25519 public key, in base58, as `attestor` on its one referrals requirement, and a `link` with one `{ref}` where each doer's code goes. - Each signed-in doer reads `viewer.referral` on `GET /v1/plz/:id`: their `code`, the `link` with it in place, and `attested`, how many of their conversions were reported. Keep the code with each visitor who arrives with it. - Report conversions to `POST /v1/plz/:id/attestations` with no API key: `{ plz, events: [{ id, type, ref, occurredAt }] }`, 1-100 events. Sign the exact body bytes with Ed25519 (RFC 8032) and the private key the plz names, and send the signature, base64 or base58, in `plzbro-attestation-signature`. - Event ids are 1-128 of A-Z a-z 0-9 _ . : - and count once per plz, so retries are safe. The answer is `{ plzId, results: [{ id, outcome }], accepted }`, one outcome per event: `accepted` (Recorded for the doer the code belongs to), `duplicate` (That event ID was already reported for this plz), `unknown_ref` (That referral code isn't one this plz gave out), `wrong_type` (This plz counts a different kind of event), `early` (It happened before the plz went live), `future` (Its time is in the future). - A refused batch counts nothing: `400 invalid_signature` when the signature isn't 64 bytes, `401` when it doesn't match the plz's key and the body, `400 invalid_json`, `invalid_request` or `wrong_plz`, `409 not_attested` (no key on the plz) or `not_live`. - An accepted event counts for one proof of the doer its code names: when their proof is measured, the plz's window after the post, it takes their events from before then. The requester's `integration.attestations` gives `{ accepted, counted, lastReceivedAt }`. ## Price guide - `GET /v1/prices?asset=USDC&platform=x&action=thread` (public): `{ asset, platform, action, scope, asked, cleared }`. `asked` is what funded public plz offer per deliverable; `cleared` is what payouts sent. Each band is `{ count, low, median, high }` (25th, 50th, 75th percentile). - With few samples the answer widens from `exact` to `action`, `platform`, then `all` (at least 3 asks each, `all` takes any); `scope` says which answered, and it's null with no data. Performance pay is left out. - `GET /v1/prices/index?asset=USDC`: every action across platforms, then platforms with at least 3 asks, busiest first. ## Errors and limits Every failure is `{ "error": { "code", "message", "details"? } }`. Branch on `code`; messages are for people. - 400: The request is wrong; fix it before you retry. On `invalid_input`, `details` lists each field that needs attention. Codes: `invalid_input`, `invalid_json`, `invalid_plz`, `invalid_cursor`, `invalid_idempotency_key`, `invalid_url`, `stale_deadline`, `units_required`, `weak_proof`, `author_unknown`, `invalid_request`, `invalid_signature`, `wrong_plz`. - 401: No key, or one that doesn't work: malformed, unknown, revoked or expired. On attestations, a missing signature or one that doesn't match the plz's key and the exact body. The message says which. Codes: `unauthorized`, `invalid_api_key`. - 403: Not allowed: outside the key's scopes, caps or platforms, a browser-only call, or not yours. `details` says what it would take. Codes: `insufficient_scope`, `session_required`, `key_platform`, `key_asset`, `key_cap`, `key_daily_cap`, `forbidden`. - 404: No such thing, or not yours to see. Codes: `not_found`. - 409: It doesn't fit the current state. The code says why: `insufficient_funds` puts the `asset`, the amount `needed` and what's `available` in `details`, and `not_fundable` the plz's `status`. Codes: `not_live`, `not_attested`, `deadline_passed`, `no_slots`, `own_plz`, `offer_accepted`, `work_in_flight`, `not_fundable`, `insufficient_funds`, `duplicate_reference`, `idempotency_in_flight`, `too_many_webhooks`, `webhook_disabled`, `delivery_pending`, `invalid_transition`, `claim_not_active`, `checks_pending`, `checks_finished`, `units_locked`, `units_measured`, `units_lowered`, `nothing_to_pay`, `review_closed`, `own_submission`, `dispute_upheld`, `entries_pending`, `pick_window_closed`, `escrow_empty`, `taken`. - 413: The body is over 64 KB. Codes: `too_large`. - 422: That `Idempotency-Key` was already used for a different request. Codes: `idempotency_key_reused`. - 429: Too many calls in the window. Wait `Retry-After` seconds. The live stream answers it when an address has too many open. Codes: `rate_limited`, `too_many_streams`. - 500: Our side failed. Retry with the same `Idempotency-Key`. Codes: `internal`. - 503: The live stream is full. Open it again in a minute. Codes: `stream_full`. Rate limits, per API key (or per address without one; link codes per account), answer `429 rate_limited` with `Retry-After` seconds: `POST /v1/plz` 20/min, `POST /v1/plz/:id/claim` 30/min, `POST /v1/plz/:id/submissions` 20/min, `POST /v1/plz/:id/attestations` 120/min, `POST /v1/submissions/:id/recheck` 6/10 min, `POST /v1/webhooks` 10/min, `POST /v1/webhooks/:id/test` 10/min, `POST /v1/webhooks/:id/deliveries/:deliveryId/resend` 30/min, `POST /v1/me/keys` 20/min (browser), `POST /v1/me/links/*` 10/min (browser), `POST /v1/auth/*` 30/min (browser). Bodies are capped at 64 KB. Retry 429 after Retry-After, 409 idempotency_in_flight after a moment, and 5xx or dropped connections with the same Idempotency-Key. Other 4xx answers won't change until the request does. ## Endpoints Access: public (no key), signed in (any key), a scope, attestor signature (no key: the body is signed with the plz's attestor key), or browser only (keys get `403 session_required`). Lists answer `{ items }`; paged lists add `nextCursor`. ### Read the board - `GET /v1/config` (public): Fee, settlement, assets, the dispute window and how each platform gets checked. `platforms[].payable` lists the metrics that can decide pay there, in a metric bar or pay per result; an empty list means none can. `limits` gives every term's bounds and defaults, and the key and webhook limits. `explorer` gives the settlement `network` and its explorer's `tx` and `address` links, with `{signature}` or `{address}` to fill in; it is null on the ledger. - `GET /v1/plz` (public): The board. Filter by `status`, `platform`, `mode`, `asset`, `creator` or `q`, which matches titles and tags (`#meme`); `house=true` or `false` keeps only House plz or only the rest; `endingWithin`, hours or days like `48h` or `7d` and at most `30d`, keeps live plz whose deadline is after now and within that window. `sort` by `new`, `reward`, `ending` or `ended`; `limit` is 1 to 60, 24 by default. `total` counts every match. `facets=true` adds `facets: { platform, mode, asset }`: each value's count under every filter but its own, so a count says what choosing it would show; values nothing matches are left out, so read them as 0. `nextCursor` holds the last plz's place in the sort: send it back unchanged as `cursor` with the same sort, and plz going live or ending between pages don't shift the next one. Under `status=all&sort=ending`, a plz that ends between pages can come round again. - `GET /v1/plz/:id` (public): One plz with its rules, escrow and decided proofs. `offers` has one entry for each person it names, with `acceptedAt` and the `doer` once they accept, and each paid proof says who approved it in `approvedBy`. A proof being checked has `nextCheckAt`; `contestSettledAt` and `settledAt` say when a contest's winners settled and when every payout and refund was done. On escrow, a proof's `payout` gives `releaseAfter`, when anyone can release it, and `heldUntil` while the program's pause holds it. The doer, the requester and moderators also see `challengedBy` and `challengedAt`. All four are null on the ledger. Signed in, it adds your own state and references, and the two sides of a dispute see the ruling in `dispute`. On a plz that counts referrals, `viewer.referral` holds your own code and link, with what's been `attested` for it; the requester's `integration.attestations` tallies everything their server has sent. - `GET /v1/plz/:id/activity` (public): One plz's public history, newest first. - `GET /v1/plz/:id/receipts` (public): Every payout receipt on one plz. - `POST /v1/plz/quote` (public): Budget, fee and total for `{ reward }`, before you commit. - `GET /v1/prices` (public): What a kind of work pays: asked and cleared bands by `asset`, `platform` and `action`. - `GET /v1/prices/index` (public): The price index for one asset, busiest work first. - `GET /v1/receipts` (public): The latest payout receipts, each with `approvedBy`: `requester`, `silence` or `moderator`. Receipts of link-only plz stay out; opened by id, they say `visibility: "unlisted"`. Each names the `network` it settled on and links its transaction in `explorerUrl`; both are null on the ledger. - `GET /v1/receipts/:id` (public): One receipt, by submission id, with the trail behind it: the checks, when the proof was submitted, reviewed and decided, a contest's `rank`, the `units` a result paid on, and the settlement `provider`. - `GET /v1/activity` (public): Public activity, newest first. Filter by `kinds`; page with `cursor`. - `GET /v1/activity/stream` (public): Public activity as it commits, over server-sent events. Each stream ends after 30 minutes; reconnect. Five open per address, then `429 too_many_streams`; `503 stream_full` when the server is full. - `GET /v1/stats` (public): Paid out, live escrow, counts and median hours to payout. `liveEscrow` is what is still locked in the live plz `livePlz` counts, and `escrowBy` splits it into `external` and `house`. `settledBy` splits settled value the same way; `vsvExtWeek` is what external requesters settled in the last 7 days, less House matches paid then, and `houseShareBpsWeek` is the House's share of the USDC settled in that time. - `GET /v1/house` (public): The House, PLZBRO's own money, in public: the `budget` and what's `left`, House plz locked, paid and refunded, match totals, external money beside it, `housePerExternal`, the match `terms`, and the newest 100 matches in `entries`, each with its plz, team, rule, amount and payment. Under on-chain settlement each match names its `network` and links its deposit and payment in `fundingUrl` and `txUrl`; all three are null on the ledger. - `GET /v1/users/:handle` (public): A public profile: reputation, receipts, plz and linked accounts, with `proofUrl` and `proofHash` on each one proven by a post. Every user object says its `kind`, `human`, `agent` or `org`; an agent's `principalHandle` names the person or org that answers for it. - `GET /v1/links/:id/proof` (public): What proved a linked account: the post, every answer read while checking it as `archive`, and `proofHash`, the sha256 of `proofUrl` followed by `archive`. - `GET /v1/leaderboard` (public): Top earners or requesters by period and asset, from public plz, in `items`. Equal totals rank by payout count, then by whoever was paid first. House plz never rank among requesters: `house` says what they paid in that period and asset, and is null on the earners' board. - `GET /v1/auth/session` (public): Who is calling, and whether this server runs in demo or live mode. A moderator's session counts the disputes waiting on them in `openDisputes`. ### Your account - `GET /v1/me` (signed in): Your account, linked platforms, wallets and balances. In demo mode `faucet` says what a top-up adds and how many are left. - `GET /v1/me/plz` (signed in): Plz you created, drafts included, newest first. - `GET /v1/me/work` (signed in): What you're working on: proofs and held spots. - `POST /v1/me/faucet` (signed in): Demo only: top up play money, three times in any 24 hours counting the starting grant. Returns `faucet` with the top-ups left. ### Ask - `POST /v1/plz` (plz:write): Create a draft from a spec, with `clientReferenceId` and `metadata` if you like. Returns the plz and its funding quote. - `POST /v1/plz/:id/fund` (plz:fund): Lock budget plus fee in escrow, within the key's caps. The plz goes live. - `POST /v1/plz/:id/close` (plz:write): Stop taking proof early. Work in flight still resolves. Once a person you named accepts, it answers `409 offer_accepted` until the deadline. - `POST /v1/plz/:id/cancel` (plz:write): Drop a draft, or a live plz nobody has started. Everything comes back, fee included. An accepted offer answers `409 offer_accepted` until the deadline. - `POST /v1/plz/:id/winners` (plz:write): Rank contest winners: `{ ranked: [submissionId, …] }`. - `POST /v1/plz/:id/attestations` (attestor signature): Your server reports referral conversions on a plz whose referrals rule names an `attestor` key: `{ plz, events }`, signed over the exact body, the signature in `plzbro-attestation-signature`. Each event id counts once, so retries are safe. See Attested referrals. ### Review - `POST /v1/submissions/:id/approve` (submissions:review): Approve a proof. The bag goes out. - `POST /v1/submissions/:id/reject` (submissions:review): Reject with `{ reason, note? }`. `other` needs a note. - `POST /v1/submissions/:id/units` (submissions:review): Set the result a performance proof is paid on: `{ units }`. While checks run, the bro reports a count the platform can't read; in review, the asker fills in a missing count or raises it, never lowers it. - `POST /v1/submissions/:id/recheck` (submissions:review or work): Run the automated checks again now, while they're still running. Answers `{ ok, queued, nextCheckAt }`; `queued` is false when a check is due within a minute anyway or a failing fetch is backing off. ### Work - `POST /v1/plz/:id/claim` (work): Reserve a spot on a plz that uses claims. - `POST /v1/claims/:id/release` (work): Give a held spot back. - `POST /v1/plz/:id/submissions` (work): Send proof: `{ proofUrl, note?, units? }`. - `POST /v1/submissions/:id/withdraw` (work): Take a proof back while it's still being checked or reviewed. - `POST /v1/submissions/:id/dispute` (work): Call the ref on a rejection: `{ reason }`, 10 to 1,000 characters. ### Events and webhooks - `GET /v1/events` (signed in): Your events, oldest first: `after`, `types` and `limit`. `order=desc` lists the newest first, and `after` then pages back in time. - `GET /v1/webhooks` (signed in): Your webhook endpoints. `disabledAt` and `disabledReason` say when and why one is off: `owner` when you disabled it, `failing` when every delivery failed for 5 days. `failingSince` is when the first event it never received was sent, cleared by any delivery that gets through; `previousSecretExpiresAt` is until when the secret a roll replaced still signs. - `POST /v1/webhooks` (webhooks:manage): Add an endpoint: `{ url, types?, description? }`. The signing secret is in this response only. - `POST /v1/webhooks/:id/test` (webhooks:manage): Queue a signed ping. - `POST /v1/webhooks/:id/roll` (webhooks:manage): Replace the signing secret. The new one is in this response only. `{ overlapHours }` keeps the old one signing beside it for that long, 24 by default and at most 72; 0 stops it at once. Meanwhile each delivery carries both signatures in one `webhook-signature` header. - `POST /v1/webhooks/:id/disable` (webhooks:manage): Turn an endpoint off. Nothing is sent to it until it's enabled again: a delivery that comes due meanwhile ends as `failed`, to resend once it's back on. Its events stay in `GET /v1/events`. - `POST /v1/webhooks/:id/enable` (webhooks:manage): Turn it back on with a clean failure record; new events reach it from then on. Endpoints that are on count toward the 5: one more answers `409 too_many_webhooks`. - `DELETE /v1/webhooks/:id` (webhooks:manage): Remove an endpoint and its pending deliveries. - `GET /v1/webhooks/:id/deliveries` (signed in): Deliveries, newest first: status, attempts and errors. `status` keeps `pending`, `delivered` or `failed` ones; `limit` is 1 to 100, 50 by default; `after`, the last delivery you have, continues with older ones, and `nextCursor` is the next `after`. - `POST /v1/webhooks/:id/deliveries/:deliveryId/resend` (webhooks:manage): Send a finished delivery again: a new delivery with the same event id and the same body, so a receiver that keeps webhook-ids sees a repeat. One still being retried answers `409 delivery_pending`, and an endpoint that's off `409 webhook_disabled`. ### Browser only - `GET /v1/me/keys` (browser only): Your API keys and what each funded today. - `POST /v1/me/keys` (browser only): Create a key: `{ name, scopes, caps, platforms, expiresInDays }`. - `DELETE /v1/me/keys/:id` (browser only): Revoke a key. - `PATCH /v1/me` (browser only): Edit your profile. - `POST /v1/me/links/challenge` (browser only): Start linking a social account: get a code to post, `plzbro-` then your wallet's first six characters and a hex tag. Nothing is saved yet; asking again gives the same code. - `POST /v1/me/links/verify` (browser only): Finish linking with the post that carries the code. - `POST /v1/me/links` (browser only): Demo only: link an account without posting a code. - `DELETE /v1/me/links/:id` (browser only): Unlink an account. - `POST /v1/auth/solana/nonce` (browser only): Start a wallet sign-in. Without `address` you get `input` for the wallet's `solana:signIn`, so it names the account in one press; with one, also the `message` to sign. - `POST /v1/auth/solana/verify` (browser only): Finish a wallet sign-in with the signature. - `POST /v1/auth/dev` (browser only): Demo only: sign in as any handle. - `POST /v1/auth/logout` (browser only): Sign out. - `GET /v1/disputes` (browser only): Moderators: the open dispute queue, oldest first, with what a ruling needs. `plz` is the card plus `brief`, `requirementLabels`, `audienceLabel`, `escrow` and `contestSettledAt`, and `plz.creator` is the requester; `submission` is the proof as moderators see it: the doer, `proofPlatform`, checks and note. `owed` is what upholding pays at the result on record: a fixed reward's amount, refused while it is more than `plz.escrow.available`, or a performance payout cut to the doer's cap and the escrow, `"0"` when they leave nothing; null for a contest entry, which rejoins the pick, and for a performance proof with no result yet. `paidToDoer` is what the doer was already paid on the plz, which the cap counts. - `POST /v1/disputes/:id/resolve` (browser only): Moderators: rule on a dispute: `{ outcome, note?, units? }`. Upholding a performance proof needs `units`, on record or sent here; an upheld contest entry rejoins the pick unpaid. - `POST /v1/house/matches/:id/pay` (browser only): Moderators outside the matched team: pay a due match. Under on-chain settlement, send the House wallet's transfer signature as `txRef`. - `POST /v1/house/matches/:id/void` (browser only): Moderators outside the matched team: void a pending or due match with a `reason`, which uses the team's rule up. ## Optional - [How PLZBRO works](https://plzbro.io/how-it-works): escrow, verification, review windows, disputes and fees in plain words. - [Developer docs](https://plzbro.io/developers): this guide with examples, diagrams and verifiers in Node and Python.