GCF Backend for Zoho Campaigns

GCF Backend for Zoho Campaigns

By Thanush Karthik | | Updated
ZohoGCF Backend
Integrating third-party services like Zoho Campaigns into a serverless backend is powerful, but it comes with unique challenges, especially around OAuth token management and specific API interaction quirks. This guide breaks down a robust Google Cloud Function solution built on Node.js/Firebase that automates monthly newsletter delivery via Zoho Campaigns, focusing on the core token logic and common integration pitfalls.

Introduction

Zoho's rise as a Made in India tech leader is inspiring. As more businesses build their operations on the Zoho suite, developers need solid, reliable code to connect it with modern cloud tools like Google Cloud Functions (GCF). The core challenge often isn't the API itself, but how to integrate it seamlessly into a serverless architecture.  

This post is my direct contribution to that technical community. We dive into Zoho Campaigns automation, tackling the integration challenges that commonly slow down serverless projects using GCF, Node.js, and Firebase.

Our goal is simple: to provide practical code patterns focused on two essential solutions: smart OAuth token management using Firestore caching (refreshing tokens only when absolutely needed) and correctly handling Zoho's specific application/x-www-form-urlencoded data format. If you're looking for proven, reliable code to connect your cloud backend to the powerful Zoho API suite, this guide provides the exact architecture you need.

This guide breaks down a robust Google Cloud Function solution built on Node.js/Firebase that automates monthly newsletter delivery via Zoho Campaigns, focusing on the core token logic and common integration pitfalls.

The Core Challenge: Zoho OAuth Token Management

Zoho access tokens typically expire in less than an hour, requiring constant refreshing. If you refresh on every request, you waste resources and risk rate limits. The solution is to use Firestore as a secure, persistent cache for the token and its expiry time.

The getZohoAccessToken Logic

The key logic here is:

  1. Retrieve the stored access_token and expires_at from Firestore.

  2. Check if the token is valid and has at least 60 seconds remaining before expiry.

  3. If it's missing or nearly expired, execute the refresh token flow.

  4. Update Firestore with the new token and expiry time.

This ensures the expensive refresh operation only runs when absolutely necessary.

// Helper to get a valid Zoho access token (refreshes if needed)
async function getZohoAccessToken(service = "campaigns") {
  const db = admin.firestore();
  const tokenDocRef = db.collection("zoho_tokens").doc(service);


  let tokenDoc = await tokenDocRef.get();
  let accessToken = null;
  let expiresAt = 0;


  if (tokenDoc.exists) {
    accessToken = tokenDoc.data().access_token;
    expiresAt = tokenDoc.data().expires_at || 0;
  }


  const now = Date.now();
  // Refresh token if it doesn't exist OR expires in less than 60 seconds
  if (!accessToken || expiresAt - now < 60000) {
    console.log("Token is missing or nearing expiry. Refreshing...");
    try {
      const refreshUrl = `${process.env.ZOHO_TOKEN_DOMAIN}/oauth/v2/token`;
      const params = new URLSearchParams({
        refresh_token: process.env.ZOHO_REFRESH_TOKEN,
        client_id: process.env.ZOHO_CLIENT_ID,
        client_secret: process.env.ZOHO_CLIENT_SECRET,
        grant_type: "refresh_token",
      });


      // Zoho refresh request (POST with URL-encoded parameters)
      const refreshRes = await axios.post(`${refreshUrl}?${params.toString()}`);


      accessToken = refreshRes.data.access_token;
      const expiresIn = refreshRes.data.expires_in || 3600;
      expiresAt = now + parseInt(expiresIn) * 1000;


      await tokenDocRef.set(
        {
          access_token: accessToken,
          expires_at: expiresAt,
          updated_at: now,
        },
        { merge: true }
      );
      console.log("Token refreshed and stored successfully.");
    } catch (error) {
      console.error(
        "Error refreshing Zoho token:",
        error.response?.data || error.message
      );
      throw new Error(`Failed to refresh Zoho token: ${error.message}`);
    }
  }
  return accessToken;
}

API Quirks: Handling The Format

A critical point for Zoho Campaigns API is how it expects data: typically as query parameters or in the legacy application/x-www-form-urlencoded format, not a standard JSON body like many modern APIs.

