Backend entitlements
This section provides details on how to manage entitlements with your own backend
Architecture & general functionning
Managing entitlements with your own backend is the most secure approach.
In this setup, responsibilities are shared between your servers and Purchasely’s servers.
The Purchasely's servers are in charge of:
- processing and verifying transactions
- notifying your servers of any update concerning a user's entitlement (eg: a subscription is renewed)
- notifying the SDK and your app of such entitlements updates
The developer's servers are responsible for:
- listening to, processing and acknowledging Entitlement Events sent through the Purchasely webhook
- granting/revoking entitlements to the users based on these events
- securing contents themselves (eg: with DRM for streaming platform) and managing accesses to contents depending on users' entitlements
A typical timeline is as follows:
- The user opens a Purchasely paywall and makes a purchase.
- This purchase is made through the store, which returns a receipt to the Purchasely SDK.
- The receipt is sent by the SDK to Purchasely’s servers for verification and recording of the purchase.
- The information about this purchase (user, plan, renewal date, etc.) is then sent to your servers via our webhook.
- Your servers record the purchase information and confirm to our servers that they have acknowledged the purchase.
- Our servers then pass the verified purchase information back to our SDK.
- Finally, our SDK hands control back to your application
To fully implement this process, we'll go step by step:
- Configuring the webhook
- Listening to webhook events
- Authenticating messages (optional but recommended)
- Updating users entitlements
- Providing an API to the app to fetch users' entitlements
Configuring the webhook
Purchasely’s servers will make HTTP calls to your servers to pass informations concerning new/updated entitlements: this calls are called "webhooks".
Here is how to configure them.
-
Fill in Client webhook URL in your Purchasely Console (this is the URL that will be called to send you the webhooks)
Purchasely Console > [YOUR APP] > Webhooks -
Enable Entitlement Events (
ACTIVATE
&DEACTIVATE
)
Event acknowledgement expected
As soon as you have configured a Client webhook URL, the Purchasely Platform will expect an acknowledgement for the all events sent on the webhook.
You can start by implementing a simple
HTTP 200
response for every message sent on the webhook.
Managing webhook messages on your backend
Managing entitlements on your backend require 4 steps:
- Listening to Entitlement Events sent on the Purchasely Webhook and acknowledging them to confirm that:
- they have been processed
- the corresponding entitlements have been updated
- Authenticating messages (optional but recommended)
- Updating users entitlements in your database by extracting the relevant information from the Entitlement Events
- Providing an API to your app to fetch the users' entitlements
1. Listening to webhook events
Even if a lot of different events can be sent in the webhook, you should only listen to the ACTIVATE
and DEACTIVATE
events to manage your entitlements.
Upon the reception of an event, you MUST return a HTTP 200
to confirm to the Purchasely Platform that your backend has successfully handled the event.
Return (almost) always a
200
response.If you don't, the Purchasely Platform will continue sending you the message following our retry strategy. This table contains approximate retry waiting times:
# Next retry backoff Total waiting time 1 0d 0h 0m 20s 0d 0h 0m 20s 2 0d 0h 0m 26s 0d 0h 0m 46s 3 0d 0h 0m 46s 0d 0h 1m 32s 4 0d 0h 1m 56s 0d 0h 3m 28s 5 0d 0h 4m 56s 0d 0h 8m 24s 6 0d 0h 11m 10s 0d 0h 19m 34s 7 0d 0h 22m 26s 0d 0h 42m 0s 8 0d 0h 40m 56s 0d 1h 22m 56s 9 0d 1h 9m 16s 0d 2h 32m 12s 10 0d 1h 50m 26s 0d 4h 22m 38s 11 0d 2h 47m 50s 0d 7h 10m 28s 12 0d 4h 5m 16s 0d 11h 15m 44s 13 0d 5h 46m 56s 0d 17h 2m 40s 14 0d 7h 57m 26s 1d 1h 0m 6s 15 0d 10h 41m 46s 1d 11h 41m 52s 16 0d 14h 5m 20s 2d 1h 47m 12s 17 0d 18h 13m 56s 2d 20h 1m 8s 18 0d 23h 13m 46s 3d 19h 14m 54s 19 1d 5h 11m 26s 5d 0h 26m 20s 20 1d 12h 13m 56s 6d 12h 40m 16s 21 1d 20h 28m 40s 8d 9h 8m 56s 22 2d 6h 3m 26s 10d 15h 12m 22s 23 2d 17h 6m 26s 13d 8h 18m 48s 24 3d 5h 46m 16s 16d 14h 5m 4s 25 3d 20h 11m 56s 20d 10h 17m 0s Any other response code than
HTTP 200
will generate a retry.Not returning a
200
will pause subsequent webhooks for the user.To ensure webhooks are always sent in the correct order (otherwise an
ACTIVATE
/DEACTIVATE
could become aDEACTIVATE
/ACTIVATE
, causing unintended access for your user), we will not send any subsequent webhooks for the affected user until the first one succeeds on your end (ie: until we receive a200
response).Once the first webhook succeeds, any paused webhooks will be sent immediately, maintaining the correct order.
Anonymous users
Sometimes you way receive webhooks targeting an Anonymous User, even if you're not supposed to have one.
In this case, just return a
200
response and ignore the content of the webhook. When the user logs in, the subscription will be automatically associated with them, and you’ll receive the corresponding webhook.If you don’t return a
200
response, we’ll keep sending the anonymous user’s webhook, and you’ll never receive the one for the connected user (because we want to guarantee the order of webhooks, as explained before).
2. Authenticating messages (optional but recommended)
This step is optional and can be implemented later on. However, we strongly encourage to authenticate webhook messages to avoid attacks such as Man in the middle.
The authentication information is contained in the HEADER of the HTTP request :
X-PURCHASELY-REQUEST-SIGNATURE
: request signatureX-PURCHASELY-TIMESTAMP
: request timestamp
⚠️ Depending on your framework, you may receive the headers under another format:
- Ruby on Rails:
HTTP_X_PURCHASELY_REQUEST_SIGNATURE
- NestJS:
x-purchasely-request-signature
⚠️ Do not use the deprecated X-PURCHASELY-SIGNATURE
header
Sample codes for signature verification:
const crypto = require("crypto");
// Request headers
// ---------------
const xPurchaselyRequestSignature = "f3c2a452e9ea72f41107321aeaf7999f1054148866a710c9b23f9f501785e2a4";
const xPurchaselyTimestamp = "1698322022";
// Request body
// ------------
// Please use the raw body and not an already parsed body.
// For example with Node:
// DO: req.rawBody.toString()
// DON'T: JSON.stringify(req.body)
const body = "{\"a_random_key\":\"a_random_value_ad\"}";
// Signature verification
// ----------------------
const webhookSharedSecret = "foobar";
const dataToSign = xPurchaselyTimestamp + body;
const computedSignature = crypto
.createHmac("sha256", webhookSharedSecret)
.update(dataToSign)
.digest("hex");
if (computedSignature === xPurchaselySignature) {
// request authenticated
}
require 'openssl'
# Request headers
# ---------------
x_purchasely_signature = "f3c2a452e9ea72f41107321aeaf7999f1054148866a710c9b23f9f501785e2a4"
x_purchasely_timestamp = "1698322022"
# Request body
# ------------
# Please use the raw body and not an already parsed body.
# For example with Ruby on Rails: request.raw_post
body = "{\"a_random_key\":\"a_random_value_ad\"}"
# Signature verification
# ----------------------
webhook_shared_secret = "foobar"
data_to_sign = x_purchasely_timestamp + body
computed_signature = OpenSSL::HMAC.hexdigest('sha256', webhook_shared_secret, data_to_sign)
if (computed_signature == x_purchasely_signature) {
# request authenticated
}
// Imports
// -------
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
// Request headers
// ---------------
val xPurchaselySignature = "f3c2a452e9ea72f41107321aeaf7999f1054148866a710c9b23f9f501785e2a4"
val xPurchaselyTimestamp = "1698322022"
// Request body
// ------------
// Please use the raw body and not an already parsed body.
val body = "{\"a_random_key\":\"a_random_value_ad\"}"
// Signature verification
// ----------------------
val webhookSharedSecret = "foobar"
val dataToSign = xPurchaselyTimestamp + body
val hmac = Mac.getInstance("HmacSHA256")
hmac.init(SecretKeySpec(webhookSharedSecret.toByteArray(), "HmacSHA256"))
val computedSignature = hmac.doFinal(dataToSign.toByteArray()).joinToString("") { "%02x".format(it) }
if (computedSignature == xPurchaselySignature) {
// request authenticated
}
You can also compute the difference between the current timestamp and the received timestamp, then decide if the difference is within your tolerance.
3. Updating users entitlements
The ACTIVATE
/ DEACTIVATE
events are carrying the information needed to determined which entitlements shall be granted / revoked to which user:
- the plan purchased
- the store used for purchasing
- the user will be identified by their user ID if they are logged in, or by an anonymous ID if they are not.
Sample payload:
- the subset tabs display only the mandatory fields necessary for managing entitlements
- the full payload tabs show the entire event payloads
{
"purchasely_one_time_purchase_id":"otp_xxx", // if the purchase is a one-time-purchase,
"purchasely_subscription_id":"subs_xxx", // if the purchase is a subscription
"event_name": "ACTIVATE",
"store": "APPLE_APP_STORE",
"user_id": "<user id provided by the app to the sdk>", // if the user is logged in
"anonymous_user_id": "<user id provided by the app to the sdk>", // if the user is not logged in
"plan": "<plan id set in the Purchasely console>"
}
{
"purchasely_one_time_purchase_id":"otp_xxx", // if the purchase is a one-time-purchase,
"purchasely_subscription_id":"subs_xxx", // if the purchase is a subscription
"event_name": "ACTIVATE",
"store": "APPLE_APP_STORE",
"user_id": "<user id provided by the app to the sdk>", // if the user is signed-in
"anonymous_user_id": "<user id provided by the app to the sdk>", // if the user is signed-out
"plan": "<plan id set in the Purchasely console>"
}
{
"plan": "monthly",
"store": "GOOGLE_PLAY_STORE",
"product": "PURCHASELY_PLUS",
"user_id": "toto",
"event_id": "5e45109f-7fac-45f8-a7e4-464892d5d35d",
"event_name": "ACTIVATE",
"offer_type": "NONE",
"api_version": 3,
"device_type": "PHONE",
"environment": "SANDBOX",
"purchased_at": "2023-12-12T14:13:11.777Z",
"purchase_type": "RENEWING_SUBSCRIPTION",
"store_country": "FR",
"next_renewal_at": "2023-12-12T14:23:11.777Z",
"purchased_at_ms": 1702390391777,
"event_created_at": "2023-12-12T14:19:26.120Z",
"is_family_shared": false,
"store_product_id": "com.purchasely.plus.monthly",
"customer_currency": "EUR",
"plan_price_in_eur": 9.99,
"next_renewal_at_ms": 1702390991777,
"event_created_at_ms": 1702390766120,
"previous_offer_type": "NONE",
"store_app_bundle_id": "com.purchasely.demo",
"subscription_status": "AUTO_RENEWING",
"store_transaction_id": "GPA.3355-5688-7970-28037..5",
"original_purchased_at": "2023-12-12T13:48:16.233Z",
"original_purchased_at_ms": 1702388896233,
"cumulated_revenues_in_eur": 69.9,
"effective_next_renewal_at": "2023-12-12T14:23:11.777Z",
"purchasely_subscription_id": "subs_D7GnVQbUxvY6YxoeK6nhyPDkmyCVcfe",
"effective_next_renewal_at_ms": 1702390991777,
"store_original_transaction_id": "GPA.3355-5688-7970-28037",
"plan_price_in_customer_currency": 9.99
}
{
"plan": "monthly",
"store": "GOOGLE_PLAY_STORE",
"product": "PURCHASELY_PLUS",
"user_id": "toto",
"event_id": "3ab7e67a-6c88-44fe-8804-39897d601136",
"event_name": "DEACTIVATE",
"offer_type": "NONE",
"api_version": 3,
"device_type": "PHONE",
"environment": "SANDBOX",
"purchased_at": "2023-12-12T14:18:11.777Z",
"purchase_type": "RENEWING_SUBSCRIPTION",
"store_country": "FR",
"next_renewal_at": "2023-12-12T14:23:11.777Z",
"purchased_at_ms": 1702390691777,
"event_created_at": "2023-12-12T14:24:09.412Z",
"is_family_shared": false,
"store_product_id": "com.purchasely.plus.monthly",
"customer_currency": "EUR",
"plan_price_in_eur": 9.99,
"next_renewal_at_ms": 1702390991777,
"event_created_at_ms": 1702391049412,
"previous_offer_type": "NONE",
"store_app_bundle_id": "com.purchasely.demo",
"subscription_status": "UNPAID",
"store_transaction_id": "GPA.3355-5688-7970-28037..5",
"original_purchased_at": "2023-12-12T13:48:16.233Z",
"original_purchased_at_ms": 1702388896233,
"cumulated_revenues_in_eur": 69.9,
"effective_next_renewal_at": "2023-12-12T14:23:11.777Z",
"purchasely_subscription_id": "subs_D7GnVQbUxvY6YxoeK6nhyPDkmyCVcfe",
"effective_next_renewal_at_ms": 1702390991777,
"store_original_transaction_id": "GPA.3355-5688-7970-28037",
"plan_price_in_customer_currency": 9.99
}
The users entitlements must be associated to the user ID
or anonymous user ID
and stored in your database. You must solely rely on the webhook events to update them.
Do not rely on the "billing cycle end dates" to invalidate subscriptions on your end
Never use the
next_renewal_at
/effective_next_renewal_at
to invalidate a subscription and always use theDEACTIVATE
event sent on the webhook for this sole purpose. These dates are only here to help your marketing team take actions (or if you want to display the next renewal date in your app).Why is relying on these dates a mistake?
If your/Purchasely/stores's servers encounter an issue, you might invalidate a user's rights simply because your data isn't up to date and the
effective_next_renewal_at
seems to be in the past.What should you do instead?
- Upon subscription renewal, the Purchasely Platform always sends a
ACTIVATE
message- Upon subscription termination, the Purchasely Platform always sends a
DEACTIVATE
message in the right timing, in other words when the entitlements should be revoked on your endIf you ever need a fail safe to unsubscribe users in case an issue occurs with the Store/Purchasely/your servers, you should let at least a 24h-margin with the given
effective_next_renewal_at
.
More details on Entitlement Events
Sample backend code for:
- receiving webhook events
- extracting the relevant information
- updating the entitlements in the database
- acknowledging the processing of the events
# This example uses Sinatra and ActiveRecord as ORM
# CONTROLLER #
# ============================================================================ #
# Uses Sinatra
post '/webhook' do
request.body.rewind
payload = JSON.parse(request.body.read)
entitlement = find_or_initialize_entitlement(payload)
entitlement.attributes = {
effective_next_renewal_at: ms_to_time(payload["effective_next_renewal_at_ms"]),
status: payload["status"],
store: payload["store"], # will never change
type: payload["type"], # will never change
last_payload: payload,
}
entitlement.save!
# Acknowledges the processing of the event by answering a 200
status 200
end
# MODEL #
# ============================================================================ #
# Uses ActiveRecord
# Entitlement
#
# For each purchase, it will keep track of:
# - the purchase type
# - the user owning it
# - the plan bought
# - the status of the sub (in short: active/not active)
#
# ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
# ⚠️ Be sure to add a UNIQ CONSTRAINT on (purchasely_id, plan, anonymous_user_id, user_id) ⚠️
# ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
class Entitlement < ActiveRecord::Base
attr_accessor(
:id,
# filled in when the purchase is associated to a not logged in user
:anonymous_user_id,
# filled in when the purchase is associated to a logged in user
:user_id,
# RENEWING_SUBSCRIPTION | NON_RENEWING_SUBSCRIPTION |
# CONSUMABLE | NON_CONSUMABLE
:type,
# subs_xxx | otp_yyy
:purchasely_id,
# Purchasely identifier of your plan, as defined in the Purchasely console
# under the "id" input
:plan,
# backup of the last payload sent
:last_payload,
# At this date the user will loose their entitlement (it is equal to the max
# between the next_renewal_at, grace_period_expires_at and defer_end_at)
:effective_next_renewal_at,
# AUTO_RENEWING | AUTO_RENEWING_CANCELED | IN_GRACE_PERIOD | ON_HOLD | PAUSED |
# REVOKED | DEACTIVATED | UNPAID
:status,
# APPLE_APP_STORE | GOOGLE_PLAY_STORE | STRIPE | HUAWEI_APP_GALLERY |
# AMAZON_APP_STORE
:store,
:created_at,
:updated_at
)
ACTIVE_STATUSES = %w[
AUTO_RENEWING
AUTO_RENEWING_CANCELED
IN_GRACE_PERIOD
]
end
# HELPERS #
# ============================================================================ #
def find_or_initialize_entitlement(payload)
Entitlement
.find_or_initialize_by(
payload.slice("anonymous_user_id", "plan", "purchasely_id", "user_id")
).last
end
def ms_to_time(time_in_ms)
return nil unless time_in_ms
Time.at(time_in_ms / 1000)
end
const express = require('express');
const app = express();
const bodyParser = require('body-parser');
const Entitlement = require('./models/entitlement');
app.use(bodyParser.json());
// Controller
// ============================================================================
// Uses Express
app.post('/webhook', async (req, res) => {
const payload = req.body;
const entitlement = await findOrInitializeEntitlement(payload);
entitlement.effective_next_renewal_at = msToTime(payload.effective_next_renewal_at_ms);
entitlement.status = payload.status;
entitlement.store = payload.store; // will never change
entitlement.type = payload.type; // will never change
entitlement.last_payload = payload;
await entitlement.save();
// Acknowledges the processing of the event by answering a 200
res.status(200).send();
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
// Model
// ============================================================================
// Uses Mongoose
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const EntitlementSchema = new Schema({
anonymous_user_id: { type: String, index: true },
user_id: { type: String, index: true },
type: { type: String, required: true },
purchasely_id: { type: String, required: true },
plan: { type: String, required: true },
last_payload: { type: Object, required: true },
effective_next_renewal_at: { type: Date },
status: { type: String, required: true },
store: { type: String, required: true },
created_at: { type: Date, default: Date.now },
updated_at: { type: Date, default: Date.now }
}, {
timestamps: true
});
EntitlementSchema.index({ anonymous_user_id: 1, plan: 1, purchasely_id: 1, user_id: 1 }, { unique: true });
const Entitlement = mongoose.model('Entitlement', EntitlementSchema);
module.exports = Entitlement;
// Helpers
// ============================================================================
async function findOrInitializeEntitlement(payload) {
let entitlement = await Entitlement.findOne({
anonymous_user_id: payload.anonymous_user_id,
plan: payload.plan,
purchasely_id: payload.purchasely_id,
user_id: payload.user_id
});
if (!entitlement) {
entitlement = new Entitlement({
anonymous_user_id: payload.anonymous_user_id,
plan: payload.plan,
purchasely_id: payload.purchasely_id,
user_id: payload.user_id
});
}
return entitlement;
}
function msToTime(timeInMs) {
if (!timeInMs) return null;
return new Date(timeInMs);
}
module.exports = { findOrInitializeEntitlement, msToTime };
Proposed Database structure:
CREATE TABLE entitlements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type STRING NOT NULL,
purchasely_id STRING NOT NULL,
anonymous_user_id STRING,
user_id STRING,
plan STRING NOT NULL,
status STRING NOT NULL,
store STRING NOT NULL,
effective_next_renewal_at DATETIME NOT NULL,
last_payload JSONB NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_user UNIQUE (purchasely_id, user_id, anonymous_user_id)
);
4. Providing an API to the app to fetch users' entitlements
Once the Entitlement Events have been acknowledged with a response code HTTP 200
, the Purchasely Platform notifies the SDK, which returns the result to the app.
The app shall then fetch a backend API and provide the user ID
as an entry parameter to determine which entitlements shall be granted to the user inside the app.
If the user is logged-out, the entry parameter should be the anonymous user ID
provided by the SDK.
Purchasely.anonymousUserId
Purchasely.anonymousUserId
Purchasely.getAnonymousUserId();
Purchasely.anonymousUserId;
Purchasely.getAnonymousUserId((anonymousId) => {
console.log("Purchasely anonymous Id: " + anonymousId);
});
private PurchaselyRuntime.Purchasely _purchasely;
_purchasely.GetAnonymousUserId();
The backend shall respond with the entitlements associated with the user ID
or anonymous user ID
.
Sample backend code for managing the backend API:
# CONTROLLER #
# ============================================================================ #
# Uses Sinatra
# API endpoint to fetch user entitlements
get '/entitlements' do
anonymous_user_id = params['anonymous_user_id'].presence
user_id = params['user_id'].presence
if anonymous_user_id.nil? && user_id.nil?
status 400
return json({error: 'user_id or anonymous_user_id is required'})
end
entitlements =
Entitlement
.where(anonymous_user_id:, user_id:) # filter on the current user
.where(status: Entitlement::ACTIVE_STATUSES) # only retrieve the active statuses
.to_a
status 200
json entitlements.map { |e| {plan: e.plan} }
end
# SIMPLIFIED MODEL #
# ============================================================================ #
# Uses ActiveRecord
class Entitlement < ActiveRecord::Base
attr_accessor(
# AUTO_RENEWING | AUTO_RENEWING_CANCELED | IN_GRACE_PERIOD | ON_HOLD | PAUSED |
# REVOKED | DEACTIVATED | UNPAID
:status,
)
ACTIVE_STATUSES = %w[
AUTO_RENEWING
AUTO_RENEWING_CANCELED
IN_GRACE_PERIOD
]
end
// Controller
// ============================================================================
// Uses Express
const express = require('express');
const app = express();
const Entitlement = require('./models/entitlement');
app.get('/entitlements', async (req, res) => {
const { anonymous_user_id, user_id } = req.query;
if (!anonymous_user_id && !user_id) {
return res.status(400).json({ error: 'user_id or anonymous_user_id is required' });
}
try {
const entitlements = await Entitlement.find({
$or: [
{ anonymous_user_id: anonymous_user_id || null },
{ user_id: user_id || null }
],
status: { $in: Entitlement.ACTIVE_STATUSES }
}).exec();
res.status(200).json(entitlements.map(e => ({ plan: e.plan })));
} catch (err) {
res.status(500).json({ error: 'An error occurred while fetching entitlements' });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
// Model
// ============================================================================
// Uses Mongoose
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const EntitlementSchema = new Schema({
anonymous_user_id: { type: String, index: true },
user_id: { type: String, index: true },
type: { type: String, required: true },
purchasely_id: { type: String, required: true },
plan: { type: String, required: true },
last_payload: { type: Object, required: true },
effective_next_renewal_at: { type: Date },
status: { type: String, required: true },
store: { type: String, required: true },
created_at: { type: Date, default: Date.now },
updated_at: { type: Date, default: Date.now }
}, {
timestamps: true
});
EntitlementSchema.index({ anonymous_user_id: 1, plan: 1, purchasely_id: 1, user_id: 1 }, { unique: true });
EntitlementSchema.statics.ACTIVE_STATUSES = [
'AUTO_RENEWING',
'AUTO_RENEWING_CANCELED',
'IN_GRACE_PERIOD'
];
const Entitlement = mongoose.model('Entitlement', EntitlementSchema);
module.exports = Entitlement;
Updated 5 months ago
You're all set with managing the entitlements! Now learn how to test your integration