Integrating Etsy sale alerts to Discord

Recently my friend started an Etsy shop selling candles inspired by various video games and TV shows and I could not be more proud! So proud, in fact, that I wanted everyone in our little Discord server to know every time her store made a sale so that we could all react with some 🎉 💥 🤑

Here’s what the final integration looks like:

Discord bot sending an alert for a new sale with a gif of Claude from the Fire Emblem franchise
Hey Claude! 😉

Initial Planning

So, together with my partner in crime Jimmy, I began looking into how to query the Etsy API for a shop’s sales data. The plan was:

  1. Poll Etsy’s API for shop sales
  2. When there’s a new sale, post a message to Discord about it

Easy!

However, we were very quickly foiled by Etsy’s API. When you first sign up for an API key, you are given “Provisional Access” so you can read public data as well as read/write to your own account’s data. If you need more than that though, you have to apply for “Full Access”.

I figured I could get away with Provisional Access given I only wanted to read sales data which I could see just by going to any Etsy shop’s site. However, it turns out sales data is actually gated off and requires authentication, maybe because it includes some sensitive stuff. Furthermore, even if you authenticate, Provisional Access only gives you your own Etsy account’s data. It very quickly told me I have no sales, since I do not have an Etsy shop!

Etsy does have a rather cryptic message (emphasis mine) that Provisional Access provides:

Authenticated read/write access to data belonging to the member who created the application. This includes reading data like Receipts and Billing as well as creating, editing or deleting a shop’s Listings. If necessary, access can be provided upon request for a small number of test users in addition to the member who owns the app.

This made me think I could request being a small test user for my friend’s shop and perhaps get the data I wanted that way. So I emailed their developer email and got a response that they are very busy right now. It’s been about two weeks since then, and I just could not wait any longer to celebrate my friend’s sales, so began seeking alternatives.

[edit: they did get back to me and linked to this Google group thread which says the v3 version of their API should be able to handle test users being added. I haven’t gotten a chance to try it out yet though]

There were a few choices from here:

  1. Get my friend to generate an Etsy API key and give it to me so I could develop my app with her data
  2. Request “Full Access”
  3. Scrape the site
  4. Think of something else

Option 1 is against Etsy’s Terms of Service and feels lousy anyway. I don’t think I would have been approved for Option 2 because I could not even build the app if I did not know what the data would look like. Option 3 might have worked, though I don’t love scraping since it tends to be fickle and Etsy probably doesn’t like it either. Which left me with Option 4, to think of a different way.

(As an aside: while researching, some of the integrations you could pay for online did use Option 1. We also looked into Zapier, though they do not have an Etsy integration at the moment.)

Jimmy gets the credit for thinking of the different way, which was to, instead of using sales data which is gated off, to use a shop’s Listings data, which is public, and therefore well within the abilities of my Provisional Access API. So we lit our Teatime! Teatime! (with Hilda) candle, took a deep breath of its soothing rose scent, and got to work.

My favorite WicksByWerby candle (photo from the WicksByWerby Etsy shop)

General Architecture

Here’s a diagram of what we ended up building.

System Diagram

In this post, I won’t go through all the parts here, but just the most important parts, which can be broken down into the following steps.

  1. Query the Etsy API for our shop’s Listings which gives us the inventory count for each item
  2. Compare the current Listings with the last time we queried’s Listings
  3. For any decrease in inventory, post a message using a Discord webhook

Just from looking at this, it looks like there would only be two main functionalities: integrating with Etsy, and integrating with Discord. However, there is actually a third thing we need which is hinted at in step 2. We need to store a previous state of some kind in order to properly do the comparison. If this were a production app, I’d use a database here. However! This is just a single threaded app with no sensitive data and only one application reading off it, and so I’ll use the most primitive “database” possible: a text file.

In pseudocode:

previous_inventory = read_last_inventory()
current_inventory = read_current_inventory_from_etsy()
diffs = compare(previous_inventory, current_inventory)
for diff in diffs:
send_discord_message(diff['name'])
# if the inventory has changed, write out the new one to
# our database
if diff:
write(current_inventory)

Step 1: Querying the Etsy API

My code samples will be in pseudocode, though if you are interested in actual code, check out our running Python service at this repository. We’ll start by making an Etsy class which will do a few key operations:

  • Query the API for the latest Listings data
  • Transform this data into just a dictionary of the stuff we’re interested in
  • Compare the last inventory data with the current one in order to detect changes in inventory

We can get the quantity of each listing by querying the Etsy API’s shop endpoint, then tacking on a param to include Listings. Comparing diffs becomes just a matter of comparing two dictionaries, and returning a third one of just the items where a quantity changed.

