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.
Thanks for telling us.
Thanks for telling us.
Last updated