Updating the amount of discount on each order

This guide shows you how to make a webhook handler that updates the discounts for a subscription on every order.

What are we building?

We want to run a loyalty campaign for our customers. It will give them increasing discounts with each order. Here's how it will work:

  • When a customer checks out, they can enter the discount code ORDER2EARN to get a 10% discount.

  • If the customer doesn't cancel their subscription, they can get a 20% discount on their second order.

  • If the customer continues their subscription for one more cycle, they can get a 30% discount on their third order.

  • The campaign only applies to the first three orders, so there won't be any more discounts after that.

How to change promotion for each order?

With regular discount codes, we can only apply one promotion to a discount code. This means that every order will receive the same discount until the code expires.

To update the discount for each order. we are going to use webhooks. We will first track when customer makes a new order. Then we will update the promotion applied to customer's subscription using GraphQL API calls.

Preparation

Creating a project access token

To update subscription promotions using the GraphQL API, we will need a project access token with Write access type. You can create this token on the Firmhouse Portal by going to the Settings > Integrations page.

Setting up the discounts and discount codes

For our campaign we need three discounts with 10%, 20%, 30% discount percentages. You can create them on Discounts page on Firmhouse Portal. To ensure each customer can use each discount only once, we will set the Limit discount per customer setting to 1.

Then we need to create a discount code, that activates the first discount. You can create that on Discount Codes page on the Firmhouse Portal

Webhook Deployment

We need to create an HTTP endpoint that acts as a webhook for Firmhouse to call. While you have the freedom to choose any programming language or tool like AWS Lambda or Azure Functions, for this tutorial, we will use Pipedream with JavaScript to deploy our webhook handler.

Webhook Handler

When the webhook handler receives the event, it needs to check if the subscription has the discount code we created. Then, we need to decide which promotion to add based on the latest promotion applied.

To understand what data we can get from the webhook call, let's start by writing the template for the webhook. In this case, we will use the Order Confirmed event.

To fetch the details of the subscription later using a GraphQL API call, we will need the subscription's token.

Although it's not necessary, we can also include the subscription's discount codes. This will allow us to exit early in the webhook handler if the campaign discount code is not used.

Why we use Order Confirmed event?

After an order is confirmed, Firmhouse will update the limits of promotions applied to the subscription. If the limits of promotions are exceeded, the promotion will be disabled. So, when we receive the event on our webhook handler, we can check if there is a valid promotion applied to the subscription. If we cannot find any, then we can apply the next promotion in our campaign.

To start, we need to extract the payload from the webhook call. This step is the only part that varies depending on the libraries/frameworks you are using. Firmhouse will send the payload using a POST request, and the data will be in the request body. You can refer to the instructions specific to your framework/library to learn how to access the request body of an HTTP call.

export default defineComponent({
  props: {
    firmhouseProjectAccessToken: {
      type: "string",
      label: "Firmhouse project access token with write",
      secret: true,
    },
  },
  async run({ steps, $ }) {
    const PROJECT_ACCESS_TOKEN = this.firmhouseProjectAccessToken
    const body = steps.trigger.event.body;
    const payload = body.subscription
       
    // the rest of our code will go here
    // ...
  },
});

Note that, the Content-Type header for the post request will be text/plain.If the http server you use depends on Content-Type header to parse the json body, you may need to parse the body as a JSON with JSON.parse(body) first.

Let's specify the discount code we're searching for and verify if it exists in the payload before making any API calls, in order to quickly exit from the handler for subscriptions that do not use the discount code.

const DISCOUNT_CODE = 'ORDER2EARN'
if (!payload.codes.includes(DISCOUNT_CODE)) return;

You can make the discount code configurable with an environment variable or component props in Pipedream. You can also set up multiple discount codes for various campaigns. We're keeping it simple here to avoid making this tutorial more complicated.

We also need to decide which promotions to apply and in what order. We can use an array of promotion IDs for that. To find the IDs, go to the Discounts page and click the Edit button next to the discount you created for this campaign. You will find the promotion ID in the address bar. The format will be like this:

/projects/<PROJECT_ID>/order_discount_promotions/<PROMOTION_ID>/edit

After obtaining the IDs of each promotion, let's create an array with them. Make sure to write them in the order they will be applied, so that we know which promotion to apply next, later on.

const PROMOTION_IDS = ["116728", "116729", "116730"];

Note that the first promotion id in this array will be directly applied with the discount code. We are just keeping it here to be able to check if the promotion is still valid and applied.

We have to get all the promotion details that were applied to the subscription using the GraphQL API. To do this, we will use the subscription token value from the webhook payload and pass it along with the project access token we created to the following function.

async function getSubscription(projectAccessToken, subscriptionToken) {
  const headers = new Headers({
    "Content-Type": "application/json",
    "X-Project-Access-Token": projectAccessToken,
  });
  const graphql = JSON.stringify({
    query: `
      query($token: ID!) {
        getSubscription(token: $token) {
          id
          appliedPromotions {
            active
            promotion {
                id
                percentDiscount
                title
                activated
            }
            discountCode {
                code
                expired
                metadata
            }
          }
        }
     }
  `,
    variables: {
      token: subscriptionToken,
    },
  });

  const requestOptions = {
    method: "POST",
    headers,
    body: graphql,
  };

  const response = await fetch(
    "https://portal.firmhouse.com/graphql",
    requestOptions
  );
  const body = await response.json();
  return body.data.getSubscription;
}