Example: User Signup (signupNewsletter)

In the signupNewsletter function (an onCall function for a frontend signup form), we must construct the contact data and URL parameters carefully:

  1. We JSON-stringify the complex contactinfo object.

  2. We use URLSearchParams to serialize all parameters.

  3. We append the resulting query string (?params.toString()) directly to the API URL.

  4. We set the Content-Type header to application/x-www-form-urlencoded, even though the body is null, to satisfy the API structure.

  5. The Authorization header uses the format: Authorization: Zoho-oauthtoken <TOKEN>.

exports.signupNewsletter = onCall(async (request) => {
  const { email, firstName, lastName } = request.data;
  if (!email) throw new Error("Missing email");

  const accessToken = await getZohoAccessToken("campaigns");

  const contactInfo = {
    "First Name": firstName || "",
    "Last Name": lastName || "",
    "Contact Email": email,
  };

  // 1. Build parameters using URLSearchParams
  const params = new URLSearchParams({
    resfmt: "JSON",
    listkey: process.env.ZOHO_LIST_KEY,
    contactinfo: JSON.stringify(contactInfo), // Must stringify nested object
    source: "Analyte",
  });

  try {
    const subscribeUrl = `${process.env.ZOHO_CAMPAIGN_DOMAIN}/
          api/v1.1/json/listsubscribe`;

    // 2. Append params to the URL and send POST with empty body
    const zohoRes = await axios.post(
      `${subscribeUrl}?${params.toString()}`,
      null,
      {
        headers: {
          Authorization: `Zoho-oauthtoken ${accessToken}`,
          // 3. Set required Content-Type header
          "Content-Type": "application/x-www-form-urlencoded",
        },
      }
    );

    const data = zohoRes.data;
    if (data.code === "0") {
      return { success: true, message: data.message };
    } else if (data.code === "1007") {
       // Crucial Error Code to watch: Unauthorized
      throw new Error("Zoho Unauthorized: Check token and scopes.");
    } else {
      throw new Error(data.message || "Zoho API error");
    }
  } catch (err) {
    console.error("Zoho API error:", err.response?.data || err.message);
    throw new Error(
      err.response?.data?.message || err.message || "Zoho API request failed"
    );
  }
});

The Newsletter Scheduler and Dynamic Content

Our solution uses two functions for the monthly newsletter:

  1. monthlyContent: An onRequest HTTP function that dynamically generates the newsletter HTML from Firestore posts.

    • Note: This guide focuses on the backend automation and Zoho API interaction, not the email's HTML generation. For the simplest implementation, you can host a static HTML page (ensuring all CSS is inline, as is standard for email) on any public server or CDN and provide that URL here.

    • Crucial: The URL must return raw HTML; it cannot be a client-side rendered page (like a React or Angular application) because Zoho’s servers do not execute JavaScript to generate the content. 

  2. sendMonthlyNewsletter: A scheduled function (onSchedule) that triggers on the first of the month, retrieves the content URL of the monthlyContent function, and sends the campaign via Zoho.

The Scheduled Send Function (sendMonthlyNewsletter)

This function executes the Zoho Campaign's two-step process: Create followed by Send.

Crucially, it uses the public URL of the monthlyContent function as the content_url parameter in the Zoho API call. This allows Zoho's servers to fetch the up-to-date HTML when the campaign is created.
We also log the campaign ID to Firestore for tracking and audit.

