Quickstart
Three calls: create a member, post a card transaction, then let them redeem. Roughly five minutes end to end with a sandbox key.
import { Trove } from "@trove/rewards";
const trove = new Trove(process.env.TROVE_SECRET_KEY);
await trove.members.create({ external_id: "user_8412" });
const event = await trove.events.create({
member: "user_8412",
type: "card.purchase",
payload: {
amount_cents: 4820,
merchant: { name: "Blue Bottle", category: "dining" },
},
});
event.outcome.awarded_units; // 144 (3x dining on $48.20)
event.outcome.reason; // why, in plain EnglishThe SDK generates idempotency keys, retries transient failures, and ships typed errors. The raw HTTP equivalents are below if you'd rather not take a dependency.
curl https://api.trovereward.com/v1/members \
-H "Authorization: Bearer $TROVE_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "external_id": "user_8412", "email": "ada@example.com" }'external_id is your own user id. Every endpoint accepts it in place of our mem_… id, so you never need to store ours.
curl https://api.trovereward.com/v1/events \
-H "Authorization: Bearer $TROVE_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"member": "user_8412",
"type": "card.purchase",
"payload": {
"amount_cents": 4820,
"merchant": { "name": "Blue Bottle", "category": "dining" }
}
}'New accounts start with four category rules — 3× dining, 3× travel, 2× groceries, and 1× on everything else. They read merchant.category, which is where your processor's MCC mapping naturally lands. Retune them under Earn rules without shipping code.
# Price it first — the split is locked for 15 minutes.
curl https://api.trovereward.com/v1/quotes \
-H "Authorization: Bearer $TROVE_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"member": "user_8412",
"type": "transfer",
"to_currency": "united_miles",
"points_applied": 25000
}'
# Then execute it.
curl https://api.trovereward.com/v1/redemptions \
-H "Authorization: Bearer $TROVE_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"quote": "qte_...",
"destination_account_ref": "UA123456789",
"destination_holder_name": "Ada Lovelace"
}'Authentication
Bearer token on every request. Keys carry their environment in the string itself, so a test key can never quietly write production data.
# Bearer (preferred)
curl https://api.trovereward.com/v1/members \
-H "Authorization: Bearer trv_sk_test_..."
# HTTP Basic also works, for curl -u habits
curl https://api.trovereward.com/v1/members -u trv_sk_test_...:| Parameter | Type | Description |
|---|---|---|
| trv_sk_test_… | sandbox | Simulated fulfillment. Self-serve, no agreement needed. Deterministic test outcomes. |
| trv_sk_live_… | live | Real fulfillment. Requires an enabled account and a signed backend agreement. |
How it works
You issue your own branded currency. Members hold it. At redemption it converts into a partner program's currency.
member holds 45,000 acme_points
│
├─ transfer 25,000 acme_points → 25,000 united_miles
│ (1:1, 1,000 minimum)
│
└─ cash + points 20,000 acme_points → $250.00 off a $400 booking
remainder $150.00 charged as cashBalances are denominated in your own currency, so the number a member sees is exact and never drifts. Conversion between your currency and a partner's happens at redemption, always rounding down, and the exact rate version used is recorded on every quote so any redemption can be replayed and audited later.
cash_total_cents is named the way it is.Members
Your end users. Create them once, then reference them by your own id.
| Parameter | Type | Description |
|---|---|---|
| external_idrequired | string | Your own id for this user. Must be unique within your account. |
| string | Optional. Used for lookup and support. | |
| full_name | string | Optional. |
| metadata | object | Arbitrary key-value data returned on every read. |
external_id already exists returns the existing member with 200 rather than a conflict. This is an upsert on purpose — "ensure this user exists" should be one call, not a create-catch-conflict-fetch dance.Accepts either mem_… or your external_id. Returns the member with their current balances.
Update email, full_name, metadata, or status. A suspended member cannot earn or redeem.
Events
Post a card transaction — or anything else a member did. Your rules decide what it's worth; the API takes no position on which events are legitimate.
| Parameter | Type | Description |
|---|---|---|
| memberrequired | string | mem_… id or your external_id. |
| typerequired | string | Event type as "noun.verb", lowercase. "card.purchase" for spend; any other type works the same way. |
| payload | object | Free-form event data. For purchases send amount_cents and merchant.category. Rule conditions read fields from here, including nested ones via dot paths. |
| occurred_at | timestamp | When it happened. Defaults to now. |
{
"id": "evt_7Kq2mXbN4pRtWvY8zAcD3fGh",
"object": "event",
"member": "mem_4Bn9xKpQ2wRs7TvY",
"external_id": "user_8412",
"type": "card.purchase",
"outcome": {
"status": "awarded",
"awarded_units": 144,
"rule": { "id": "rule_2Wq8", "name": "3× on dining" },
"reason": "Matched \"3× on dining\". 4820 × 3/100 = 144 units.",
"transaction": "txn_9Fh3kLm5nPq7"
}
}{
"id": "evt_3Jm8pQr5tVwX",
"object": "event",
"type": "card.refund",
"outcome": {
"status": "ignored",
"awarded_units": 0,
"rule": null,
"reason": "No active earn rule is configured for event type \"card.refund\".",
"transaction": null
}
}201 with status: "ignored" and a written reason. "Why didn't my member earn?" should be answerable from the response, without a support conversation.Currencies
Your branded currency plus every transfer partner available to your account.
{
"object": "list",
"data": [
{
"id": "cur_2Wq8mNp4",
"object": "currency",
"code": "acme_points",
"name": "Acme Points",
"kind": "tenant",
"millicents_per_point": 1250
},
{
"id": "cur_united",
"object": "currency",
"code": "united_miles",
"name": "United MileagePlus",
"kind": "partner",
"partner_type": "airline",
"min_transfer_units": 1000,
"transfer_increment": 1000,
"enabled": true,
"transfer_bonus_basis_points": 0
}
]
}millicents_per_point is what one point is worth in thousandths of a cent — 1250 means 1.25¢ per point. It drives cash-and-points pricing.
This field was previously called cent_value_per_thousand. Both keys are still returned and always carry the same number — cents per 1,000 points and millicents per point are the same unit. The old key is deprecated and will be removed at the next major version.
Quotes
Price a redemption before committing to it. A quote locks the rate for 15 minutes so the number on the screen is the number that gets charged.
| Parameter | Type | Description |
|---|---|---|
| memberrequired | string | mem_… id or your external_id. |
| typerequired | string | "transfer", "cash_and_points", or "cash_back". |
| to_currency | string | Partner code. Required for transfer. |
| points_applied | integer | Points to spend. Required for transfer and cash_back. For cash_and_points, omit to apply the maximum the member can afford. |
| cash_total_cents | integer | Total price in cents. Required for cash_and_points. |
{
"id": "qte_5Rt7yUiO2pAsDf",
"object": "quote",
"type": "cash_and_points",
"from_currency": "acme_points",
"points_applied": 20000,
"points_value_cents": 25000,
"cash_remainder_cents": 15000,
"cash_total_cents": 40000,
"cash_currency": "USD",
"status": "open",
"expires_at": "2026-08-03T18:45:00.000Z"
}{
"id": "qte_8Kp2mNbV5xZaQw",
"object": "quote",
"type": "cash_back",
"from_currency": "acme_points",
"points_applied": 20000,
"points_value_cents": 25000,
"cash_total_cents": 0,
"cash_remainder_cents": 0,
"status": "open",
"expires_at": "2026-08-03T18:45:00.000Z"
}For cash_back, points_value_cents is what the member is owed — the payout — rather than a discount on something they are buying. Nothing is being purchased, so both cash fields are zero.
Statement credits are subject to your program's minimum, configurable under Currencies and defaulting to $25. Below it you get below_minimum_cash_back, which states the shortfall so you can show the member how much further they need to go. Set the minimum to 0 to let them redeem any amount.
Redemptions
Execute a quote. Points are burned before the partner is called, so a balance can never be spent twice while a transfer is in flight.
| Parameter | Type | Description |
|---|---|---|
| quoterequired | string | The qte_… id to execute. Each quote is single-use. |
| destination_account_ref | string | The member's account number in the partner program. Required for transfers. |
| destination_holder_name | string | Name on the partner account. Most programs match this and reject mismatches. |
| Status | Meaning |
|---|---|
| processing | Points burned, awaiting partner confirmation. Real transfers take minutes to days. |
| completed | Partner confirmed. Fires redemption.completed. |
| failed | Partner rejected. Points already returned via a reversing ledger transaction. |
| reversed | Completed, then unwound. |
"simulated": true. Outcomes are deterministic from the destination account so you can test each branch on demand: a ref ending 0000 fails as an invalid account, 0001 fails as a name mismatch, 0002 stays pending, anything else completes.Settlement
Statement credits sit in processing until you confirm the money landed. Trove burns the points and records the obligation; the cash moves on your rails.
| Parameter | Type | Description |
|---|---|---|
| settlement_referencerequired | string | Your core transaction id or ACH trace number. What lets a dispute be traced from our ledger onto your rails. Max 200 characters. |
Settling an already-settled credit returns 200 with the original result rather than an error — a duplicate confirmation is ordinary and harmless.
The same transition, in bulk, for confirming a day's credits in one call. Send up to 200 per request; the response echoes max_per_request so you can chunk without hardcoding it.
| Parameter | Type | Description |
|---|---|---|
| settlements[].redemptionrequired | string | The rdm_… id. A redemption may appear only once per request. |
| settlements[].statusrequired | string | Either "settled" or "rejected". |
| settlements[].payout_centsrequired | integer | Required on both statuses. Cross-checked against what was instructed — a confirmation for a different amount is refused, never settled for the other figure. |
| settlements[].settlement_reference | string | Required when status is "settled". |
| settlements[].settled_at | string | ISO 8601. Optional — defaults to now. Supply it when the money landed before the call. |
| settlements[].reject_reason | string | Required when status is "rejected". Surfaced in your dashboard. Max 500 characters. |
{
"object": "settlement_result",
"settled": 2,
"rejection_recorded": 1,
"already_settled": 0,
"refused": 1,
"count": 4,
"max_per_request": 200,
"results": [
{ "redemption": "rdm_8f2", "outcome": "settled",
"payout_cents": 3125, "settled_at": "2026-08-07T03:14:00.000Z",
"settlement_reference": "CORE-1" },
{ "redemption": "rdm_a71", "outcome": "rejection_recorded",
"payout_cents": 3125, "reason": "Account closed",
"points_returned": false },
{ "redemption": "rdm_c04", "outcome": "already_settled",
"settled_at": "2026-08-06T02:00:00.000Z" },
{ "redemption": "rdm_d19", "outcome": "refused",
"code": "payout_mismatch",
"message": "rdm_d19 was instructed for 3125 cents but the confirmation says 9999." }
]
}400 naming the index, and nothing is applied, so you never have to work out how far it got.failed and fires redemption.settlement_rejected carrying "points_returned": false. A reject code cannot distinguish "account closed" from "we'll retry tomorrow", and auto-reversing the second would pay the member twice — so the member holds neither the points nor the cash until you decide. Unconfirmed and rejected credits are listed in your dashboard under Credits.settlement_transport records which route each confirmation arrived by.Ledger
Every entry that ever moved, and a reconciliation endpoint that proves the balances add up.
Append-only double-entry. Every transaction's entries sum to zero, so the whole book balances at all times. Filter by member or kind. Entries come inline — a transaction without its entries isn't meaningful.
{
"object": "reconciliation",
"balanced": true,
"checked_at": "2026-08-03T18:30:00.000Z",
"drift": [],
"outstanding_liability": [
{ "currency_id": "cur_2Wq8mNp4", "outstanding_units": 4820000 }
]
}balanced: true with an empty drift array is the healthy state. Call it on a schedule — you shouldn't have to take our word for it.Webhooks
Redemptions settle asynchronously, so webhooks are how you find out anything finished.
| Parameter | Type | Description |
|---|---|---|
| points.earned | event | An event matched a rule and points were issued. |
| points.earn_skipped | event | An event was posted but earned nothing. Includes the reason. |
| redemption.created | event | Points burned, handed off to the partner. |
| redemption.completed | event | Partner confirmed the transfer. |
| redemption.failed | event | Partner rejected it. Points already returned. |
import crypto from "node:crypto";
// Trove-Signature: t=1735689600,v1=<hex hmac-sha256 of "<t>.<raw body>">
// Deliberately the same scheme Stripe uses — if you already verify Stripe
// webhooks, this is the same code with a different header name.
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("="))
);
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (age > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1)
);
}Any 2xx counts as delivered. Failures retry on a backoff of roughly 1m, 5m, 30m, 2h, 5h, then stop. You can replay any delivery from the dashboard.
Idempotency
Required on every mutating endpoint. Retrying is always safe.
curl https://api.trovereward.com/v1/events \
-H "Authorization: Bearer $TROVE_SECRET_KEY" \
-H "Idempotency-Key: 8f4e2a10-9c3b-4d5e-8a7f-1b2c3d4e5f60" \
-d '{ "member": "user_8412", "type": "card.purchase",
"payload": { "amount_cents": 4820 } }'Retrying with the same key returns the original response and never performs the work twice. Replays carry an Idempotent-Replay: true header. Keys last 24 hours.
409 idempotency_key_reused. That's almost always a key accidentally reused across a loop — returning the unrelated first response would be far worse than an error.Pagination
Cursor-based. Offset pagination silently skips or repeats records when new data arrives mid-scan, which is a correctness bug when you're reconciling a ledger.
curl "https://api.trovereward.com/v1/events?limit=50&starting_after=evt_7Kq2mXbN" \
-H "Authorization: Bearer $TROVE_SECRET_KEY"| Parameter | Type | Description |
|---|---|---|
| limit | integer | 1–100. Defaults to 25. |
| starting_after | string | Cursor: an object id from a previous page. |
Responses carry has_more and next_cursor. Keep going while has_more is true.
Errors
One envelope for every failure, so you write a single error handler. Messages state the actual values that broke the request.
{
"error": {
"type": "invalid_request_error",
"code": "insufficient_balance",
"message": "Member mem_4Bn9 has 4,200 acme_points but this redemption requires 30,000.",
"param": "points_applied",
"doc_url": "https://www.trovereward.com/docs#insufficient_balance",
"request_id": "req_9Fh3kLm5nPq7RtVw"
}
}request_id is on every response, success or failure, in the Trove-Request-Id header. It's the one string to include in a support message.
| Code | HTTP | When |
|---|---|---|
| missing_api_key | 401 | No Authorization header was sent. |
| invalid_api_key | 401 | The key doesn't match any active key. |
| revoked_api_key | 401 | This key was revoked in the dashboard. |
| live_mode_not_enabled | 403 | Live mode requires a signed fulfillment agreement. Use a test key. |
| missing_idempotency_key | 400 | This endpoint requires an Idempotency-Key header. |
| idempotency_key_reused | 409 | Same key, different request body. |
| member_not_found | 404 | No member with that id or external_id. |
| currency_not_found | 404 | No such currency code. |
| insufficient_balance | 400 | The member doesn't hold enough points. The message states both numbers. |
| quote_expired | 400 | Quotes are valid for 15 minutes. |
| quote_already_used | 400 | Each quote can be redeemed once. |
| below_minimum_transfer | 400 | Under the partner's minimum, usually 1,000 units. |
| below_minimum_cash_back | 400 | Under the program's minimum statement credit. Names the shortfall. |
| invalid_transfer_increment | 400 | Not a multiple of the partner's increment. |
| partner_not_enabled | 403 | That transfer partner isn't enabled for your account. |
| member_suspended | 400 | Suspended members can't earn or redeem. |
| fulfillment_unavailable | 503 | The backend partner is unreachable. Safe to retry with the same key. |