Softechinfra
Development

Build a Customer-Service Chatbot With OpenAI Functions and Freshdesk in One Sunday

A working OpenAI function-calling chatbot wired into Freshdesk — order lookups, refund tickets, human handoff — built in a single Sunday on a small API budget. Full code, the 3 guardrails, and when not to ship it.

Hrishikesh BaidyaHrishikesh Baidya
July 6, 202512 min read
Build a Customer-Service Chatbot With OpenAI Functions and Freshdesk in One Sunday

A Jaipur home-decor brand was paying two agents to answer the same four questions all day: "where's my order," "I want a refund," "do you ship to my pincode," and "is this back in stock." On a Sunday in July 2025 we wired an OpenAI function-calling chatbot into their Freshdesk account. By Monday morning it was answering 61% of incoming chats without a human, and creating clean refund tickets for the rest. Total build time: about seven hours. This is the full walkthrough — code included.

7 hrs
Sunday Build Time
61%
Chats Resolved Without a Human
4
Functions the Bot Can Call

The 60-Word Answer

Define your four most common support tasks as JSON function schemas. Pass them to OpenAI's chat.completions endpoint with tools. When the model returns a tool_call, your code runs the matching Freshdesk or order-DB query and feeds the result back. The model writes the customer reply. Add three guardrails — confirm-before-write, a confidence floor, and a human-handoff function — and you have a safe v1.

Why Function Calling, Not a Decision Tree

Old-style chatbots used keyword rules: if the message contains "refund," show the refund flow. They break the moment a customer types "I got the wrong cushion cover, send my money back." Function calling flips this. You describe what your system can do as a list of functions, and the model decides which one fits the customer's actual sentence — including messy, multi-intent ones. OpenAI shipped this as stable tools/tool_calls in the Chat Completions API, and as of July 2025 every current model supports parallel tool calls (the model can ask to run two functions in one turn).

The practical win: you stop writing intent classifiers. You write database queries and let the model route to them.

📦
get_order_status
Takes an order ID or email, returns the latest shipment status from your order DB. The single most-asked question — answer it without a human.
💸
create_refund_ticket
A write action. Creates a Freshdesk ticket tagged "refund" with the order context pre-filled. Always confirms with the customer first.
📍
check_pincode_serviceable
Checks a pincode against your courier's serviceable list. Pure read, zero risk — let the bot answer instantly.
🙋
escalate_to_human
The safety valve. The model calls this whenever it is unsure, angry-customer language appears, or the topic is off-menu. Routes to a live agent.

What You'll Need (Prerequisites Checklist)

  • An OpenAI API key with billing enabled
  • A Freshdesk account (the Growth plan or higher exposes the Tickets API)
  • Your Freshdesk domain and an API key from Profile Settings
  • Node.js 20+ (the code below is JavaScript; Python is near-identical)
  • Read access to wherever order data lives — Shopify Admin API, a Postgres table, or a Google Sheet for a quick start
  • One test order you can safely look up and one you can safely refund

Step 1: Describe Your Functions as JSON Schemas

This is 80% of the work. Each function gets a name, a one-line description the model reads to decide when to use it, and a typed parameter list. Be specific in the description — that text is your only steering wheel.

// tools.js
  export const tools = [
    {
      type: "function",
      function: {
        name: "get_order_status",
        description: "Look up the current shipment status of an order. Use when the customer asks where their order is, or for tracking. Requires an order ID OR the email used at checkout.",
        parameters: {
          type: "object",
          properties: {
            order_id: { type: "string", description: "Order ID, e.g. JD-10482" },
            email: { type: "string", description: "Email used at checkout" }
          },
          required: []
        }
      }
    },
    {
      type: "function",
      function: {
        name: "create_refund_ticket",
        description: "Create a refund request ticket. ONLY call after the customer has explicitly confirmed they want a refund and you have the order ID.",
        parameters: {
          type: "object",
          properties: {
            order_id: { type: "string" },
            reason: { type: "string", description: "Why the customer wants a refund" },
            email: { type: "string" }
          },
          required: ["order_id", "reason", "email"]
        }
      }
    },
    {
      type: "function",
      function: {
        name: "check_pincode_serviceable",
        description: "Check whether a 6-digit Indian pincode is serviceable for delivery.",
        parameters: {
          type: "object",
          properties: { pincode: { type: "string", description: "6-digit pincode" } },
          required: ["pincode"]
        }
      }
    },
    {
      type: "function",
      function: {
        name: "escalate_to_human",
        description: "Hand off to a human agent. Call this when you are unsure, the customer is upset, the request is a complaint, or the topic is outside orders/refunds/shipping.",
        parameters: {
          type: "object",
          properties: { summary: { type: "string", description: "One-line summary for the agent" } },
          required: ["summary"]
        }
      }
    }
  ];

