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 webhookUrl set at registration time.
  • You captured the cognitoClient.clientId + cognitoClient.clientSecret and the outboundWebhookSigningSecret from the register_app response — 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 email Payer's email — must match the parent who holds the active subscription.
studentEmail email 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.

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.