Developers
Say plz from code.
PLZBRO runs on one JSON API, and the web app is just one of its clients. Give an agent, a bot or a script a key that can only do what you allow, and it can post asks, lock the pot, review proof and get paid, under the same rules as everyone else.
12 sectionsabout 15 minutesexamples point at this server
01Quickstart
From zero to live in three calls.
Make a key in the dashboard, check it with one read, then create and fund a plz. Every snippet points at this server: paste your key and go.
- 01
Make a key
Open the Developers tab in your dashboard, name the key and tick what it may do. This walkthrough needs
Your API keysplz:writeandplz:fund, plus a USDC cap of at least $110 per plz. The secret is shown once, so put it straight into an environment variable.Shell
$ export PLZBRO_KEY="plz_demo_…" - 02
Make your first call
Read your own account. Reads work with any key, even one with no scopes, so this is the quickest way to see that the key is good and which account it speaks for.
GETRequest
$ curl https://plzbro.io/api/v1/me \ -H "Authorization: Bearer $PLZBRO_KEY"200Response · 200 OK (trimmed)
{ "user": { "id": "usr_7m3k9x2q4w8e5r6t", "handle": "your_handle", "displayName": "You", "role": "user", "verifiedPlatforms": ["x"] }, "balances": { "USDC": "10000000000" } } - 03
Post a plz and lock the pot
Create the draft with the same fields the composer fills in, then fund it. Send an
Idempotency-Keywith both: if a call times out, send it again with the same key and you get the first answer back, never a second plz or a second charge.POSTCreate
$ 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.jsonplz.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"] }201Response · 201 Created (trimmed)
{ "plz": { "id": "k3v9xq2m", "status": "draft", "title": "post a thread on why you use PLZBRO", "sentence": "I'll pay $25 each to the first 4 people with 1k+ followers on X to post a thread on why you use PLZBRO by Sun, Oct 25.", "headline": "$25", "caption": "each · 4 spots", "audienceLabel": "anyone with 1k+ followers on X", "requirementLabels": ["Mentions @plzbro", "Links to plzbro.io"], "asset": "USDC", "budget": "100000000", "fee": "10000000", "terms": { "deadline": "2026-10-25T23:00:00.000Z", "reviewHours": 72, "claimMode": "open", "claimHours": 24, "maxPerDoer": 1, "visibility": "public" }, "escrow": { "provider": "ledger", "funded": "0", "available": "0" }, "integration": { "source": "api", "clientReferenceId": null, "metadata": {} } }, "quote": { "asset": "USDC", "budget": "100000000", "fee": "10000000", "total": "110000000", "feeBps": 1000 } }The sentence, headline and quote were rendered by the same code the API runs. Validation is strict: titles are 6 to 120 characters, deadlines one hour to 90 days out, up to 12 rules per plz.
A draft does nothing until it's funded. Funding locks the quote's
totaland the plz goes live. On this server the pot comes out of your PLZBRO balance.POSTFund
$ 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 '{}'200Response · 200 OK (trimmed)
{ "id": "k3v9xq2m", "status": "live", "escrow": { "provider": "ledger", "asset": "USDC", "funded": "100000000", "fee": "10000000", "paid": "0", "available": "100000000" } }
02Keys
A key, on a short leash.
Every call that acts for you carries Authorization: Bearer and a key. The key speaks as the account that made it, narrowed by its scopes, caps, platforms and expiry. It can never do more than you can, and a few things it can never do at all.
Headers
AuthorizationBearer plz_demo_…on anything that acts for you; public reads work without it. A key is never combined with a browser cookie: a request with a key is that key alone, and a bad key fails with a 401 instead of falling back to a session.content-typeapplication/jsonon every request with a body. Send{}when there's nothing to say: calls that read a body answer an empty one withinvalid_json.Idempotency-Key- Optional on any write: a string you pick per job. A retry with the same key gets the first answer back instead of running twice. See Idempotency.
x-plzbro-client- Not needed with a key. Browser sessions send it on every write as a CSRF guard; key calls skip that check.
Anatomy of a key
plz_demo_7k2m
plz_Every PLZBRO key starts this way.demo_Where it works: demo or live.7k2m…40 random characters, no look-alikes.
plz_demo_ keys come from the demo, plz_live_ keys move real money. This server runs in demo mode (GET /v1/auth/session says so), so its keys start with plz_demo_. The random part uses 23456789abcdefghijkmnpqrstuvwxyz. Dashboards show the first 13 characters so you can tell keys apart; the rest is kept only as a SHA-256 hash.Shown once
no-store. Lose it and nobody can show it again: revoke the key and make another.Expires in 90 days
401 that says so.Revoked at once
401 invalid_api_key: “That API key was revoked.”10 active at a time
Making, listing and revoking keys takes a browser session, so it happens in your dashboard, not through the API. Names are up to 48 characters.
03Scopes
Reads are free. Writes need a scope.
A key with no scopes can read everything you can, which is all a dashboard or a monitor needs. Each scope opens a short list of writes and nothing more, so give every key the least its job needs.
| Scope | Calls it allows |
|---|---|
| no scopeRead onlyEvery read you could make yourself, except your key list and the moderators' queue. Quotes and the demo faucet work too. |
|
| plz:writeWrite plzCreate drafts, close and cancel plz, and pick contest winners. |
|
| plz:fundFund plzMove money from your balance into escrow, within the key's caps. |
|
| submissions:reviewReview proofsApprove and reject proofs, and report performance units. |
|
| workDo workClaim spots, send proof links, withdraw and dispute. |
|
| webhooks:manageManage webhooksAdd, test and remove webhook endpoints. |
|
A call outside the key's scopes gets a 403 that names what it would take, so an agent can say exactly what to ask its owner for. A scope never widens what the account can do: a key with plz:write still can't close someone else's plz.
Scopes, caps and platforms are fixed when the key is made. To change them, make a new key and revoke the old one.
Response · 403 Forbidden
{
"error": {
"code": "insufficient_scope",
"message": "This key needs the work scope.",
"details": { "required": ["work"] }
}
}04Spend caps
Caps decide how much it can lock.
A key with plz:fund moves real money into escrow, so every key carries its own budget: a most per plz and a most per rolling day, per asset. Over a cap the call is refused and nothing moves.
One key, one day
USDC · $150 per plz · $300 per day
- 09:12Fund a $110 plz · funded today $110200live
- 11:40Fund a $110 plz · funded today $220200live
- 16:05Fund a $110 plz · would be $330403key_daily_cap
The rules
- Caps are per asset, in minor units. An asset with no cap can't be funded with the key at all, so a
plz:fundkey without caps can't fund anything. - What counts is the total you lock: budget plus fee, the quote's
total. - The day rolls: every plz the key funded in the last 24 hours counts, even ones since canceled or refunded.
- Caps bind the key, not you. Over a cap, fund that plz on the web instead, where you're signed in yourself.
- Checked inside the funding transaction with the key locked, so two calls at once can't both squeeze under the cap.
- A key can also be held to some platforms. It can only create plz there; anything else gets
key_platform.
When a cap says no
key_asset- No cap for this asset.details: asset
key_cap- The plz's total is over the per-plz cap.details: asset, total, perPlz
key_daily_cap- It would take the day over the daily cap.details: asset, total, spentToday, perDay
key_platform- The key can't create plz on this platform.details: platforms
All four are 403. The dashboard shows what each key funded today.
Response · 403 Forbidden (the 16:05 funding)
{
"error": {
"code": "key_daily_cap",
"message": "This would take the key over its daily cap. Fund it on the web, or try again later.",
"details": {
"asset": "USDC",
"total": "110000000",
"spentToday": "220000000",
"perDay": "300000000"
}
}
}05Idempotency
Retry anything. It runs once.
Networks drop answers, and an agent that isn't sure will try again. Send an Idempotency-Key with a write and the retry gets the first answer back, never a second plz or a second charge.
| When you retry | Outcome | You get |
|---|---|---|
| Same key, same request, first one answered | replay | The first answer again, status and body, with idempotent-replayed: true. Nothing runs twice. |
| Same key, same request, first one still running | 409 | idempotency_in_flight. Wait a moment and send it again. |
| Same key, different request | 422 | idempotency_key_reused. A key names one request: a method, a path and the exact body bytes. |
| First one answered 5xx or 429 | runs | Nothing was kept, so the retry runs for real. |
| First one never answered | runs | After 60 seconds its claim lapses and a retry takes over. |
The rules
- A key is 1 to 255 visible ASCII characters. A UUID is perfect; so is your own job id, like
fund-k3v9xq2m. - Keys belong to your account, shared by all your API keys and your browser, and are forgotten after 24 hours.
- Refusals are kept too. If a fund came back
409 insufficient_funds, the same key replays that answer: top up, then send a new key. - Answers that carry a secret, like a new webhook or a rolled secret, are never kept. Retrying those runs them again.
- Reads ignore the header. They're safe to repeat anyway.
A retry, answered from memory
$ curl -i 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
HTTP/1.1 201 Created
content-type: application/json; charset=UTF-8
idempotent-replayed: true
{"plz": {"id": "k3v9xq2m", …}, "quote": {…}}06Your references
Your ids, on our records.
Send your own reference and a few strings of metadata when you create a plz. They come back on the plz and on every event about it, so an integration can match PLZBRO to its own records without keeping a lookup table.
Two optional fields on create
clientReferenceId- 1 to 100 characters, unique among your plz, canceled ones included. Sending one you've used gets
409 duplicate_referencewith that plz's id, so a create you lost track of is one call away. metadata- Up to 20 string values. Keys are 1 to 40 letters, digits or
_ . : -; values up to 500 characters.
Where they come back
- As
integrationon the plz, whenever you read it as its requester:GET /v1/plz/:idand the create, fund, close and winners answers. - In
dataon every event you get about the plz, by poll or by webhook. integration.sourcesays how the plz was made:apiwith a key,webin the app.- Both are set once, at create. Nothing changes them later.
plz.json (excerpt)
{
"title": "post a thread on why you use PLZBRO",
"…": "…",
"clientReferenceId": "launch-thread-0042",
"metadata": {
"campaign": "launch",
"cohort": "a"
}
}Response · 201 Created (excerpt)
{
"plz": {
"id": "k3v9xq2m",
"…": "…",
"integration": {
"source": "api",
"clientReferenceId": "launch-thread-0042",
"metadata": {
"campaign": "launch",
"cohort": "a"
}
}
}
}Response · 409 Conflict
{
"error": {
"code": "duplicate_reference",
"message": "You already have a plz with that clientReferenceId.",
"details": { "plzId": "k3v9xq2m" }
}
}07Events
Every step, in order.
Each change to a plz you asked for, or to work you do, becomes an event for you, written in the same transaction as the change. Read them in order from GET /v1/events, or have them pushed to a webhook. Same events, same ids.
Read the feed
Oldest first. Keep the id of the last event you handled and ask for what came after it. Works with any key, even one with no scopes.
after- The last event id you handled. Must be one of yours, or you get
400 invalid_cursor. Leave it out to start from your first event. types- Comma-separated, like
payout.sent,plz.refunded. Unknown names are skipped; if none is known, you get every type. limit- 1 to 100, default 50.
orderasc, the default, ordescfor the newest first;afterthen pages back in time.nextCursor- In the answer: set when another page is ready right now. Null means you're caught up, so wait before you ask again.
Request
$ curl -G https://plzbro.io/api/v1/events \
-H "Authorization: Bearer $PLZBRO_KEY" \
-d after=evt_2k8m4q9x6w3n5p7r \
-d types=payout.sentResponse · 200 OK
{
"items": [
{
"id": "evt_6m2k9x4q8w3n7p5r",
"type": "payout.sent",
"createdAt": "2026-10-21T18:42:07.512Z",
"plzId": "k3v9xq2m",
"submissionId": "sub_8f3k2m9x7q4w2e5r",
"data": {
"amount": "25000000",
"asset": "USDC",
"txRef": "tx_dw3xc96m9f5rmw97",
"provider": "ledger",
"clientReferenceId": "launch-thread-0042",
"metadata": {
"campaign": "launch",
"cohort": "a"
}
}
}
],
"nextCursor": null
}follow-events.mjs
let after = await loadCursor(); // the last event id you handled, or null
for (;;) {
const url = new URL("https://plzbro.io/api/v1/events?limit=100");
if (after) url.searchParams.set("after", after);
const response = await fetch(url, { headers: { authorization: `Bearer ${process.env.PLZBRO_KEY}` } });
if (!response.ok) throw new Error(`events: ${response.status}`);
const page = await response.json();
for (const event of page.items) {
await handle(event); // make this safe to run twice: a crash can replay one event
after = event.id;
await saveCursor(after);
}
// nextCursor means another page is ready now; otherwise wait before asking again.
if (!page.nextCursor) await new Promise((resolve) => setTimeout(resolve, 15_000));
}Event types
Payloads are thin: ids, amounts and reasons. Fetch the plz when you need the whole picture. The bro gets a copy of events about their own work, without your references.
| Event | When | data | Sent to |
|---|---|---|---|
| plz.live | Funded: the pot is locked and the plz takes proof. | amount, asset | asker |
| plz.closed | Stopped taking proof: deadline, every spot filled, budget spent, or closed by you. | reason | asker |
| plz.completed | Settled with at least one payout. | - | asker |
| plz.expired | Settled with nothing approved. Everything went back. | - | asker |
| plz.canceled | Canceled before anyone started. | - | asker |
| plz.refunded | Unused budget and its share of the fee came back to you. | amount, asset, fee | asker |
| claim.created | A bro reserved a spot. | - | askerbro |
| submission.created | Proof arrived. Automated checks start. | - | askerbro |
| submission.needs_review | Checks are done and the asker's review is due. | verdict, reviewEndsAt, autoApproveAt, units, unitsSource | askerbro |
| submission.updated | A proof's result changed during review, which can change what silence does. | changed, verdict, reviewEndsAt, autoApproveAt, units, unitsSource | askerbro |
| submission.approved | Approved by the asker, or picked as a contest winner. | amount, asset, rank | askerbro |
| submission.auto_approved | Approved by silence when the review window ran out. | amount, asset, rank | askerbro |
| submission.rejected | Turned down by the asker, the checks or the rules, or by silence when there was nothing to pay. | reason | askerbro |
| submission.disputed | The bro called the ref on a rejection. | - | askerbro |
| dispute.resolved | The ref decided. | outcome | askerbro |
| payout.sent | Money left escrow for the bro. | amount, asset, txRef, provider | askerbro |
What's in data
amount- Minor units, as a string, in
asset. Onplz.refundedit includes the fee coming back. reason- Why a plz closed:
deadline,filled,budgetorrequester. On a rejection, its code, likelow_quality,slots_filledorno_result. verdictpasswhen the checks read every rule,manualwhen 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
unitscame from:measured,reported(the bro),requesterormoderator. changed- On
submission.updated, what changed:unitsfor now. The rest of the payload is the proof's review as it stands, as onsubmission.needs_review. rank- Contest place, on winning proofs only.
outcomeupheld(the bro gets paid, or a contest entry rejoins the pick) ordenied.txRef- The settlement reference printed on the receipt.
clientReferenceIdmetadata- Yours, on your copy of every event about the plz, when you set them.
Order and repeats
- The feed runs in the order events were written. One change can write several: canceling a live plz writes
plz.refunded, thenplz.canceled. - An event id is unique to you and never changes. Webhooks send it as
webhook-id, so the feed and your endpoint can share one “seen” list. - Handle each event so running it twice is harmless. A crash between handling and saving your cursor replays one.
- Webhooks retry independently, so they can arrive out of order. Sort by the feed, or by
createdAt, when order matters.
08Webhooks
We'll call you. Signed.
Point an endpoint at your server and each of your events is POSTed to it seconds after it happens, signed the Standard Webhooks way, and retried for 27 h 36 min if you're down.
Add an endpoint
With the webhooks:manage scope, or from the dashboard. Up to 5 endpoints, each with its own secret.
url- Up to 500 characters, no credentials in it. This demo takes http too; live servers take https only. Local and private addresses are refused, so test through a tunnel.
types- The event types to send. Leave it out, or send
null, for all of them. description- Up to 120 characters, for you.
Request
$ curl https://plzbro.io/api/v1/webhooks \
-H "Authorization: Bearer $PLZBRO_KEY" \
-H "content-type: application/json" \
--data '{
"url": "https://example.com/hooks/plzbro",
"types": ["payout.sent", "plz.refunded"],
"description": "payouts to the books"
}'Response · 201 Created
{
"endpoint": {
"id": "whk_q4m8x2k9w7n3p5r6",
"url": "https://example.com/hooks/plzbro",
"types": ["payout.sent", "plz.refunded"],
"description": "payouts to the books",
"createdAt": "2026-10-20T09:30:00.000Z",
"disabledAt": null,
"disabledReason": null,
"failingSince": null,
"previousSecretExpiresAt": null
},
"secret": "whsec_…"
}The secret is in this answer only. Store it like a password; it's how you know a request came from PLZBRO.
What arrives
A POST with the event as JSON: its type, a timestamp and the same object GET /v1/events returns as data. Three headers carry the proof.
To your endpoint (signed with the test secret below)
POST /hooks/plzbro HTTP/1.1
content-type: application/json
user-agent: PLZBRO-Webhooks/1.0 (+https://plzbro.io/developers)
webhook-id: evt_6m2k9x4q8w3n7p5r
webhook-timestamp: 1792608129
webhook-signature: v1,any0rxq1r8A+Y0yvgbFOqmVcJ51oVQvuriGupBTe4kE=
{"type":"payout.sent", "timestamp":"2026-10-21T18:42:07.512Z", "data": {"id":"evt_6m2k9x4q8w3n7p5r", "type":"payout.sent", "createdAt":"2026-10-21T18:42:07.512Z", "plzId":"k3v9xq2m", "submissionId":"sub_8f3k2m9x7q4w2e5r", "data": {"amount":"25000000", "asset":"USDC", "txRef":"tx_dw3xc96m9f5rmw97", "provider":"ledger", "clientReferenceId":"launch-thread-0042", "metadata": {"campaign":"launch", "cohort":"a"}}}}webhook-id- The event's id. The same on every retry and every endpoint, so it's your key for skipping repeats.
webhook-timestamp- When this try was sent, in Unix seconds. Each try is signed afresh.
webhook-signaturev1,and a base64 HMAC-SHA256. While a rolled secret's old one still signs there are two, one for each secret, space-separated.
Check the signature
- Join
webhook-id,webhook-timestampand the raw body with dots:{id}.{timestamp}.{body}. - Sign that with HMAC-SHA256. The key is your secret after
whsec_, base64-decoded. - Base64 the result and compare it with each v1 signature in the header, in constant time.
- Refuse timestamps more than 5 minutes from your clock, so an old request can't be replayed.
No dependencies. Any Standard Webhooks library works as well.
verify-webhook.mjs
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
/**
* True when a PLZBRO webhook is genuine and fresh. Pass the raw body exactly
* as it arrived: parsing and re-serializing it changes the bytes.
*/
export function verifyWebhook(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= TOLERANCE_SECONDS)) return false;
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest();
// The header may carry several signatures, space-separated; one v1 match is enough.
return signatures.split(" ").some((entry) => {
const [version, signature = ""] = entry.split(",");
const given = Buffer.from(signature, "base64");
return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
});
}
// Express: read the body raw, verify, then parse.
// app.post("/hooks/plzbro", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyWebhook(process.env.PLZBRO_WEBHOOK_SECRET, req.headers, req.body)) return res.sendStatus(400);
// const event = JSON.parse(req.body);
// res.sendStatus(204);
// });verify_webhook.py
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_webhook(secret: str, headers, raw_body: bytes) -> bool:
"""True when a PLZBRO webhook is genuine and fresh. Pass the raw body exactly as it arrived."""
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (msg_id and timestamp and signatures):
return False
try:
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
except ValueError:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
# The header may carry several signatures, space-separated; one v1 match is enough.
return any(
version == "v1" and hmac.compare_digest(signature.encode(), expected)
for version, _, signature in (entry.partition(",") for entry in signatures.split(" "))
)
# Flask: request.headers is case-insensitive and get_data() returns the raw bytes.
# ok = verify_webhook(os.environ["PLZBRO_WEBHOOK_SECRET"], request.headers, request.get_data())Test vector
The Standard Webhooks spec's own example; PLZBRO's tests sign it and expect exactly this. Turn the freshness check off, feed these in, and your code should accept. It should also accept the request above, signed with the same secret.
- secret
- whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
- webhook-id
- msg_p5jXN8AQM9LWM0D4loKWxJek
- webhook-timestamp
- 1614265330
- body
- {"test": 2432232314}
- webhook-signature
- v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
Retries, counted from the first try
- Try 1nowseconds after the event
- Try 21mwaited 1 min
- Try 36mwaited 5 min
- Try 436mwaited 30 min
- Try 52h 36mwaited 2 h
- Try 67h 36mwaited 5 h
- Try 717h 36mwaited 10 h
- Try 827h 36mwaited 10 h
- Thenfailedstays in your log
disabledReason: "failing", until you enable it.Test, roll, watch, resend, switch off
POST /v1/webhooks/:id/test
ping that goes out within seconds. Its webhook-id starts with ping_ and it never shows in the feed.POST /v1/webhooks/:id/roll
{ overlapHours }, 24 by default and up to 72, the old one keeps signing beside it, both signatures in one header, until previousSecretExpiresAt. 0 swaps it at once, retries included.GET /v1/webhooks/:id/deliveries
status; limit is up to 100, 50 by default, and after, the last one you have, pages back.POST /v1/webhooks/:id/deliveries/:deliveryId/resend
webhook-id and body. One still being retried answers 409 delivery_pending.POST /v1/webhooks/:id/disable
POST /v1/webhooks/:id/enable, which starts again with a clean failure record. A delivery that comes due meanwhile ends as failed, to resend once it's back on. disabledReason says who switched it off: owner or failing.DELETE /v1/webhooks/:id
GET /v1/webhooks/:id/deliveries · 200 OK (one item)
{
"items": [
{
"id": "whd_rk543ishngn3phte",
"eventId": "evt_6m2k9x4q8w3n7p5r",
"eventType": "payout.sent",
"status": "pending",
"attempts": 1,
"lastStatus": 503,
"lastError": "Answered 503: upstream unavailable",
"nextAttemptAt": "2026-10-21T18:43:16.512Z",
"deliveredAt": null,
"createdAt": "2026-10-21T18:42:07.512Z"
}
],
"nextCursor": null
}09Attested referrals
Your server counts. Signed.
Pay per referral and let your own server say what converted. Name an Ed25519 public key on the plz, and every signup, wallet connect or purchase your server signs counts once, for the doer whose code it carries.
Name your key on the plz
On the referrals requirement of a plz that pays per referral. Only one referrals requirement can count attested events.
attestor- Your server's Ed25519 public key in base58, the form of a Solana address. The private key never leaves your server.
link- Optional. Where doers send people, with one
{ref}where each doer's code goes. Up to 240 characters. viewer.referral- What each signed-in doer reads on
GET /v1/plz/:id: theircode, thelinkwith it in place, andattested, how many of their conversions you've reported.
Keep the code with each visitor who arrives with it, and send it back as the event's ref when they convert.
plz.json (excerpt)
{
"requirements": [
{
"type": "referrals",
"count": 1,
"event": "signup",
"attestor": "FVen3X669xLzsi6N2V91DoiyzHzg1uAgqiT8jZ9nS96Z",
"link": "https://yourapp.xyz/join?ref={ref}"
}
],
"reward": {
"mode": "performance",
"asset": "USDC",
"budget": "500000000",
"rate": "5000000",
"unit": "referral",
"windowHours": 168
}
}Send a batch
Up to 100 events a call, and no API key: the signature is the credential. It covers the exact body, which names the plz, so a batch can't be altered or replayed on another plz.
To PLZBRO (signed with the test key below)
POST /api/v1/plz/k3v9xq2m/attestations HTTP/1.1
content-type: application/json
plzbro-attestation-signature: 3DTtuDn1Swx0KpVoBpkSPkEajrMtLnCnVYzSIe42dQuFHMBrBzIC/qkT+ddIW8sS9b4V79NwQMH5JPyPcknzCw==
{"plz":"k3v9xq2m", "events":[{"id":"signup-48213", "type":"signup", "ref":"usr_7m3k9x2q4w8e5r6t.3f9a1c0b7e", "occurredAt":"2026-10-21T18:40:12.000Z"}, {"id":"signup-48214", "type":"signup", "ref":"usr_2q9w4k7m3x8n5p6r.b81d40e2a9", "occurredAt":"2026-10-21T18:41:55.000Z"}]}plz- The plz's id, the same as in the path.
events[].id- Your id for the event: 1 to 128 letters, digits or
_ . : -. Each counts once per plz, so sending it again is safe. events[].typesignup,wallet_connect,purchaseorcustom: the event the plz's rule counts.events[].ref- The code the person arrived with, as you received it.
events[].occurredAt- When it happened, ISO 8601 with a zone. Events from before the plz went live, or more than 5 minutes ahead of our clock, don't count.
Sign it
- Serialize the batch once, as UTF-8 JSON.
- Sign those bytes with Ed25519, as RFC 8032 defines it, using the private key whose public half the plz names.
- Send the 64-byte signature, base64 or base58, in
plzbro-attestation-signature, and the same bytes as the body.
Makes the key on first run and prints the public half to put on the plz.
attest.mjs
import { createPrivateKey, createPublicKey, generateKeyPairSync, sign } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
const API = "https://plzbro.io/api";
const PLZ = "k3v9xq2m"; // the plz whose referrals rule names your key
// Once: make a key pair. The private key stays on your server; the public key goes on the plz as `attestor`.
if (!existsSync("attestor.pem")) {
const { privateKey } = generateKeyPairSync("ed25519");
writeFileSync("attestor.pem", privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
}
const key = createPrivateKey(readFileSync("attestor.pem"));
console.log("attestor:", base58(Buffer.from(createPublicKey(key).export({ format: "jwk" }).x, "base64url")));
/** Report conversions, each with your own event id and the ref the person arrived with. */
export async function attest(events) {
const body = JSON.stringify({ plz: PLZ, events });
// Sign exactly the bytes you send: serializing again could change them.
const signature = sign(null, Buffer.from(body), key).toString("base64");
const response = await fetch(`${API}/v1/plz/${PLZ}/attestations`, {
method: "POST",
headers: { "content-type": "application/json", "plzbro-attestation-signature": signature },
body,
});
if (!response.ok) throw new Error(`attestations: ${response.status} ${await response.text()}`);
return response.json(); // { plzId, results: [{ id, outcome }], accepted }
}
function base58(bytes) {
const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
let n = BigInt(`0x${bytes.toString("hex")}`);
let out = "";
for (; n > 0n; n /= 58n) out = alphabet[Number(n % 58n)] + out;
for (const byte of bytes) {
if (byte) break;
out = `1${out}`;
}
return out;
}
// await attest([{ id: "signup-48213", type: "signup", ref: "usr_7m3k9x2q4w8e5r6t.3f9a1c0b7e", occurredAt: new Date().toISOString() }]);attest.py
import base64
import json
import os
import urllib.request
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
API = "https://plzbro.io/api"
PLZ = "k3v9xq2m" # the plz whose referrals rule names your key
# Once: make a key pair. The private key stays on your server; the public key goes on the plz as attestor.
if not os.path.exists("attestor.pem"):
pem = Ed25519PrivateKey.generate().private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()
)
with open(os.open("attestor.pem", os.O_WRONLY | os.O_CREAT, 0o600), "wb") as file:
file.write(pem)
with open("attestor.pem", "rb") as file:
key = serialization.load_pem_private_key(file.read(), password=None)
def base58(data: bytes) -> str:
alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
n, out = int.from_bytes(data, "big"), ""
while n:
n, digit = divmod(n, 58)
out = alphabet[digit] + out
return "1" * (len(data) - len(data.lstrip(b"