Store → In-App Purchases
Server-to-server endpoint your app calls to charge the parent's saved payment method off-session against a pre-registered catalog item. The endpoint accepts the request and returns 202 pending; the terminal outcome is delivered asynchronously via a signed webhook to the webhookUrl you set on register_app.
When to Use This
Charge for in-experience purchases (a student prize, a premium unlock, a one-off content pack) after the parent has already bought your app through the Store checkout. The Store already has the parent's saved payment method from that subscription and reuses it — your app never handles card data.
Prerequisites
- Your app is registered in the Store (see Store → Integration) with a
webhookUrlset at registration time. - You captured the
cognitoClient.clientId+cognitoClient.clientSecretand theoutboundWebhookSigningSecretfrom theregister_appresponse — they're returned once at registration. - The parent has an active subscription to your app (they completed at least one storefront checkout).
- You've created at least one catalog item on your app's plan.
Step 1: Create a Catalog Item
A catalog item is what your app charges for — a displayName, a priceCents, and an ISO 4217 currency. Create one via the Store MCP tool create_catalog_item, passing your app's planId:
{
"planId": "<your app's planId>",
"displayName": "Student Prize",
"priceCents": 10000,
"currency": "USD"
}
The response includes an id. Your app passes that id as catalogItemId on every purchase against this item.
Step 2: Mint an M2M Access Token
The purchase endpoint is authenticated by an OAuth 2.0 client-credentials token issued by the Store's Cognito user pool, with scope https://store.timeback.com/apps/v1/purchases.write. Use the clientId + clientSecret from your register_app response:
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 3: Call POST /apps/v1/purchases
curl -X POST https://api.store.timeback.com/apps/v1/purchases \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"catalogItemId": "1a1a1a1a-1111-1111-1111-111111111111",
"parentEmail": "parent@example.com",
"studentEmail": "student@example.com",
"appTransactionId": "b2b2b2b2-2222-2222-2222-222222222222"
}'
| Field | Type | Description |
|---|---|---|
catalogItemId |
UUID | Catalog item to charge for. Must belong to the calling app. |
parentEmail |
Payer's email — must match the parent who holds the active subscription. | |
studentEmail |
Beneficiary student's email. | |
appTransactionId |
UUID | Caller-generated per-attempt id. Used as Stripe's idempotency key and echoed on the outbound webhook. Retrying the same id is rejected with 400. |
Response — 202 Accepted:
{
"inAppPurchaseId": "3c3c3c3c-3333-3333-3333-333333333333",
"appTransactionId": "b2b2b2b2-2222-2222-2222-222222222222",
"status": "pending"
}
The response is always pending. The terminal outcome (succeeded or failed) arrives via the outbound webhook once Stripe confirms the off-session charge.
Validation Errors
| Status | Condition |
|---|---|
400 |
Unknown catalogItemId (or item belongs to a different app). |
400 |
The parent has no active subscription for the calling app. |
400 |
The parent has no saved payment method on file with the Store. |
400 |
Duplicate appTransactionId — an in-app purchase already exists for that id. |
403 |
The M2M client isn't bound to a known store app. |
Step 4: Receive the Outbound Webhook
Once Stripe confirms the charge, the Store POSTs a signed JSON envelope to your webhookUrl.
Event Types
| Event | Meaning |
|---|---|
purchase.succeeded |
Off-session charge succeeded; the in-app purchase is paid. |
purchase.failed |
Off-session charge failed. data.failure carries a code and message. |
Envelope
{
"id": "e1e1e1e1-eeee-eeee-eeee-eeeeeeeeeeee",
"type": "purchase.succeeded",
"timestamp": "2026-07-03T18:12:04.512Z",
"data": {
"inAppPurchaseId": "3c3c3c3c-3333-3333-3333-333333333333",
"appTransactionId": "b2b2b2b2-2222-2222-2222-222222222222",
"catalogItemId": "1a1a1a1a-1111-1111-1111-111111111111",
"subscriptionId": "4d4d4d4d-4444-4444-4444-444444444444",
"parentEmail": "parent@example.com",
"studentEmail": "student@example.com",
"amountCents": 10000,
"currency": "USD"
}
}
For purchase.failed, data additionally includes:
{
"failure": {
"code": "card_declined",
"message": "Your card was declined."
}
}
Headers
| Header | Value |
|---|---|
content-type |
application/json |
x-timeback-webhook-timestamp |
Unix seconds when the delivery was signed. Use for replay protection. |
x-timeback-webhook-signature |
Hex-encoded HMAC-SHA256(secret, "${timestamp}.${rawBody}") — keyed by your outboundWebhookSigningSecret. |
Verify the Signature
Always verify the signature against the raw request body (before JSON parsing) — re-serializing the body can alter key order or whitespace and break the signature.
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhook(secret: string, timestamp: string, rawBody: string, signature: string): boolean {
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'utf-8');
const b = Buffer.from(signature, 'utf-8');
return a.length === b.length && timingSafeEqual(a, b);
}
app.post('/webhooks/store', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.header('x-timeback-webhook-timestamp');
const signature = req.header('x-timeback-webhook-signature');
const rawBody = req.body.toString('utf-8');
if (!verifyWebhook(process.env.STORE_WEBHOOK_SECRET!, timestamp!, rawBody, signature!)) {
return res.status(401).send('Invalid signature');
}
if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp!, 10)) > 300) {
return res.status(401).send('Timestamp too old');
}
const envelope = JSON.parse(rawBody);
// Deduplicate by envelope.id — the same envelope may be re-delivered on retry.
// Then act on envelope.type + envelope.data.
res.status(200).send('OK');
});
The same envelope id may be delivered more than once (at-least-once delivery). Deduplicate by id before acting.
Related Docs
Store → Integration
Register your app, set webhookUrl and the plan's paymentModel, and create catalog items via MCP.
Store → MCP Setup
Connect Cursor / VS Code / Claude Code / ChatGPT to the Store MCP, and see what register_app returns.
Webhooks
The Platform's webhook subscription model — same HMAC scheme, different event surface.
