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.
One word: reward. The endpoint was
grantCredits, the idempotency key wasgrantReference, and this file wasgrant-reward.md; all three say reward now (2026-08-30), because an integrator reading grant beside reward looks for two concepts where there is one. The endpoint’s name isrewardEndpoint, and the field you configure it under isrewardEndpoint/rewardApiKey(ADR 0013). What we deliver may be credits, tokens, seats, render minutes or a free month, and a partner-facing name that says credits about a thing that is not one is a name we would have had to keep forever. Nothing about the request changed in the rename; the payload gained one field, described below.
Idempotency Requirement
Your reward endpoint MUST be idempotent on the
rewardReferencekey.If Earnesty sends a request with a
rewardReferenceyou have already processed, your endpoint MUST skip the duplicate and return a success response (2xx). Do not credit twice for the same reference.
This is a contractual requirement. Earnesty’s delivery worker retries failed deliveries seven times over about three days (1m, 10m, 1h, 6h, then a day apart) — and a reward that exhausts those retries is not lost: the next time your endpoint answers any delivery with 2xx, your stranded rewards are requeued automatically, oldest first, each with a fresh retry ladder. So the contract is one sentence: if your endpoint ever answers 2xx again, you eventually receive every reward exactly once. Every successful delivery also carries X-Earnesty-Pending: <n> — how many other undelivered rewards you have at that moment — so a backlog announces itself exactly when you are healthy enough to hear it. If your endpoint is not idempotent, a USER may receive duplicate rewards that cannot be automatically corrected.
rewardReference is unique across every reward type, so a handler that dedupes on it alone is still correct now that rewards carry a type.
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"
}
| Field | Type | Description |
|---|---|---|
productUserId | string | The USER’s ID in your system — the same ID you passed to POST /v1/users |
amount | integer | How much to credit, in rewardType |
rewardType | string | Which reward. The immutable key of one of your reward types, matching ^[a-z][a-z0-9_]{0,31}$ |
rewardReference | string | Unique idempotency key for this reward (see format below) |
kind | string | The occasion: post (the verified post), reply_bonus (its conversation, settled once ~5 days later), or test (the console’s test button) |
earnedAt | string | When the reward was earned, ISO 8601 |
postUrl | string | null | The post this pays for; null on a test reward |
metadata | object | Your own tags from the enroll call, verbatim; {} when none were set |
We are on the other end of this too
Earnesty is an APP on Earnesty: posting about us earns seats, and those seats reach our billing
through this contract, not through a shortcut behind it. POST /v1/billing/reward-grants is our
handler — same payload, bearer token, idempotent on rewardReference, 200 on a replay — and the
delivery worker that calls yours calls it, with no branch anywhere that asks whose APP a reward is
for (ADR 0016 §1).
Two things follow that are worth having in writing. Every retry, every failure and every replay
you can hit, we hit first. And the failure modes below are not hypotheticals we imagined for you:
a wrong key here is our seats piling up as failed rows, same as yours.
Switch on rewardType. Never on rewardReference
rewardType is the field that tells you which reward to credit. Today every app has one — its key is shown under the reward’s name in Configuration → step 1 — and it never changes. Drops will add more, each with its own key, so a handler that switches on this field now needs no change when they arrive.
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 — a Drop, once those exist — 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.
| Occasion | Format | Example |
|---|---|---|
| The post itself | claim:{post_id} | claim:1234567890 |
| The reply bonus | bonus:{post_id} | bonus:1234567890 |
- The post reward is issued once per verified post.
- The reply bonus settles once, five days after the post was made, on the number of distinct accounts that replied (ADR 0010). There is no per-day suffix; if you integrated against
bonus:{id}:day{N}, that model is gone.
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.
Handling Duplicates
When you receive a request, check if the rewardReference has already been processed:
- Look up the
rewardReferencein your records. - If found — the reward was already applied. Skip it and return 2xx.
- If not found — apply the reward, store the
rewardReference, and return 2xx.
Implementation Example (pseudocode)
function handleReward(request):
reference = request.body.rewardReference
userId = request.body.productUserId
amount = request.body.amount
rewardType = request.body.rewardType
// Refuse a reward you have not shipped support for, rather than guessing.
if rewardType not in SUPPORTED:
return { status: 400, body: { error: "unknown_reward_type" } }
// Check for duplicate
existing = db.query("SELECT 1 FROM earnesty_rewards WHERE reward_reference = ?", reference)
if existing:
return { status: 200, body: { success: true, credited: false, reason: "duplicate_reward_reference" } }
// Apply the reward
db.transaction:
db.execute("INSERT INTO earnesty_rewards (reward_reference, user_id, reward_type, amount, created_at) VALUES (?, ?, ?, ?, NOW())", reference, userId, rewardType, amount)
credit(userId, rewardType, amount)
return { status: 200, body: { success: true, credited: true } }
Tip: Use a UNIQUE constraint on
reward_referencein your database. This ensures idempotency even under concurrent requests — an INSERT that violates the constraint tells you the reward was already processed.
The Test Reward
Configuration → step 4 → Try it: send a test reward sends your endpoint a real reward — not a ping.
It carries your default reward type and an amount of one, addressed to a productUserId you
type in (use your own test account), with a reference of the form test:<uuid>:
{
"productUserId": "acct_yours",
"amount": 1,
"rewardType": "credits",
"rewardReference": "test:3f1c…"
}
Then it sends the same request again, and shows you both answers. A correct endpoint answers 2xx twice and credits once. Handle it exactly like any other reward; there is nothing to special- case, and that is the point — passing the test means real deliveries will pass, because it is the same request from the same code.
Earlier, saving the endpoint sent a ping with rewardType: "__earnesty_test__" and a zero
amount. It is gone: a handler that followed the advice above and refused an unknown reward type
failed it. Saving now just stores the pair.
The same page lists your recent deliveries with the status and body your endpoint returned on the last attempt, so a wrong key reads as “401” in your console rather than as a question to us.
Error Responses
If your endpoint returns a non-2xx status, Earnesty will retry with exponential backoff:
| Attempt | Retry after |
|---|---|
| 1 | 1 second |
| 2 | 2 seconds |
| 3 | 4 seconds |
| 4 | 8 seconds |
| 5 | 16 seconds |
After 5 failed attempts, the delivery is marked as failed and logged for investigation on our side.
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
Authorizationheader 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 — the mention and the partner tag, in the first half of the text. That
attestation (post URL, posted-at, verified-at, the terms in force) 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 day-5 settlement we fetch the post one more time. Gone, or the disclosure tag 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:
- It is shown once, in the response that creates your app — copy it then. It is encrypted at rest and we cannot read it back to you afterwards.
- It is rotatable: Configuration → step 4 → Rotate signing secret, or
POST /v1/apps/:id/signing-secret/rotate. The new one is shown once. - Never had one? Apps created before signing existed have a secret they were never shown. Rotate: that is how you get a usable one, and it is the only way — we cannot read yours back.
- 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_SECRETany 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
| Requirement | Detail |
|---|---|
| Idempotency | MUST deduplicate on rewardReference |
| Which reward | Switch on rewardType — never on rewardReference or amount |
| Auth | Bearer token (your API key) in Authorization header |
| Success signal | Any 2xx status code |
| Retry behavior | Seven attempts over ~3 days, then automatic redelivery once your endpoint answers 2xx again |
| Duplicate handling | Skip it, return 2xx |
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.