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.

Reward Endpoint — Integration Contract

New here? Start with the three-step overview; 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.

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.

Earnesty makes seven delivery attempts with backoff (1m, 10m, 1h, 6h, then a day apart). A later successful delivery triggers recovery for failed deliveries whose last attempt was more than one hour ago, up to three recovery cycles. Persistently refused deliveries need investigation. This is not exactly-once transport: your ledger must apply each reference at most once. X-Earnesty-Pending reports the backlog on successful delivery.

rewardReference is unique across every reward type, so a handler can dedupe on it alone.


Request Format

POST {your reward endpoint}
Authorization: Bearer {your API key}
Content-Type: application/json

Body

{
  "productUserId": "user-123",
  "amount": 500,
  "rewardType": "tokens",
  "rewardReference": "claim:1234567890"
}
FieldTypeDescription
productUserIdstringThe USER’s ID in your system — the same ID you passed to POST /v1/users
amountintegerHow much to credit, in rewardType
rewardTypestringWhich reward. The immutable key of one of your reward types, matching ^[a-z][a-z0-9_]{0,31}$
rewardReferencestringUnique idempotency key for this reward (see format below)
kindstringThe occasion: post (the verified post), carry (Post performance bonus, assessed once ~5 days later), revocation, or test (the console’s test button)
earnedAtstringWhen the reward was earned, ISO 8601
postUrlstring | nullThe post this pays for; null on a test reward
metadataobjectYour own tags from the enroll call, verbatim; {} when none were set

Switch on rewardType. Never on rewardReference

rewardType is the field that tells you which reward to credit. Apps manage these keys in App settings → Reward types. Ongoing rewards use the default type; Drops and Milestones can use other types. Display names may change, but keys stay fixed. Test each type in Integration before activating offers that use it.

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 — for example, a special Drop reward — 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.

OccasionFormatExample
The post itselfclaim:{post_id}claim:1234567890
Carrycarry:{post_id}carry:1234567890
  • The post reward is issued once per verified post.
  • Carry settles once, five days after the post was made. We read the post’s public counts (impressions, likes, replies, reposts, quotes, bookmarks), rank them against the other posts in your program over the last 30 days, and pay zero, a quarter, half or all of the configured Post performance maximum. The maximum is independent of the posting reward, which can be zero. A bonus requires at least 10 reported views and one engagement, and is capped by the remaining allowance for its reward type.

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.


Ordering, concurrency and deleted accounts

Delivery order is not guaranteed. A revocation may arrive before or concurrently with its grant. Record it even if the grant is missing, and suppress that grant if it later arrives. Both branches must lock the same retained account row inside a transaction before checking or updating the ledger. Spending and account deletion must take that lock too. Use READ COMMITTED, or SERIALIZABLE with retry. Locking a nonexistent grant row is insufficient.

Keep UNIQUE delivery references, separate grant/revocation records and an index on revokes. Remove only the named grant’s remaining amount under the default consumable policy; absorb already-spent units. Never debit unrelated earnings. For deleted accounts, retain a minimal non-personal ledger identity and record late deliveries as discarded before returning 2xx. An unknown account without a deletion record must fail rather than silently lose a reward.

The downloaded EARNESTY.md on the Integration page contains the complete transactional reference handler. Test grant-first, revocation-first, duplicate and concurrent deliveries, concurrent spending, and late deliveries after deletion against your actual database.

The Test Reward

Integration → Send test reward sends your endpoint a real reward — not a ping. It carries your selected reward type and an amount of one, addressed to a productUserId you type in (use an account of your own), with a reference of the form test:<uuid>:

{
  "productUserId": "acct_yours",
  "amount": 1,
  "rewardType": "credits",
  "rewardReference": "test:3f1c…",
  "kind": "test",
  "postUrl": null
}

Then it sends the same request again. Authenticate, apply and deduplicate kind: "test" through your normal balance transaction, just like post and carry. Test rewards affect the selected account’s actual spendable balance. Use an account of your own.

The expected result is explicit: send one unit twice with the same reward reference and see the account’s spendable balance increase by exactly one, not zero or two. Two 2xx responses show acceptance, not proof of a balance change; check the balance too.

Once the test reward is accepted, Send test revocation sends its revocation: kind: "revocation", revokes set to the test reward’s reference, rewardReference of the form revoke:test:<uuid>, sent twice. The balance should return to where it started.

Logging-only behavior or a separate test ledger is an explicit customization, not the reference handler’s default. Document it if chosen: it will not satisfy the console’s standard spendable-balance check.

The dashboard’s Programme operations section lists every accepted post with what it awarded and how delivery went. A reward your endpoint refused is opened already, with the status and body it returned on the last attempt, so a wrong key reads as “401” in your console rather than as a question to us. The CSV download carries the same answers for every post.


Error Responses

If your endpoint returns a non-2xx status, Earnesty will retry with exponential backoff:

The retry delays are 1 minute, 10 minutes, 1 hour, 6 hours, then a day apart, with seven failed delivery attempts before the record is marked failed. Later successful deliveries can trigger bounded recovery requeues; do not treat delivery as ordered or as exactly-once transport. Apply each reference at most once in your ledger.

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 phrase in the position your market asks for, and your exact brand @handle in the post. These are independent checks: Ad — I use @Acme can qualify without putting the phrase beside the handle. That attestation (post URL, posted-at, verified-at, the terms in force, and the market, language, placement and policy version the disclosure passed under) 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 post’s settlement check (five days after posting on X; each other platform’s own delay) we fetch the post one more time. Gone, made private, or the disclosure 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.

