Integrating Etsy sale alerts to Discord

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
  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
My favorite WicksByWerby candle (photo from the WicksByWerby Etsy shop)

General Architecture

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

System Diagram
  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
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
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)

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
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()

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)

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)

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.

# listing_images.json{
"listing_id": {
"name": "Zagreus",
"images": [
"https://yayzagreus.fakeurl.com/hotfoot.jpg",
"https://zagreusfanclub.fakeurl.com/pom_of_power.jpg"
]
}
...
}
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)
# 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)
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}
}

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

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store