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.

-- 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:

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:

{ "productUserId": "acct_123", "amount": 10, "rewardType": "credits", "rewardReference": "claim:1841…" }

Insert the reference; if it was already there, do nothing. Return 2xx either way.

// Express / Hono / a Supabase edge function — the shape is the same.
export async function receiveReward(req: Request): Promise<Response> {
  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.

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.

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.

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.


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.


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.