Store → Refunds

Refund a completed storefront or in-app purchase. Every refund path issues a real Stripe refund, updates the Store's purchase row, and — on success — fires a signed outbound webhook to the app that owns the purchase.

Refund Surfaces

Refunds can be issued from three places:

  • Parent self-serve — full refunds only, on storefront purchases within 7 days of purchase. Parents see a Refund button on refundable rows at store.timeback.com/account/purchases. Nothing for your app to do — the Store handles it end-to-end and delivers a purchase.refunded webhook to your webhookUrl.
  • Developer RESTPOST /apps/v1/purchases/{purchaseId}/refund for storefront purchases and POST /apps/v1/in-app-purchases/{inAppPurchaseId}/refund for in-app purchases. Both accept an optional amountCents for partial refunds. Authenticated with the same M2M client-credentials token you use for in-app purchases.
  • Developer MCP — the refund_purchase tool wraps both REST endpoints so you can drive refunds from Cursor / VS Code / Claude Code / ChatGPT. Same semantics, MCP transport.

Rules

One refund per purchase

A purchase can be refunded at most once. Any second refund attempt — partial after partial, partial after full, or full after partial — is rejected with 409 and code: "already-refunded". The one exception is a full-refund retry against an already-fully-refunded purchase, which is idempotent and returns 200 with status: "already_refunded" (this keeps parent-UI retries and network re-tries from breaking).

7-day refund window

Every refund path enforces a 7-day window from the purchase's created_at. Out-of-window attempts return 403 with code: "refund-window-expired".

Entitlement and subscription side effects

Refund Student entitlement Stripe subscription
Storefront full (parent or developer) Revoked — the beneficiary's user_profile is deleted on the Platform and the Store's beneficiary row is flipped to inactive Cancelled if the purchase was subscription-backed
Storefront partial (developer only) Preserved — the student keeps access Not cancelled
In-app purchase (full or partial, developer only) Preserved Not touched

Partial refunds are meant to compensate the parent without removing access — no side effects beyond the money movement. Only a full refund on a storefront purchase revokes the entitlement.

Developer REST — Storefront Refund

