Monday, October 27, 2025

ESP32-based BBW (Body Care Store) Sale Tracker and Display

 

The BBW Sale Tracker

 

This is the BBW tracker!  A device I made because we missed one too many candle sales at our favorite smell store.  It uses an ESP32 to scrape the BBW "Top Offers" section and uses the results to determine whether or not key items are on sale.  The design is mostly intentional.. I didn't expect the infill to be visible but I wanted to overlay a few layers of clear filament over it and I didn't think to increase the "Bottom Shell Thickness" which I've since fixed in other projects.  And the back is held on by rubber bands.  Which kind of looks nice, I guess?  lol😅

First, I'll cover a little bit about the hardware then we'll talk through the software firmware!  The project is an Arduino IDE Sketch and it is relatively straightforward but, I will say, it may be a little difficult to replicate as you'll have to retrieve session keys and stuff on your own (but we'll get to that).

Hardware 

The BBW tracker uses 6 5mm green LEDs which are hooked up to IO pins through 220ohm resistors to control current. The ESP sits in a socket so I can easily remove it.  There is one extra pin on the socket that is unused and should be ignored (it's down below the red USB wire). 


This is a shot of the underside of the board rotated to be in the same orientation as the previous image.  (Please don't judge my soldering, this was the most soldering I'd done in a while :D) 
 
You can see the resistors are soldered directly to the pins of the socket then to the LEDs which feed straight to the GND pin of the ESP.


 This is the circuit diagram showing accurate pinouts and wiring.


Here you can see the assembly, I tried to design it with dividers so the LEDs would glow out of the front.  Though using thin white segments was probably not the best idea!  It definitely bleeds but you can clearly see which items are marked as on sale and which ones are not still!


 It's powered through the +5v and GND from a USB cable soldered directly to the VIN and GND pins on the ESP32!  I had hoped that was possible because this box wouldn't properly fit a USB cable plugged into the ESP! 

 3D-Printed Box

 

Not much to say here!  It's a box with proper sizing for my pcb that fits everything.  The front is, I think 1mm, of clear PLA with white in the background and black lettering! 

 I would recommend you make your own as mine doesn't currently latch and it's held together by rubber bands... lol But if people want I can upload the model somewhere as whatever file type is useful for y'all! I just don't have a good way to share now and ultimately it's a box! It's the functionality that matters most! 

Firmware

The firmware here is pretty straightforward as well!  

I'm trying to avoid directly naming the company because I'm not associated with them.  But they have an "All Offers" section under Offers on their main page. This page seems to list all of their current offers and it lists them in a way we can pull down, parse, and analyze!

The most difficult part was figuring out how to get the data reliably. It's a 2 part system, you need a session token to call the slots URL and that token expires at some point, though I haven't yet run into that.  The number 2592000 is visible as an expiration in the returned object but I'm not 100% confident whether it's milliseconds or seconds nor am I sure of exactly which token is expiring, though I'd think the refresh token because mine has been running on the same session token.  I have tested this for around a month now and the system has only needed an occasional reboot.  Speaking of..

 Errors and Debugging

A lot of information is provided as output through the USB serial connection viewable by using the "Serial Monitor" in Arduino IDE or monitoring with ESP-IDF.  

The GPIO2 LED on my ESP32 Devkit v1/WROOM board is used as an ERROR signal to indicate there is a problem.  After stabilizing the firmware and letting it run, I've seen several errors but they were always remedied by a restart and are usually days apart.  I'd have to assume some issue with the internet connection or a major AWS outage (lol)

 The Tokens

This part was interesting.  I used Chrome's Developer Tools to artificially slow the connection down so I was able to see the page load piece by piece until I saw the main list of Top Offers load.  I found a "slots" request that had "top-offers" at the end and decided to investigate.  There were several that seemed to end with "=top-offers" but the list always seems to be in the one that looks like the screenshot. 

 

The data inside showed a list of offers I could retrieve and search through for specific strings like "3-wick candles" or "hand soap" and drive each specific status LED based on whether or not they're found!  

However, requests to this endpoint must be made with a token.  I think this is using something like Salesforce Commerce Cloud  So I also had to find the token URL..

To explain this like it was a linear process would not do it justice.  

I spent quite a while trying to find some way to identify the token URL.  What ended up working was to use "Incognito Mode" so that my cookies would be flushed and it thought I needed a new token every time.  Then I just used the Dev Tools filtering to watch for "token" and sure enough:

 

However, quick snag, this grant_type is "client_credentials" but we want "refresh_token".  What ended up working for me in the end was to just wait in a non-incognito window for a while and then refresh the page with Dev Tools still filtered for "token".  Then, since it's already established a session with you, it'll call refresh to keep the token alive instead!

Success!  I copied a curl request from dev tools and then emulated the important parts in code and tested it out to great success!  Eventually.. but that's not important lol 

These URLS could just be pasted into my sketch and everything worked!  After I wrote it all so that it worked.  

The refresh_token and client_id come from the Response payload in the token request or can be retrieved independently by running the curl request copied.  

 

Success!! 

From there, I wrote the sketch below!  I have blanked out anything that was potentially proprietary with fill in the blanks (make sure to fill them all in! check for channel_id too) and hopefully the above can help you learn to make your own!   

Firmware Code 

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>

const char* ssid     = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASS";

// Refresh token endpoint
const char* token_url = "YOUR_TOKEN_URL";

// Slots endpoint
const char* slots_url = "YOUR_SLOTS_URL";

// Values from your curl
const char* client_id     = "YOUR_CLIENT_ID";
const char* refresh_token = "YOUR_REFRESH_TOKEN";

const int CANDLE_LED = 25; //13;
const int HANDSOAP_LED = 26; //12;
const int MOISTURE_LED = 27; // 14;
const int TRAVEL_LED = 14;
const int DETERG_LED = 12;
const int BOOSTER_LED = 13;
const int ERROR_LED = 2;

String accessToken;
unsigned long lastRefresh = 0;
unsigned long tokenTTL = 0;

String getAccessToken() {
  HTTPClient http;
  http.begin(token_url);
  http.addHeader("Content-Type", "application/x-www-form-urlencoded");
  http.addHeader("User-Agent", "ESP32");

  String body = "grant_type=refresh_token";
  body += "&refresh_token=" + String(refresh_token);
  body += "&client_id=" + String(client_id);
  body += "&channel_id=(FILLTHISIN!!!_It's the NameOfTheCompany written like that)";
  body += "&dnt=1";

  int code = http.POST(body);
  String token;
  if (code > 0) {
    String payload = http.getString();
    Serial.println("Token response:");
    Serial.println(payload);

    DynamicJsonDocument doc(2048);
    DeserializationError err = deserializeJson(doc, payload);
    if (!err) {
      token = doc["access_token"].as<String>();
      tokenTTL = doc["expires_in"].as<unsigned long>() * 1000UL; // ms
      lastRefresh = millis();
    }
  } else {
    Serial.printf("Token request error: %d\n", code);
  }
  http.end();
  return token;
}

void checkOffers() {
  if (accessToken == "") {
    accessToken = getAccessToken();
    if (accessToken == "") {
      digitalWrite(ERROR_LED, HIGH);
      digitalWrite(BOOSTER_LED, HIGH);
      digitalWrite(CANDLE_LED, HIGH);
      return;
    }
  }

  HTTPClient http;
  http.begin(slots_url);
  http.addHeader("Authorization", "Bearer " + accessToken);
  http.addHeader("User-Agent", "ESP32");

  int code = http.GET();
  if (code > 0) {
    String payload = http.getString();
    Serial.println("Slots response received");

    String lower = payload;
    lower.toLowerCase();

    if (lower.indexOf("3-wick candles") != -1) {
      Serial.println("Candle Sale FOUND!");
      digitalWrite(CANDLE_LED, HIGH);
    } else {
      Serial.println("Candle Sale NOT found.");
      digitalWrite(CANDLE_LED, LOW);
    }

    if (lower.indexOf("hand soap") != -1) {
      Serial.println("Hand soap Sale FOUND!");
      digitalWrite(HANDSOAP_LED, HIGH);
    } else {
      Serial.println("Hand soap Sale NOT found.");
      digitalWrite(HANDSOAP_LED, LOW);
    }

    if (lower.indexOf("moisturizer") != -1) {
      Serial.println("moisturizer Sale FOUND!");
      digitalWrite(MOISTURE_LED, HIGH);
    } else {
      Serial.println("moisturizer Sale NOT found.");
      digitalWrite(MOISTURE_LED, LOW);
    }

    if (lower.indexOf("travel") != -1) {
      Serial.println("travel Sale FOUND!");
      digitalWrite(TRAVEL_LED, HIGH);
    } else {
      Serial.println("travel Sale NOT found.");
      digitalWrite(TRAVEL_LED, LOW);
    }

    if (lower.indexOf("detergent") != -1) {
      Serial.println("detergent Sale FOUND!");
      digitalWrite(DETERG_LED, HIGH);
    } else {
      Serial.println("detergent Sale NOT found.");
      digitalWrite(DETERG_LED, LOW);
    }

    if (lower.indexOf("booster") != -1) {
      Serial.println("booster Sale FOUND!");
      digitalWrite(BOOSTER_LED, HIGH);
    } else {
      Serial.println("booster Sale NOT found.");
      digitalWrite(BOOSTER_LED, LOW);
    }
  } else {
    Serial.printf("Slots request error: %d\n", code);
    digitalWrite(ERROR_LED, HIGH);
    digitalWrite(TRAVEL_LED, HIGH);
    digitalWrite(CANDLE_LED, HIGH);
  }
  http.end();
}

void setup() {
  Serial.begin(115200);
  pinMode(CANDLE_LED, OUTPUT);
  pinMode(HANDSOAP_LED, OUTPUT);
  pinMode(MOISTURE_LED, OUTPUT);
  pinMode(TRAVEL_LED, OUTPUT);
  pinMode(DETERG_LED, OUTPUT);
  pinMode(BOOSTER_LED, OUTPUT);
  pinMode(ERROR_LED, OUTPUT);

  WiFi.begin(ssid, password);
  Serial.print("Connecting");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".\n");
  }
  Serial.println("Connected!");

  accessToken = getAccessToken();
}

void loop() {
  // Refresh token before expiry (5 min cushion)
  if (millis() - lastRefresh > (tokenTTL - 300000)) {
    accessToken = getAccessToken();
  }

  checkOffers();
  delay(60000); // check every 1 min
}

ESP32-based BBW (Body Care Store) Sale Tracker and Display

    This is the BBW tracker!   A device I made because we missed one too many candle sales at our favorite smell store.  It uses an ESP32 to...