Engineering an Automated Scheduling System: Cal.com, Razorpay, and ERPNext
When Amaya set out to offer technical and 3D prototyping consultations through Veronami, she hit an immediate systems bottleneck.
On the surface, selling a 30-minute consultation for ₹500 sounds trivial. But underneath, a production-grade workflow requires:
- Reserving calendar availability without double-booking or scheduling collisions.
- Collecting localized Indian payments over UPI, cards, and net banking via Razorpay.
- Preventing calendar abuse (avoiding public link leakage or unpaid slot reservations).
- Generating formal accounting structures—Customer DocTypes, Sales Orders, and submitted Sales Invoices—in an ERPNext ledger.
Because Cal.com lacks a native, turnkey Razorpay plugin, Amaya chose not to shoehorn bloated third-party automation tools like Zapier into the stack. Instead, she engineered a custom Node.js middleware bridge to orchestrate the entire lifecycle cleanly.
The Architecture: Scheduling-First with Ephemeral Holds
Rather than using payment links disconnected from calendar state, Amaya implemented an asynchronous booking reservation pattern:
Plaintext
[ Client picks 30-min slot on Cal.com ]
│
▼ (BOOKING_CREATED / Requires Confirmation = ON)
[ Express Middleware: api.veronami.com ]
├── 1. Generates dynamic Razorpay Payment Link (₹500, 30-min expiry)
├── 2. Stores booking metadata in an ephemeral memory store
└── 3. Dispatches customized confirmation email with payment URL
│
▼
[ Client pays via Razorpay ]
│
▼ (Webhook: payment_link.paid / payment.captured)
[ Express Middleware: Reconciles Transaction ]
├── 1. Cryptographically validates HMAC-SHA256 signature
├── 2. Calls Cal.com API v2 (/confirm) to lock calendar reservation
├── 3. Upserts Customer & Submits Sales Invoice in ERPNext
└── 4. Cleanses ephemeral hold from memory
Step 1: Handling Inbound Reservations from Cal.com
Amaya configured the consultation event type in Cal.com with Requires Confirmation enabled. When an attendee selects an appointment, Cal.com fires a BOOKING_CREATED webhook to her middleware.
Her Express handler intercepts the payload, dynamically provisions a ₹500 Razorpay Payment Link valid for exactly 30 minutes, records the transaction state, and sends an automated checkout link to the attendee:
JavaScript
app.post('/api/webhooks/calcom', async (req, res) => {
const { triggerEvent, payload } = req.body;
if (triggerEvent !== 'BOOKING_REQUESTED') {
return res.status(200).json({ status: 'ignored_event' });
}
const bookingId = payload.id || payload.bookingId;
const bookingUid = payload.uid;
const attendee = payload.attendees?.[0];
if (!attendee?.email) {
return res.status(400).json({ error: 'Attendee details missing' });
}
// 1. Generate a 30-minute dynamic Razorpay payment link
const paymentLink = await razorpay.paymentLink.create({
amount: 50000, // ₹500 in paise
currency: 'INR',
accept_partial: false,
description: '30-Min Prototyping & Systems Consultation',
customer: { name: attendee.name || 'Client', email: attendee.email },
notify: { sms: false, email: false },
notes: { service: 'consultation_30min', booking_uid: bookingUid },
expire_by: Math.floor(Date.now() / 1000) + (30 * 60)
});
// 2. Track pending hold with a TTL
pendingConsultations.set(paymentLink.id, {
bookingId,
bookingUid,
customerEmail: attendee.email,
customerName: attendee.name || 'Client',
expiresAt: Date.now() + (30 * 60 * 1000)
});
// 3. Dispatch payment email to attendee
await transporter.sendMail({
from: `"Veronami" <${process.env.SMTP_USER}>`,
to: attendee.email,
subject: 'Confirm Your 30-Minute Consultation (Veronami)',
html: `<p>Your slot is held. Complete payment within 30 minutes: <a href="${paymentLink.short_url}">Pay ₹500</a></p>`
});
return res.status(200).json({ status: 'payment_link_issued', linkId: paymentLink.id });
});
Step 2: Auto-Expiring Abandoned Reservations
To protect her calendar from phantom holds, Amaya scheduled a background cleanup worker. Every few minutes, any reservation that remains unpaid past its 30-minute window is rejected via the Cal.com API, freeing the slot for other clients:
JavaScript
setInterval(async () => {
const now = Date.now();
for (const [linkId, item] of pendingConsultations.entries()) {
if (item.expiresAt <= now) {
try {
await axios.patch(
`https://api.cal.com/v1/bookings/${item.bookingId}?apiKey=${process.env.CALCOM_API_KEY}`,
{
status: 'REJECTED',
rejectionReason: 'Payment window expired (30 minutes)'
}
);
} catch (err) {
console.error(`Failed to release hold for ${item.bookingUid}:`, err.message);
}
pendingConsultations.delete(linkId);
}
}
}, 5 * 60 * 1000);
Step 3: Reconciling Payments and ERP Accounting
When the client completes payment, Razorpay fires payment.captured or payment_link.paid. The middleware:
- Validates the
x-razorpay-signatureheader using constant-time HMAC comparison. - Confirms the Cal.com reservation via API v2.
- Automatically posts the client details and submitted invoice to ERPNext's REST API.
JavaScript
app.post('/api/webhooks/razorpay', async (req, res) => {
const signature = req.headers['x-razorpay-signature'];
const expectedSig = crypto
.createHmac('sha256', process.env.RAZORPAY_WEBHOOK_SECRET)
.update(req.rawBody)
.digest('hex');
if (signature !== expectedSig) {
return res.status(400).json({ error: 'Invalid HMAC signature' });
}
const { payload } = req.body;
const payment = payload.payment?.entity || payload.payment_link?.entity;
const paymentLinkId = payload.payment_link?.entity?.id || payment.payment_link_id;
const pending = paymentLinkId ? pendingConsultations.get(paymentLinkId) : null;
if (!pending) return res.status(200).json({ status: 'ignored_or_unmatched' });
// 1. Confirm Cal.com slot using API v2
await axios.post(
`https://api.cal.com/v2/bookings/${pending.bookingUid}/confirm`,
{},
{
headers: {
'Authorization': `Bearer ${process.env.CALCOM_API_KEY}`,
'cal-api-version': '2024-08-13'
}
}
);
// 2. Sync to ERPNext (Upsert Customer & Submit Sales Invoice)
await syncConsultationToERPNext(pending.customerName, pending.customerEmail, payment.id);
// 3. Clear temporary store
pendingConsultations.delete(paymentLinkId);
return res.status(200).json({ status: 'ok', type: 'consultation_reconciled' });
});
The Outcome
By building her own middleware rather than relying on brittle no-code platforms, Amaya achieved three key operational boundaries:
- Zero Ghost Bookings: Cal.com never permanently books a slot unless domestic UPI or card funds are captured.
- Automated Bookkeeping: Every consultation instantly translates into a valid, submitted invoice inside ERPNext with payment IDs attached.
- Deterministic Isolation: General payments are routed to distinct fulfillment pipelines without triggering calendar links.
Comments ()