Core concepts

Idempotency and retries

Pass the id you already have for the event, and a retry can never happen twice.

Every call that records something new takes an Idempotency-Key header. It is required, not optional, because the alternative is a customer earning twice on one order when a request times out. (Exchanging a link token takes none, and cannot be retried: the first attempt spends the token, so a second one is 404 LINK_TOKEN_INVALID even when the first worked. Send the customer through the widget again instead; they get the same customer id.)

-H "Idempotency-Key: ORD-10233"

Use the id you already have

This is the whole guide. Pass the id your system already has for the thing you are recording:

CallKey to use
POST /v1/earnYour order id
POST /v1/redeemYour order id
POST /v1/refundsYour refund id

One key per event, not per order. An order and its refund are two events. Give the refund its own id: keyed by the order, two partial refunds of the same amount would look like one request sent twice, and the second would be skipped. Earn, redeem and refund share one set of keys, so a refund sent under its order's key is refused with IDEMPOTENCY_MISMATCH rather than recorded. If your system has no id for an event, build one: ORD-10233-refund-1.

A generated UUID protects you only inside one process. The cases that actually bite are the ones that cross processes: a worker redeployed mid-request, a queue redelivering a job, a customer double-tapping Pay, a load balancer retrying a timeout. In every one of those, your order id is the same and a UUID is not.

What a replay returns

The first result's amounts, with no second effect. A replayed earn returns the reward it created, not a new one, as that reward is now, and sends no second text (sms.status is not_sent). The response says so:

{ "replayed": true, "earnedCents": 300, "reward": { "id": "rwd_…" } }

Treat replayed: true as success. It means the work is already done.

A reused key with a different body is refused

If you send the same key with a different amount, order id or customer, you get 409 IDEMPOTENCY_MISMATCH rather than the old result. That is a real protection: it catches the case where a cashier corrects an amount and resubmits, which would otherwise silently return the original figures and leave the corrected order with no transaction at all.

So: a key identifies one attempt at one thing. Change the thing, change the key.

Keys are scoped to your business

Keys are namespaced per business, so you and another partner can both use order-1. Letters, digits, ., _ and -, up to 128 characters. If your order numbers look like #1042 or ORD/2026/01, turn them into a key the same way every time (strip the #, or replace / with -), so a retry from anywhere produces the same key. A key is remembered for good, not for a day: a replay works a year later.

Retrying

Retry on a network failure, a timeout, or a 5xx — with the same key. Do not retry a 4xx: the request was understood and refused, and sending it again will be refused the same way.

429 is the exception worth planning for. It means you are over a rate limit, and the response carries Retry-After in seconds. Back off for that long in your own queue.

Our SDK does all of this for you, and computes the key once before its retry loop — so every attempt at one call carries the same key:

const trovy = new Trovy({
  apiKey: process.env.TROVY_SECRET_KEY!,
  maxRetries: 2,    // network failures, timeouts, 5xx (not 501)
  timeoutMs: 30_000 // per attempt
});
 
await trovy.rewards.earn(body, { idempotencyKey: order.id });

It deliberately does not sleep through a 429: that comes back as a TrovyError with retryAfterSeconds, so your queue decides when to go again rather than a checkout request stalling for thirty seconds.

When you do not know whether it landed

A network failure leaves the outcome unknown — the request may have been applied before the connection dropped. This is exactly what the key is for: retry with the same key. If the first attempt landed, you get its result; if it did not, the work happens now. Either way the customer earns once.

In the SDK that case is a distinct class:

import { isTrovyConnectionError } from "@trovy/sdk";
 
try {
  await trovy.rewards.earn(body, { idempotencyKey: order.id });
} catch (err) {
  if (isTrovyConnectionError(err)) {
    // No answer came back. Queue a retry with the SAME key.
    await queue.retry({ orderId: order.id });
  }
}