Now that we have a method to retrieve subscription details, we can do so using the subscription token in our payload.

const subscription = await getSubscription(
    PROJECT_ACCESS_TOKEN,
    payload.token
  ); 

If there is an active campaign promotion, we don't need to add any additional promotions, so we can simply exit in that case. This won't occur if you set the Limit discount per customer setting to 1 when creating the promotion, as it will be deactivated after being used once. However, if it can be used multiple times, we need to make sure that we are not attempting to apply two promotions simultaneously.

// Creating a set of promotion ids for efficient lookup
const campaignPromotionIdsSet = new Set(PROMOTION_IDS);
const { appliedPromotions } = subscription;

// Check if one of the promotions are still active
const activePromotion = appliedPromotions.find(
  (ap) => ap.active && campaignPromotionIdsSet.has(ap.promotion.id)
);

if (activePromotion) {
  console.log(`Subscription ${subscription.id} has an active promotion ${activePromotion.promotion.id}, not adding another one`);
  return;
}

Currently, we have confirmed that the discount code is being used and there are no active promotions on the subscription. Now, we can check if there are any additional promotions that we can include. If we have no more promotions to add, we can exit the handler at this point.

// Make a set out of the promotion ids previously applied to subscription for efficient lookup
const appliedPromotionIds = new Set(
  appliedPromotions.map((ap) => ap.promotion.id)
);

// Find the promotion that is not applied yet
// We are iterating the promotion ids in order
// So the first one we find, is the next one we should apply
const nextPromotionId = PROMOTION_IDS.find(
  (id) => !appliedPromotionIds.has(id)
);

// If there is no more promotions that we can apply, we can stop here
if (!nextPromotionId) {
  console.log(
    `There is no promotion left to add for the subscription`
  );
  return;
}

We now have the next promotion id to apply to the subscription. So we need to call the GraphQL API to apply it.

We will create a function that executes the applyPromotionToSubscription mutation on the GraphQL API. This function will require the subscription id, promotion id, and project access token as input.

async function applyPromotionToSubscription(
  subscriptionId,
  promotionId,
  projectAccessToken
) {
  const headers = new Headers({
    "Content-Type": "application/json",
    "X-Project-Access-Token": projectAccessToken,
  });
  const graphql = JSON.stringify({
    query: `
      mutation($input: ApplyPromotionToSubscriptionInput!) {
        applyPromotionToSubscription(input: $input) {
          errors {
            message
            attribute
            path
          }
        }
     }
    `,
    variables: {
      input: {
        subscriptionId,
        promotionId,
      },
    },
  });

  const requestOptions = {
    method: "POST",
    headers,
    body: graphql,
  };

  const response = await fetch(
    "https://portal.firmhouse.com/graphql",
    requestOptions
  );
  const body = await response.json();
  const { errors } = body.data.applyPromotionToSubscription;
  if (errors) {
    console.log(errors);
  }
}

Finally, we can use the applyPromotionToSubscription method to apply the next promotion to customer's subscription.

console.log(`Adding promotion ${nextPromotionId} to subscription ${subscription.id}`);
await applyPromotionToSubscription(
  subscription.id,
  nextPromotionId,
  PROJECT_ACCESS_TOKEN
);

Before deploying, it is recommended to secure the endpoint. Firmhouse currently supports basic authentication, which allows you to set a username and password for the webhook. These credentials will be sent in the Authorization header, in the following format.

Authorization: Basic <"username:password" as base64 encoded string>

We can declare a function to check if the Authorization header is correct.

async function checkAuthorization(authorizationHeader, validUsername, validPassword) {
  if (validUsername && validPassword) {
    const validAuth = new Buffer(`${validUsername}:${validPassword}`).toString(
      "base64"
    );
    if (authorizationHeader !== `Basic ${validAuth}`) {
      return false;
    }
  }
  return true;
}

Let's use the method to check if the authorization is valid. Remember to modify how you access the authorization header based on the library/framework you are using.

const username = this.webhookUsername
const password = this.webhookPassword
    
if(!checkAuthorization(steps.trigger.event.headers.authorization, username, password)) {
    console.log('Not authorized')
    return;
}

To get a full picture of the handler here is the complete version with Authentication.