class Etsy:
api_key = "SECRET ETSY API KEY"
store_name = "wicksbywerby"
def request_listings():
"""Query the etsy shop for the latest listings"""
endpoint = "https://openapi.etsy.com/v2/shops/{self.shop_name}"
response = http_get(endpoint,
params={
"includes": "Listings",
"api_key": api_key
})
return response
def transform_data(listings):
"""Transform raw etsy response data. We are only interested
in items that are active or sold out, and also only
interested in quantities"""
inventory_state = {}
for item in listings():
if item['state'] == 'active' or item['state'] == 'sold_out':
inventory_state[item['listing_id'] = {
"listing_id": item['listing_id'],
"name": item['title'],
"quantity": item['quantity']
}
return inventory_state
def get_inventory_state():
"""Just a combination of the other two functions"""
listings = request_listings()
return transform_data(listings)

And that’s pretty much our Etsy class!

Step 2: Comparing Listings

Here, we want a function that takes two inventory states and gives us back their diff. Using the output of our Etsy class get_inventory_state() function, we can write a function that looks like this:

def get_inventory_diff(prev_inventory, cur_inventory):
"""Compare previous and current inventories and return
a dictionary of diffs, keyed by id of the listing. Only
return diffs where inventory has decreased, indicating
a sale."""
diffs = {}
for listing_id in prev_inventory:
name = prev_inventory[listing_id]['name']
prev_quantity = prev_inventory[listing_id]['quantity']
cur_quantity = cur_inventory[listing_id]['quantity']
if prev_quantity > cur_quantity:
diffs[listing_id] = {
'num_sales': cur_quantity - prev_quantity,
'name': name
}
return diffs

The problem is, where do we get the prev_inventory from? The answer to that would be from the previous run of this script, which means each time we run this script, if there is a change, we need to save the inventory state to be the next run’s prev_inventory. So we can make a new class that serves as our interface to our “database”, in this case just a text file in something like JSON.

class Database:
data_dir = "/data"
inventory_file = "inventory.json"
def write_inventory(inventory):
file_obj = open(inventory_file)
file_obj.write(inventory)
def get_inventory():
file_obj = open(inventory_file)
return file_obj.read()

Easiest database ever 😎

Step 3: Post to Discord

You’ll need a Discord webhook which the Discord official docs will tell you exactly how to do. I found this guide very helpful for figuring out how to structure my Discord payload.

class Discord:
webhook_url = "DISCORD WEBHOOK URL"

def send_sales_message(message):
"""Post a message to Discord with an image"""
payload = {
"username": "Sale Alerter",
"avatar_url": "link_to_image",
"content": message,
}
http_post(webhook_url, json=payload)

Okay, so at this point we just need a message to go along with it. We can get the name of the item sold from our inventory diff.

Putting it all together

Using the components in our pseudocode, the main function would look something like:

# get our data
previous_inventory = Database.get_inventory()
current_inventory = Etsy.get_inventory_state()
# compare the data
diffs = get_inventory_diff(previous_inventory, current_inventory)
# send a message for each inventory diff
for listing_id in diffs:
item = diffs[listing_id]
msg = "Shop just sold {item['num_sales']} of {item['name']}!!"
Discord.send_sale_message(msg)
# write out inventory for next run
if diffs:
Database.write_inventory(current_inventory)

And that’s it!

Deploying

We have this running on an EC2 instance via a cron job. It runs once a minute to check the Etsy inventory. We’ve found this is plenty, and sometimes gives a notification even faster than our friend’s Etsy app does!

# crontab# once every minute: 
# 1. go to the directory where our code lives
# 2. activate the Python virtual environment
# 3. run the program and output logs to a file
*/1 * * * * cd /sales-alerter && venv/bin/python3 -m sales.main >> /sales-alerter/logs.txt 2>&1

Limitations

Because we can only look at Listing data instead of actual Sales data, we run the risk of false positives if the shop owner manually lowers their own inventory. There are a few ways to work around this, including comparing total number of sales data which is what we ended up doing. However, it’s a bit messy because the Etsy API does not offer total number of sales publicly either. We ended up having to resort to scraping for that data, though it’s only for a small field and only happens if there is a change in inventory detected to begin with.

Extra Credit

Admittedly one thing that really drove this project was I thought it’d be fun to include a gif related to the item sold, to really up the festive spirit and get us all excited when an item sells. For example, if a Haikyu!! related candle sold, I could put in a gif of some epic volleyball or something.

To enable this, there’s one more data file and one more class we can add.

First, we need a data file of listing ids mapped to image urls we want to use. I used a JSON file for this.

# listing_images.json{
"listing_id": {
"name": "Zagreus",
"images": [
"https://yayzagreus.fakeurl.com/hotfoot.jpg",
"https://zagreusfanclub.fakeurl.com/pom_of_power.jpg"
]
}
...
}

This does require some manual work, but it’s pretty fun just to find your favorite images/gifs of some cool characters.

Then, before we post our Discord message, we choose an image to send along with it.

def get_image_for_item(listing_id):
"""Read in our list of potential images and return one
random URL"""

all_listing_images = read(listing_images.json)
item_image_urls = all_listing_images[listing_id]['images']
return random(item_image_urls)

So our main logic would be altered to (alterations in bold):

# get our data
previous_inventory = Database.get_inventory()
current_inventory = Etsy.get_inventory_state()
# compare the data
diffs = get_inventory_diff(previous_inventory, current_inventory)
# send a message for each inventory diff
for listing_id in diffs:
item = diffs[listing_id]
msg = "Shop just sold {item['num_sales']} of {item['name']}!!"
image_url = get_image_for_item(listing_id)
Discord.send_sale_message(msg, image_url)
# write out inventory for next run
if diffs:
Database.write_inventory(current_inventory)

And in our Discord class:

class Discord:
...
def send_sales_message(message, image_url):
"""Post a message to Discord with an image"""
payload = {
"username": "Sale Alerter",
"avatar_url": "link_to_image",
"content": message,
"embeds": {
"image": {"url": image_url}
}

}

And now we have an image to go with our sale!

New Hades Bundle Sale! Embedded image from the Supergiant Games Twitter account

Thanks for reading! Overall, this was just a fun project to spread a little joy in a friend’s hard work ❤️

(check out her shop here!)

software engineer | writer