Quickstart

Three calls: create a member, post a card transaction, then let them redeem. Roughly five minutes end to end with a sandbox key.

Get a sandbox key from the signup page — self-serve, no sales call. Every example below works against it immediately.
Node SDK — the short version
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 English

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

1 — Create a member
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.

2 — Post a card transaction
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.

3 — Quote and redeem
# 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_...:
ParameterTypeDescription
trv_sk_test_…sandboxSimulated fulfillment. Self-serve, no agreement needed. Deterministic test outcomes.
trv_sk_live_…liveReal fulfillment. Requires an enabled account and a signed backend agreement.
Keys are stored only as a salted hash. The plaintext is shown once, at creation, and cannot be recovered — if you lose it, revoke it and create a new one.

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 cash

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

Every amount in this API is a whole number. Points are whole units; cash is whole cents. There are no decimals anywhere, which is why cash_total_cents is named the way it is.

Members

Your end users. Create them once, then reference them by your own id.

POST/v1/members
ParameterTypeDescription
external_idrequiredstringYour own id for this user. Must be unique within your account.
emailstringOptional. Used for lookup and support.
full_namestringOptional.
metadataobjectArbitrary key-value data returned on every read.
Creating a member whose 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.
GET/v1/members/:id

Accepts either mem_… or your external_id. Returns the member with their current balances.

PATCH/v1/members/:id

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.

POST/v1/events
ParameterTypeDescription
memberrequiredstringmem_… id or your external_id.
typerequiredstringEvent type as "noun.verb", lowercase. "card.purchase" for spend; any other type works the same way.
payloadobjectFree-form event data. For purchases send amount_cents and merchant.category. Rule conditions read fields from here, including nested ones via dot paths.
occurred_attimestampWhen it happened. Defaults to now.
Post transactions on authorization or on settlement — your choice, but pick one and stay consistent. Settlement is the safer default since authorizations can be reversed, and points already issued against a reversed auth have to be clawed back with an adjustment.
Response — awarded
{
  "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"
  }
}
Response — nothing awarded
{
  "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
  }
}
An event that earns nothing is still a success, not an error — it returns 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.

GET/v1/currencies
{
  "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.

POST/v1/quotes
ParameterTypeDescription
memberrequiredstringmem_… id or your external_id.
typerequiredstring"transfer", "cash_and_points", or "cash_back".
to_currencystringPartner code. Required for transfer.
points_appliedintegerPoints to spend. Required for transfer and cash_back. For cash_and_points, omit to apply the maximum the member can afford.
cash_total_centsintegerTotal price in cents. Required for cash_and_points.
Fractional cash + 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"
}
Statement credit — points out, cash back
{
  "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.

Points are capped at what's needed to cover the price. Over-applying and getting nothing back would be a silent, unrecoverable loss of value, so the cap is applied rather than treated as an error.

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.

POST/v1/redemptions
ParameterTypeDescription
quoterequiredstringThe qte_… id to execute. Each quote is single-use.
destination_account_refstringThe member's account number in the partner program. Required for transfers.
destination_holder_namestringName on the partner account. Most programs match this and reject mismatches.
StatusMeaning
processingPoints burned, awaiting partner confirmation. Real transfers take minutes to days.
completedPartner confirmed. Fires redemption.completed.
failedPartner rejected. Points already returned via a reversing ledger transaction.
reversedCompleted, then unwound.
Sandbox transfers are simulated and move nothing. Every sandbox redemption carries "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.

POST/v1/redemptions/:id/settle
ParameterTypeDescription
settlement_referencerequiredstringYour 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.

POST/v1/redemptions/settle

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.

ParameterTypeDescription
settlements[].redemptionrequiredstringThe rdm_… id. A redemption may appear only once per request.
settlements[].statusrequiredstringEither "settled" or "rejected".
settlements[].payout_centsrequiredintegerRequired 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_referencestringRequired when status is "settled".
settlements[].settled_atstringISO 8601. Optional — defaults to now. Supply it when the money landed before the call.
settlements[].reject_reasonstringRequired 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." }
  ]
}
Partial success is normal and returns 200. Per-item outcomes are facts about a redemption, not errors in your request — a batch where 2 of 200 were already settled has done its job. A malformed item is different: the whole request is refused with 400 naming the index, and nothing is applied, so you never have to work out how far it got.
A rejection does not return the member's points. The redemption moves to 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.
These are the same semantics as the CREDITACK batch file feed, driving the same code path. A tenant on files and a tenant on the API cannot get different behaviour, and 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.

GET/v1/transactions

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.

GET/v1/ledger/reconciliation
{
  "object": "reconciliation",
  "balanced": true,
  "checked_at": "2026-08-03T18:30:00.000Z",
  "drift": [],
  "outstanding_liability": [
    { "currency_id": "cur_2Wq8mNp4", "outstanding_units": 4820000 }
  ]
}
This recomputes every balance from raw entries and reports any account where the cached value disagrees. 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.

ParameterTypeDescription
points.earnedeventAn event matched a rule and points were issued.
points.earn_skippedeventAn event was posted but earned nothing. Includes the reason.
redemption.createdeventPoints burned, handed off to the partner.
redemption.completedeventPartner confirmed the transfer.
redemption.failedeventPartner rejected it. Points already returned.
Verifying a signature (Node)
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.

Reusing a key with a different body returns 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"
ParameterTypeDescription
limitinteger1–100. Defaults to 25.
starting_afterstringCursor: 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.

CodeHTTPWhen
missing_api_key401No Authorization header was sent.
invalid_api_key401The key doesn't match any active key.
revoked_api_key401This key was revoked in the dashboard.
live_mode_not_enabled403Live mode requires a signed fulfillment agreement. Use a test key.
missing_idempotency_key400This endpoint requires an Idempotency-Key header.
idempotency_key_reused409Same key, different request body.
member_not_found404No member with that id or external_id.
currency_not_found404No such currency code.
insufficient_balance400The member doesn't hold enough points. The message states both numbers.
quote_expired400Quotes are valid for 15 minutes.
quote_already_used400Each quote can be redeemed once.
below_minimum_transfer400Under the partner's minimum, usually 1,000 units.
below_minimum_cash_back400Under the program's minimum statement credit. Names the shortfall.
invalid_transfer_increment400Not a multiple of the partner's increment.
partner_not_enabled403That transfer partner isn't enabled for your account.
member_suspended400Suspended members can't earn or redeem.
fulfillment_unavailable503The backend partner is unreachable. Safe to retry with the same key.