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

  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

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

  • 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

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

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

# 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

# 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

Extra Credit

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