Store purchases

App Store and Play purchases through RevenueCat and a relay.

Gemmein never talks to the App Store or Play. A store purchase reaches it as a webhook from RevenueCat into a relay, which grants the product the same way a Stripe purchase does.

The Webhooks page offers three templates under RevenueCat (app stores): the purchase below, revenuecat-expiration for EXPIRATION (access ends), and revenuecat-refund for a refund, which RevenueCat sends as a CANCELLATION with cancel_reason CUSTOMER_SUPPORT. They are three relays because they take three different actions: a purchase fulfils the product, an expiry ends the access, and a refund undoes the purchase. Give each relay its own URL and its own secret, and put that secret in RevenueCat's Authorization header field (shared_token reads the authorization header).

RevenueCat sends a token, not a signature. The same token comes on every call, so anyone who captures one call can send fake events at any time. A relay on a shared token only writes records and calls your URL unless its trigger sets "trustUnsignedEvents": true. Each RevenueCat template sets it, with this warning in its note, so the relay can grant and refund; saving the template is your choice to accept that, and Logs records who turned it on. Your AI asks you before setting it. See Unsigned events.

The purchase relay's when names the three purchase events as a set: "when": { "event_type": { "in": ["INITIAL_PURCHASE", "RENEWAL", "NON_RENEWING_PURCHASE"] } }. Anything else sent to its URL answers 200, is not recorded and grants nothing. Narrow the RevenueCat integration to the same three events as well. On the Payments page, set the product's "How it's sold" to the purchase relay, which lets it grant a purchase.

{
  "name": "revenuecat-purchase",
  "trigger": {
    "kind": "receiver",
    "verify": { "scheme": "shared_token", "header": "authorization" },
    "trustUnsignedEvents": true,
    "map": {
      "event_id": "event.id",
      "event_type": "event.type",
      "person_token": "event.app_user_id",
      "product_id": "event.product_id",
      "store": "event.store",
      "transaction_id": "event.transaction_id"
    },
    "when": { "event_type": { "in": ["INITIAL_PURCHASE", "RENEWAL", "NON_RENEWING_PURCHASE"] } }
  },
  "actions": [
    { "type": "fulfil_product", "product": "Pro", "ref": "{{mapped.transaction_id}}" }
  ]
}

Both other relays carry the same verify and "trustUnsignedEvents": true. The expiry relay maps the same fields without transaction_id and has one action, { "type": "revoke_access", "entitlement": "access:pro" }, where the entitlement is the key the purchase granted. A subscription ending is not a refund: the access ends and the credits already bought stay.

The refund relay maps the same fields as the purchase relay plus "cancel_reason": "event.cancel_reason", and has one action, { "type": "refund_product", "product": "Pro", "ref": "{{mapped.transaction_id}}" }. The ref is the reference the purchase relay fulfilled the purchase under, so the refund undoes that one purchase: the access it granted ends, the credits it added are taken back (first from that purchase's own grant, then from other credits the person holds, never below zero), and its receipt says refunded. Access held through any other purchase, such as a subscription beside a refunded credit pack, is untouched. A refund RevenueCat sends again finds the purchase already refunded and changes nothing.

That key comes from the product's name the first time it is saved: access: plus the name in lowercase, with every run of characters that are not letters or digits turned into one dash. A product named Pro Max carries access:pro-max. The key is created once, so a later rename keeps it and nobody loses what they hold.

The other two relays have their own filters too: revenuecat-expiration has "when": { "event_type": "EXPIRATION" } and revenuecat-refund has "when": { "event_type": "CANCELLATION", "cancel_reason": "CUSTOMER_SUPPORT" }. A when with two fields matches only when both match.

A cancellation keeps the access until the period ends. RevenueCat sends CANCELLATION as soon as someone turns off auto-renew (cancel_reason UNSUBSCRIBE, BILLING_ERROR and the rest), and EXPIRATION when the period they paid for ends. The cancellation matches none of the three relays, answers 200 and is not recorded; the expiration relay takes the access away at the end of the period.

Each relay guards itself with its when. One URL pasted into the wrong RevenueCat integration, or an integration widened later, would otherwise grant on an ending or take access away from someone who paid, on the same event that granted it. An event the filter does not match answers 200 and is not recorded.

A receiver map names the person one way: person_email (their address, which invites someone who has never signed in), person_id (a Gemmein person id) or person_token (the store account token their app filed with a store). Two of them in one map is refused when the relay is saved. The store relays use person_token, so neither the person id nor the address ever enters RevenueCat's ledger.

The webhook arrives after the store's own confirmation. The purchase is complete on the device before Gemmein has heard about it, so the balance a phone reads back a moment later may still be the old one. Show the pack as arriving until the balance reads back, never as failed, and never grant the goods on the client.

Was this page helpful?

Last updated