Parcels left at the door can get wet or disappear. This capstone builds a box that only opens with a code. You give the courier a delivery code; it opens the box once, and as soon as the lid is closed again, the box locks itself and sends a notification to your phone. Your own owner code always opens it, and you can change the delivery code from the keypad. Three wrong codes in a row block the keypad for a minute and send you a warning.
What you'll learn
- How to read codes from a keypad and compare them
- How to build a lock from a servo and a printed bolt
- How to follow the lid with a tilt switch and lock again automatically
- How to plan a device with clear states: locked, open for delivery, open for the owner, blocked
What you need
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
- 4×4 keypad (ESP32 IoT Starter Kit)
- Servo motor SG90 with horns and screws (ESP32 IoT Starter Kit)
- Tilt switch (ESP32 IoT Starter Kit)
- Green LED and 1 resistor, 220 Ω (ESP32 IoT Starter Kit)
- Passive buzzer (ESP32 IoT Starter Kit)
- A box with a lid, for example a small wooden crate or a plastic storage box
- A phone with the free ntfy app
- A 2.4 GHz Wi-Fi network
- The printed parts from the 3D printing box below (latch), or the cardboard version described there
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
Wiring

| From | To |
|---|---|
| Keypad R1, R2, R3, R4 | ESP32 GPIO 32, 33, 25, 26 |
| Keypad C1, C2, C3, C4 | ESP32 GPIO 27, 14, 13, 4 |
| Servo signal (orange) | ESP32 GPIO 18 |
| Servo power (red) and GND (brown) | 5V and GND |
| Tilt switch, one leg | ESP32 GPIO 19 |
| Tilt switch, other leg | GND |
| ESP32 GPIO 21 | 220 Ω resistor, then the green LED's long leg |
| LED short leg | GND |
| Passive buzzer + | ESP32 GPIO 23 |
| Passive buzzer − | GND |
The keypad goes on the outside of the box (glue it into a cut-out, or stick it on with its own adhesive back), and the rest stays inside. Power the box from a USB charger or a power bank. This is a fun project, not a safe: keep valuables elsewhere.
Set up ntfy
Install the ntfy app and subscribe to a long, random topic name such as eementor-box-k4w9p2, as in the motion alert lesson. Topics on the free server are public, so never use a name that is easy to guess, and never send personal details through it.
The sketch
In the Arduino IDE, open Library Manager and install the Keypad library by Mark Stanley and Alexander Brevig, and ESP32Servo by Kevin Harrington.
Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only).
Put your topic into TOPIC, and choose your own owner code. Find your servo's two angles with the bolt in place: LOCKED_ANGLE pushes the bolt out under the catch, OPEN_ANGLE pulls it back.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <Keypad.h>
#include <ESP32Servo.h>
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* TOPIC = "YOUR_SECRET_TOPIC";
const String OWNER_CODE = "2580";
const byte ROWS = 4, COLS = 4;
char keys[ROWS][COLS] = {{'1', '2', '3', 'A'}, {'4', '5', '6', 'B'}, {'7', '8', '9', 'C'}, {'*', '0', '#', 'D'}};
byte rowPins[ROWS] = {32, 33, 25, 26};
byte colPins[COLS] = {27, 14, 13, 4};
Keypad keypad = Keypad(makeKeymap(keys), rowPins, colPins, ROWS, COLS);
const int SERVO_PIN = 18, LID_PIN = 19, LED_PIN = 21, BUZZER_PIN = 23;
const int LOCKED_ANGLE = 10, OPEN_ANGLE = 100; // servo angles, see above
enum State { LOCKED_STATE, DELIVERY_OPEN, OWNER_OPEN, BLOCKED, NEW_CODE };
State state = LOCKED_STATE;
Servo latch;
Preferences prefs;
String typed;
String deliveryCode;
int wrongTries = 0;
int lidClosedReading;
bool lidWasOpened = false;
unsigned long stateSince = 0;
void notify(const char* title, const String& message) {
if (WiFi.status() != WL_CONNECTED) return;
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
http.begin(client, String("https://ntfy.sh/") + TOPIC);
http.addHeader("Title", title);
http.addHeader("Tags", "package");
int code = http.POST(message);
http.end();
Serial.printf("ntfy answered %d\n", code);
}
void beep(int freq, int ms) {
tone(BUZZER_PIN, freq, ms);
delay(ms + 40);
}
bool lidOpen() {
return digitalRead(LID_PIN) != lidClosedReading;
}
void setState(State s) {
state = s;
stateSince = millis();
typed = "";
lidWasOpened = false;
bool unlocked = (s == DELIVERY_OPEN || s == OWNER_OPEN);
latch.write(unlocked ? OPEN_ANGLE : LOCKED_ANGLE);
digitalWrite(LED_PIN, unlocked);
}
void checkCode() {
if (typed == OWNER_CODE) {
wrongTries = 0;
beep(1500, 100); beep(2000, 100);
setState(OWNER_OPEN);
} else if (typed == deliveryCode) {
wrongTries = 0;
beep(1500, 100); beep(2000, 100);
setState(DELIVERY_OPEN);
} else {
wrongTries++;
beep(300, 500);
typed = "";
if (wrongTries >= 3) {
setState(BLOCKED);
notify("Parcel box", "Three wrong codes were typed. The keypad is blocked for a minute.");
}
}
}
void setup() {
Serial.begin(115200);
pinMode(LID_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
latch.attach(SERVO_PIN);
prefs.begin("parcelbox");
deliveryCode = prefs.getString("delivery", "1357");
lidClosedReading = digitalRead(LID_PIN); // start the box with the lid closed!
setState(LOCKED_STATE);
WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
Serial.println("Parcel box ready");
}
void loop() {
char key = keypad.getKey();
if (key) beep(1000, 30);
switch (state) {
case LOCKED_STATE:
if (!key) break;
if (key == '*') typed = ""; // clear
else if (key == '#') checkCode(); // enter
else if (key == 'A' && typed == OWNER_CODE) { // owner code + A: change the delivery code
setState(NEW_CODE);
beep(2000, 300);
} else if (typed.length() < 8) typed += key;
break;
case NEW_CODE:
if (key == '#' && typed.length() >= 4) {
deliveryCode = typed;
prefs.putString("delivery", deliveryCode);
beep(1500, 100); beep(2000, 100); beep(2500, 100);
Serial.println("New delivery code saved");
setState(LOCKED_STATE);
} else if (key == '*' || millis() - stateSince > 30000) {
setState(LOCKED_STATE);
} else if (key && isDigit(key) && typed.length() < 8) {
typed += key;
}
break;
case DELIVERY_OPEN:
case OWNER_OPEN:
if (lidOpen()) lidWasOpened = true;
if (lidWasOpened && !lidOpen()) { // the lid was opened and is closed again
delay(1500); // let it settle
bool delivery = (state == DELIVERY_OPEN);
setState(LOCKED_STATE);
beep(800, 200);
if (delivery) notify("Parcel delivered", "A parcel was put in the box. It is locked again.");
} else if (!lidWasOpened && millis() - stateSince > 20000) {
setState(LOCKED_STATE); // nobody opened it: lock again
}
break;
case BLOCKED:
digitalWrite(LED_PIN, (millis() / 200) % 2);
if (millis() - stateSince > 60000) {
wrongTries = 0;
setState(LOCKED_STATE);
}
break;
}
}
Use it
- Type the owner code and press #: the latch opens and the LED lights. Open the lid, close it, and the box locks again after a moment.
- To set a new delivery code, type the owner code, press A (long beep), type the new code (4 to 8 digits) and press #.
- Give the delivery code to the courier, for example in the delivery notes: "Parcel box by the door, code 1357#".
- * clears what you typed.
How it works
The box is always in one of five states, listed in the enum. Each state has its own small job in switch (state): when locked, it collects key presses; when open, it watches the lid; when blocked, it waits a minute. setState() is the only place that moves the servo and the LED, so the latch can never be in the wrong position for the state.
The tilt switch is taped inside the lid. With the lid closed, its ball rests on one side; when the lid swings up, the ball rolls and the reading changes. The sketch learns the "closed" reading when it starts. It waits until the lid has been opened and closed again before locking, so the box can't lock while a parcel is going in.
The delivery code is saved with Preferences, so a power cut doesn't reset it. The owner code is in the sketch, so you can always get in. If you also want to change it from the keypad, save it in Preferences in the same way.
Try this next
- Make the delivery code work only once: after a delivery, create a new random code and send it to your phone.
- Add an ultrasonic sensor inside to report whether there is still a parcel in the box.
- Show the box's state on a web page, like in the countdown lesson.
Troubleshooting
- The board restarts when the servo moves: servos draw bursts of current. Use a good USB port or a powered hub, and check that the servo's red wire is on 5V.
- The box locks while the lid is open: the tilt switch doesn't change when the lid opens. Turn it so its ball rolls when the lid lifts, and restart with the lid closed.
- Every code is wrong: press # after the code, and * first to clear any stray key presses.
- No notification: check the topic in the sketch and in the app letter by letter, and the Wi-Fi.
- A printed part is too tight or too loose: printers differ by a few tenths of a millimetre. Sand or file a tight hole, and fix a loose part with a drop of hot glue or a strip of tape.