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:
| Call | Key to use |
|---|---|
POST /v1/earn | Your order id |
POST /v1/redeem | Your order id |
POST /v1/refunds | Your 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 });
}
}