{
  "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=<unix seconds>,v1=<hex hmac>

where v1 = HMAC-SHA256(signing secret, "<t>.<raw request body>"). 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.

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:

  • You generate it when you need it: Configuration → step 5 → Generate signing secret, or POST /v1/apps/:id/signing-secret/rotate. It is shown once, in that response, and encrypted at rest; we cannot read it back to you afterwards.
  • Generating again replaces it. The previous secret stops signing immediately, and a delivery your endpoint rejects is retried for about three days, which is your window to update EARNESTY_SIGNING_SECRET.
  • 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

RequirementDetail
IdempotencyMUST deduplicate on rewardReference
Which rewardSwitch on rewardType — never on rewardReference or amount
AuthBearer token (your API key) in Authorization header
Success signalAny 2xx status code
Retry behaviorSeven attempts over ~3 days, then automatic redelivery once your endpoint answers 2xx again
Duplicate handlingSkip it, return 2xx

Funding, validation deadlines and processing fees

A validation request normally receives HTTP 202 and a durable queue receipt. That is permission to process the request, not a reward promise or an ordinary funding reservation. If the available balance cannot cover the check when the worker runs it, for any offer including a Drop, the attempt fails with funding_unavailable. If funding runs out between the read and acceptance, acceptance also fails. The request does not wait for a future top-up.

After a confirmed top-up, the same unaccepted post can be submitted again using a new request key once the previous request is terminal. A repeated old key returns the original receipt; use it only to recover a pending request or uncertain network response. Requests are limited to one per enrolled account per 60 seconds. For both Validate and exact-URL submission, the current post window is 6 days, 23 hours and 30 minutes before the new request’s receipt time, through receipt time. Submit before the post ages out. A queued request currently has a 10-minute recovery deadline; use the returned deadlineAt rather than a client timer as the authority. Provider errors, read budgets and normal eligibility checks can still prevent acceptance. A top-up does not revive a terminal receipt or guarantee that an expired offer or otherwise ineligible post will earn.

Drops reserve nothing in advance: each accepted Drop post is charged from the available balance, and a Drop’s places are a cap, not a hold. A viewer’s place is not reserved either. A Drop requires an eligible validation receipt before its closing time; a new receipt after closing does not recover a missed Drop. An already accepted reward continues delivery and settlement even if general funds subsequently run out.

The X processing fee is 5 Earnesty Credits ($0.05) per accepted post validation, including its later settlement check. One Earnesty Credit represents one US cent. The fee and accepted reward record commit atomically; rollback leaves neither charged nor accepted. A qualifying bonus-only post is still an accepted validation and incurs this fee.

EventProcessing fee effect
Enrollment, empty/invalid checks, duplicates, funding refusalsNo accepted-post fee
Accepted postOne fee at the applicable rate
Later performance bonus or settlement checkIncluded; no additional fee
Delivery retries and duplicate deliveriesNo additional fee
Revocation or an undeliverable rewardNo additional fee and no automatic refund of the original processing fee
Console test deliveriesNo processing fee

Rates can change with notice; Billing shows the current rate, and a change applies to Drop posts accepted after it like any other. Processing fees pay Earnesty’s work, not the allowance your app grants its users. Auto-recharge is optional and only confirmed funds can be spent.

Delivery amounts

amount is a JSON number representing whole units, with no fractional precision. Accept integers from 1 to 2,147,483,647 for post, carry (the performance bonus) and test. For revocation, accept 0 to 2,147,483,647: zero means the original grant was not found; use revokes to identify and suppress/reverse the grant, not the numeric amount as a debit. Zero-value post or bonus grants are not delivered. Configuration limits can be lower than this wire/storage bound. Reject strings, fractions, negative numbers and out-of-range values with 400; in JavaScript use Number.isSafeInteger plus these bounds. Keep aggregate balances in a wider exact integer type.

Release an account and acknowledge late deliveries

From your backend, send:

POST /v1/apps/{appId}/users/{urlEncodedProductUserId}/release
Authorization: Bearer $EARNESTY_API_KEY

There is no request body. URL-encode the product account ID as a path segment. Success is {"released":true,"alreadyReleased":false}; repeating a completed release returns {"released":true,"alreadyReleased":true}. Unknown app/account IDs return 404.

Release stops new earning for that enrollment, disconnects its X accounts and closes unsettled posts without future bonuses. It does not erase history or cancel already-created grants or revocations; these continue delivery and retries. Work already in flight can race with release, so never assume release drains the delivery queue. Re-enrollment with the same ID revives the identity. For a shared workspace, release when that product account is deleted, not when one member leaves.

For a permanently deleted product account, authenticate and validate each late delivery, then durably record its reference and disposition against a retained non-personal account identifier. Acknowledge with 2xx after committing, without recreating the account or making rewards spendable. Retain revocations so later grants remain suppressed. Never reuse that account ID for someone else. A missing account with no deletion record is an integration error, not proof of deletion: return a non-2xx response and investigate. A database outage also requires a non-2xx response so the delivery can retry.