{"openapi":"3.1.0","info":{"title":"Trovy API","version":"1","description":"The Trovy loyalty engine, for a partner's own backend: instant rewards behind your own checkout. Trovy handles no payments.\n\n## Authentication\n\nEvery request carries a secret key: `Authorization: Bearer trv_live_…` against production, `Authorization: Bearer trv_test_…` against the sandbox. Keys belong on your server only — never in a mobile app or a web page. Your team creates, rotates and revokes them in the Trovy dashboard.\n\n## Sandbox\n\nYour team makes its own sandbox in the Trovy dashboard (Integrations → Developer API → Create sandbox): a test copy of your business, with its own test keys. `https://api.sandbox.trovy.ca` runs the same API against it. Test keys only work there, and live keys only against production. No text message ever leaves a sandbox: every response that would have sent one carries the rendered message in `sms.preview`. Its sign-up form accepts only fictional phone numbers (a US or Canadian area code, or 721, then 555-0100 to 555-0199), so a sandbox can never reach a real person.\n\n## Idempotency\n\nEvery request that changes something takes an `Idempotency-Key` header. Retrying with the same key returns the original result, so a timed-out call can never earn twice. Reusing a key with a different body is refused.\n\n## Errors\n\nErrors are `{ \"error\": \"…\", \"code\": \"…\", \"requestId\": \"…\" }`. `code` is stable and documented on each operation; quote `requestId` when you email developers@trovy.ca. A 401 means fix your credentials (rotate the key); a 403 means the key is recognised and something else forbids the call.\n\n## Rate limits\n\nLimits are per key. Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`; a 429 adds `Retry-After`.\n\n## Versioning\n\nThe version is in the path. Changes within `/v1` are additive: new endpoints, new optional fields, new error codes. Anything that would break a working integration ships as `/v2`."},"servers":[{"url":"https://api.sandbox.trovy.ca","description":"Sandbox — test keys (trv_test_…), no live texts"},{"url":"https://api.trovy.ca","description":"Production — live keys (trv_live_…)"}],"tags":[{"name":"Business","description":"Your business, its program terms and its locations."},{"name":"Customers","description":"Members of your business, linked through the sign-up widget."},{"name":"Rewards","description":"Earn on an order, redeem at checkout, adjust on refund."}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"A secret key: trv_live_… against production, trv_test_… against the sandbox."}},"schemas":{"BusinessResponse":{"type":"object","properties":{"business":{"$ref":"#/components/schemas/Business"},"program":{"$ref":"#/components/schemas/Program"}},"required":["business","program"]},"Business":{"type":"object","properties":{"id":{"type":"string","example":"clx1business0001"},"name":{"type":"string","example":"Sunny Threads"},"environment":{"type":"string","enum":["sandbox","live"],"description":"Which deployment answered. Test keys only ever see `sandbox`."},"timezone":{"type":"string","example":"America/Toronto","description":"IANA zone the business reports in."},"currency":{"type":"string","example":"USD","description":"ISO 4217 code every `...Cents` field in this API is counted in. One business has one currency and it never changes once it has transacted, so read it once at setup rather than per call."},"products":{"type":"object","properties":{"instantRewards":{"type":"boolean","example":true}},"required":["instantRewards"],"description":"Which products your business has switched on. With instant rewards off, `POST /v1/earn` answers `REWARDS_NOT_ENABLED`. Rewards customers already hold stay redeemable either way."}},"required":["id","name","environment","timezone","currency","products"]},"Program":{"type":["object","null"],"properties":{"name":{"type":"string","example":"Sunny Threads Rewards"},"rewardRateBps":{"type":"integer","example":500,"description":"Reward rate in basis points: 500 = 5% of the order."},"capCents":{"type":["integer","null"],"example":2000,"description":"Most a customer's waiting reward can grow to, in cents. `null` means uncapped."},"minRedemptionPurchaseCents":{"type":"integer","example":1000,"description":"An order must reach this, in cents, before a reward can be applied to it."},"rewardValidityDays":{"type":"integer","example":30,"description":"Days a reward stays redeemable once it becomes usable."},"redemptionDelayDays":{"type":"integer","example":1,"description":"Whole days, in your business's timezone, before a newly earned reward can be applied: 0 = right away, 1 = from the next day."},"recurring":{"type":"boolean","description":"When true, an order that redeems a reward earns again on the remainder."}},"required":["name","rewardRateBps","capCents","minRedemptionPurchaseCents","rewardValidityDays","redemptionDelayDays","recurring"],"description":"The active rewards program, or `null` when none is linked to the Developer API yet."},"Error":{"type":"object","properties":{"error":{"type":"string","example":"Invalid API key."},"code":{"type":"string","enum":["API_KEY_MISSING","API_KEY_INVALID","API_KEY_REVOKED","API_KEY_DISABLED","API_KEY_WRONG_TYPE","API_NOT_ENABLED","VALIDATION_ERROR","NOT_FOUND","LINK_TOKEN_INVALID","CUSTOMER_NOT_FOUND","IDEMPOTENCY_MISMATCH","DUPLICATE_ORDER","ACTIVE_REWARD_EXISTS","NO_ACTIVE_PROGRAM","FIRST_ORDER_ONLY","STORE_NOT_FOUND","REWARD_NOT_FOUND","REWARD_ALREADY_REDEEMED","REWARD_EXPIRED","REWARD_NOT_YET_USABLE","REWARD_VOIDED","REWARD_CHANGED","NOT_OLDEST_REWARD","MINIMUM_ORDER_NOT_MET","ORDER_NOT_FOUND","ORDER_AMBIGUOUS","NOT_REFUNDABLE","REFUND_EXCEEDS_REMAINING","ALREADY_FULLY_REFUNDED","REWARDS_NOT_ENABLED","RATE_LIMITED","INTERNAL_ERROR"],"example":"API_KEY_INVALID","x-enumDescriptions":{"API_KEY_MISSING":"No `Authorization: Bearer <key>` header was sent.","API_KEY_INVALID":"The key is malformed, unknown, or minted for the other environment.","API_KEY_REVOKED":"The key was revoked. Create a new one in the dashboard.","API_KEY_DISABLED":"Trovy disabled this key. Contact developers@trovy.ca — rotating will not help.","API_KEY_WRONG_TYPE":"A Shopify or publishable key was sent where a secret key is required.","API_NOT_ENABLED":"The Developer API is not enabled for this business.","VALIDATION_ERROR":"The request failed validation; `details` lists the issues.","NOT_FOUND":"No such route, or no such resource for this business.","LINK_TOKEN_INVALID":"The link token is unknown, expired, already used, or was issued for another business. The customer verifies again in the widget.","CUSTOMER_NOT_FOUND":"No member of your business matches — the phone number is not a member's, or the customer id is not one of yours.","IDEMPOTENCY_MISMATCH":"This `Idempotency-Key` was already used for a request with a different body. Use a new key for a new order event.","DUPLICATE_ORDER":"An order with this `orderId` is already recorded for your business. Order ids are unique per business.","ACTIVE_REWARD_EXISTS":"The 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_PROGRAM":"Your business has no active rewards program linked to the Developer API.","FIRST_ORDER_ONLY":"The program rewards a customer's first order only, and this customer has already earned.","STORE_NOT_FOUND":"`storeId` is not one of your active locations (`GET /v1/stores`).","REWARD_NOT_FOUND":"No such reward for this customer at your business.","REWARD_ALREADY_REDEEMED":"The reward was already redeemed.","REWARD_EXPIRED":"The reward has expired.","REWARD_NOT_YET_USABLE":"The 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_VOIDED":"The reward was cancelled by refunds of the orders that earned it.","REWARD_CHANGED":"The reward changed between your read and this call (a refund landed). Read it again.","NOT_OLDEST_REWARD":"Rewards are redeemed oldest first; `details.oldestRewardId` is the one to apply.","MINIMUM_ORDER_NOT_MET":"The order does not reach the reward's minimum; `details.minimumOrderCents`.","ORDER_NOT_FOUND":"No order with this `orderId` was recorded through the API for your business.","ORDER_AMBIGUOUS":"More than one purchase is recorded under this `orderId`. Email developers@trovy.ca.","NOT_REFUNDABLE":"This order cannot be refunded.","REFUND_EXCEEDS_REMAINING":"`amountCents` is more than what is left to refund; `details.remainingRefundableCents`.","ALREADY_FULLY_REFUNDED":"The order has already been fully refunded.","REWARDS_NOT_ENABLED":"Instant rewards is switched off for your business, so orders earn nothing. Rewards customers already hold stay redeemable.","RATE_LIMITED":"Too many requests; wait `Retry-After` seconds.","INTERNAL_ERROR":"Something failed on our side. Quote `requestId` when you email developers@trovy.ca."}},"requestId":{"type":"string","description":"Identifies this request in Trovy's logs. Quote it when you email developers@trovy.ca."},"details":{"description":"The failing fields on a 400; on several 409s the value to act on (see each code)."}},"required":["error","code"]},"StoresResponse":{"type":"object","properties":{"stores":{"type":"array","items":{"$ref":"#/components/schemas/Store"}}},"required":["stores"]},"Store":{"type":"object","properties":{"id":{"type":"string","example":"clx1store00001"},"name":{"type":"string","example":"Downtown"},"isDefault":{"type":"boolean","description":"The location orders are attributed to when none is given."}},"required":["id","name","isDefault"]},"LinkCustomerResponse":{"type":"object","properties":{"customer":{"type":"object","properties":{"id":{"type":"string","example":"clx1cust00001","description":"Your customer id for this member — see `Customer`."},"name":{"type":["string","null"],"example":"Maria"},"isNew":{"type":"boolean","description":"True when this verification created the trovy account; false when an existing member linked to you."}},"required":["id","name","isNew"]}},"required":["customer"]},"LinkCustomerRequest":{"type":"object","properties":{"linkToken":{"type":"string","minLength":1,"maxLength":200,"example":"Xm3kQ…","description":"The one-time token the sign-up widget handed your page (`onSuccess`, or your link route). Valid for five minutes, exchangeable once."}},"required":["linkToken"]},"LookupCustomerResponse":{"type":"object","properties":{"linked":{"type":"boolean","description":"Whether this member has linked to your business through the sign-up widget. Only a linked member has an id you can act on; for anyone else, show the widget."},"customer":{"$ref":"#/components/schemas/Customer"}},"required":["linked","customer"]},"Customer":{"type":["object","null"],"properties":{"id":{"type":"string","example":"clx1cust00001","description":"Your customer id for this member. Store it against your own account record; every rewards call takes it. It means nothing at any other business."},"name":{"type":["string","null"],"example":"Maria","description":"First name, as the member gave it."}},"required":["id","name"],"description":"`null` until the member links."},"LookupCustomerRequest":{"type":"object","properties":{"phone":{"type":"string","pattern":"^\\+?1?[\\s().-]*[2-9](?:[\\s().-]*\\d){9}$","example":"+14165550100","description":"A North American number. Formatting is ignored: `+14165550100`, `14165550100` and `(416) 555-0100` are the same number."}},"required":["phone"]},"RewardsResponse":{"type":"object","properties":{"rewards":{"type":"array","items":{"$ref":"#/components/schemas/Reward"},"description":"Usable rewards first, then soonest-expiring. Normally zero or one: a customer redeems a usable reward before they earn again."}},"required":["rewards"]},"Reward":{"type":"object","properties":{"id":{"type":"string","example":"clx1reward0001"},"valueCents":{"type":"integer","example":250,"description":"What the reward is worth, in cents. It grows as the customer earns again before redeeming."},"minimumOrderCents":{"type":"integer","example":1000,"description":"An order must reach this, in cents, to have the reward applied."},"expiresAt":{"type":["string","null"],"example":"2026-10-12T14:00:00.000Z","description":"ISO 8601. `null` means it does not expire."},"usableFrom":{"type":"string","example":"2026-09-16T04:00:00.000Z","description":"ISO 8601. The first moment the reward can be applied. A program with a redemption delay holds a new reward back until a local midnight at your business, so it can't be spent on the order that earned it."},"usable":{"type":"boolean","description":"Whether `usableFrom` has passed. Decide at checkout from this flag, not from the list being non-empty: a customer whose only reward is not usable yet earns (`earnReward`), and the order adds to that reward."}},"required":["id","valueCents","minimumOrderCents","expiresAt","usableFrom","usable"]},"EarnResponse":{"type":"object","properties":{"replayed":{"type":"boolean","description":"True when this is the stored result of an earlier call with the same `Idempotency-Key`; nothing was recorded twice."},"customer":{"$ref":"#/components/schemas/Customer"},"order":{"$ref":"#/components/schemas/Order"},"earnedCents":{"type":"integer","example":250,"description":"What this order added to the customer's reward, in cents. 0 when the order was too small to earn — rewards round to whole dollars."},"reward":{"allOf":[{"$ref":"#/components/schemas/Reward"},{"type":["object","null"],"description":"The customer's reward after this order — its full value, not just this order's share. `null` when nothing was earned."}]},"transactionId":{"type":"string","example":"clx1tx000001","description":"Trovy's id for this order. Quote it when you email developers@trovy.ca."},"sms":{"$ref":"#/components/schemas/Sms"}},"required":["replayed","customer","order","earnedCents","reward","transactionId","sms"]},"Order":{"type":"object","properties":{"id":{"type":"string","example":"ORD-10233","description":"Your order id, as you sent it."},"amountCents":{"type":"integer","example":5000}},"required":["id","amountCents"]},"Sms":{"type":"object","properties":{"status":{"type":"string","enum":["sent","preview","failed","not_sent"],"description":"`sent`: the customer was texted. `preview`: sandbox — nothing was sent and `preview` holds the text. `failed`: the text could not be sent; the order and reward still stand. `not_sent`: nothing to say (no reward, or a replay)."},"preview":{"type":["string","null"],"description":"Sandbox only: the full text the customer would have received."}},"required":["status"]},"EarnRequest":{"type":"object","properties":{"customerId":{"type":"string","minLength":1,"example":"clx1cust00001","description":"The customer id from `linkCustomer`."},"orderId":{"type":"string","minLength":1,"maxLength":64,"example":"ORD-10233","description":"Your own order id. Unique per business — recording a second order under the same id is refused with `DUPLICATE_ORDER` — and what a refund names later."},"amountCents":{"type":"integer","minimum":1,"maximum":5000000,"example":5000,"description":"The order total in cents, before any reward is taken off."},"storeId":{"type":"string","minLength":1,"example":"clx1store00001","description":"Which of your locations fulfilled the order (`listStores`). Defaults to your default location. Reporting only — a reward earned at one location is redeemable at any."}},"required":["customerId","orderId","amountCents"]},"RedeemResponse":{"type":"object","properties":{"replayed":{"type":"boolean","description":"True when this is the stored result of an earlier call with the same `Idempotency-Key`; nothing was recorded twice."},"customer":{"$ref":"#/components/schemas/Customer"},"order":{"$ref":"#/components/schemas/Order"},"redeemedCents":{"type":"integer","example":250,"description":"What came off the order: the reward's full value, in cents."},"newReward":{"allOf":[{"$ref":"#/components/schemas/Reward"},{"type":["object","null"],"description":"On a recurring program, the reward this order earned on what the customer actually paid. Otherwise `null`."}]},"transactionId":{"type":"string","example":"clx1tx000001","description":"Trovy's id for this order. Quote it when you email developers@trovy.ca."},"sms":{"$ref":"#/components/schemas/Sms"}},"required":["replayed","customer","order","redeemedCents","newReward","transactionId","sms"]},"RedeemRequest":{"type":"object","properties":{"customerId":{"type":"string","minLength":1,"example":"clx1cust00001","description":"The customer id from `linkCustomer`."},"rewardId":{"type":"string","minLength":1,"example":"clx1reward0001","description":"The reward to apply, from `listCustomerRewards`."},"orderId":{"type":"string","minLength":1,"maxLength":64,"example":"ORD-10233","description":"Your own order id. Unique per business — recording a second order under the same id is refused with `DUPLICATE_ORDER` — and what a refund names later."},"amountCents":{"type":"integer","minimum":1,"maximum":5000000,"example":5000,"description":"The order total in cents, before any reward is taken off."},"storeId":{"type":"string","minLength":1,"example":"clx1store00001","description":"Which of your locations fulfilled the order (`listStores`). Defaults to your default location. Reporting only — a reward earned at one location is redeemable at any."}},"required":["customerId","rewardId","orderId","amountCents"]},"RefundResponse":{"type":"object","properties":{"replayed":{"type":"boolean","description":"True when this is the stored result of an earlier call with the same `Idempotency-Key`; nothing was recorded twice."},"refund":{"type":"object","properties":{"id":{"type":"string","example":"clx1refund0001"},"orderId":{"type":"string","example":"ORD-10233"},"amountCents":{"type":"integer","example":2000},"rewardReversedCents":{"type":"integer","example":100,"description":"Reward value taken back from the customer, in cents, in proportion to the refund. 0 when there was nothing live to take back — see `rewardAdjustment`."},"createdAt":{"type":"string","example":"2026-09-12T14:00:00.000Z"}},"required":["id","orderId","amountCents","rewardReversedCents","createdAt"]},"order":{"type":"object","properties":{"id":{"type":"string","example":"ORD-10233"},"amountCents":{"type":"integer","example":5000,"description":"The order total as recorded."},"paidCents":{"type":"integer","example":4750,"description":"What the customer actually paid: the total less any reward applied."},"refundedCents":{"type":"integer","example":2000,"description":"Refunded so far, this call included."},"remainingRefundableCents":{"type":"integer","example":2750},"fullyRefunded":{"type":"boolean"}},"required":["id","amountCents","paidCents","refundedCents","remainingRefundableCents","fullyRefunded"]},"reward":{"type":["object","null"],"properties":{"id":{"type":"string"},"status":{"type":"string","example":"active","description":"`active`, `redeemed`, `expired` or `voided`."},"valueCents":{"type":"integer"},"expiresAt":{"type":["string","null"]}},"required":["id","status","valueCents","expiresAt"],"description":"The reward the order earned, after this refund. `null` when the order earned none."},"rewardAdjustment":{"type":"string","enum":["applied","nothing_earned","already_reversed","reward_redeemed","reward_expired","reward_voided","reward_missing"],"description":"`applied`: the live reward was reduced by `rewardReversedCents`. `nothing_earned`: the order earned no reward. `already_reversed`: earlier refunds already took back all it earned. `reward_redeemed` / `reward_expired` / `reward_voided` / `reward_missing`: the reward is no longer live, so nothing was taken back."},"sms":{"$ref":"#/components/schemas/Sms"}},"required":["replayed","refund","order","reward","rewardAdjustment","sms"]},"RefundRequest":{"type":"object","properties":{"orderId":{"type":"string","minLength":1,"maxLength":64,"example":"ORD-10233","description":"The order you refunded, as you recorded it with `earnReward` or `redeemReward`."},"amountCents":{"type":"integer","minimum":1,"maximum":5000000,"example":2000,"description":"What you refunded the customer, in cents. Partial refunds are fine; across calls the total can never exceed what they paid."}},"required":["orderId","amountCents"]}},"parameters":{}},"paths":{"/v1/business":{"get":{"operationId":"getBusiness","tags":["Business"],"summary":"Your business and its active rewards program","description":"Use it to confirm a key is wired to the business you expect, and to read the program terms your checkout applies: the rate, the cap, and the minimum order a reward can be applied to.","responses":{"200":{"description":"The business behind the key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessResponse"}}}},"401":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X GET https://api.sandbox.trovy.ca/v1/business \\\n  -H \"Authorization: Bearer $TROVY_SECRET_KEY\""},{"lang":"typescript","label":"TypeScript","source":"// The business behind the key, its program and its currency.\n\nimport { Trovy } from \"@trovy/sdk\";\n\nconst trovy = new Trovy({ apiKey: process.env.TROVY_SECRET_KEY! });\n\nconst { business, program } = await trovy.business.get();\n\nconsole.log(business.name, business.currency);\nconsole.log(program?.rewardRateBps);"}]}},"/v1/stores":{"get":{"operationId":"listStores","tags":["Business"],"summary":"Your locations","description":"Active locations, for attributing an order to the one that fulfilled it. Attribution is reporting only — a reward can be earned at one location and redeemed at another.","responses":{"200":{"description":"Active locations, oldest first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoresResponse"}}}},"401":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X GET https://api.sandbox.trovy.ca/v1/stores \\\n  -H \"Authorization: Bearer $TROVY_SECRET_KEY\""},{"lang":"typescript","label":"TypeScript","source":"// The business's active locations.\n\nimport { Trovy } from \"@trovy/sdk\";\n\nconst trovy = new Trovy({ apiKey: process.env.TROVY_SECRET_KEY! });\n\nconst { stores } = await trovy.business.listStores();\n\nfor (const store of stores) {\n  console.log(store.id, store.name, store.isDefault);\n}"}]}},"/v1/customers/link":{"post":{"operationId":"linkCustomer","tags":["Customers"],"summary":"Exchange a sign-up widget token for a customer id","description":"When the sign-up form hands your page a `linkToken` (`onSuccess`, or your link route), post it here from your server. The answer is the customer id you store against your own account record and pass to every rewards call. A token is valid for five minutes and can be exchanged once; if your exchange fails or times out, have the customer verify again — their membership already exists, so it takes seconds.","x-trovy-idempotency-exempt":"A link token is single-use by itself: a retry answers 404 and the member verifies again.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkCustomerRequest"}}}},"responses":{"200":{"description":"The member behind the token, as your business knows them.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkCustomerResponse"}}}},"400":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X POST https://api.sandbox.trovy.ca/v1/customers/link \\\n  -H \"Authorization: Bearer $TROVY_SECRET_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"linkToken\": \"lt_7f3a9c1e4b8d2a6f\"\n  }'"},{"lang":"typescript","label":"TypeScript","source":"// Exchange a widget link token for your customer id.\n\nimport { Trovy } from \"@trovy/sdk\";\n\nconst trovy = new Trovy({ apiKey: process.env.TROVY_SECRET_KEY! });\n\ndeclare const linkToken: string;\ndeclare const accountId: string;\ndeclare function saveTrovyCustomerId(accountId: string, trovyCustomerId: string): Promise<void>;\n\n// The token comes from the widget, through your own page. It is single-use and\n// lives five minutes: exchange it as soon as it arrives.\nconst { customer } = await trovy.customers.link({ linkToken });\n\n// Store customer.id against your own account record. Every later call takes it.\nawait saveTrovyCustomerId(accountId, customer.id);"}]}},"/v1/customers/lookup":{"post":{"operationId":"lookupCustomer","tags":["Customers"],"summary":"Find a member of your business by phone number","description":"Tells you whether a phone number belongs to a member of your business, and whether that member has linked to you through the sign-up widget. Only a linked member has an id you can act on; for anyone else, show the widget. Non-members are a 404, so this is also how you decide whether to say \"welcome back\". Limited to 120 calls a minute per key.","x-trovy-idempotency-exempt":"A read. It takes a POST only so the phone number travels in the body, never in a URL.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LookupCustomerRequest"}}}},"responses":{"200":{"description":"A member of your business.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LookupCustomerResponse"}}}},"400":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X POST https://api.sandbox.trovy.ca/v1/customers/lookup \\\n  -H \"Authorization: Bearer $TROVY_SECRET_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"phone\": \"+14165550100\"\n  }'"},{"lang":"typescript","label":"TypeScript","source":"// Whether a phone number is already a member linked to you.\n\nimport { isTrovyError, Trovy } from \"@trovy/sdk\";\n\nconst trovy = new Trovy({ apiKey: process.env.TROVY_SECRET_KEY! });\n\ndeclare const phone: string;\ndeclare function showSignupWidget(): void;\ndeclare function continueWithCustomer(customerId: string): void;\n\ntry {\n  const result = await trovy.customers.lookup({ phone });\n\n  if (result.linked && result.customer) {\n    continueWithCustomer(result.customer.id);\n  } else {\n    // A member of your business who has not connected to your app yet. The\n    // widget links them; they are not registered twice.\n    showSignupWidget();\n  }\n} catch (err) {\n  // Not a member of your business is a 404, so it arrives as a thrown error.\n  if (isTrovyError(err) && err.code === \"CUSTOMER_NOT_FOUND\") {\n    showSignupWidget();\n  } else {\n    throw err;\n  }\n}"}]}},"/v1/customers/{id}/rewards":{"get":{"operationId":"listCustomerRewards","tags":["Rewards"],"summary":"A customer's rewards waiting to be redeemed","description":"The customer's rewards your API can redeem: those earned under a program that runs on the Developer API, usable ones first, then soonest-expiring. A reward from a program that runs only somewhere else, for example only at the till, is not listed and cannot be redeemed here. Normally zero or one: a customer redeems a usable reward before they earn again, and a new qualifying order grows the reward they already have. Call it when the customer reaches checkout: apply a reward with `POST /v1/redeem` when it is `usable` and the order reaches its minimum; otherwise record the order with `POST /v1/earn`, or nothing while a usable reward is waiting.","parameters":[{"schema":{"type":"string","minLength":1,"example":"clx1cust00001","description":"The customer id from `linkCustomer`."},"required":true,"description":"The customer id from `linkCustomer`.","name":"id","in":"path"}],"responses":{"200":{"description":"The customer's live rewards.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RewardsResponse"}}}},"401":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"curl -X GET https://api.sandbox.trovy.ca/v1/customers/cus_01HX8Z9K2M4N6P8Q/rewards \\\n  -H \"Authorization: Bearer $TROVY_SECRET_KEY\""},{"lang":"typescript","label":"TypeScript","source":"// A customer's live rewards, usable ones first.\n\nimport { Trovy } from \"@trovy/sdk\";\n\nconst trovy = new Trovy({ apiKey: process.env.TROVY_SECRET_KEY! });\n\ndeclare const customerId: string;\ndeclare const order: { totalCents: number };\n\nconst { rewards } = await trovy.rewards.list(customerId);\n\n// Usable rewards first, then the soonest to expire. Offer the first only if it\n// is usable now and this order reaches its minimum: the rewards guide says what\n// to record when it does not.\nconst next = rewards[0];\nif (next?.usable && order.totalCents >= next.minimumOrderCents) {\n  console.log(`Offer ${next.valueCents} off, until ${next.expiresAt}`);\n}"}]}},"/v1/earn":{"post":{"operationId":"earnReward","tags":["Rewards"],"summary":"Record an order and earn the customer a reward","description":"Call it once the order is paid. The reward is the program's rate of the order total, rounded to whole dollars, so small orders can earn nothing (`earnedCents: 0`) — that is still a recorded order, not an error. A customer with a usable reward waiting cannot earn again until it is redeemed: the answer is 409 `ACTIVE_REWARD_EXISTS` with the reward in `details`, and your checkout should offer to apply it (`POST /v1/redeem`) instead. A reward the program is still holding back (`usable: false`) doesn't block: the order adds to it. Retrying with the same `Idempotency-Key` returns the original result with `replayed: true`.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[A-Za-z0-9_.-]+$","example":"ORD-10233","description":"Sent as `Idempotency-Key` (header names are case-insensitive). The id your system already has for this event: your order id for an earn or redeem, your refund's own id for a refund. Retrying with the same key returns the original result (`replayed: true`); reusing it with a different body is refused with `IDEMPOTENCY_MISMATCH`. Letters, digits, `.`, `_` and `-`, up to 128 characters."},"required":true,"description":"Sent as `Idempotency-Key` (header names are case-insensitive). The id your system already has for this event: your order id for an earn or redeem, your refund's own id for a refund. Retrying with the same key returns the original result (`replayed: true`); reusing it with a different body is refused with `IDEMPOTENCY_MISMATCH`. Letters, digits, `.`, `_` and `-`, up to 128 characters.","in":"header","name":"idempotency-key"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EarnRequest"}}}},"responses":{"200":{"description":"The stored result of an earlier call with this Idempotency-Key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EarnResponse"}}}},"201":{"description":"The order was recorded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EarnResponse"}}}},"400":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"ORDER_ID=ORD-10233   # your own order id: the key and the body name the same order\n\ncurl -X POST https://api.sandbox.trovy.ca/v1/earn \\\n  -H \"Authorization: Bearer $TROVY_SECRET_KEY\" \\\n  -H \"Idempotency-Key: $ORDER_ID\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"customerId\": \"cus_01HX8Z9K2M4N6P8Q\",\n    \"orderId\": \"ORD-10233\",\n    \"amountCents\": 5000\n  }'"},{"lang":"typescript","label":"TypeScript","source":"// Record a paid order and earn a reward on it.\n\nimport { Trovy } from \"@trovy/sdk\";\n\nconst trovy = new Trovy({ apiKey: process.env.TROVY_SECRET_KEY! });\n\ndeclare const customerId: string;\ndeclare const order: { id: string; totalCents: number };\n\n// Call this after the payment succeeds. amountCents is what the customer paid\n// you, in your business's currency.\nconst earn = await trovy.rewards.earn(\n  { customerId, orderId: order.id, amountCents: order.totalCents },\n  // Your own order id, so a retry from any process replays instead of earning twice.\n  { idempotencyKey: order.id }\n);\n\nconsole.log(earn.earnedCents);"}]}},"/v1/redeem":{"post":{"operationId":"redeemReward","tags":["Rewards"],"summary":"Apply a customer's reward to an order","description":"Take the reward's `valueCents` off the order yourself, capture the rest, then call this once the order is paid. The order must reach the reward's `minimumOrderCents`. On a recurring program the order also earns a new reward on what the customer actually paid (`newReward`). Rewards are redeemed oldest first. A reward the program is still holding back (`usable: false`) is refused with `REWARD_NOT_YET_USABLE`. Retrying with the same `Idempotency-Key` returns the original result with `replayed: true`.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[A-Za-z0-9_.-]+$","example":"ORD-10233","description":"Sent as `Idempotency-Key` (header names are case-insensitive). The id your system already has for this event: your order id for an earn or redeem, your refund's own id for a refund. Retrying with the same key returns the original result (`replayed: true`); reusing it with a different body is refused with `IDEMPOTENCY_MISMATCH`. Letters, digits, `.`, `_` and `-`, up to 128 characters."},"required":true,"description":"Sent as `Idempotency-Key` (header names are case-insensitive). The id your system already has for this event: your order id for an earn or redeem, your refund's own id for a refund. Retrying with the same key returns the original result (`replayed: true`); reusing it with a different body is refused with `IDEMPOTENCY_MISMATCH`. Letters, digits, `.`, `_` and `-`, up to 128 characters.","in":"header","name":"idempotency-key"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedeemRequest"}}}},"responses":{"200":{"description":"The stored result of an earlier call with this Idempotency-Key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedeemResponse"}}}},"201":{"description":"The reward was applied and the order recorded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedeemResponse"}}}},"400":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"ORDER_ID=ORD-10456   # a new order, so a new key: one key names one event\n\ncurl -X POST https://api.sandbox.trovy.ca/v1/redeem \\\n  -H \"Authorization: Bearer $TROVY_SECRET_KEY\" \\\n  -H \"Idempotency-Key: $ORDER_ID\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"customerId\": \"cus_01HX8Z9K2M4N6P8Q\",\n    \"rewardId\": \"rwd_01HX8Z9K2M4N6P8Q\",\n    \"orderId\": \"ORD-10456\",\n    \"amountCents\": 12000\n  }'"},{"lang":"typescript","label":"TypeScript","source":"// Apply a reward to an order, once the order is paid.\n\nimport { Trovy } from \"@trovy/sdk\";\n\nconst trovy = new Trovy({ apiKey: process.env.TROVY_SECRET_KEY! });\n\ndeclare const customerId: string;\ndeclare const reward: { id: string; valueCents: number };\ndeclare const order: { id: string; totalCents: number };\ndeclare function capturePayment(amountCents: number): Promise<void>;\n\n// Take the reward off the order yourself and capture the remainder FIRST. A\n// redeemed reward is never handed back, so redeeming ahead of a capture that\n// then fails would cost the customer their reward. A reward is used whole: on an\n// order worth less than the reward, the rest is forfeited.\nawait capturePayment(Math.max(0, order.totalCents - reward.valueCents));\n\nconst redeem = await trovy.rewards.redeem(\n  // amountCents is the order's full total, before the reward.\n  { customerId, rewardId: reward.id, orderId: order.id, amountCents: order.totalCents },\n  { idempotencyKey: order.id }\n);\n\n// On a recurring program, what the customer paid may have earned the next one.\nconsole.log(redeem.redeemedCents, redeem.newReward?.valueCents);"}]}},"/v1/refunds":{"post":{"operationId":"createRefund","tags":["Rewards"],"summary":"Record a refund and adjust the reward it earned","description":"Call it after you refund the customer, in full or in part; the total across calls can never exceed what they paid. The reward the order earned shrinks in proportion while it is still live — a reward already redeemed, expired or cancelled is left alone, and `rewardAdjustment` says which. Refunding an order that redeemed a reward never reinstates that reward; only the new reward the order earned is reduced. Retrying with the same `Idempotency-Key` returns the original result.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[A-Za-z0-9_.-]+$","example":"ORD-10233","description":"Sent as `Idempotency-Key` (header names are case-insensitive). The id your system already has for this event: your order id for an earn or redeem, your refund's own id for a refund. Retrying with the same key returns the original result (`replayed: true`); reusing it with a different body is refused with `IDEMPOTENCY_MISMATCH`. Letters, digits, `.`, `_` and `-`, up to 128 characters."},"required":true,"description":"Sent as `Idempotency-Key` (header names are case-insensitive). The id your system already has for this event: your order id for an earn or redeem, your refund's own id for a refund. Retrying with the same key returns the original result (`replayed: true`); reusing it with a different body is refused with `IDEMPOTENCY_MISMATCH`. Letters, digits, `.`, `_` and `-`, up to 128 characters.","in":"header","name":"idempotency-key"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefundRequest"}}}},"responses":{"200":{"description":"The stored result of an earlier call with this Idempotency-Key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefundResponse"}}}},"201":{"description":"The refund was recorded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefundResponse"}}}},"400":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"See `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"curl","label":"curl","source":"REFUND_ID=ORD-10233-refund-1   # your refund's own id, never the order's key\n\ncurl -X POST https://api.sandbox.trovy.ca/v1/refunds \\\n  -H \"Authorization: Bearer $TROVY_SECRET_KEY\" \\\n  -H \"Idempotency-Key: $REFUND_ID\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"orderId\": \"ORD-10233\",\n    \"amountCents\": 5000\n  }'"},{"lang":"typescript","label":"TypeScript","source":"// Reverse an order and claw back what it earned.\n\nimport { Trovy } from \"@trovy/sdk\";\n\nconst trovy = new Trovy({ apiKey: process.env.TROVY_SECRET_KEY! });\n\ndeclare const order: { id: string };\ndeclare const refundedCents: number;\ndeclare const refundId: string;\n\n// Partial refunds are allowed and claw back proportionally. Name the original\n// order, not the earn call, and key the call by your refund's own id.\nawait trovy.rewards.refund(\n  { orderId: order.id, amountCents: refundedCents },\n  { idempotencyKey: refundId }\n);"}]}}},"webhooks":{}}