POST /v1/users — Enrollment Contract

Step 3 of the three-step overview.

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:

{
  "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.

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": "<sentence>", "code": "<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.

StatuscodeWhen
401unauthorizedNo valid API key in the Authorization header — one answer for missing, malformed, unknown and revoked keys.
400invalid_jsonThe body is not JSON.
400missing_fieldappId or productUserId absent or blank.
400invalid_app_idappId is not a UUID.
400invalid_product_user_idproductUserId longer than 256 characters, or carrying control characters.
400invalid_prioritypriority present and not a boolean.
400invalid_metadatametadata not an object of at most 20 bounded string pairs (keys ≤ 40 chars, string values ≤ 500, no control characters).
400invalid_testtest present and not a boolean.
409test_user_limitThe app already has its 3 test users. Reuse one — enrollment is idempotent.
404app_not_foundNo 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.


Working with a coding agent? Point it at earnesty.app/docs/integration/llms-full.txt — every page on this site as one Markdown file, no key required.