Widespread adoption of Amazon Web Services has been well known for some time and we have observed that most of our customers are already using AWS services. Getting listed on the marketplace can allow our customers to take advantage of a process they are accustomed to as well as capitalize on an EDP (Enterprise Discount Plan).
AWS Marketplace
AWS has a marketplace where vendors can provide their own machine or docker images. Since 2019, it is also possible to provide SaaS products which are not required to be directly associated with machine images or other AWS services. You may have a web application and provide your solution on AWS marketplace as a SaaS product.
What is a SaaS Contract?
SaaS Contract allows users to use your product for a predetermined period of time according to contract terms. SaaS Contract product does not require SaaS providers to continuously send usage reports to AWS (contrary to SaaS Subscription products which will not be covered in this post).
Steps for Integrating with AWS Marketplace
- Create a AWS Marketplace Reseller account: /partners/aws-marketplace/
- Create a SaaS Contract product in the reseller portal
- Follow guidance of AWS operations team to complete integration and limited publishing
- Release!
During steps 2 and 3, you need to decide the pricing dimension and price for the unit. For SaaS contracts this means a unit of usage limit you may optionally enforce. It can be daily data usage, installation count or something else.
In sections below we will take a look at integration details.
Integrating a Golang Backend With Marketplace
Since at Edge Delta we develop highly performant and scalable software with minimal resource usage, we mostly use Golang. For completing step 3, we integrated the marketplace with our Golang backend.
Technical integration consists of 3 workflows:
- Handling sign up redirects from AWS marketplace in UI.
- Getting notifications about new purchases and other status changes.
- Getting contract status after sign up and during usage.
During the limited publishing phase AWS operations team provides a product code and AWS Simple Notification Service topic required for integration steps.
Handling Sign Up Redirects
During creation of the product in the reseller portal a sign up URL is requested. This URL will receive a HTTP Post request with form data when a customer finds your product page and clicks the subscribe button. Form will have an opaque token named x-amzn-marketplace-token.
You need to resolve this token into product code and customer identifier using ResolveCustomer method of AWS Go SDK marketplace metering service.
Here is a sample function to resolve registration token (x-amzn-marketplace-token):
(all raw text available in appendix for easy copy and paste)
From this point on you know the product code and customer identifier and you can associate the aws user with your own user entity that will be created after filling the subscription form.
Handling Purchase Notifications
When the customer clicks the subscribe button, automatic renewal kicks in or a cancellation requested and accepted, customers entitlement statuses changes. This “entitlement-updated” notification is the only thing that needs to be listened to if you do not provide SaaS Subscription. Upon entitlement status change AWS will send a notification to the SNS topic specific to your product. Following AWS advice we used a SQS topic configured to listen to this SNS topic and a lambda function triggered by new messages arriving to this topic. This simplified implementation greatly.
Within lambda function you need to parse a json message like below:
{
"action": "entitlement-updated",
"customer-identifier": "*****",
"product-code": "***"
}
Due to trigger configuration lambda function handler will directly get SQS messages as argument.
(all raw text available in appendix for easy copy and paste)
Since SQS message actually keeps SNS message as a string it needs to be marshalled twice. First unmarshall is from SQS body string to SNS body and second one is from SNS body message field to notification struct we expected.
Since we are only interested in and expect “entitlement-updated” events we can log the other message types. In a subscription type product integration they need to be handled.
This does not tell you what has changed about this customer. If you need to act upon this notification you need to get contract status.
Getting Contract Status
You can get contract status from AWS on demand, periodically or upon AWS notification.
To do so you need to use the GetEntitlements method of AWS SDK Marketplace Entitlement Service. Using the customer-identifier you received in the notification you can get entitlements(contracts) with expiration date and pricing dimension. The way you call it is similar to the ResolveCustomer method.
We are only interested in non-expired entitlements. During creation of the product in the reseller portal we choose `Enable Tiered Dimensions” with options “Buyer can choose only one tier offered” meaning there can be only one active entitlement of a product for a given customer at any instance.
You can use this information to send notifications or take actions about new subscriptions, renewals or cancelations. You can check the defined pricing dimension to determine whether usage limits have been exceeded.
Conclusion
AWS marketplace provides a frictionless medium to onboard users that can be utilized by startups and vendors of all sizes, and allows them to take advantage of the EDP (Enterprise Discount Program) offered by AWS. We covered the SaaS Contract product integration which is simple to implement and inline with common enterprise customer procurement processes.
Appendix for easy copy and paste…
import (
...
mms "github.com/aws/aws-sdk-go/service/marketplacemetering"
)
func (c *Client) ResolveCustomer(registrationToken string) (*core.AWSCustomer, error) {
in := &mms.ResolveCustomerInput{}
in.SetRegistrationToken(registrationToken)
if err := in.Validate(); err != nil {
return nil, fmt.Errorf("unable to resolve aws session, err: %v", err)
} out, err := c.svc.ResolveCustomer(in)
if err != nil {
return nil, fmt.Errorf("unable to resolve customer id from marketplace, err: %v", err)
}
return &core.AWSCustomer{ID: *out.CustomerIdentifier, ProductCode: *out.ProductCode}, nil
}
func main() {
lambda.Start(handler.Handle)
}
func Handle(ctx context.Context, sqsEvent events.SQSEvent) error {
h, err := newHandler()
if err != nil {
return err
}
return h.process(ctx, sqsEvent)
}
func (h *handler) process(ctx context.Context, sqsEvent events.SQSEvent) error {
for _, m := range sqsEvent.Records {
m := m
if err := h.processSQSMessage(ctx, &m); err != nil {
return err
}
}
return nil
}
type notification struct {
Action string `json:"action"`
CustomerIdentifier string `json:"customer-identifier"`
ProductCode string `json:"product-code"`
}
type body struct {
Message string `json:"Message"` // Contains notification as string
}
func (h *handler) processSQSMessage(ctx context.Context, m *events.SQSMessage) error {
// SNS message string is in SQS Message Body
var b body
if err := json.Unmarshal([]byte(m.Body), &b); err != nil {
return err
}
// Notification string is in Message fields of SNS message
var n notification
if err := json.Unmarshal([]byte(b.Message), &n); err != nil {
return err
}
if err := h.processNotification(n); err != nil {
return err
}
return nil
}
func (h *handler) processNotification(n notification) error {
if n.ProductCode != productCode {
return fmt.Errorf("invalid product code %s", n.ProductCode)
}
switch n.Action {
case "entitlement-updated":
return h.handleEntitlementUpdated(n)
// Below cases will be populated when we support subscriptions.
case "subscribe-success":
fallthrough
case "subscribe-fail":
fallthrough
case "unsubscribe-pending":
fallthrough
case "unsubscribe-success":
fallthrough
default:
return fmt.Errorf("unsupported aws marketplace notification action %v", n)
}
}
func (c *Client) GetActiveContract(key *core.AWSCustomerKey) (*core.AWSContract, error) {
in, err := prepareGetEntitlementsInput(key)
if err != nil {
return nil, fmt.Errorf("unable prepare get entitlements input %v", err)
}
out, err := c.svc.GetEntitlements(in)
if err != nil {
return nil, fmt.Errorf("unable get entitlements %v", err)
}
return filterEntitlementOutput(out), nil
}
func prepareGetEntitlementsInput(key *core.AWSCustomerKey) (*mes.GetEntitlementsInput, error) {
in := &mes.GetEntitlementsInput{}
in.SetProductCode(key.ProductCode)
in.SetFilter(map[string][]*string{
mes.GetEntitlementFilterNameCustomerIdentifier: aws.StringSlice([]string{key.ID}),
})
in.SetMaxResults(20)
return in, in.Validate()
}
func filterEntitlementOutput(out *mes.GetEntitlementsOutput) *core.AWSContract {
var contract *core.AWSContract
for _, e := range out.Entitlements {
if e.ExpirationDate.Before(time.Now()) {
log.Info("Ignore expired entitlement %v", e)
continue
}
if contract != nil {
log.Warn("Multiple active entitlements %v", e)
}
log.Info("Found active entitlement %v", e)
contract = &core.AWSContract{ContractType: *e.Dimension, ContractExpiration: e.ExpirationDate.String()}
}
return contract
}