Build
TypeScript SDK
A typed API client with retries and idempotency built in, the sign-up form for React, and the Next.js route between them.
npm install @trovy/sdkNode 20 or newer, zero dependencies, MIT. ESM and CommonJS both work. One package, four entry points:
| Import | Runs | What it is |
|---|---|---|
@trovy/sdk | your server | The API client below. Takes your secret key. |
@trovy/sdk/react | the browser | <TrovySignup>, the sign-up form as a React component. Takes your publishable key. |
@trovy/sdk/next | your server | createLinkHandler, the route that exchanges the form's token. See Next.js. |
@trovy/sdk/widget | the browser | mount, the same form without React. |
The component's props, states and error codes are listed in full in the package README.
The API client
import { Trovy } from "@trovy/sdk";
const trovy = new Trovy({ apiKey: process.env.TROVY_SECRET_KEY! });That is the whole setup. The base URL comes from the key — a trv_test_ key reaches
the sandbox, a trv_live_ key reaches production — so there is nothing to configure
and no way to point a test key at live data.
Server-side only. A publishable key passed here throws a TypeError immediately,
with a message pointing at the widget where it belongs.
What it does for you
- Computes the idempotency key once, before its retry loop, so every attempt at one call carries the same key. A retry after a timeout replays instead of earning a second reward.
- Retries network failures, timeouts and
5xxother than501, with jittered backoff, honoringRetry-After. Exceptcustomers.link: its first attempt spends the token, so it is never retried. - Does not retry a
4xx— including429, which comes back as aTrovyErrorcarryingretryAfterSecondsso your own queue decides when to go again. - Types every request and response from the same OpenAPI document the API serves.
The surface
| Method | Does |
|---|---|
business.get() | The business, its program, its currency |
business.listStores() | Active locations |
customers.link(body) | Exchange a widget token for your customer id |
customers.lookup(body) | Whether a phone number is linked to you |
rewards.list(customerId) | A customer's live rewards |
rewards.earn(body, opts) | Record a paid order and earn |
rewards.redeem(body, opts) | Apply a reward |
rewards.refund(body, opts) | Reverse an order |
Still pass your own idempotencyKey on every write. The SDK generates one when you
do not, but that only protects retries inside one process — see
Idempotency.
Options
const trovy = new Trovy({
apiKey: process.env.TROVY_SECRET_KEY!,
maxRetries: 2, // default; 0 disables retrying
timeoutMs: 30_000, // default, PER ATTEMPT
fetch: myFetch, // for tests, or a proxied environment
});timeoutMs is per attempt, so the worst case for a write is roughly
(maxRetries + 1) × timeoutMs plus backoff. Size it against whatever deadline your own
request is under.
Cancel with a signal. An abort is final — the SDK stops rather than retrying:
await trovy.business.get({ signal: AbortSignal.timeout(2_000) });Errors
See Errors for the two classes and why to use the guards rather than
instanceof.
Next.js
Three pieces: your keys, a route that links a verified customer to your signed-in user, and the form. Start with a sandbox and its test keys.
1. Keys. Under the publishable key's Websites this key may load on, add your
site's origin, and http://localhost:3000 while you develop, then press Save
websites: a key with none loads nowhere.
# .env.local
TROVY_SECRET_KEY=trv_test_…
NEXT_PUBLIC_TROVY_PUBLISHABLE_KEY=trv_pk_test_…2. The route. createLinkHandler is the /api/trovy/link route that
Customers and the widget asks you to write. It reads who is
signed in from your own session before it spends the token, refuses anything but a
same-origin JSON POST, exchanges the token with your secret key, and hands you the
customer id to save. The id never reaches the browser.
// app/api/trovy/link/route.ts
import { createLinkHandler } from "@trovy/sdk/next";
import { auth } from "@/auth";
import { db } from "@/db";
export const POST = createLinkHandler({
secretKey: process.env.TROVY_SECRET_KEY,
getUser: async () => (await auth())?.user?.id ?? null,
onLinked: async ({ userId, customer }) => {
await db.user.update({ where: { id: userId }, data: { trovyCustomerId: customer.id } });
},
});3. The form. <TrovySignup> is a Client Component. A Server Component can render
it only without callbacks, because a callback is a function and Next.js refuses to pass
one to a Client Component ("Event handlers cannot be passed to Client Component
props"). Put it in a small Client Component of your own:
// app/join-rewards.tsx
"use client";
import { useRouter } from "next/navigation";
import { TrovySignup } from "@trovy/sdk/react";
export function JoinRewards() {
const router = useRouter();
return (
<TrovySignup
publishableKey={process.env.NEXT_PUBLIC_TROVY_PUBLISHABLE_KEY!}
linkUrl="/api/trovy/link"
// The route has saved the link by now: re-render the page, which finds it.
onLinked={() => router.refresh()}
onError={(error) => console.warn(error.stage, error.code, error.message)}
unavailableFallback={<p>Rewards sign-up is unavailable right now.</p>}
/>
);
}onError is where you hear about the form: every code it can carry is listed under
the sign-up form's codes. While you build,
WIDGET_UNAVAILABLE on every load means the page's origin is not in the key's
allowed origins.
Testing the route. @trovy/sdk/next imports server-only, so it loads only under
the react-server condition Next.js gives a route, and anywhere else the import fails
on purpose. To test your route with Vitest, install server-only as a dev dependency
and give those tests the same condition:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({ ssr: { resolve: { conditions: ["react-server"] } } });Without React
import { mount } from "@trovy/sdk/widget";
const form = mount("#rewards", {
publishableKey: "trv_pk_test_…",
linkUrl: "/api/trovy/link",
onLinked: ({ name }) => showThanks(name),
onError: ({ stage, code }) => console.warn(stage, code),
});
form.unmount(); // when the page goes awaySame options, states and codes as the component. If you would rather exchange the
token yourself, pass onSuccess instead of linkUrl and call customers.link from
your server.
Types
The schemas are exported, so you can name one:
import type { components } from "@trovy/sdk";
type EarnRequest = components["schemas"]["EarnRequest"];They are generated from the committed contract, so what your editor shows is what goes over the wire.
Versioning
The API is versioned by path (/v1) and changes additively within it: a field may be
added, never removed or renamed. The SDK follows semantic versioning and is before
1.0: pin an exact version, and read its changelog before you upgrade.
Generating a client in another language? Use this site's
/openapi/v1.json. The copy the API serves at /v1/openapi.json can
also list operations that are not generally available yet.