Handling sandbox webhooks

  • 6 October 2021
  • 1 reply
  • 1510 views

Userlevel 5
Badge +9

RevenueCat can send webhook notifications to your backend any time an event happens in your app. Events include initial purchases, cancellations, renewals and more. Sandbox purchases also generate webhooks. This post is a deep dive into how to handle webhook events for sandbox purchases.

What are sandbox webhook events?

Sandbox webhook events are generated from sandbox transactions. Sandbox events have environment=sandbox and they contain data about sandbox transactions, like expiration date, trial period, etc. We send them when you make sandbox purchases so that you can test your backend code to make sure it handles webhook events properly.

To learn more about what’s included in a webhook event, see our webhooks guide.

Note that some events, like transfers, aren't production or sandbox, and so don't have an environment property.

The following user webhook events don't have an environment property:

  • TRANSFER

  • SUBSCRIBER_ALIAS

This is because these events are user events, not transaction events.

Sandbox transactions vs. sandbox users

RevenueCat has no concept of a sandbox user. That's because users can have both sandbox and production purchases. This is similar to Apple's TestFlight feature, which allows users to switch between sandbox and production versions of the app and make purchases in each version using one Apple ID.

This means that you should not consider users sandbox or production either. You should mark sandbox transactions as non-production in your backend so you don't accidentally include them in your metrics.

TestFlight transactions are considered to be sandbox transactions so they result in webhooks with environment=sandbox.

What should you do with sandbox events?

Sandbox events are useful to test your webhook processing code without having to make real transactions. However, there are different ways of accommodating them in your production code.

Option 1: Process sandbox webhook events with production code

The best option is to process sandbox events as if they were production events so that you can fully test your production code. That means sending any push notifications or emails in response to webhook events, saving them in your database alongside production webhooks, etc. This may feel counterintuitive, but Apple and Google handle this situation similarly. Apple requires you to make a production Apple ID solely for sandbox testing, and Google sends real confirmation emails for sandbox purchases. Handling them this way allows you to completely test your production code end-to-end with test transactions to catch issues before they affect end users.

// incomingWebhook is a Firebase Cloud Function that extracts the event out
// of the request body. The function doesn't differentiate between sandbox
// and production webhooks.

const functions = require('firebase-functions');

exports.incomingWebhook = functions.https.onRequest((request, response) => {

// Get the event from the request's body.
const event = request.body.event;

// Log the event for testing purposes.
functions.logger.info(`Received event: ${JSON.stringify(event)}`);

// Optionally, you can save the webhook in a database for
// future reference. For example:
admin.initializeApp();

const db = admin.firestore();

const docRef = db.collection('webhooks').doc(`${event.id}`);

docRef.set(event)
.then(functions.logger.info)
.catch(functions.logger.error);

// Continue processing the webhook, for example send a push notification,
// make an API request, etc. This is when you can check the `environment`
// property to handle sandbox webhook events differently.

// Make sure to notify RevenueCat that the event has been
// successfully received.
return response.status(200).send('Received event');
});

 

Beyond just putting the sandbox events through your production flow, you can customize your logic in places where it makes sense. For example, if you normally send a confirmation email on initial purchase events, you can include messaging that clearly informs the customer that they won’t be charged for their sandbox purchase.

// This sample Firebase Cloud Function illustrates sending different confirmation
// emails for an initial purchase depending on the webhook's environment.

const functions = require('firebase-functions');

exports.sendEmaiForInitialPurchase = functions.https.onRequest((request, response) => {

// Get the event from the request's body.
const event = request.body.event;

if (event.type === 'INITIAL_PURCHASE') {
if (event.environment === 'SANDBOX') {
sendEmail('Thanks for your purchase! You were not charged for this test purchase.');
} else {
sendEmail('Thanks for your purchase!');
}
}

// Notify RevenueCat that the webhook was successfully received.
return response.status(200).send('Received event');
});

 

Option 2: Route sandbox webhook events to a separate code path

