Purpose: Document webhooks for third-party integrations (SMS delivery status, payment events)
ReserveEasy uses webhooks to receive real-time notifications from external services (Twilio for SMS, Stripe for payments). This allows us to track delivery status and payment confirmations asynchronously.
What is a Webhook?
A webhook is a callback HTTP POST request sent from Service A → Service B when an event occurs.
Example: When Twilio delivers an SMS, it calls our webhook endpoint to report status.
Endpoint: POST /api/v1/webhooks/twilio/sms-status
Purpose: Receive SMS delivery status updates from Twilio
When Twilio delivers (or fails to deliver) an SMS, it sends:
POST /api/v1/webhooks/twilio/sms-status
Content-Type: application/x-www-form-urlencoded
MessageSid=SM1234567890abcdef
SmsSid=SM1234567890abcdef
AccountSid=AC1234567890abcdef
From=+15551234567
To=+15559876543
MessageStatus=delivered
ApiVersion=2010-04-01Key Fields:
MessageSid: Unique identifier for this SMSMessageStatus: One of:queued,sent,delivered,failed,undeliveredTo: Recipient phone numberFrom: Sender phone number (our Twilio number)
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "received"
}Twilio expects a 200 OK response. If we return an error, Twilio will retry the webhook up to 3 times.
-
Look up booking by phone number:
SELECT id FROM bookings WHERE customer_phone = '+15559876543' AND status = 'confirmed' ORDER BY created_at DESC LIMIT 1;
-
Update booking record:
UPDATE bookings SET sms_delivered = true, sms_delivered_at = NOW() WHERE id = 12345;
-
If status = 'failed':
- Log error
- Trigger email fallback
- Alert support team
Endpoint: POST /api/v1/webhooks/stripe/payment-events
Purpose: Receive payment confirmation, refund status
When a payment succeeds or fails:
POST /api/v1/webhooks/stripe/payment-events
Content-Type: application/json
Stripe-Signature: t=1234567890,v1=abc123...
{
"id": "evt_1234567890",
"object": "event",
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_1234567890",
"amount": 6000,
"currency": "usd",
"status": "succeeded",
"metadata": {
"booking_id": "12345"
}
}
}
}Event Types We Handle:
payment_intent.succeeded→ Payment captured successfullypayment_intent.payment_failed→ Payment declinedcharge.refunded→ Refund processed
Stripe signs webhooks using HMAC SHA256. We must verify the signature to prevent spoofing.
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
app.post("/api/v1/webhooks/stripe/payment-events", (req, res) => {
const sig = req.headers["stripe-signature"];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err) {
console.error("Webhook signature verification failed:", err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Process event
if (event.type === "payment_intent.succeeded") {
const paymentIntent = event.data.object;
const bookingId = paymentIntent.metadata.booking_id;
// Update booking status
updateBookingPaymentStatus(bookingId, "paid");
}
res.json({ received: true });
});On payment_intent.succeeded:
- Update booking:
deposit_paid = true - Send confirmation SMS
- Log payment in
transactionstable
On payment_intent.payment_failed:
- Mark booking as
payment_pending - Send email: "Your payment failed. Please update your card."
- Hold booking for 2 hours, then auto-cancel
On charge.refunded:
- Update booking:
deposit_refunded = true, refunded_at = NOW() - Send email: "Your $60 deposit has been refunded"
Always verify that webhooks come from the actual service (Twilio, Stripe), not an attacker.
Twilio: Check X-Twilio-Signature header
Stripe: Check Stripe-Signature header
Webhooks must be sent to https:// endpoints, never http://.
Process webhooks asynchronously. Don't make the webhook caller wait.
Bad:
app.post("/webhook", async (req, res) => {
await sendEmail(); // Slow! Twilio times out after 10 seconds
res.send("OK");
});Good:
app.post("/webhook", (req, res) => {
queue.push(req.body); // Add to job queue
res.send("OK"); // Return immediately
});
// Separate worker processes queue
worker.process(async (job) => {
await sendEmail(job.data);
});Webhooks may be sent multiple times (if response is slow or times out). Handle duplicates gracefully.
// Check if we've already processed this event
const existingEvent = await db.webhookEvents.findOne({
event_id: event.id,
});
if (existingEvent) {
console.log("Duplicate event, ignoring");
return res.json({ received: true });
}
// Store event ID to prevent re-processing
await db.webhookEvents.create({
event_id: event.id,
processed_at: new Date(),
});
// Now process the event
processEvent(event);Twilio and Stripe need a public URL to send webhooks. Use ngrok to expose localhost:
# Install ngrok
brew install ngrok
# Start your local server
npm run dev # Runs on localhost:3000
# Create tunnel
ngrok http 3000
# ngrok will give you a URL like:
# https://abc123.ngrok.ioConfigure Webhook URL:
- Twilio: Set SMS Status Callback to
https://abc123.ngrok.io/api/v1/webhooks/twilio/sms-status - Stripe: Add webhook endpoint
https://abc123.ngrok.io/api/v1/webhooks/stripe/payment-events
Stripe provides a CLI tool that forwards webhook events to localhost:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward events to local server
stripe listen --forward-to localhost:3000/api/v1/webhooks/stripe/payment-events
# Trigger test event
stripe trigger payment_intent.succeededView in Twilio Console:
- Go to Programmable SMS → SMS Logs
- Click on a message
- See "Webhook Responses" section
- Check HTTP status code (should be 200)
View in Stripe Dashboard:
- Go to Developers → Webhooks
- Click on your endpoint
- See "Recent Deliveries"
- Status codes, response times, retry attempts
| Error Scenario | Response Code | Service Behavior |
|---|---|---|
| Webhook processed successfully | 200 OK | No retry |
| Temporary error (database timeout) | 500 Internal Server Error | Retry 3 times with exponential backoff |
| Invalid signature | 400 Bad Request | No retry (security issue) |
| Unknown event type | 200 OK (but log warning) | No retry (we might not handle this event yet) |
Retry Schedule (Twilio & Stripe):
- Attempt 1: Immediate
- Attempt 2: +1 minute
- Attempt 3: +5 minutes
- Attempt 4: +30 minutes
- Give up after 4 attempts
const crypto = require("crypto");
app.post("/api/v1/webhooks/twilio/sms-status", async (req, res) => {
// 1. Verify signature (security)
const twilioSignature = req.headers["x-twilio-signature"];
const url = `https://api.reserveeasy.com/api/v1/webhooks/twilio/sms-status`;
const params = req.body;
const expectedSignature = crypto
.createHmac("sha1", process.env.TWILIO_AUTH_TOKEN)
.update(
url +
Object.keys(params)
.sort()
.map((k) => k + params[k])
.join(""),
)
.digest("base64");
if (twilioSignature !== expectedSignature) {
console.error("Invalid Twilio signature");
return res.status(403).send("Forbidden");
}
// 2. Extract data
const { MessageSid, MessageStatus, To } = req.body;
// 3. Update database (async, non-blocking)
queue.add("update-sms-status", {
messageSid: MessageSid,
status: MessageStatus,
phone: To,
});
// 4. Return 200 immediately
res.status(200).json({ status: "received" });
});
// Worker (separate process)
queue.process("update-sms-status", async (job) => {
const { messageSid, status, phone } = job.data;
// Find booking
const booking = await db.bookings.findOne({
customer_phone: phone,
status: "confirmed",
order: [["created_at", "DESC"]],
});
if (!booking) {
console.warn(`No booking found for phone ${phone}`);
return;
}
// Update delivery status
await db.bookings.update(
{
sms_delivered: status === "delivered",
sms_status: status,
sms_delivered_at: new Date(),
},
{
where: { id: booking.id },
},
);
// If failed, trigger email fallback
if (status === "failed" || status === "undelivered") {
await sendEmailConfirmation(booking);
}
});