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.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); use the owner-side POST …/unbind action 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.

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.

StatuscodeWhen
400invalid_display_namedisplayName 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.
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).
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.

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 (from binding.created events or the rewards page); platform is x, youtube or tiktok and defaults to x, and any other value is refused with 400 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-handle with { "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 answers 400 invalid_platform, and a handle that does not fit the platform answers 400 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_taken when 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.