Core concepts

Errors

One body shape, a stable code to branch on, and a request id to quote.

Every failure answers the same shape:

{
  "error": "No order ORD-10233 was recorded for your business.",
  "code": "ORDER_NOT_FOUND",
  "requestId": "5f1e0e37-2795-4a07-9564-1e19ad52a023"
}
FieldUse it for
codeBranching. Stable, documented, safe to compare.
errorShowing a human. Written for your staff or your customer; wording may change.
requestIdQuote it when you email developers@trovy.ca. It identifies the exact request in our logs. Log it.
detailsMachine-readable detail, when there is some: the failing fields on a 400, and on several 409s the figure you need to act on (minimumOrderCents, remainingRefundableCents, the waiting reward).

Branch on code, never on the message. The messages are written to be read and will be improved; the codes are a contract.

Statuses

StatusMeaningRetry?
400The request is malformed. details says which field.No — fix it.
401The key is missing, malformed, unknown or revoked.No.
403The key is the wrong type, disabled, or the Developer API is not enabled.No.
404The thing does not exist for your business.No.
409A conflict: a duplicate order, a reused key with a different body, an already-redeemed reward.No — read the code.
429Over a rate limit. Retry-After says how long.Yes, after waiting.
5xxOur fault.Yes, same idempotency key.

A 404 never distinguishes "does not exist" from "belongs to someone else". That is deliberate: the difference would let a key holder probe for other businesses' customers and orders.

Every code

These are the codes of the /v1 API. The sign-up form has codes of its own, listed below.

Every error code the Trovy API can return
CodeWhat it means
API_KEY_MISSINGNo Authorization: Bearer <key> header was sent.
API_KEY_INVALIDThe key is malformed, unknown, or minted for the other environment.
API_KEY_REVOKEDThe key was revoked. Create a new one in the dashboard.
API_KEY_DISABLEDTrovy disabled this key. Contact developers@trovy.ca — rotating will not help.
API_KEY_WRONG_TYPEA Shopify or publishable key was sent where a secret key is required.
API_NOT_ENABLEDThe Developer API is not enabled for this business.
VALIDATION_ERRORThe request failed validation; details lists the issues.
NOT_FOUNDNo such route, or no such resource for this business.
LINK_TOKEN_INVALIDThe link token is unknown, expired, already used, or was issued for another business. The customer verifies again in the widget.
CUSTOMER_NOT_FOUNDNo member of your business matches — the phone number is not a member's, or the customer id is not one of yours.
IDEMPOTENCY_MISMATCHThis Idempotency-Key was already used for a request with a different body. Use a new key for a new order event.
DUPLICATE_ORDERAn order with this orderId is already recorded for your business. Order ids are unique per business.
ACTIVE_REWARD_EXISTSThe customer has a usable reward waiting (details.reward). An order applies it with POST /v1/redeem or records nothing; it cannot earn on top. A reward that is not usable yet never blocks an earn: the order adds to it.
NO_ACTIVE_PROGRAMYour business has no active rewards program linked to the Developer API.
FIRST_ORDER_ONLYThe program rewards a customer's first order only, and this customer has already earned.
STORE_NOT_FOUNDstoreId is not one of your active locations (GET /v1/stores).
REWARD_NOT_FOUNDNo such reward for this customer at your business.
REWARD_ALREADY_REDEEMEDThe reward was already redeemed.
REWARD_EXPIREDThe reward has expired.
REWARD_NOT_YET_USABLEThe program holds a newly earned reward back until details.usableFrom (a local midnight at your business), so it can't be applied to the order that earned it or to another order that day.
REWARD_VOIDEDThe reward was cancelled by refunds of the orders that earned it.
REWARD_CHANGEDThe reward changed between your read and this call (a refund landed). Read it again.
NOT_OLDEST_REWARDRewards are redeemed oldest first; details.oldestRewardId is the one to apply.
MINIMUM_ORDER_NOT_METThe order does not reach the reward's minimum; details.minimumOrderCents.
ORDER_NOT_FOUNDNo order with this orderId was recorded through the API for your business.
ORDER_AMBIGUOUSMore than one purchase is recorded under this orderId. Email developers@trovy.ca.
NOT_REFUNDABLEThis order cannot be refunded.
REFUND_EXCEEDS_REMAININGamountCents is more than what is left to refund; details.remainingRefundableCents.
ALREADY_FULLY_REFUNDEDThe order has already been fully refunded.
REWARDS_NOT_ENABLEDInstant rewards is switched off for your business, so orders earn nothing. Rewards customers already hold stay redeemable.
RATE_LIMITEDToo many requests; wait Retry-After seconds.
INTERNAL_ERRORSomething failed on our side. Quote requestId when you email developers@trovy.ca.

