# Earnesty — integration documentation Everything needed to integrate Earnesty, as one file. Your users post about your product on X; Earnesty verifies the post and POSTs a reward to your backend, which credits the user. You keep your own ledger. Your own App ID, X handle, partner tag and reward endpoint are in your console under Configuration. Nothing in this file is specific to one app. Rendered: https://earnesty.app/docs/integration/ --- # Integrating Earnesty — three steps Your users post about your product on X. Earnesty verifies the post and tells your backend to reward the user. You keep your own ledger; we never hold a balance for anyone. The whole integration is **one column, one route, one call**, in that order. Both examples below are TypeScript; the shapes are plain HTTP and JSON, so they transcribe to anything. --- ## 1. One column — somewhere for a reward to land Most products meter a plan, not a balance: a monthly quota, a question allowance, seats on a tier. A reward from Earnesty is extra on top of that, so it needs a place to accumulate that your entitlement check already reads. ```sql -- On whatever row your entitlement check reads: the account, the org, the user. ALTER TABLE accounts ADD COLUMN earned_bonus integer NOT NULL DEFAULT 0; -- One row per reward we ever sent you. The UNIQUE constraint is the idempotency. CREATE TABLE earnesty_rewards ( reward_reference text PRIMARY KEY, account_id text NOT NULL, reward_type text NOT NULL, amount integer NOT NULL, -- What is left of THIS grant. Spend against it (oldest first), so a -- revocation can give back one grant's unspent credits without touching -- another's. A flat balance alone cannot tell them apart. remaining integer NOT NULL, received_at timestamptz NOT NULL DEFAULT now() ); ``` Then one line where you compute what a user may do: ```ts const allowed = plan.monthlyQuota + account.earned_bonus; ``` If your product already has a credits balance, skip the column and credit into that. The `earnesty_rewards` table stays: it is what makes the route below safe to call twice. --- ## 2. One route — receive a reward, apply it once We `POST` to a URL you choose, with a bearer token you choose. The body is four fields: ```json { "productUserId": "acct_123", "amount": 10, "rewardType": "credits", "rewardReference": "claim:1841…" } ``` Insert the reference; if it was already there, do nothing. Return 2xx either way. ```ts // Express / Hono / a Supabase edge function — the shape is the same. export async function receiveReward(req: Request): Promise { if (req.headers.get('authorization') !== `Bearer ${process.env.EARNESTY_REWARD_KEY}`) { return new Response('unauthorized', { status: 401 }); } const { productUserId, amount, rewardType, rewardReference, kind } = await req.json(); if (rewardType !== 'credits') { // A reward you have not shipped support for. Refuse it; do not guess. return Response.json({ error: 'unknown_reward_type' }, { status: 400 }); } if (kind !== 'post' && kind !== 'reply_bonus') { // Not everything that arrives here is credit: `revocation` takes some // back, `test` is the console's button, and a kind you do not know is a // future occasion, not a failure. Crediting without looking is how a // revocation gets applied with the sign inverted. The full branch — // including giving back one grant's remainder — is in reward-endpoint.md. return Response.json({ success: true, ignored: true }); } const inserted = await db.transaction(async (tx) => { const { rowCount } = await tx.query( `INSERT INTO earnesty_rewards (reward_reference, account_id, reward_type, amount, remaining) VALUES ($1, $2, $3, $4, $4) ON CONFLICT (reward_reference) DO NOTHING`, [rewardReference, productUserId, rewardType, amount] ); if (rowCount === 1) { await tx.query(`UPDATE accounts SET earned_bonus = earned_bonus + $1 WHERE id = $2`, [ amount, productUserId, ]); } return rowCount === 1; }); return Response.json({ success: true, credited: inserted }); } ``` That is the whole handler. Full contract, including why a duplicate must be a 2xx and what we do when it is not: [the reward endpoint contract](https://earnesty.app/docs/integration/reward-endpoint/). **Check it before a single post exists.** *Configuration → step 4 → Try it: send a test reward* sends a real reward — one unit of your default reward, to a user id you type (use your own test account) — and then sends it again with the same reference. You see both answers, and the account should be up by one, not two. The dashboard lists every real delivery afterwards with what your endpoint said about it. --- ## 3. One call — enroll a user, show them their code When a user opts in, call us from your backend. What comes back is what they need to post — their claim code — and a link to their own status page. ```ts const res = await fetch('https://api.earnesty.app/v1/users', { method: 'POST', headers: { Authorization: `Bearer ${EARNESTY_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ appId: EARNESTY_APP_ID, productUserId: account.id }), }); const { code, statusPageUrl, seatState } = await res.json(); // Show `code` and link `statusPageUrl`. Branch on `seatState` — a waitlisted user has a code too. ``` Idempotent: calling again for the same user returns the same code. The link's token lasts a day, so render it from a fresh call wherever the user sees it rather than storing or emailing it. Want the page in your own design? `GET /v1/status?t=…` is the JSON it reads. Contract, including the `priority` flag for users you are paid by: [enrolling users](https://earnesty.app/docs/integration/enroll-user/). **Where the link belongs: your own limit screen.** The moment a user reaches a limit is one only you can see — we hold no balance — and it is the right place for the offer: one line, in your words, linking to `statusPageUrl` from a fresh call. One rule: the link is a standing offer, never "you're out, post to refill". Eligibility is the same whether a balance is full or empty, and the copy around the link should read that way. Why, and the shape of it: [enrolling users](https://earnesty.app/docs/integration/enroll-user/). --- ## How this API changes Fields are only ever added, never removed or retyped. Parse what you use and ignore what you don't recognise — an unknown field in a payload or reply is a new feature, not an error. There is no version header because nothing has ever needed one; if a breaking change ever becomes necessary, it will arrive behind an explicit opt-in, never by the existing contract shifting under you. ## What you never do - **Mirror a balance.** We do not hold one for you and we do not want yours. - **Parse `rewardReference`.** Its format is ours. Switch on `rewardType`. - **Poll us.** Every reward is pushed, and retried for you if your endpoint is down. Why the boundary sits here: you own the user and the ledger that serves them; we own finding the post and proving it qualifies. Neither side needs the other's database. --- # Reward Endpoint — Integration Contract New here? Start with the [three-step overview](README.md); this page is the full contract. ## Overview Earnesty POSTs a **reward** to your reward endpoint to credit a USER in your product. This endpoint **MUST be idempotent** — Earnesty may call it more than once for the same reward due to retries, network issues, or delivery worker restarts. Your endpoint must handle duplicates safely. > **One word: reward.** The endpoint was `grantCredits`, the idempotency key was > `grantReference`, and this file was `grant-reward.md`; all three say *reward* now > (2026-08-30), because an integrator reading *grant* beside *reward* looks for two > concepts where there is one. The endpoint's name is `rewardEndpoint`, and the field > you configure it under is `rewardEndpoint` / `rewardApiKey` (ADR 0013). What we > deliver may be credits, tokens, seats, render minutes or a free month, and a > partner-facing name that says *credits* about a thing that is not one is a name > we would have had to keep forever. Nothing about the request changed in the > rename; the payload gained one field, described below. --- ## Idempotency Requirement > **Your reward endpoint MUST be idempotent on the `rewardReference` key.** > > If Earnesty sends a request with a `rewardReference` you have already processed, your endpoint MUST skip the duplicate and return a success response (2xx). Do not credit twice for the same reference. This is a **contractual requirement**. Earnesty's delivery worker retries failed deliveries seven times over about three days (1m, 10m, 1h, 6h, then a day apart) — and a reward that exhausts those retries is not lost: **the next time your endpoint answers any delivery with 2xx, your stranded rewards are requeued automatically**, oldest first, each with a fresh retry ladder. So the contract is one sentence: if your endpoint ever answers 2xx again, you eventually receive every reward exactly once. Every successful delivery also carries `X-Earnesty-Pending: ` — how many other undelivered rewards you have at that moment — so a backlog announces itself exactly when you are healthy enough to hear it. If your endpoint is not idempotent, a USER may receive duplicate rewards that cannot be automatically corrected. `rewardReference` is unique across every reward type, so a handler that dedupes on it alone is still correct now that rewards carry a type. --- ## Request Format ``` POST {your reward endpoint} Authorization: Bearer {your API key} Content-Type: application/json ``` ### Body ```json { "productUserId": "user-123", "amount": 500, "rewardType": "tokens", "rewardReference": "claim:1234567890" } ``` | Field | Type | Description | |-------|------|-------------| | `productUserId` | string | The USER's ID in your system — the same ID you passed to `POST /v1/users` | | `amount` | integer | How much to credit, in `rewardType` | | `rewardType` | string | **Which reward.** The immutable key of one of your reward types, matching `^[a-z][a-z0-9_]{0,31}$` | | `rewardReference` | string | Unique idempotency key for this reward (see format below) | | `kind` | string | The occasion: `post` (the verified post), `reply_bonus` (its conversation, settled once ~5 days later), or `test` (the console's test button) | | `earnedAt` | string | When the reward was earned, ISO 8601 | | `postUrl` | string \| null | The post this pays for; `null` on a test reward | | `metadata` | object | Your own tags from the enroll call, verbatim; `{}` when none were set | --- ## We are on the other end of this too Earnesty is an APP on Earnesty: posting about us earns seats, and those seats reach our billing through this contract, not through a shortcut behind it. `POST /v1/billing/reward-grants` is our handler — same payload, bearer token, idempotent on `rewardReference`, `200` on a replay — and the delivery worker that calls yours calls it, with no branch anywhere that asks whose APP a reward is for (ADR 0016 §1). Two things follow that are worth having in writing. Every retry, every failure and every replay you can hit, we hit first. And the failure modes below are not hypotheticals we imagined for you: a wrong key here is our seats piling up as `failed` rows, same as yours. ## Switch on `rewardType`. Never on `rewardReference` `rewardType` is the field that tells you which reward to credit. Today every app has one — its key is shown under the reward's name in *Configuration → step 1* — and it never changes. Drops will add more, each with its own key, so a handler that switches on this field now needs no change when they arrive. ```js switch (body.rewardType) { case 'tokens': return addTokens(user, body.amount); case 'seats': return addSeats(user, body.amount); default: return 400; // a type you have not shipped support for } ``` **Do not infer the reward from `rewardReference`.** Its format is ours and is documented below so you can read a reward in your own logs — not so you can parse it. We reserve the right to change it, and an integration that switches on it would break silently when we did. The same goes for `amount`: two rewards can legitimately share a number. A `rewardType` you do not recognise should be refused rather than guessed at. It means a reward your handler does not know about — a Drop, once those exist — and crediting it as your default currency is worse than a failed delivery you can see. --- ## Reward Reference Format The `rewardReference` uniquely identifies each reward. Use it as your deduplication key. | Occasion | Format | Example | |------|--------|---------| | The post itself | `claim:{post_id}` | `claim:1234567890` | | The reply bonus | `bonus:{post_id}` | `bonus:1234567890` | - The **post reward** is issued once per verified post. - The **reply bonus** settles **once**, five days after the post was made, on the number of distinct accounts that replied (ADR 0010). There is no per-day suffix; if you integrated against `bonus:{id}:day{N}`, that model is gone. Both occasions pay in the same reward type — the one the post was priced in. `grantType` is not on the wire, because the occasion of a reward is our business and the kind of thing given is yours. --- ## Expected Response Return a **2xx status** on success. Earnesty treats any 2xx response as a successful delivery. ### Success (new reward) ``` HTTP/1.1 200 OK Content-Type: application/json { "success": true, "credited": true } ``` ### Success (duplicate — already processed) ``` HTTP/1.1 200 OK Content-Type: application/json { "success": true, "credited": false, "reason": "duplicate_reward_reference" } ``` Both responses are treated as successful delivery by Earnesty. The body format is flexible — only the 2xx status matters to Earnesty's delivery worker. --- ## Handling Duplicates When you receive a request, check if the `rewardReference` has already been processed: 1. **Look up** the `rewardReference` in your records. 2. **If found** — the reward was already applied. Skip it and return 2xx. 3. **If not found** — apply the reward, store the `rewardReference`, and return 2xx. ### Implementation Example (pseudocode) ``` function handleReward(request): reference = request.body.rewardReference userId = request.body.productUserId amount = request.body.amount rewardType = request.body.rewardType // Refuse a reward you have not shipped support for, rather than guessing. if rewardType not in SUPPORTED: return { status: 400, body: { error: "unknown_reward_type" } } // Check for duplicate existing = db.query("SELECT 1 FROM earnesty_rewards WHERE reward_reference = ?", reference) if existing: return { status: 200, body: { success: true, credited: false, reason: "duplicate_reward_reference" } } // Apply the reward db.transaction: db.execute("INSERT INTO earnesty_rewards (reward_reference, user_id, reward_type, amount, created_at) VALUES (?, ?, ?, ?, NOW())", reference, userId, rewardType, amount) credit(userId, rewardType, amount) return { status: 200, body: { success: true, credited: true } } ``` > **Tip:** Use a UNIQUE constraint on `reward_reference` in your database. This ensures idempotency even under concurrent requests — an INSERT that violates the constraint tells you the reward was already processed. --- ## The Test Reward *Configuration → step 4 → Try it: send a test reward* sends your endpoint a **real reward** — not a ping. It carries your default reward type and an amount of one, addressed to a `productUserId` you type in (use your own test account), with a reference of the form `test:`: ```json { "productUserId": "acct_yours", "amount": 1, "rewardType": "credits", "rewardReference": "test:3f1c…" } ``` Then it sends **the same request again**, and shows you both answers. A correct endpoint answers 2xx twice and credits once. Handle it exactly like any other reward; there is nothing to special- case, and that is the point — passing the test means real deliveries will pass, because it is the same request from the same code. Earlier, saving the endpoint sent a ping with `rewardType: "__earnesty_test__"` and a zero amount. It is gone: a handler that followed the advice above and refused an unknown reward type failed it. Saving now just stores the pair. The same page lists your recent deliveries with the status and body your endpoint returned on the last attempt, so a wrong key reads as "401" in your console rather than as a question to us. --- ## Error Responses If your endpoint returns a non-2xx status, Earnesty will retry with exponential backoff: | Attempt | Retry after | |---------|-------------| | 1 | 1 second | | 2 | 2 seconds | | 3 | 4 seconds | | 4 | 8 seconds | | 5 | 16 seconds | After 5 failed attempts, the delivery is marked as failed and logged for investigation on our side. **Return 4xx** for permanent errors (bad request, unauthorized). Earnesty will still retry these — ensure your auth credentials are correctly configured. **Return 5xx** for transient errors (database down, timeout). Earnesty will retry automatically. --- ## Security Earnesty authenticates using the API key you provide during configuration. The key is sent as a Bearer token in the `Authorization` header. - Store your API key securely. - Validate the `Authorization` header on every request. - Reject requests with invalid or missing credentials with 401. ### How we hold your API key Your reward API key is **encrypted at rest** (AES-256-GCM, key held in Google Secret Manager and never in the database). It has to be reversible rather than hashed, because we present it to your endpoint on every delivery — so it is encrypted, not one-way. We never display it back to you after you save it; leaving the field blank on the configuration screen means "unchanged", not "clear it". To replace it, type the new one and save — and send a test reward, which uses the key as typed. ### Your compliance record Every reward exists because we verified, at a recorded moment, that the post carried the required disclosure — the mention and the partner tag, in the first half of the text. That attestation (post URL, posted-at, verified-at, the terms in force) is retained per post and surfaces as `post.verified` in `GET /v1/events`. If a user later deletes the post or edits the disclosure out, the record shows the disclosure was present when the reward was earned and that its removal was the user's act, after the fact — which is the evidence that matters if the question is ever asked. We keep the attestation, not the post's text: what your users write is theirs. ### Revocation At the day-5 settlement we fetch the post one more time. Gone, or the disclosure tag edited out, and the claim is **revoked** — and you are told the same way you are told about a reward: **we POST a notice to your reward endpoint**, signed, retried and requeued like any delivery. You never have to poll for one. ```json { "productUserId": "acct_123", "kind": "revocation", "revokes": "claim:1841…", "amount": 25, "rewardType": "credits", "rewardReference": "revoke:1841…" } ``` `revokes` names the grant to reverse; `amount` is what that grant was worth; the notice's own `rewardReference` is its idempotency key, so a retry is safe. **It is not a negative delivery** — nothing is debited from your side by us. Reverse it, absorb it, or reverse only the unspent part: your ledger, your call. Answer 2xx as you would for any delivery. **The window is the settlement, and it closes.** The check happens once, five days after the post. A post deleted or edited on day six keeps its credits — we do not look again, and a revocation is equally final in the other direction: putting the tag back changes nothing, ever, and the user's next post starts clean. `reward.revoked` still appears in `GET /v1/events` for audit, but you do not need to read it to stay correct. Together with the compliance record above this closes the disclosure loop: present when earned, removal recorded as the user's act, reward revoked and reported. ### The delivery signature Every reward delivery (the console's test button included) carries ``` X-Earnesty-Signature: t=,v1= ``` where `v1 = HMAC-SHA256(signing secret, ".")`. Verify it and the bearer token both: the bearer says who is allowed, the signature says this exact body left Earnesty recently — a request replayed from a proxy log fails your timestamp check even though its bearer is right. ```ts import crypto from 'node:crypto'; function verifySignature(header: string, rawBody: string, secret: string): boolean { const m = header.match(/^t=(\d+),v1=([0-9a-f]{64})$/); if (!m) return false; const [, t, v1] = m; if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // 5-minute tolerance const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected)); } ``` Verify against the **raw body bytes**, before any JSON parsing or re-serialisation — a re-stringified object is a different byte sequence and a false negative. ### Your webhook signing secret The secret behind that signature, per app: - **It is shown once**, in the response that creates your app — copy it then. It is encrypted at rest and we cannot read it back to you afterwards. - **It is rotatable**: *Configuration → step 4 → Rotate signing secret*, or `POST /v1/apps/:id/signing-secret/rotate`. The new one is shown once. - **Never had one?** Apps created before signing existed have a secret they were never shown. Rotate: that is how you get a usable one, and it is the only way — we cannot read yours back. - **Rotating has no cliff, because retries are the overlap.** We start signing with the new secret immediately, so deliveries in the gap fail your verifier — and are retried for about three days. Update `EARNESTY_SIGNING_SECRET` any time in that window and everything queued arrives. Nothing is lost; there is nothing to coordinate. **Rotating your reward API key** (the bearer you chose) has no server-side overlap either: during a rotation, have your handler accept either of the two values — one `||` in your code — then drop the old one. The signature stays valid throughout, since the signing secret is independent of the bearer. --- ## Summary | Requirement | Detail | |-------------|--------| | **Idempotency** | MUST deduplicate on `rewardReference` | | **Which reward** | Switch on `rewardType` — never on `rewardReference` or `amount` | | **Auth** | Bearer token (your API key) in `Authorization` header | | **Success signal** | Any 2xx status code | | **Retry behavior** | Seven attempts over ~3 days, then automatic redelivery once your endpoint answers 2xx again | | **Duplicate handling** | Skip it, return 2xx | --- # POST /v1/users — Enrollment Contract Step 3 of the [three-step overview](README.md). ## Overview Call this when a USER opts into your earned tier. What comes back is the string that has to appear in that USER's first post — their Claim Code, assigned once and never replaced — and a signed link to their status page. Authentication is an API key (`Authorization: Bearer …`), because your backend is the caller. ``` POST /v1/users Authorization: Bearer {your_api_key} Content-Type: application/json { "appId": "…", // required, UUID, must belong to your organization "productUserId": "…", // required, the USER's id in YOUR system "priority": false // optional, boolean — see below } ``` Response `201` for a new enrollment that took a seat, `200` for a repeat call or an enrollment that went to the waitlist: ```json { "appUserId": "…", "seatState": "enrolled", // or "waitlisted" "existing": false, "code": "#earnesty_7K4P", "statusPageUrl": "https://…" } ``` ## Choosing `productUserId` It is your identifier for **whoever owns the reward** — rewards are credited to it, and the rewards page shows its balance. For a product billed per organization or workspace, that is usually the organization id, so rewards land beside the subscription and quota they expand. Know the shape before you pick: **one enrolled id binds to one X account, and one X account binds to one enrolled id.** The first qualifying post (carrying the `#earnesty_` code) makes the link, and from then on that X account's posts credit that id — and only that account's posts do. What that means in practice: - **One person, one account: enroll their org id.** The simple case, and most products. - **A team posts into one balance, and admits its own members.** Several X accounts can earn into one enrolled id. The FIRST account binds on its own: posting a code that only came from inside your product is proof enough. After that the code is public — it rode a post — so it stops being a way in, and there are two ways to add the next account, both on the rewards page and neither needing anything from your backend: - **An invite code** (the normal way). Someone on the page mints a fresh code, sends it privately, and the person who posts it is connected straight away. It has never been published, works once, and lasts a week. - **Approving a request** (the catch-all). Somebody who copies the visible code from an existing post does not connect — they appear on the page as a request for an existing member to approve or turn away. The post that asked is credited when they are approved, if the shared cooldown allowed a post at the moment it was made. You never learn anyone's X handle, and you never have to. `binding.pending` in `GET /v1/events` tells you a request is waiting, if you want to nudge someone. Each account disconnects individually. One current limit: the cooldown is per enrolled id, so a team shares one earning cadence. A departed teammate's account is disconnected from the rewards page (each account has its own disconnect); an owner-side disconnect API, for when they left without doing it, is tracked and coming. - **Switching X accounts, or "changing" the id:** enrollment is registration, so there is nothing to edit. The user disconnects their X account on the rewards page (or you enroll a new `productUserId`, which mints a new code), and their next post carrying the new code binds fresh. An X account still bound elsewhere is refused until it disconnects — that is the hijack guard. History never moves: rewards already credited stay where they landed. ## Showing the user their status page `statusPageUrl` is the user's own view of their earning status, authenticated by the token in its query string. **Embed it.** The host sets no `X-Frame-Options` and no `frame-ancestors`, so an iframe works from any origin today, and the page sets no cookies — the token in the URL is what authenticates. If we ever add a per-app origin allowlist, registering yours will be part of shipping it and existing embeds will not be broken without notice. Opening it from a button is equally fine; embedding is what most apps want. Fetch it at the moment you render it: the token lasts 24 hours, and this call is idempotent and cheap, so there is no reason to store or email the link. Want the page in your own design? `GET /v1/status?t=…` with the same token returns the JSON the page renders. ## Where the link goes: your own limit screen The moment a user reaches a limit — the quota is spent, the plan's allowance is used up, the paywall is about to render — is a moment only you can see. We hold no balance, so nothing on our side can notice it, and no endpoint of ours will ever pretend to. Your limit screen already exists; give it one more line, in your own words, that links to the user's status page. That puts the earned tier inside your product, in your voice, at the point where someone is deciding what to do next — and it needs no knowledge of ours, and gives us none of your plans, prices or units. The call is the one above: `POST /v1/users` from your backend when the screen renders, `statusPageUrl` from the response. It is idempotent, the seat is already theirs, and the token is fresh each time. One rule, and every page of ours follows it too: **the link surfaces a standing offer; being low is never the reason to post.** A user's eligibility is a function of timestamps — when their last credited post was made, and their cooldown — and it is the same whether their balance is full or empty. "Post about us and your allowance grows" is the offer. "You're out — post to refill" is a toll booth, and it teaches the audience to read every post that way. An offer shown at a limit is fine; a limit given as the reason is not. ## Errors Every refusal is `{ "error": "", "code": "" }`. Branch on `code` — it is stable; the sentence is for a person and may change. The table is the one in `packages/shared/src/enroll-errors.ts`, and a test fails if this page and that file disagree. | Status | `code` | When | |---|---|---| | 401 | `unauthorized` | No valid API key in the `Authorization` header — one answer for missing, malformed, unknown and revoked keys. | | 400 | `invalid_json` | The body is not JSON. | | 400 | `missing_field` | `appId` or `productUserId` absent or blank. | | 400 | `invalid_app_id` | `appId` is not a UUID. | | 400 | `invalid_product_user_id` | `productUserId` longer than 256 characters, or carrying control characters. | | 400 | `invalid_priority` | `priority` present and not a boolean. | | 400 | `invalid_metadata` | `metadata` not an object of at most 20 bounded string pairs (keys ≤ 40 chars, string values ≤ 500, no control characters). | | 400 | `invalid_test` | `test` present and not a boolean. | | 409 | `test_user_limit` | The app already has its 3 test users. Reuse one — enrollment is idempotent. | | 404 | `app_not_found` | No app with that id in the organization the key belongs to. An app another organization owns is a 404, not a 403. | A `200` or `201` never carries `error`. `201` is a new enrollment; `200` is a repeat call, or a new enrollment that landed on the waitlist. ## `metadata` Optional object of up to 20 string pairs — your tags, stored verbatim and echoed in every reward delivery for this user, so your handler can route a reward without a lookup (`{"team": "org_42"}` is the canonical use). Present replaces the stored object; absent leaves it alone, like `priority`. We never read it. ## `test` Optional boolean, default `false`, set at creation only. A test user (#68) holds no capacity seat, is always `enrolled` (never waitlisted), is capped at 3 per app, and is the only kind of user `POST /v1/test/posts` accepts. Their rewards flow through the real pipeline to your real endpoint — isolation is the `productUserId` you choose for them. ## Waitlist and seats **Seats are not a cap on your product — they are how many of your users can be earning at once, and you control the number.** Capacity is an org-wide pool across all your apps, and it grows two ways: your organization posting about Earnesty (the free plan carries 100 and grows to 500 that way), or a larger plan (1,000 growing to 2,000; 5,000 growing to 10,000). Running out is a signal to expand, not a wall. **A seat is spent by enrolling, not by posting.** Enrol a user and they hold a seat whether or not they ever post, so calling this for every signup spends your capacity on the majority who never will. Call it the first time you show someone the earning surface instead — when they open the rewards page, tap "get more", or land on whatever screen carries the offer. **This is not an opt-in step and adds nothing for the user to do**: they click the same button either way, and the enrollment happens behind it. Everyone who never goes looking simply keeps the baseline your earned tier wraps, costs you no seat, and never needed one — which is what makes a hundred seats go a long way. **Staging costs you no seats.** Mark your staging app as *Staging* in the console (**Configuration → step 4 → What this app is**). Its enrollments never draw on the organization's pool, so a staging copy cannot quietly spend production's places. It behaves identically in every other way, including real deliveries to whatever endpoint it has, and is bounded on its own at 50 enrollments — enough for a staging copy, not enough to run a programme on. **A seat is held until you release it.** It never expires, and we never reclaim one on our own — a user between posts or inside their cooldown still holds theirs, and taking back a place somebody already had is worse than never granting it. Releasing is yours to do. **Give a seat back when a user goes.** `POST /v1/apps/{appId}/users/{productUserId}/release` when someone deletes their account or you remove them: the seat returns to the pool, their X accounts are disconnected, and everything they earned stays — history never moves. Enrolling the same `productUserId` later brings them back, subject to capacity like anyone new. Without this a pool only ever fills up, since somebody who enrolled once and never posted would hold a place forever. **A suspended user holds no seat either.** Ban someone and their place returns to the pool, since earning is off for them anyway. Unbanning restores their earning whether or not the pool has room — capacity gates new enrollments, never one you already have. **A whole team can post against one seat.** A seat is the enrollment — your `productUserId` — not the X account. Up to five accounts can bind to one enrollment and earn into the same balance, sharing one cooldown between them, so an organization with several people who take turns posting costs you exactly one seat. A user enrolled past capacity is `waitlisted`: they keep the baseline your earned tier wraps, and earning is what waits for a seat. **Do not prompt a waitlisted user to post** — the hosted rewards page already handles this state (it says they are waitlisted and describes the mechanic without instructing it), which is a reason to embed it rather than rebuild it. The right UI copy on your side is "earning opens when a seat does," nothing more. **A post made before the seat opened never earns.** Not on promotion, not retroactively, no exceptions — earning starts with the first qualifying post made after the seat opens. This is the answer to the support ticket before it is filed: "I posted while I was on the waitlist" earns nothing, by design, and both your UI and ours say so up front. When seats do fill, the fix is yours and it is quick: post about Earnesty, or move up a plan. Nobody is stuck in the meantime — a waitlisted user keeps everything the baseline gives them. Watch `seat.opened` in `GET /v1/events`, or read `seatState` from the enroll call you already make on every rewards-page render. ## Owner actions Levers for the situations you cannot leave to the user, addressed by app and your own id — `/v1/apps/{appId}/users/{productUserId}/…` — with your API key (or a console session): - **`POST …/unbind`** — disconnect an X account the user cannot or will not disconnect (the teammate who left). Body `{ "authorId": "…" }` for one account (from `binding.created` events or the rewards page), or empty for all. Same effects as the user's own disconnect. - **`POST …/release`** — this user is gone (deleted their account, say). The seat returns to your pool, their accounts are disconnected, credited history stays. Re-enrolling the same id revives them. - **`POST …/ban`** / **`POST …/unban`** — earning off, and back on. Banned, a user's posts never verify and their rewards page says earning is off for this account; everything already credited stays — history never moves. Both idempotent, both audited. - **`POST .../allow-handle`** with `{ "handle": "teammate_x" }` — *optional.* Pre-approve an X account by handle, for the rare product that already knows its users' handles. Most apps need nothing here: approval happens on the rewards page, where somebody can actually recognise the account. - **`PATCH …`** with `{ "productUserId": "new_id" }` — rename your identifier for the user. Nothing strands: bindings, rewards and history hang off our internal id; enroll and reward deliveries use the new name from then on. `409 product_user_id_taken` when the new id is already enrolled. Errors follow the usual shape: `{ "error", "code" }`. ## Idempotency Calling again with the same `productUserId` returns the existing enrollment and the **same** code. A retry never costs a USER their seat and never hands you a different string to show them. Branch on `seatState`, never on the presence of the code: a waitlisted USER gets one too, because it identifies them rather than promising them anything. ## `priority` Optional boolean, default `false`. **It moves a USER to the front of the waitlist. That is all it does.** Set it for a USER you are paid by. When seats open up, waitlisted USERS carrying the flag are promoted before those without it; within each group the order is still the order people joined. What it does **not** do: - **It does not exempt anyone from the count.** A priority USER occupies a seat exactly like everyone else, and when the pool is full they are waitlisted — ahead of the others, but waitlisted. A paying USER of your product costs Earnesty exactly as much to verify as a free one; if the flag bought a free seat, an APP could enroll without limit by setting it on everybody. - **It does not change what a post earns**, how often a USER may post, or anything about verification. - **It does not displace anyone.** Nobody already enrolled is ever moved to make room. On a repeat call, omitting `priority` leaves whatever is stored, and sending it sets it. So an APP that sends `true` at signup and omits the field on a retry cannot demote its own USER by accident, and a USER who stops paying is `{"priority": false}` on the same call. A value that is not a boolean is refused with a `400` rather than coerced. The pool itself is your organization's Earnesty credit balance, shared across every APP you run, and one credit is one seat. --- # Testing the whole loop The console's *Try it* button proves your endpoint's contract (delivery + idempotency). This proves everything else — verification, binding, the cooldown, the reply bonus, and the delivery worker's real retries — without a tweet. ## 1. Enroll a test user `POST /v1/users` with `"test": true`. A test user holds no capacity seat, is always enrolled, and is capped at 3 per app. Use a staging `productUserId` — rewards flow through the real pipeline to your configured endpoint. ## 2. Simulate a post ```http POST /v1/test/posts Authorization: Bearer {your_api_key} Content-Type: application/json { "appId": "…", "productUserId": "…", "replies": 15 } ``` Default `text` is a qualifying post (mention, tag, the user's code). The synthetic post runs the real `verifyPost`: the first one binds the test user's synthetic account, later ones exercise the repeat path, a second one inside the cooldown is refused with the real reason. Send your own `text` to test the refusals — drop the tag, bury it in the back half. `replies` settles the reply bonus immediately (same maths, same ledger, own `bonus:` reference) instead of on day five. `"revoke": true` drives a revocation through the same code the day-5 settlement runs: the post is marked revoked and a `kind: "revocation"` notice is queued for your endpoint, signed and retried like any delivery. That makes the revocation branch — the one your handler is required to implement — the one you can actually exercise end to end. It settles the bonus at zero, as a real revocation does, so send it without `replies`. ## 3. Watch it arrive Both grants reach your endpoint through the delivery worker — bearer, signature, retries and all — within about a minute, and show up in `GET /v1/events` and the dashboard's delivery log marked TEST. The grants carry `kind: "post"` / `"reply_bonus"` like real traffic, so your handler's real branches run; isolation is the test `productUserId` you chose. Errors are `{ "error", "code" }`: `missing_field`, `invalid_app_id`, `invalid_product_user_id`, `invalid_text`, `invalid_replies` (400), `unauthorized` (401), `not_test_user` (403), `app_not_found`, `user_not_found` (404). --- # GET /v1/events — the event log Everything that happens to your programme, as a retrievable list — poll it to reconcile your ledger, catch a delivery your endpoint missed, and learn when a waitlisted user gets a seat. When push webhooks ship, they will deliver these same events; building on this log now means changing nothing later. ```http GET /v1/events?product_id=&limit=25 Authorization: Bearer {your_api_key} ``` Reply: `{ "events": [ { "id", "type", "createdAt", "data" } ], "hasMore": true }` — newest first. Page with `starting_after=`. Every `data` carries `productUserId`; the rest is per type: | `type` | When | `data` | |---|---|---| | `user.enrolled` | A user was enrolled (first call only) | `seatState` | | `seat.opened` | A waitlisted user was promoted to an earning seat | — | | `binding.created` | An X account was connected — a first qualifying post, or a member approving a waiting account | `platform`, `handle` | | `binding.pending` | An account posted the code and is waiting for approval on the rewards page | `platform`, `handle` | | `binding.removed` | An X account was disconnected — by the user on the rewards page, or by you as the owner | `platforms` | | `post.verified` | A post passed verification and was credited | `amount`, `rewardType`, `postedAt`, `postUrl` | | `reward.delivered` | Your endpoint accepted a delivery | `rewardReference`, `amount`, `rewardType` | | `reward.revoked` | A credited post was deleted or its disclosure edited out before the day-5 settlement. **Also pushed to your reward endpoint** as `kind: "revocation"`, so this row is audit rather than the way you find out | `rewardReference`, `amount`, `reason` (`post_deleted` \| `disclosure_removed`), `postUrl` | | `reward.delivery_failed` | A delivery exhausted its retries (seven over ~3 days) without a 2xx | `rewardReference`, `amount`, `attempts` | Reconciliation recipe: nightly, walk events since your last stored cursor; every `reward.delivered` should exist in your rewards table by `rewardReference`. A `reward.delivery_failed` is a reward your endpoint never accepted — but it is not stranded: once your endpoint answers any delivery with 2xx again, failed rewards are requeued automatically and arrive on their own. Crediting from the event is still sanctioned belt-and-braces (idempotent by reference; a later redelivery dedupes into a 2xx), and `X-Earnesty-Pending` on each successful delivery tells you the backlog size — but with automatic recovery, this log is an audit tool, not a correctness requirement. `product_id` is the App ID — the same value the guide's constants table calls App ID; the query parameter keeps the older name. Errors are `{ "error", "code" }`: `missing_field`, `invalid_limit`, `invalid_cursor` (400), `unauthorized` (401), `app_not_found` (404).