# Reward Endpoint — Integration Contract

New here? Start with the [three-step overview](README.md); 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 was
> `grantReference`, and this file was `grant-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 is `rewardEndpoint`, and the field
> you configure it under is `rewardEndpoint` / `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 `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.

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

```json
{
  "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.

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

1. **Look up** the `rewardReference` in your records.
2. **If found** — the reward was already applied. Skip it and return 2xx.
3. **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_reference` in 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>`:

```json
{
  "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 `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 — 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.

```json
{
  "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.

```ts
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 4 → 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

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