Tip: The description field is prompt engineering in disguise. We rewrote create_refund_ticket's description three times before the model stopped creating refund tickets on the first "I might want a refund." Specific verbs ("ONLY call after the customer has explicitly confirmed") fixed it.

Step 2: The Function-Calling Loop

The pattern is a loop: send the conversation, check if the model wants to call a tool, run it, send the result back, repeat until the model returns a plain text reply for the customer.

// chat.js
  import OpenAI from "openai";
  import { tools } from "./tools.js";
  import { runTool } from "./handlers.js";

const openai = new OpenAI();

const SYSTEM = You are the support assistant for Jaipur Decor. Be warm, concise, and reply in the customer's language (Hindi or English). You can look up orders, check pincodes, and start refunds. Never invent an order status — always call get_order_status. If you are unsure, call escalate_to_human.;

export async function reply(history) { const messages = [{ role: "system", content: SYSTEM }, ...history];

for (let hop = 0; hop < 5; hop++) { const res = await openai.chat.completions.create({ model: "gpt-4o-mini", messages, tools, tool_choice: "auto", temperature: 0.3 });

const msg = res.choices[0].message; messages.push(msg);

if (!msg.tool_calls) { return msg.content; // plain reply for the customer }

for (const call of msg.tool_calls) { const args = JSON.parse(call.function.arguments); const result = await runTool(call.function.name, args); messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(result) }); } } return "Let me get a teammate to help with this."; }

The for loop cap (5 hops) is a guardrail in itself — it stops a runaway model from calling tools forever. We have never seen a legitimate support turn need more than three hops.

Step 3: Wire the Handlers to Freshdesk and Your Order DB

The handlers are plain functions. Here are the two that touch real systems — the order lookup (read) and the Freshdesk ticket (write).

// handlers.js
  const FD = process.env.FRESHDESK_DOMAIN;   // e.g. jaipurdecor
  const FD_KEY = process.env.FRESHDESK_API_KEY;