Refund a storefront purchase your app owns. Authenticate with the same M2M client-credentials token you use for the in-app purchase endpoint (scope: https://store.timeback.com/apps/v1/purchases.write).

Endpoint

POST /apps/v1/purchases/{purchaseId}/refund

Request body

The body must be a JSON object. Send {} for a full refund; include amountCents (strictly less than the purchase amount) for a partial refund. The Content-Type must be application/json — an empty request body is rejected by the JSON validator before it reaches the refund service.

# Full refund
curl -X POST https://api.store.timeback.com/apps/v1/purchases/{purchaseId}/refund \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{}'

# Partial refund
curl -X POST https://api.store.timeback.com/apps/v1/purchases/{purchaseId}/refund \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"amountCents": 2500}'
Field Type Description
amountCents integer, optional Partial-refund amount in cents. Must be a positive integer strictly less than the purchase amount. Omit for a full refund.

Response — 200 OK

{ "status": "refunded", "refundState": "full" }
Field Values
status refunded (this call issued a Stripe refund) or already_refunded (idempotent same-shape retry on an already-fully-refunded purchase)
refundState full when the row is now fully refunded, partial when a partial refund landed

Developer REST — In-App Purchase Refund

Same shape as the storefront path, targeting an in-app purchase row instead. The entitlement rule is different — IAP refunds never revoke access.

Endpoint

POST /apps/v1/in-app-purchases/{inAppPurchaseId}/refund

Request and response

Identical to the storefront endpoint. Send {} for a full refund or {"amountCents": N} for a partial. Response uses the same { status, refundState } shape.

Listing Purchases

Two developer-scoped list endpoints return every paid purchase / in-app purchase for the calling app so you can find rows to refund. Neither pre-filters by refund eligibility — your app applies the 7-day + one-refund rule based on the returned purchasedAt and refundState.

Endpoint Returns
GET /apps/v1/purchases Storefront purchases scoped to the calling app
GET /apps/v1/in-app-purchases In-app purchases scoped to the calling app

Both accept optional parentEmail and studentEmail filters and are paginated via limit (max 50, default 10) and offset (default 0). Rows include the current refundState (null, partial, or full), refundedAmountCents, and refundedAt when applicable.

Developer MCP — refund_purchase

The refund_purchase Store MCP tool routes to both REST endpoints. Same fields, MCP transport.

{
  "purchaseType": "storefront",
  "purchaseId": "3c3c3c3c-3333-3333-3333-333333333333",
  "storeAppId": "77752607-7069-44f9-8659-d744edf4d0e5",
  "amountCents": 2500
}
Field Type Description
purchaseType enum storefront refunds a storefront purchase; in-app refunds an in-app purchase.
purchaseId UUID Purchase or in-app purchase id. Interpretation is scoped by purchaseType.
storeAppId UUID The store app that owns the purchase. The caller must hold app:manage_store on this app; a mismatch is treated as not found.
amountCents integer, optional Partial-refund amount. Omit for a full refund. Must be strictly less than the purchase amount.

The tool verifies app:manage_store on the target storeAppId before routing, so a call scoped to an app you don't manage is rejected up front. On success the tool returns the same { status, refundState } payload as the REST endpoints.

Outbound Webhooks

Every successful refund emits a signed outbound webhook to the webhookUrl you registered on register_app, using the same HMAC-SHA256 scheme as the in-app purchase outcome webhooks. If your app doesn't have a webhookUrl registered, the delivery is dropped silently — the refund still lands in the DB and at Stripe.

Event types

Event When
purchase.refunded A storefront purchase was refunded (full or partial).
in-app-purchase.refunded An in-app purchase was refunded (full or partial).

purchase.refunded envelope

{
  "id": "e1e1e1e1-eeee-eeee-eeee-eeeeeeeeeeee",
  "type": "purchase.refunded",
  "timestamp": "2026-07-08T10:58:47.300Z",
  "data": {
    "purchaseId": "3c3c3c3c-3333-3333-3333-333333333333",
    "parentEmail": "parent@example.com",
    "studentEmails": ["student1@example.com"],
    "amountCents": 49900,
    "refundAmountCents": 49900,
    "refundedAmountCents": 49900,
    "currency": "USD",
    "refundState": "full",
    "refundedAt": "2026-07-08T10:58:46.474Z"
  }
}
Field Description
purchaseId Storefront purchase UUID.
parentEmail Payer email.
studentEmails Beneficiary student emails on the purchase.
amountCents Purchase total (unchanged from the original purchase).
refundAmountCents Amount refunded on this event.
refundedAmountCents Cumulative refunded amount on the purchase after this event.
currency ISO 4217 currency code.
refundState partial or full.
refundedAt ISO-8601 timestamp of the refund.

in-app-purchase.refunded envelope

Same envelope shape, IAP-specific data:

{
  "id": "e1e1e1e1-eeee-eeee-eeee-eeeeeeeeeeee",
  "type": "in-app-purchase.refunded",
  "timestamp": "2026-07-08T11:06:19.100Z",
  "data": {
    "inAppPurchaseId": "d4256366-544f-4c50-b4b6-214e10eacef7",
    "appTransactionId": "8c2436a6-def7-4744-8dfe-134b47439eb4",
    "catalogItemId": "e9ab29b0-6579-4c3a-ba6e-6db8589118da",
    "subscriptionId": "07cb97d3-6e86-44b7-8d00-5a3421b8934b",
    "parentEmail": "parent@example.com",
    "studentEmail": "student@example.com",
    "amountCents": 2500,
    "refundAmountCents": 500,
    "refundedAmountCents": 500,
    "currency": "USD",
    "refundState": "partial",
    "refundedAt": "2026-07-08T11:06:19.026Z"
  }
}

studentEmail is a single value on IAPs (each IAP is scoped to one student).

Verify signatures against the raw request body using the same code path shown in Verify the Signature.

Idempotency

Each refund path uses a distinct Stripe idempotency key so retries at any layer converge to the same Stripe refund object:

  • Full refund: <purchaseId> (or <inAppPurchaseId>).
  • Partial refund: <purchaseId>:partial (or <inAppPurchaseId>:partial).

Combined with a row-level lock on the purchase row inside the Store's refund transaction, this means:

  • A network retry of the exact same request returns the same outcome without double-refunding Stripe or double-emitting the webhook.
  • Concurrent refunds against the same purchase serialize on the row lock. The second caller reads the terminal state after the first commits and either short-circuits (same-shape full retry) or rejects with 409 already-refunded (any other shape).

Error Reference

Status Code When
400 (no code) amountCents is not a positive integer, or >= purchase.amountCents, or the request body is missing / not JSON.
403 refund-window-expired The purchase is more than 7 days old.
404 (no code) The purchase or in-app purchase does not exist, or is not scoped to the calling app.
409 already-refunded A refund on this purchase has already been recorded (any state) — a same-shape retry on a fully-refunded purchase is the only exception (returns 200 already_refunded).

Store → Introduction

What the Store provides and the publish path.

Store → In-App Purchases

The flow that creates refundable in-app purchase rows.

Store → MCP Setup

Connect Cursor / VS Code / Claude Code / ChatGPT to the Store MCP to use refund_purchase.

Store → Integration

The full Store MCP tool surface and the storefront listing lifecycle.