Handler
async function getSubscription(projectAccessToken, subscriptionToken) {
  const headers = new Headers({
    "Content-Type": "application/json",
    "X-Project-Access-Token": projectAccessToken,
  });
  const graphql = JSON.stringify({
    query: `
        query($token: ID!) {
          getSubscription(token: $token) {
            id
            appliedPromotions {
              active
              promotion {
                  id
                  percentDiscount
                  title
                  activated
              }
              discountCode {
                  code
                  expired
                  metadata
              }
            }
          }
       }
    `,
    variables: {
      token: subscriptionToken,
    },
  });

  const requestOptions = {
    method: "POST",
    headers,
    body: graphql,
  };

  const response = await fetch(
    "https://portal.firmhouse.com/graphql",
    requestOptions
  );
  const body = await response.json();
  return body.data.getSubscription;
}
async function applyPromotionToSubscription(
  subscriptionId,
  promotionId,
  projectAccessToken
) {
  const headers = new Headers({
    "Content-Type": "application/json",
    "X-Project-Access-Token": projectAccessToken,
  });
  const graphql = JSON.stringify({
    query: `
        mutation($input: ApplyPromotionToSubscriptionInput!) {
          applyPromotionToSubscription(input: $input) {
            errors {
              message
              attribute
              path
            }
          }
       }
      `,
    variables: {
      input: {
        subscriptionId,
        promotionId,
      },
    },
  });

  const requestOptions = {
    method: "POST",
    headers,
    body: graphql,
  };

  const response = await fetch(
    "https://portal.firmhouse.com/graphql",
    requestOptions
  );
  const body = await response.json();
  const { errors } = body.data.applyPromotionToSubscription;
  if (errors) {
    console.log(errors);
  }
}
async function checkAuthorization(authorizationHeader, validUsername, validPassword) {
    if (validUsername && validPassword) {
      const validAuth = new Buffer(`${validUsername}:${validPassword}`).toString(
        "base64"
      );
      if (authorizationHeader !== `Basic ${validAuth}`) {
        return false;
      }
    }
    return true;
  }

export default defineComponent({
  props: {
    firmhouseProjectAccessToken: {
      type: "string",
      label: "Firmhouse project access token with write",
      secret: true,
    },
    webhookUsername: {
      type: "string",
      label: "The username for the webhook",
    },
    webhookPassword: {
      type: "string",
      label: "The password for the webhook",
      secret: true,
    },
  },
  async run({ steps, $ }) {
    const username = this.webhookUsername;
    const password = this.webhookPassword;
    
    if(!checkAuthorization(steps.trigger.event.headers.authorization, username, password)) {
        console.log('Not authorized')
        return;
    }
    
    const PROJECT_ACCESS_TOKEN = this.firmhouseProjectAccessToken;
    const body = steps.trigger.event.body;
    const payload = body.subscription;

    const DISCOUNT_CODE = "ORDER2EARN";
    if (!payload.codes.includes(DISCOUNT_CODE)) return;
    const PROMOTION_IDS = ["116728", "116729", "116730"];

    const subscription = await getSubscription(
      PROJECT_ACCESS_TOKEN,
      payload.token
    );

    const campaignPromotionIdsSet = new Set(PROMOTION_IDS);
    const { appliedPromotions } = subscription;

    // Check if one of the promotions are still active
    const activePromotion = appliedPromotions.find(
      (ap) => ap.active && campaignPromotionIdsSet.has(ap.promotion.id)
    );

    if (activePromotion) {
      console.log(
        `Subscription ${subscription.id} has an active promotion ${activePromotion.promotion.id}, not adding another one`
      );
      return;
    }

    // Make a set out of the promotion ids previously applied to subscription for efficient lookup
    const appliedPromotionIds = new Set(
      appliedPromotions.map((ap) => ap.promotion.id)
    );

    // Find the promotion that is not applied yet
    // We are iterating the promotion ids in order
    // So the first one we find, is the next one we should apply
    const nextPromotionId = PROMOTION_IDS.find(
      (id) => !appliedPromotionIds.has(id)
    );

    // If there is no more promotions that we can apply, we can stop here
    if (!nextPromotionId) {
      console.log(`There is no promotion left to add for the subscription`);
      return;
    }

    console.log(
      `Adding promotion ${nextPromotionId} to subscription ${subscription.id}`
    );
    await applyPromotionToSubscription(
      subscription.id,
      nextPromotionId,
      PROJECT_ACCESS_TOKEN
    );
  },
});

Deployment and Testing

We have our handler ready, now we need to deploy it so that Firmhouse can call it as a webhook.

  1. First create a new workflow in Pipedream.

  2. Create a new HTTP/ Webhook Request trigger and then copy the URL. We will use it while creating the webhook on Firmhouse Portal.

  1. Create new step to add our handler then copy our handler code to there. Then you can click on refresh fields button to display the configuration fields. Then enter the project access token, webhook username and webhook password to those fields.

  2. Now you can create the webhook on Firmhouse portal. In Firmhouse navigate to Apps in the sidebar. Find the Webhooks app and click Configure. Then you can fill the details as shown in the picture. You can copy the webhook payload template we created here. After you fill the details click on Save.

  3. Now go ahead and create a new subscription with the discount code ORDER2EARN. After you complete the payment, you will see the event on Pipedream. You can select the event then click on Continue and then Test to test the handler with that event. You can check the logs to see if worked correctly. If it did, you can check that on Customers page on Firmhouse as well. Select the customer you just created, on Applied discounts section you should see that second order discount is applied.

  4. After you make sure that everything is working correctly, you can click Deploy on Pipedream to let it process events as they arrive.

Last updated