export async function runTool(name, args) { if (name === "get_order_status") { // Replace with your real source — Shopify, Postgres, etc. const order = await db.orders.findByIdOrEmail(args.order_id, args.email); if (!order) return { found: false }; return { found: true, status: order.status, courier: order.courier, tracking: order.tracking_url, eta: order.eta }; }

if (name === "create_refund_ticket") { const auth = Buffer.from(${FD_KEY}:X).toString("base64"); const r = await fetch(https://${FD}.freshdesk.com/api/v2/tickets, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": Basic ${auth} }, body: JSON.stringify({ subject: Refund request — order ${args.order_id}, description: Reason: ${args.reason}, email: args.email, priority: 2, status: 2, tags: ["refund", "bot-created"] }) }); const ticket = await r.json(); return { ticket_id: ticket.id, created: true }; }

if (name === "check_pincode_serviceable") { const ok = await courier.isServiceable(args.pincode); return { pincode: args.pincode, serviceable: ok }; }

if (name === "escalate_to_human") { await freshdeskAssignToGroup(args.summary); return { escalated: true }; } }

Freshdesk's Tickets API uses HTTP Basic auth with your API key as the username and any string as the password — that ${FD_KEY}:X is correct, not a typo. The priority: 2, status: 2 maps to Medium/Open in Freshdesk's enums.

💬
Customer message arrives
🧠
Model picks a function
⚙️
Your code runs it
✅
Model writes the reply

Step 4: Verify It Works

Run two test conversations before you connect it to live chat:

1
Read path: "Where is order JD-10482?"
Expect one get_order_status call and a reply quoting the real status and tracking URL. If the model invents a status, your system prompt's "never invent" line needs strengthening.
2
Write path: "I want a refund for JD-10482, it arrived broken"
Expect the bot to confirm first ("Just to confirm, you'd like a refund for JD-10482?"), then call create_refund_ticket only after you say yes. Check Freshdesk for a real ticket tagged "refund" and "bot-created".

The 3 Guardrails That Make This Safe to Ship

A read-only bot is safe by default. The moment it can write — create tickets, issue refunds, change addresses — you need these three.

🔒
Confirm before any write
Bake "confirm before calling create_refund_ticket" into the system prompt AND the function description. Two layers, because one prompt line is not reliable enough alone.
📊
A confidence floor
If the model has not gathered required args after two hops, it calls escalate_to_human instead of guessing. We enforce this in the prompt and watch the escalation rate.
🚨
A hard human handoff
The escalate_to_human function is always present. Angry language, legal threats, or anything off-menu routes to a live agent within Freshdesk's SLA.
📝
Log every tool call
Store the full message array for each conversation. When the bot does something odd, you replay the exact transcript instead of guessing. This is your debugging lifeline.

When NOT to Build This

This pattern is wrong for some cases, and pretending otherwise is how you ship a bot that embarrasses the brand.

Skip the bot if: your support volume is under ~30 chats a day (a human is cheaper and warmer); your refund policy needs human judgement on every case (don't automate a discretionary decision); or your order data lives in a system with no API (you'll spend the Sunday building scrapers, not the bot). Also skip gpt-4o-mini if your queries need deep reasoning over policy documents — that's a RAG problem, covered in our RAG helpdesk bot guide, not a function-calling one.

One more honest limit: function calling does not "understand" your business. It maps sentences to functions. If a customer asks something your four functions can't answer, a well-built bot escalates — a badly built one hallucinates. The escalation rate is the metric that tells you which one you shipped.

Real Example: The Jaipur Decor Build

A 14-person home-decor D2C brand in Jaipur, ~120 support chats/day, two full-time agents drowning in "where's my order."

We shipped the four functions above on a Sunday. Order data came from their Shopify Admin API; refunds and escalations went into Freshdesk. The first week's numbers: 61% of chats fully resolved by the bot, 28% escalated cleanly with context attached, 11% the bot mishandled (mostly multi-order messages we hadn't designed for). We added a fifth function for multi-order lookups the next weekend. By week three, one agent moved to handling returns logistics full-time — the queue no longer needed two people on chat.

We applied the same function-calling spine when our AI automation team later built a 3-day Diwali support chatbot on Claude Haiku + Freshdesk + WhatsApp — same skeleton, different model, WhatsApp instead of web chat. The voice version of this idea powers part of TalkDrill, our in-house English-speaking app, where the model routes learners to the right practice scenario.

ApproachBuild timeHandles messy phrasingMaintenance
Keyword decision tree2–3 daysPoorlyHigh — every new phrasing breaks it
OpenAI function calling1 SundayWellLow — add a function, not a rule
Full RAG over docs1–2 weeksWellMedium — re-index when docs change

Frequently Asked Questions

How much does an OpenAI function-calling chatbot cost to run per month?

At gpt-4o-mini rates and ~120 chats/day, the Jaipur brand's monthly API token spend stayed low. Each chat ran 2–4 model calls. Heavier reasoning models cost 10–20x more, so start cheap and upgrade only the conversations that need it.

Which OpenAI model should I use for a support bot?

Start with gpt-4o-mini — it handles function routing and friendly replies well at a low price. Move to a larger model only if you see the bot picking wrong functions or writing weak replies. Test the cheap model first; most support flows never need more.

Can the bot reply in Hindi?

Yes. Add one line to the system prompt — "reply in the customer's language" — and the model matches Hindi, Hinglish, or English automatically. For voice or heavy regional-language support, pair it with a dedicated ASR; see our Hindi-first chatbot tutorial.

How do I stop the bot from issuing refunds it shouldn't?

Two layers. Put "confirm before calling create_refund_ticket" in both the system prompt and the function's description, and keep refunds as ticket creation (a human approves the payout) rather than direct money movement in v1. Never let a v1 bot move money unattended.

Does this work with Zendesk or Intercom instead of Freshdesk?

Yes. Only the handler functions change — swap the Freshdesk REST call for the Zendesk or Intercom ticket API. The function schemas and the OpenAI loop stay identical. That portability is the whole point of the function-calling design.

How do I measure if the bot is actually helping?

Track three numbers weekly: resolution rate (chats closed without a human), escalation rate (clean handoffs), and mishandle rate (wrong function or bad reply, found by sampling 20 transcripts). A healthy v1 sits around 55–65% resolution with under 15% mishandles.

Want a support chatbot live on your helpdesk this month?

We ship a working OpenAI or Claude function-calling bot wired into Freshdesk, Zendesk, or WhatsApp for Indian SMBs in 5–7 working days. Suitable if you're drowning in repetitive support chats and have order data behind an API. No slides — just your problem and our honest take.

Book a 20-min Call

As Hrishikesh, our CTO, puts it: a support bot's job is not to be clever, it's to be reliable about four boring things and honest about everything else.

Tags:
OpenAIFunction CallingChatbotFreshdeskCustomer SupportD2CNode.js
Share this post:
Hrishikesh Baidya

Hrishikesh Baidya

CTO at Softechinfra specializing in Python, system architecture, and building secure, scalable software solutions.