If your system isn't built to ingest sandbox events in a production environment (for example, if you're forwarding them to another service that isn't able to handle them), then the other option is to filter them out as soon as you receive them and send them through a special sandbox flow. This can include putting them into a database that has its own dashboard for internal use or logging when you receive them for debugging purposes.

// catchRevenueCatWebhook is a Firebase Cloud Function that extracts the event out
// of the request body and filters out sandbox events before continuing.

const admin = require('firebase-admin');
const functions = require('firebase-functions');

exports.catchRevenueCatWebhook = functions.https.onRequest((request, response) => {

// Get the event from the request's body.
const event = request.body.event;

// If the webhook event was generated for a sandbox transaction,
// log the event and notify RevenueCat that the webhook was
// successfully received.
if (event.environment === 'SANDBOX') {

// Log the sandbox event for testing purposes.
functions.logger.info(`Received sandbox event: ${JSON.stringify(event)}`);

// Optionally, you can save the webhook in a database for
// future reference. For example:

admin.initializeApp();

const db = admin.firestore();

const docRef = db.collection('sandbox_webhooks').doc(`${event.id}`);

docRef.set(event)
.then(functions.logger.info)
.catch(functions.logger.error);

// Notify RevenueCat that the webhook was successfully received.
return response.status(200).send('Received sandbox event');
}

// The event is production, so handle it as normal.
// For example, send an email, record it in your database, etc.

// Make sure to notify RevenueCat that the event has been
// successfully received.
return response.status(200).send('Received production event');
});

 

The issue with this technique is that TRANSFER and SUBSCRIBER_ALIAS events don't differentiate between sandbox and production environments, so they don't have environment properties. You should be prepared to handle transfer/alias events that happen as a result of your tests. One way to do this is by automatically assuming that any event without an environment property is a user event and figure out whether this event should go through your production or sandbox flows.

// This sample Firebase Cloud Function illustrates how you would differentiate between
// user and transaction webhook events.

const functions = require('firebase-functions');

exports.catchRevenueCatWebhook = functions.https.onRequest((request, response) => {

// Get the event from the request's body.
const event = request.body.event;

const isUserEvent = event.environment;

if (isUserEvent) {
// This event is a user event such as TRANSFER or SUBSCRIBER_ALIAS, handle accordingly.
} else {
// Route purchase events based on environment property.
if (event.environment === 'SANDBOX') {
// Route sandbox event to sandbox code
} else {
// Route production event to production code
}
}

// Notify RevenueCat that the webhook was successfully received.
return response.status(200).send('Received event');
});

 

Option 3: Create a RevenueCat app specifically for testing

If you really don't want to deal with sandbox data in your production databases and don't want to spend the engineering effort to reroute sandbox webhook events in your production code, you can create a RevenueCat app specifically for sandbox testing.

This involves creating a second RevenueCat app that's identical to your production app, including product setup, App Store Shared Secret and Google Play Service Credentials, bundle ID and package name, etc, with the only difference being the webhook URL. The webhook URL for the second app would point to your staging backend endpoint. Once this is all set up, build your app using the staging app's public API key when making sandbox purchases and the production app's API key when publishing a release.

Note that even with a separate webhook for sandbox purchases, you may still get some sandbox purchases sent to the production webhook endpoint if you use TestFlight. That’s because you need to publish production builds to TestFlight, but purchases made in TestFlight are still sandbox purchases. Since TestFlight users are usually the same as your end users, you should process these sandbox purchases as if they were production with respect to entitlement access.

A potential issue with this option is that it's easy to accidentally release an update for your app with the staging app's API key, which would disrupt your users' access to their purchases. Ensure you have a good process in place to prevent this, including shipping production releases to TestFlight, automatic build scripts to switch API keys depending on build environment, and using phased releases to slowly roll out updates to your users.

For more information about webhooks,, check out our webhooks documentation and Firebase sample code.


1 reply

Badge

So RevenueCat staff, now that RevenueCat supports multiple web hooks and filtering production vs staging transactions, would there be a preferred fourth option, using one web hook filtering production transactions, and another web hook filtering sandbox transactions?

Reply