// Scheduled function to create and send monthly newsletter via Zoho Campaigns
exports.sendMonthlyNewsletter = onSchedule("0 5 1 * *", async (event) => {
  const db = admin.firestore();

  // 1. Initial Checks (e.g., if campaign is paused/weekly)
  // ... (omitted for brevity, but check schedule setting here)
  // The URL Zoho will fetch to get the email content
  const contentUrl = `https://asia-southeast1-${process.env.GCLOUD_PROJECT}.cloudfunctions.net/monthlyContent`;
 
  // Define metadata
  const monthStr = new Date().toLocaleString("default", { month: "long", year: "numeric" });
  const campaignName = `Monthly Newsletter - ${monthStr}`;
  const subject = `Analyte Updates - ${monthStr}`;
  const logDocRef = db.collection("newsletter_campaigns").doc(monthStr.replace(" ", "-"));

  try {
    const accessToken = await getZohoAccessToken("campaigns");

    // --- A. CREATE CAMPAIGN ---
    const createParams = new URLSearchParams({
      resfmt: "json",
      campaignname: campaignName,
      subject,
      content_url: contentUrl, // Zoho fetches HTML from this URL
      list_details: JSON.stringify({ [process.env.ZOHO_LIST_KEY]: [] }),
      // ... other params like from_email, topicId ...
    });

    const createCampaignUrl = `${process.env.ZOHO_CAMPAIGN_DOMAIN}/
          api/v1.1/createCampaign`;
    const createRes = await axios.post(
      createCampaignUrl,
      createParams.toString(),
      {
        headers: {
          Authorization: `Zoho-oauthtoken ${accessToken}`,
          "Content-Type": "application/x-www-form-urlencoded",
        },
      }
    );

    const campaignId = createRes.data.campaignKey;
    await logDocRef.set({ campaignId, /* ... status logging ... */ }, { merge: true });

    if (!campaignId) {
      throw new Error("Campaign creation failed: " + createRes.data.message);
    }

    // --- B. SEND CAMPAIGN ---
    const sendCampaignUrl = `${process.env.ZOHO_CAMPAIGN_DOMAIN}
         /api/v1.1/sendcampaign`;
    const sendParams = new URLSearchParams({
      resfmt: "json",
      campaignkey: campaignId,
    });

    const sendRes = await axios.post(sendCampaignUrl, sendParams.toString(), {
      headers: {
        Authorization: `Zoho-oauthtoken ${accessToken}`,
        "Content-Type": "application/x-www-form-urlencoded",
      },
    });

    const sendCode = sendRes.data.response?.code;
  
    // 4. Log final status and recipient count
    await logDocRef.update({
        sendTime: new Date(),
        sendSuccess: sendCode === 0,
        /* ... more status logging ... */
    });

    return { success: sendCode === 0, campaignId };
  } catch (error) {
    // ... Log fatal error details ...
  }
});

This architecture provides a scalable, cost-effective, and robust way to manage your newsletter workflow entirely within the Google Cloud ecosystem, integrated seamlessly with the power of Zoho Campaigns.

Conclusion

This GCF/Firebase solution establishes a robust, cost-effective, and scalable backend for Zoho Campaigns automation by skillfully tackling two primary serverless integration challenges: stateless OAuth token management and Zoho's unique API data formatting. The core logic involves using Firestore to securely cache and selectively refresh the expiring Zoho access token, minimizing expensive refresh operations, while all API calls correctly adhere to the application/x-www-form-urlencoded content type, as demonstrated in both the subscription and scheduled newsletter send functions. It is important to note that this guide focuses entirely on the token-refresh mechanism and API interaction after the initial, one-time generation of the refresh token (which is a standard and well-documented first step in Zoho OAuth flows, often covered in external tutorials).

00
Engage with this post
Like, star, and comment to support the author and help others discover great content.
Loading...

More from this author

Two students. Same medical college. Completely different experiences.
We walked into medical college thinking we knew what we were signing up for. Spoiler: we absolutely did not. Two students, two perspectives, and several questionable decisions later, here we are.
Microbiome Gut Brain Axis : Our Second Brain
Discover how your gut microbiome acts as a second brain through the gut-brain axis, influencing your mood, mental health, and daily wellness.
Microplastics:Our Arterial Threat
Microplastics cause heart harm. They trigger atherosclerosis, a buildup of arterial fats that impacts men and women differently. Recent genetic modeling confirms these tiny fragments move through our …
Spinal Injury: The Organoid Fix
Spinal Cord Injury (SCI) causes permanent paralysis because the body cannot naturally regrow severed nerve fibers (axons) across the lesion site. With 302,000 Americans affected, fully restorative tre…
Brain-Computer Interfaces: The Mind’s New Frontier
The boundary between human thought and machine intelligence is dissolving. Brain-computer interfaces (BCIs)—once the stuff of science fiction—are now a rapidly developing technology with potential to …