Working with a coding agent? Point it at
earnesty.app/docs/integration/llms-full.txt
— all public integration guides in one Markdown file, no key required.
POST /v1/users — Enrollment Contract
Step 3 of the three-step overview.
Registration is unlimited. Funding and offer eligibility are checked separately when accepting a post; see processing credits.
Overview
Call this for every user at signup — not when they show interest, and not as an optional step. It registers them in your earned tier and returns what makes them able to earn: their Claim Code, the string that has to appear in their first post, 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
"displayName": "Acme Design Team" // optional account label
}
Response 201 for a new enrollment, 200 for a repeat call:
{
"appUserId": "…",
"existing": false,
"code": "#earnesty_7K4P",
"statusPageUrl": "https://…"
}
Choosing productUserId
Use a stable identifier from your product that your reward endpoint can map to the account whose allowance the reward expands. For a product with personal accounts, that is the personal account ID. For a product with team or workspace billing, you have two choices, and per-person is usually the better one:
- Enroll each person (their user ID). Each teammate gets their own rewards page from their own login, connects their own accounts, and has their own cooldown. Your reward endpoint receives the person’s ID and credits the workspace they belong to.
- Enroll the workspace (its ID). The team shares one rewards page and one cooldown. The first account connects on its own; later ones are approved on the page. Free accounts qualify too; a payment method or paid subscription is not required.
Earnesty delivers the supplied productUserId to your endpoint. It does not look up a
payment-provider customer or infer ownership. Your customer’s product account is distinct
from your organization’s Earnesty processing wallet. The hosted page reports rewards
earned; your product keeps the spendable balance.
One enrollment can connect up to five X accounts. They share its reward recipient and ordinary cooldown. An X account cannot be connected to multiple enrolled accounts within the same app at once.
What that means in practice:
-
For a personal product account, enroll its account ID; for a team, enroll each person. The simple case, and most products.
-
A workspace enrollment 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. The next account that posts it appears on the rewards page as a request for an existing member to approve or turn away, with nothing needed from your backend. The post that asked is credited when it is 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.pendinginGET /v1/eventstells 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); use the owner-sidePOST …/unbindaction below when a teammate leaves without disconnecting. -
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.
Optional account display name
Send displayName to make the owner dashboard recognizable: a person’s preferred name for a personal account, or the team/workspace name for a shared account. No legal name or email address is required. The label is for the app owner’s reporting, not a public social profile.
- A nonempty string, at most 120 characters after trimming, without control characters.
- Omitted on repeat enrollment: preserve the existing name.
- Supplied on repeat enrollment: replace the name.
- Explicit
null: clear the name. - Empty or whitespace-only strings:
400 invalid_display_name.
The account ID, Claim Code and reward routing never change with this label. Optional metadata remains opaque routing data; Earnesty does not infer names from it.
{ "appId": "YOUR_APP_UUID", "productUserId": "workspace_123", "displayName": "Acme Design Team" }
Earnesty stores the verified platform author ID and known handle for each newly verified post. The dashboard shows the post’s author separately from the account receiving rewards. Current connected handles appear with account totals. A handle is a recorded label and can change; profile links use the stable X author ID. Before a name or handle is available, the app’s account ID is the fallback and is always available in reporting details.
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. The link lasts 30 days: store
statusPageUrl and statusPageUrlExpiresAt on the account’s server-side record, show it only
to that account’s signed-in members, and never email it. For automatic sizing and access-link renewal, use the embed helper.
Custom-rendered white-label UI is planned for a future paid offering.
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, registration is free, and the
token is fresh each time.
The offer is available regardless of the user’s balance. Your app can surface it when credits run low or keep it in a permanent rewards area; Earnesty does not monitor the balance or trigger that placement.
Recurring eligibility follows the configured cooldown. Posts published inside the cooldown window do not become eligible by being submitted later. Drops and Milestones follow their own eligibility rules and can qualify outside the recurring cooldown. Funding and per-user reward limits still apply.
Errors
Every refusal is { "error": "<sentence>", "code": "<code>" }. Branch on code — it is stable;
the sentence is for a person and may change.
| Status | code | When |
|---|---|---|
| 400 | invalid_display_name | displayName is not a nonempty name of at most 120 characters without control characters. Omit it to keep the stored name, or send null to clear it. |
| 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). |
| 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.
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. We never read it.
Registration and earning availability
Register users at signup and show their hosted rewards page. Registration is free and does not spend processing credits. Available offers, cooldowns, limits and wallet funding determine whether a post can earn. The hosted page handles these states.
One enrollment can link up to five X accounts, sharing the same reward recipient and recurring cooldown. Release a user when their account is removed; their X accounts disconnect and credited history remains. Registering the same ID again restores the identity. Banning pauses earning without erasing history.
Posts made before registration are not retroactively rewarded.
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 account the user cannot or will not disconnect (the teammate who left). Body{ "authorId": "…", "platform": "youtube" }for one account (frombinding.createdevents or the rewards page);platformisx,youtubeortiktokand defaults tox, and any other value is refused with400 invalid_platform.{ "platform": … }alone disconnects every account on that platform, and an empty body disconnects them all. Same effects as the user’s own disconnect.POST …/release— this user is gone (deleted their account, say). Their accounts are disconnected and 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-handlewith{ "handle": "teammate_x" }— optional. Pre-approve one more account by handle, for the rare product that already knows its users’ handles. Add"platform": "youtube"or"platform": "tiktok"for a video account; without it the handle is an X username. A YouTube channel is named by its @handle, not its channel id, and a TikTok account by its username. The approval counts on that platform only. An unknown platform answers400 invalid_platform, and a handle that does not fit the platform answers400 invalid_handle. 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_takenwhen the new id is already enrolled.
Errors follow the usual shape: { "error", "code" }.
Idempotency
Registering the same productUserId returns the existing identity and claim code. Refresh the signed statusPageUrl when displaying the rewards page; do not store it as a permanent URL.
Releasing the product account
Use POST /v1/apps/{appId}/users/{urlEncodedProductUserId}/release with your backend
API key and no body. Release stops new earning but does not cancel already-created
deliveries. See release and late-delivery handling
for responses, shared-account handling and acknowledgment after deletion.
Contextual earning opportunities
Use the posting-offer API and optional button to show a currently eligible recurring offer after a useful result or when access runs low. Your app chooses placement and presentation; the hosted page supplies instructions and history.