Build

Customers and the widget

How a customer joins, and why the form is in an iframe you do not control.

A customer becomes a member one way: through the Trovy sign-up widget. Your page embeds it, the customer enters their phone number and a code, and the widget hands your page a single-use token. Your server exchanges that token for a customer id.

There is no endpoint you can call to sign someone up, and that is the point. The widget is where the phone number, the verification code and the consent record live — on Trovy's origin, inside a cross-origin iframe — so your site never handles any of them and your compliance surface stays small.

Embed it

Using React or Next.js? <TrovySignup> from @trovy/sdk/react is this form as a component, and createLinkHandler from @trovy/sdk/next is the route below. See TypeScript SDK. Everything on this page applies to both.

<div id="trovy-signup"></div>
<script src="https://js.trovy.ca/widget/v1.js"></script>
<script>
  var el = document.getElementById("trovy-signup");
  var options = {
    publishableKey: "trv_pk_test_…",
    // result is { linkToken, expiresAt, firstName }.
    onSuccess: function (result) {
      // Send the token to YOUR server, which exchanges it. Never exchange it here.
      fetch("/api/trovy/link", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-CSRF-Token": yourCsrfToken, // the same protection as your other routes
        },
        body: JSON.stringify({ linkToken: result.linkToken }),
      }).then(function (res) {
        if (res.ok) return showThanks(result.firstName);
        // Not linked. Show the form again: verifying again returns the same customer.
        widget.unmount();
        widget = Trovy.mount(el, options);
      });
    },
    onError: function (err) {
      console.error(err.code, err.message);
    },
  };
  var widget = Trovy.mount(el, options);
</script>

The form says "You are in" once the customer has verified, before your server has linked them, so check your route's answer and show the form again if it failed.

The same script serves test and live keys. With a trv_pk_test_ key the form is your sandbox's: it accepts only fictional phone numbers (a US or Canadian area code, or 721, then 555-0100 to 555-0199), sends no text, and shows you the code on screen.

Before any of this works, set that publishable key's allowed origins in your dashboard to the sites you will embed on. The dashboard lists them under the key as Websites this key may load on: add each one, then press Save websites. The frame is served with a Content-Security-Policy built from that list, so an unlisted site cannot embed your sign-up form at all — and a key with no origins shows an unavailable page. A website you have just saved can take up to a minute to start working. An origin is an https:// site; a sandbox key may also list a http://localhost address, and a live key may not.

Who can join

Trovy texts North American (+1) numbers in the United States and Canada, and in Sint Maarten (+1 721). Other +1 numbers (the rest of the Caribbean, Bermuda, Puerto Rico and the US Pacific territories) and every number outside +1 are refused before any text is sent: the form tells the customer it cannot text their number yet, and onError receives PHONE_NOT_SUPPORTED. Tell your customers who can join before they try.

Theme

Three knobs, each validated and clamped:

Trovy.mount(el, {
  publishableKey: "trv_pk_live_…",
  theme: { accent: "#1d9bf0", radius: 8, mode: "system" },
  onSuccess: handle,
});

accent must be #rrggbb, radius is 0–24, mode is light, dark or system. Anything else is ignored rather than passed through.

Mobile apps

There is no native SDK: the form runs in a WebView, on a page you host.

  1. Host the page on your own https:// site and list that origin, like any website. A page loaded from a bundled file (file://), a custom scheme (capacitor://, ionic://) or, on a live key, localhost cannot be listed, so the form never loads there and onError receives WIDGET_UNAVAILABLE.

  2. Link it to the signed-in user. Either open the page with a short-lived, single-use sign-in link your backend minted for that user, so your link route sees their session like any web request; or skip the route and hand the token to your app from onSuccess:

    • iOS (WKWebView): window.webkit.messageHandlers.trovy.postMessage(result.linkToken), with a WKScriptMessageHandler registered as trovy;
    • Android: a @JavascriptInterface method, for example window.TrovyBridge.linked(result.linkToken);
    • React Native: window.ReactNativeWebView.postMessage(result.linkToken).

    Your app then sends the token to your backend with the user's own session, and your backend exchanges it. The token lives five minutes.

  3. Close the WebView when the link succeeds, and refresh the screen that opened it.

  4. Let the terms open. The consent's terms and privacy links open in a new window. Hand new-window requests to the system browser (a WKUIDelegate on iOS; setSupportMultipleWindows and onCreateWindow on Android), or the links do nothing.

Single-page apps

mount returns a handle. Call unmount() when the component goes away, or every old listener stays live:

const widget = Trovy.mount(el, options);
// later
widget.unmount();

When the form cannot load

onError is also how you hear that the form never appeared. If the frame has not announced itself ten seconds after mount, the loader hides it and calls onError with WIDGET_UNAVAILABLE. While you build, the usual cause is that your page's origin is not in the key's allowed origins: the browser then refuses to draw the frame, and nothing inside it can tell you so. List the origin exactly as the browser reports it — scheme and host, and a port only when it is not the default.

