Store → Discount Codes
Run promos on your Store listing without building redemption logic. You create a code through the Store MCP. A parent can type it at checkout, and your backend can check it first with the same M2M credentials you already use for in-app purchases. The Store validates it, prices it, and records which purchase it applied to.
Scope
Every discount code belongs to exactly one of your apps. storeAppId is required on create_discount_code, and the tool checks your app:manage_store grant on that app before writing — so you can create codes for your own listings and only those. A code presented at checkout for a different app is rejected as app_mismatch.
Codes apply to storefront checkout only. In-app purchases never carry a discount — they charge the catalog item price as configured. The M2M validate route below is a preview for that storefront path, not a way to discount an in-app charge.
Creating a Code
create_discount_code takes:
| Field | Required | Notes |
|---|---|---|
code |
yes | The string parents type. Case-insensitive and unique across the whole Store. |
discountType |
yes | flat (fixed cents off) or percent (basis points off). |
amountCents |
for flat |
Positive integer. Must be omitted for percent. |
percentBps |
for percent |
1–10000, where 100 = 1%. Must be omitted for flat. |
currency |
for flat |
ISO 4217. Must be omitted for percent, since a percentage is currency-free. |
storeAppId |
yes | The app the code applies to. |
expiresAt |
no | ISO 8601. Must be in the future — Stripe rejects a past expiry. Omit for none. |
maxRedemptions |
no | Global cap across all parents. Omit for unlimited. |
firstTimeCustomerOnly |
no, defaults false |
When true, only valid for a parent with no previous paid purchase. |
A flat code only applies to a plan in the same currency; mismatches are rejected at checkout rather than converted.
Codes Are Immutable
There is no update tool. A code's terms are fixed the moment it is created.
This is deliberate. Your code string is also the identifier of the underlying Stripe coupon, and Stripe coupons cannot be edited — changing a discount means deleting and recreating the coupon. Because Stripe's discount records point at the coupon by ID rather than storing a copy of its terms, recreating one would retroactively rewrite what every past purchase appears to have been discounted by. Freezing terms at creation keeps your historical promo reporting honest.
To change a promo: deactivate the old code and create a new one with the new terms.
Retiring a Code
deactivate_discount_code takes the code and retires it. A retired code stops validating at checkout immediately, and purchases that already used it keep their attribution — your revenue history is unaffected.
Two consequences worth planning around:
- Deactivation is permanent. There is no reactivation tool. Since terms are fixed at creation, a revived code would come back with exactly the terms you retired it for.
- The string is spent. A retired code's string stays claimed forever and cannot be reused for a new code. Version your promo codes (
LAUNCH20,LAUNCH20-Q2) rather than planning to recycle one.
Deactivating an already-retired code succeeds and changes nothing, so retries are safe.
Listing Your Codes
list_discount_codes requires storeAppId and returns that app's codes, including retired ones and their current state. discountType is the only optional filter. Each row carries redeemedCount so you can see how a promo is performing against its maxRedemptions cap.
Validate From Your Backend (REST)
Campaign and onboarding backends can check a code with the same Cognito client-credentials token used for in-app purchases and refunds. There is no API key. The scope is https://store.timeback.com/apps/v1/purchases.write.
Parent checkout is unchanged: the storefront still calls POST /store/v1/discount-codes/validate with a parent session. Do not send client-credentials to /store/v1.
Step 1: Mint an M2M Access Token
curl -X POST https://timeback-st-prod-userauth.auth.us-east-1.amazoncognito.com/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-u "<CLIENT_ID>:<CLIENT_SECRET>" \
-d "grant_type=client_credentials&scope=https://store.timeback.com/apps/v1/purchases.write"
Response:
{
"access_token": "eyJraWQiOiJ...",
"expires_in": 3600,
"token_type": "Bearer"
}
Cache the access_token and reuse it for the full hour.
Step 2: Call POST /apps/v1/discount-codes/validate
The body matches parent checkout validate: code, appSlug, buyerEmail, and optional seatCount. appSlug must be your Store app — the M2M client is bound to one listing. A foreign slug is 403 with the same unknown-app message as an unknown client. Missing Authorization is 401. A parent ID token on this path is 401.
curl -X POST https://api.store.timeback.com/apps/v1/discount-codes/validate \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"code": "SCRIBBLELAUNCH",
"appSlug": "scribble",
"buyerEmail": "parent@example.com",
"seatCount": 3
}'
| Field | Required | Notes |
|---|---|---|
code |
yes | The promo string. Case-insensitive. |
appSlug |
yes | Must match the Store app bound to this M2M client. |
buyerEmail |
yes | The parent who would redeem. Used for firstTimeCustomerOnly and pricing. |
seatCount |
no | Positive integer. Pass it when the checkout would include extra seats so the preview matches. |
Well-formed authorized requests return 200. The authoritative discount is recomputed when the parent checkout session is created; this response is a preview.
Response — valid:
{
"valid": true,
"originalAmountCents": 100000,
"discountCents": 20000,
"discountedAmountCents": 80000,
"currency": "USD"
}
Response — rejected:
{
"valid": false,
"reason": "expired"
}
There is no remaining-redemption-count field. If a campaign only needs to know whether a coupon is still sendable, map reason as:
limit_reached,expired,inactive,unknown— do not send this code.not_first_time,app_mismatch— the code is still live; this recipient or app cannot use it.currency_mismatch— the code is still live; it does not apply to this plan's currency.
Why a Code Was Rejected
Validation runs when the parent enters the code, when your backend calls the M2M route above, and again authoritatively when the checkout session is created. A rejection carries one of:
| Reason | Meaning |
|---|---|
unknown |
No code with that string exists. |
inactive |
The code was deactivated. |
expired |
expiresAt has passed. |
limit_reached |
maxRedemptions has been reached. |
not_first_time |
firstTimeCustomerOnly is set and the parent has purchased before. |
currency_mismatch |
A flat code's currency differs from the plan's currency. |
app_mismatch |
The code belongs to a different app. |
Related Docs
Store → Integration
The full MCP tool surface and the runtime entitlement check at app launch.
Store → MCP Setup
Connect Cursor / VS Code / Claude Code / ChatGPT to the Store MCP.
Store → In-App Purchases
Server-to-server purchases, which discount codes do not apply to.
Store → Refunds
What happens to a discounted purchase when it is refunded.
