Engineering an Automated Scheduling System: Cal.com, Razorpay, and ERPNext

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:

  1. Reserving calendar availability without double-booking or scheduling collisions.
  2. Collecting localized Indian payments over UPI, cards, and net banking via Razorpay.
  3. Preventing calendar abuse (avoiding public link leakage or unpaid slot reservations).
  4. 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:

  1. Validates the x-razorpay-signature header using constant-time HMAC comparison.
  2. Confirms the Cal.com reservation via API v2.
  3. 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.
Amaya Kulkarni

Amaya Kulkarni

I am a freelance software dev, and a student at BITS Pilani studying cs.
Pune