It is not final. A frame that was only slow is shown again and works, so leave the widget mounted and show your own fallback beside it ("Ask at the counter to join").

codeWhat happened
WIDGET_UNAVAILABLEThe form did not load within ten seconds: an unlisted origin, a revoked key, or an outage. The customer sees nothing, so say something yourself.
NETWORK_ERRORThe customer's browser got no answer from Trovy. The form says so and they can try again.
anything elseAn API error code, such as TOO_MANY_REQUESTS, SMS_UNAVAILABLE or API_KEY_REVOKED; Errors lists every one. The customer reads a message written for them. Codes about your key or your setup reach them only as "Sign-up isn't available right now" — the real code comes to you, here.

The form only renders inside a page whose origin you listed. Opening the frame's URL on its own shows the unavailable page, by design: a sign-up with nowhere to deliver its token would enrol a customer you never hear about.

Exchange the token

curl -X POST https://api.sandbox.trovy.ca/v1/customers/link \
  -H "Authorization: Bearer $TROVY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "linkToken": "lt_7f3a9c1e4b8d2a6f"
  }'

The token is single-use and lives five minutes. Exchange it as soon as it reaches your server. A second attempt — or an expired one, or one belonging to another business — is 404 LINK_TOKEN_INVALID, with no way to tell those apart. If the exchange times out, the customer is already a member: send them through the widget again and they will verify and get a fresh token without re-registering.

Exchange it for the person who is signed in. Your /api/trovy/link route should require your own session, attach the customer id to that account, and carry the same CSRF protection as your other routes that change something. The token proves that someone verified a phone number. It does not say which of your users they are, so a route that accepts a token from anyone lets one person's membership be attached to another person's account. createLinkHandler from @trovy/sdk/next does all of this: it asks your getUser who is signed in before it spends the token.

Store the returned customer.id against your own account record. It is not a phone number, it is yours alone, and it means nothing at any other business. Every rewards call takes it. isNew is true when this sign-up created the customer's Trovy account, and false for someone who was already a Trovy member, here or at another business: for your analytics.

One phone number is one member. If two of your accounts verify the same number, both get the same customer id and share its rewards. Decide what your app does then: allow it, move the link to the newest account, or refuse. Do not put a unique constraint on the column unless you handle the conflict before telling the customer they are in.

Is this person already a member?

curl -X POST https://api.sandbox.trovy.ca/v1/customers/lookup \
  -H "Authorization: Bearer $TROVY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+14165550100"
  }'

Three answers, and two of them mean "show the widget":

AnswerMeansDo
404 CUSTOMER_NOT_FOUNDNot a member of your businessShow the widget
200, linked: falseA member of your business (they joined in store, say) who has not connected to your app yet. customer is nullShow the widget: they verify, you get their id, and they are not registered twice
200, linked: trueConnectedUse customer.id

The 404 is an error, so with the SDK it is a thrown TrovyError, not a return value. It does not distinguish someone who has never heard of Trovy from someone who is a member elsewhere, because that would be a membership oracle anyone with a key could query.

The widget itself never asks. A returning member and a brand-new one walk the identical path and see identical screens. So if your app does not collect phone numbers, skip the lookup: show the widget to any signed-in user who is not linked yet, and a returning member is linked again without registering twice.

Texts your customers receive

Trovy sends every text, and each one ends "Powered by trovy.ca | Reply STOP to opt out".

TextWhen
Sign-up codeThe customer asks the form for a code. Fixed wording.
WelcomeThe first time someone joins your business, through the form or any other way.
Reward earnedAn earn that returned a reward.
Reward redeemedA redeem. On a recurring program it names the new reward too.
Reward reducedA refund that reduced or cancelled a reward.
Expiry reminderBefore a reward expires, only if the program's reminders are switched on. They are off until you switch them on.

The response to each earn, redeem and refund says in sms whether that call's text went out. Change the wording of every text except the code in the dashboard, under Settings → Message Templates; the expiry reminders are on the program's Messages tab. See what went out, and whether it arrived, under Settings → Logs. The default reminder ends "Show your phone number at checkout to redeem", which is written for a counter: if your customers order online, reword it before you switch reminders on.

What the customer agreed to

One Trovy membership per phone number, across every business in the network. Trovy sends the texts and Trovy's member terms apply, which the customer accepts in the widget — timestamped, with a separate and never-pre-checked box for promotional messages. Replying STOP to a Trovy text stops every text to that number, sign-up codes included, until the customer replies START; they stay a member, and their rewards keep working.

Your branding is on the page around the widget, and the business's name is inside it. What the customer is joining is a co-branded network, not a private list you own.