Posted by

HubSpot Serverless Functions: Keep API Keys Out of the Browser

Sooner or later a HubSpot page needs data from an API that requires a secret: a private app token, a weather service key, an internal pricing endpoint. Putting that key in front-end JavaScript means anyone can read it from the browser's network tab. HubSpot CMS serverless functions solve this by running a small Node.js function on HubSpot's side, so the secret never reaches the visitor.

Availability note: the official serverless functions pages we reviewed do not state which HubSpot subscription is required. Before you design around them, check that they are available in your account and read the current developer documentation, as plans and limits change.

Which model this article uses: the example below is a design-manager function. It receives (context, sendResponse) and answers by calling sendResponse. Functions built inside a HubSpot project use a different shape: an async function that receives context and returns an object such as { statusCode: 200, body: data }. Do not mix the two styles.

When a serverless function is the right tool

  • You must call an API with a secret, and the call can be triggered from a public page.
  • You want to reshape or filter a third-party response before the browser sees it.
  • You need a small backend endpoint without running your own server.

It is not the right tool for long-running jobs or heavy processing. HubSpot documents these limits:

  • 10 seconds of execution time per function, and 600 total execution seconds per minute per account
  • No more than 100 endpoints per account
  • No more than 50 secrets per account
  • 128 MB of memory and a 6 MB invocation payload
  • 4 KB of data per function log

Project layout

Functions live in a folder ending in .functions inside your theme, next to a serverless.json configuration file:

my-theme/
  quote-proxy.functions/
    serverless.json
    get-quote.js

A typical serverless.json:

{
  "runtime": "nodejs20.x",
  "version": "1.0",
  "environment": {},
  "secrets": ["QUOTE_API_KEY"],
  "endpoints": {
    "quote": {
      "method": "GET",
      "file": "get-quote.js"
    }
  }
}

HubSpot's reference lists nodejs20.x as the current runtime, so use that and re-check the reference when you start a new function. The secrets array lists the names of secrets your function is allowed to read. The key in endpoints becomes part of the public URL.

Storing the secret

Add the secret with the HubSpot CLI instead of writing it into any file:

hs secrets add QUOTE_API_KEY

The CLI prompts for the value. Never commit it to Git. The name must also appear in the secrets array of serverless.json; inside the function the value is then available as process.env.QUOTE_API_KEY. Never return a secret in a response or write it to the logs, because that exposes it to the front end.

Writing the function

const axios = require('axios');

exports.main = async (context, sendResponse) => {
  const city = context.params.city;

  if (!city) {
    sendResponse({ statusCode: 400, body: { error: 'city is required' } });
    return;
  }

  try {
    const res = await axios.get('https://api.example.com/quote', {
      params: { city },
      headers: { Authorization: `Bearer ${process.env.QUOTE_API_KEY}` }
    });
    sendResponse({ statusCode: 200, body: { quote: res.data.quote } });
  } catch (err) {
    sendResponse({ statusCode: 502, body: { error: 'Upstream request failed' } });
  }
};

Notes on this example:

  • context.params holds query string values and HTML form-POSTed values. A JSON POST (content type application/json) arrives in context.body instead. Always validate both, because anyone can call a public endpoint.
  • Return only the fields the page needs. Do not forward the whole upstream response.
  • Return a generic error message. Do not leak upstream details or the secret.
  • Dependencies: axios (and @hubspot/api-client) are preloaded, so no install is needed for this example. Design-manager functions do not support external dependencies on their own, so if you need other packages, bundle your modules with webpack so the bundled file is your function file. HubSpot documents a package.json for functions built with projects only, where it configures the dependencies the function needs.

Calling it from the page

Once deployed, the endpoint is served from your own connected domain under the /_hcms/api/ path:

fetch('/_hcms/api/quote?city=Manila')
  .then(r => r.json())
  .then(data => {
    document.querySelector('#quote').textContent = data.quote;
  });

Because it is same-origin, you avoid CORS headaches for pages on that domain.

Deploying and testing (CLI workflow)

  1. Authenticate the CLI with hs auth.
  2. Add the secret with hs secrets add QUOTE_API_KEY.
  3. Upload the theme with hs cms upload (or run hs cms watch to upload on every change).
  4. Call the endpoint directly in the browser or with curl, then from the page.
  5. When something fails, read the function logs with hs logs. Remember that logs are limited to 4 KB per function log.

Secrets are cached, so a changed value can take about a minute to reach your function.

Security checklist

  • Treat every endpoint as public. Validate and sanitize all input.
  • Do not build a generic proxy that forwards any URL or any HubSpot API call a visitor asks for. Expose narrow, purpose-specific endpoints.
  • If the function writes data (for example creating contacts), add a basic abuse control such as a honeypot field or a CAPTCHA verification before calling the API.
  • Use the minimum scopes on any private app token you store.
  • Cache where you can, so public traffic does not burn through the upstream API's rate limits.

Summary

Serverless functions let a HubSpot page use secret-protected APIs safely: store the key with hs secrets add, list its name in serverless.json, read it from process.env on the server side, validate input, and return only what the page needs. Start with one small endpoint and confirm availability and current limits in HubSpot's documentation before relying on it in production.

Sources