Editor's Note: In our code deep dive series, we provide an end-to-end walkthrough of how to implement in-skill purchasing (ISP) in your Alexa skill. We use the Premium Hello World Skill (available on GitHub), which is a sample skill that demonstrates how to use ISP features by offering a “Premium Greeting Pack” that greets the customer in a variety of languages like French, Spanish, Hindi, and more. We will explain each line of code as we walk through several scenarios that monetized skills should be able to handle. If you'd like, you can follow along by referencing the steps in the GitHub guide to set up the Premium Hello World skill on your developer account. In the previous blog post, we added an entitlement to our Premium Hello World Skill. In today’s post, we will extend our Premium Hello World Skill, and add a new subscription-based in-skill product to it. Subscriptions offer access to premium content or features for a period of time. Customers are charged on a recurring basis until they cancel their subscription.
The premium subscription for our sample skill will be a monthly subscription that will greet the customers in native accents using Amazon Polly. Additionally, this will include the functionality for the Greeting Pack, which was an entitlement we added to our skill in the previous post. So, at the end of this post, our skill will have two products to offer - a Greeting Pack ($0.99) - greets customers in multiple languages, and a Premium Subscription ($2.99 per month) that will greet the customers in multiple languages using an Amazon Polly voice. Let’s begin!
Here are the four scenarios we will cover in this post as it relates to in-skill subscriptions.
Be sure to check the certification guidelines which are required for skill publication. In addition, check out these best ISP practices which, though not required, will make your skill eligible for Amazon promotion.
In this scenario, the customer has not bought the premium subscription, and expresses interest in buying it by saying, “Buy Premium Subscription.” This utterance is mapped to the intent BuyPremiumSubscriptionIntent
, and the handler BuyPremiumSubscriptionIntentHandler
gets triggered in our Lambda code.
//Inside BuyPremiumSubscriptionIntentHandler
const BuyPremiumSubscriptionIntentHandler = {
canHandle(handlerInput){
return handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
handlerInput.requestEnvelope.request.intent.name === 'BuyPremiumSubscriptionIntent'
},
handle(handlerInput){
//handle code goes here
}
Inside the handle
block, we call the Alexa Monetization API, which returns the list of products available for the skill in the given locale.
//Inside BuyPremiumSubscriptionIntentHandler
const BuyPremiumSubscriptionIntentHandler = {
canHandle(handlerInput){
return handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
handlerInput.requestEnvelope.request.intent.name === 'BuyPremiumSubscriptionIntent'
},
handle(handlerInput){
const locale = handlerInput.requestEnvelope.request.locale;
const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();
return monetizationClient.getInSkillProducts(locale).then(function(res){
//do stuff with the list of inSkillProducts returned by the Monetization API
})
}
}
The result (res) of this API looks something like this -
//res
{
inSkillProducts:[
{
productId:'amzn1.adg.product.c9a06b43-2f8b-46f4-9bfa-6dadb764af97',
referenceName:'Personality_Subscription',
type:'SUBSCRIPTION',
name:'Personality Subscription',
summary:'The Personality Subscription greets the user in a variety of voices.',
entitled:'ENTITLED',
entitlementReason:'PURCHASED',
purchasable:'NOT_PURCHASABLE',
activeEntitlementCount:1,
purchaseMode:'TEST'
},
{
productId:'amzn1.adg.product.7902d846-6253-43bf-b66f-7b9d78bfaa1a',
referenceName:'Premium_Greeting',
type:'ENTITLEMENT',
name:'Premium Greeting Pack',
summary:'The Premium Greeting Pack says hello in a variety of languages like French, Spanish, Hindi, and more.',
entitled:'NOT_ENTITLED',
entitlementReason:'NOT_PURCHASED',
purchasable:'PURCHASABLE',
activeEntitlementCount:0,
purchaseMode:'TEST'
}
],
nextToken:null,
truncated:false
}
Next, we filter this list of products available for purchase to find the product with the reference name "PremiumSubscription”.
//Inside BuyPremiumSubscriptionIntentHandler
const BuyPremiumSubscriptionIntentHandler = {
canHandle(handlerInput){
return handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
handlerInput.requestEnvelope.request.intent.name === 'BuyPremiumSubscriptionIntent'
},
handle(handlerInput){
const locale = handlerInput.requestEnvelope.request.locale;
const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();
return monetizationClient.getInSkillProducts(locale).then(function(res){
const premiumSubscriptionProduct = res.inSkillProducts.filter(
record => record.referenceName === "Premium_Subscription"
);
})
}
}
Finally, we send a Connections.SendRequest
directive back to Alexa, along with the productID that the customer is looking to Buy
. At this point, Alexa’s Purchase Experience Flow takes over, and responds back to the customer with more details about the subscription (as provided by you when you created the product), along with the pricing information (again, as provided by you when you created the product) on developer.amazon.com or using the ASK-CLI - “Premium Subscription is free for 7 days….Would you like start the free trial?”
//Inside BuyPremiumSubscriptionIntentHandler
const BuyPremiumSubscriptionIntentHandler = {
canHandle(handlerInput){
return handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
handlerInput.requestEnvelope.request.intent.name === 'BuyPremiumSubscriptionIntent'
},
handle(handlerInput){
const locale = handlerInput.requestEnvelope.request.locale;
const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();
return monetizationClient.getInSkillProducts(locale).then(function(res){
const premiumSubscriptionProduct = res.inSkillProducts.filter(
record => record.referenceName === "Premium_Subscription"
);
return handlerInput.responseBuilder
.addDirective({
type: "Connections.SendRequest",
name: "Buy",
payload: {
InSkillProduct:{
productId: premiumSubscriptionProduct[0].productId
}
},
token:"correlationToken"
})
.getResponse();
})
}
}
Serving the Premium Subscription Functionality:
Next, the customer responds with a “Yes” to begin the free trial, at which point, we serve the greetings using Amazon Polly.
To serve the “Premium Subscription” functionality, we call the getResponseBasedOnAccessType()
function from our BuyResponseHandler
To do this, we create a few helper functions that will help us switch to a Polly voice that matches the language of the greeting we serve. Update our getResponseBasedOnAccessType() helper function to include the logic for the subscription product.
//Inside getResponseBasedOnAccessType(handlerInput,res)
const personalitySubscriptionProduct = res.inSkillProducts.filter(
record => record.referenceName === `Personality_Subscription`
);
//Inside getResponseBasedOnAccessType(handlerInput,res)
if (isEntitled(premiumSubscriptionProduct)){
//Customer has bought the Premium Subscription. Switch to Polly Voice, and return special hello
theGreeting = getSpecialHello();
cardText = `Here's your special greeting: ${theGreeting["greeting"]} ! That's hello in ${theGreeting["language"]}.`;
speechText = `Here's your special greeting: ${theGreeting["greeting"]} ! That's hello in ${theGreeting["language"]}. ${getRandomYesNoQuestion()}`;
repromptOutput = `${getRandomYesNoQuestion()}`
speechText = getVoiceTalentToSay(speechText,theGreeting["language"]);
}
function getSpecialHello() {
const special_greetings = [
{ language: "hindi", greeting: "Namaste" },
{ language: "french", greeting: "Bonjour" },
{ language: "spanish", greeting: "Hola" },
{ language: "japanese", greeting: "Konichiwa" },
{ language: "italian", greeting: "Ciao" }
];
return randomize(special_greetings);
}
Create a helper function called getVoiceTalentToSay()
that intercepts the speechText and adds the Polly SSML to it, before it’s sent back to Alexa.
function getVoiceTalentToSay(speakOutput,language){
const personality = getVoicePersonality(language);
const generatedSpeech = `<voice name="${personality}"> ${speakOutput} </voice>`
console.log(generatedSpeech);
return generatedSpeech
}
Create getVoicePersonality()
helper function to find an Amazon Polly personality for a specific language. Here’s a complete list of Amazon Polly voices you can use in your skill.
function getVoicePersonality(language){
const personalities = [
{"language":"hindi","name":["Aditi","Raveena"]},
{"language":"german","name":["Hans", "Marlene", "Vicki"]},
{"language":"spanish","name":["Conchita", "Enrique"]},
{"language":"french","name":["Celine", "Lea", "Mathieu"]},
{"language":"japanese","name":["Mizuki", "Takumi"]},
{"language":"italian","name":["Carla", "Giorgio"]}
]
const personality = personalities.filter(
record => record.language === language
)
return randomize(personality[0].name);
}
Like Scenario 1, this request is mapped to BuyPremiumSubscriptionIntent
, and our code in BuyPremiumSubscriptionIntentHandler
sends a Connections.SendRequest
directive to Alexa. Since Alexa keeps track of the products the customer has bought, it responds back with a “you don’t have any active subscriptions” message.
const BuyPremiumSubscriptionIntentHandler = {
canHandle(handlerInput){
return handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
handlerInput.requestEnvelope.request.intent.name === 'BuyPremiumSubscriptionIntent'
},
handle(handlerInput){
const locale = handlerInput.requestEnvelope.request.locale;
const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();
return monetizationClient.getInSkillProducts(locale).then(function(res){
// Filter the list of products available for purchase to find the product with the reference name "Premium_Subscription"
const premiumSubscriptionProduct = res.inSkillProducts.filter(
record => record.referenceName === "Premium_Subscription"
);
//Send Connections.SendRequest Directive back to Alexa to switch to Purchase Flow
return handlerInput.responseBuilder
.addDirective({
type: "Connections.SendRequest",
name: "Buy",
payload: {
InSkillProduct:{
productId: premiumSubscriptionProduct[0].productId
}
},
token:"correlationToken"
})
.getResponse();
})
}
}
We create a new Intent called CancelPremiumSubscriptionIntent
, that handles utterances like “Cancel Subscription”, “Cancel Premium Subscription” etc. The functionality for this intents handled by CancelPremiumSubscriptionIntentHandler
in our Lambda code.
const CancelPremiumSubscriptionIntentHandler = {
canHandle(handlerInput) {
return (
handlerInput.requestEnvelope.request.type === "IntentRequest" &&
handlerInput.requestEnvelope.request.intent.name === "CancelPremiumSubscriptionIntent"
);
},
handle(handlerInput) {
const locale = handlerInput.requestEnvelope.request.locale;
const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();
return monetizationClient.getInSkillProducts(locale).then(function(res) {
const premiumProduct = res.inSkillProducts.filter(
record => record.referenceName === `Premium_Subscription`
);
return handlerInput.responseBuilder
.addDirective({
type: "Connections.SendRequest",
name: "Cancel",
payload: {
InSkillProduct: {
productId: premiumProduct[0].productId
}
},
token: "correlationToken"
})
.getResponse();
});
}
};
At this point, the customer can either respond with a “Yes” to confirm cancelation (ACCEPTED), or a “No” to withdraw the cancelation (DECLINED). We handle both these situations in our CancelProductResponseHandler
, by checking the value of purchaseResult
in the payload sent back to our skill.
const CancelProductResponseHandler = {
canHandle(handlerInput) {
return handlerInput.requestEnvelope.request.type === 'Connections.Response' &&
handlerInput.requestEnvelope.request.name === 'Cancel';
},
handle(handlerInput) {
console.log('IN: CancelProductResponseHandler.handle');
const locale = handlerInput.requestEnvelope.request.locale;
const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();
const productId = handlerInput.requestEnvelope.request.payload.productId;
return monetizationClient.getInSkillProducts(locale).then(function(res) {
const product = res.inSkillProducts.filter(
record => record.productId === productId
);
console.log(`PRODUCT = ${JSON.stringify(product)}`);
if (handlerInput.requestEnvelope.request.status.code === '200') {
if (handlerInput.requestEnvelope.request.payload.purchaseResult === 'ACCEPTED') {
//The cancelation confirmation response is handled by Alexa's Purchase Experience Flow.
//Simply add to that with getRandomYesNoQuestion()
const speechText = `${getRandomYesNoQuestion()}`;
const repromptOutput = getRandomYesNoQuestion();
return handlerInput.responseBuilder
.speak(speechText)
.reprompt(repromptOutput)
.getResponse();
}
else if (handlerInput.requestEnvelope.request.payload.purchaseResult === 'DECLINED') {
const speechText = `${getRandomYesNoQuestion()}`;
const repromptOutput = getRandomYesNoQuestion();
return handlerInput.responseBuilder
.speak(speechText)
.reprompt(repromptOutput)
.getResponse();
}
else if (handlerInput.requestEnvelope.request.payload.purchaseResult === 'NOT_ENTITLED') {
//No subscription to cancel.
//The "No subscription to cancel" response is handled by Alexa's Purchase Experience Flow.
//Simply add to that with getRandomYesNoQuestion()
const speakOutput = `${getRandomYesNoQuestion()}`;
const repromptOutput = getRandomYesNoQuestion();
return handlerInput.responseBuilder
.speak(speakOutput)
.reprompt(repromptOutput)
.getResponse();
}
}
// Something failed.
console.log(`Connections.Response indicated failure. error: ${handlerInput.requestEnvelope.request.status.message}`);
return handlerInput.responseBuilder
.speak('There was an error handling your purchase request. Please try again or contact us for help.')
.getResponse();
});
},
};
Like Scenario 3, this request is mapped to CancelPremiumSubscriptionIntent
, and our code in CancelPremiumSubscriptionIntentHandler
sends a Connecions.SendRequest
directive to Alexa. Since Alexa keeps track of the products the customer has bought, it responds back with a “you don’t have any active subscriptions” message.
const CancelPremiumSubscriptionIntentHandler = {
canHandle(handlerInput) {
return (
handlerInput.requestEnvelope.request.type === "IntentRequest" &&
handlerInput.requestEnvelope.request.intent.name === "CancelPremiumSubscriptionIntent"
);
},
handle(handlerInput) {
const locale = handlerInput.requestEnvelope.request.locale;
const monetizationClient = handlerInput.serviceClientFactory.getMonetizationServiceClient();
return monetizationClient.getInSkillProducts(locale).then(function(res) {
const premiumProduct = res.inSkillProducts.filter(
record => record.referenceName === `Premium_Subscription`
);
return handlerInput.responseBuilder
.addDirective({
type: "Connections.SendRequest",
name: "Cancel",
payload: {
InSkillProduct: {
productId: premiumProduct[0].productId
}
},
token: "correlationToken"
})
.getResponse();
});
}
};
We hope you find this new series helpful as you embark on the journey to use ISP to sell premium content in US skills and enrich your Alexa skill experience. You can reach out to me on Twitter @amit. We can't wait to see what you build!
Check out these additional resources for more guides and best practices to consider when building a monetized skill: