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/sdk

Node 20 or newer, zero dependencies, MIT. ESM and CommonJS both work. One package, four entry points:

ImportRunsWhat it is
@trovy/sdkyour serverThe API client below. Takes your secret key.
@trovy/sdk/reactthe browser<TrovySignup>, the sign-up form as a React component. Takes your publishable key.
@trovy/sdk/nextyour servercreateLinkHandler, the route that exchanges the form's token. See Next.js.
@trovy/sdk/widgetthe browsermount, 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 5xx other than 501, with jittered backoff, honoring Retry-After. Except customers.link: its first attempt spends the token, so it is never retried.
  • Does not retry a 4xx — including 429, which comes back as a TrovyError carrying retryAfterSeconds so your own queue decides when to go again.
  • Types every request and response from the same OpenAPI document the API serves.

The surface

MethodDoes
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 away

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