The ones worth handling by name

IDEMPOTENCY_MISMATCH (409) — you reused a key with a different body. See Idempotency.

DUPLICATE_ORDER (409) — you have already recorded a purchase under that order id. If this is a retry, use the same idempotency key and it will replay instead.

ACTIVE_REWARD_EXISTS (409) — the customer has a usable reward waiting, so this order cannot earn on top. details.reward is that reward: if the order reaches its minimumOrderCents, offer it and redeem instead; if not, record nothing for this order. See Rewards.

MINIMUM_ORDER_NOT_MET (409) — the order does not reach the reward's minimum. The message carries the figure formatted in the business's currency, and details carries minimumOrderCents, so you can show either.

CUSTOMER_NOT_FOUND (404) — that id is not one of your customers. Ids come only from POST /v1/customers/link, and they are not phone numbers.

API_KEY_DISABLED (403) — Trovy switched this key off. Rotating will not help; email developers@trovy.ca.

In the SDK

Two classes, split by whether the write might have landed:

import { isTrovyError, isTrovyConnectionError } from "@trovy/sdk";
 
try {
  await trovy.rewards.redeem(body, { idempotencyKey: order.id });
} catch (err) {
  if (isTrovyError(err)) {
    if (err.status >= 500) {
      // Our fault, and the SDK already retried. The write may have landed:
      // try again later with the same key.
      return queue.retry({ orderId: order.id });
    }
    // The API answered and decided. Final for this request.
    if (err.code === "MINIMUM_ORDER_NOT_MET") return showThreshold(err.message);
    if (err.status === 429) return backOff(err.retryAfterSeconds);
    log.error({ code: err.code, requestId: err.requestId });
    throw err;
  }
  if (isTrovyConnectionError(err)) {
    // No answer came back. The write may or may not have applied — retry with
    // the same key.
    return queue.retry({ orderId: order.id });
  }
  throw err;
}

Use the guards rather than instanceof: two copies of the package in one dependency tree give two distinct classes, and instanceof then fails on an error that is obviously ours.

The sign-up form's codes

The form reports through its onError callback: Trovy.mount, <TrovySignup> and mount from @trovy/sdk/widget alike. The customer never reads these codes: the form shows them a message written for them, or, for anything about your key or your setup, "Sign-up isn't available right now".

CodeWhat happened
INVALID_CONFIGThe options are wrong: a key that is not a publishable key (a secret key is refused before anything is sent), or neither or both of linkUrl and onSuccess. The SDK only.
WIDGET_UNAVAILABLEThe form did not load within ten seconds. On every load: this page's origin is not in the key's allowed origins, or the key lists none. Otherwise a revoked key or an outage.
NETWORK_ERRORThe customer's browser got no answer from Trovy. They can try again.
WIDGET_ERRORTrovy failed without saying why (a 5xx). The customer can try again.
VALIDATION_ERRORNot a valid phone number, or not six digits.
PHONE_NOT_SUPPORTEDA number Trovy does not text: anything outside the US, Canada and Sint Maarten (+1 721). See who can join. In a sandbox, also any number outside 555-0100 to 555-0199.
CODE_INVALIDThe code is wrong, expired or used up. The customer asks for a new one.
TOO_MANY_REQUESTSThe form's send limits, counted exactly: too many codes asked for one number, or through your key or your business. Retry-After says how long, and the form counts the customer down.
RATE_LIMITEDToo many requests from one visitor's address, before any of the above is counted.
SMS_UNAVAILABLEThe text could not be sent. Usually brief, so the customer can try again, but it lasts for a number that replied STOP (until it replies START), and for everyone once the platform's daily sign-up ceiling is reached.
API_KEY_REVOKED, API_KEY_DISABLED, API_KEY_INVALID, API_KEY_WRONG_TYPE, API_NOT_ENABLEDAbout your publishable key, as in the table above.
LINK_…With linkUrl only: handing the token to your route failed. Each carries a recovery saying what can be done; the package README lists them.

Calling the widget endpoints yourself you may also meet WIDGET_NOT_CONFIGURED (the key lists no origins) and ORIGIN_NOT_ALLOWED (the caller is not the form's own frame). Through the form neither arrives: the frame cannot be drawn at all, so both show up as WIDGET_UNAVAILABLE.