Dev.to Security 🔐 Cybersecurity 👁 0 📖 3 min read

Building a Zapier Integration for a Tiny SaaS: The API Key Default I Almost Shipped

I run a small SaaS that puts an AI assistant on small business websites. It answers visitors and captures leads. Owners kept asking the same thing: "Can the leads go straight into my CRM / Google Sheet / Slack?" The ans

I run a small SaaS that puts an AI assistant on small business websites. It answers visitors and captures leads. Owners kept asking the same thing: "Can the leads go straight into my CRM / Google Sheet / Slack?"

The answer to "connect to everything" is usually Zapier, so I built a public Zapier integration. It took an afternoon to get working and most of a day to get right. This post is about the gap between those two, including one default that would have leaked every customer's API key into URLs.

The API surface: two endpoints

Zapier's polling triggers need surprisingly little from you:

  1. An endpoint to test the connection, so Zapier can confirm a key is valid when a user connects.
  2. An endpoint that returns recent items as an array, newest first. Zapier polls it every few minutes and fires a Zap for anything it hasn't seen.
GET /api/agent?zapier=me
X-API-Key: aicf_abc123_0123456789abcdef01234567

200 OK
{ "ok": true, "id": "abc123", "name": "Bright Smile Dental", "site": "brightsmile.com" }
GET /api/agent?zapier=leads
X-API-Key: aicf_abc123_0123456789abcdef01234567

200 OK
[
  {
    "id": "abc123-1793812345678",
    "created_at": "2026-11-05T09:30:12.000Z",
    "name": "Sara Khan",
    "email": "[email protected]",
    "message": "Do you do teeth whitening on Saturdays?",
    "conversation": "Visitor: ...\nAssistant: ..."
  }
]

The one thing that matters most in the second response is id. Zapier deduplicates polling results by id. If your ids are unstable (array index, a random value per request), every poll looks like new data and users get duplicate Zaps. Mine combine the account id and the lead's creation timestamp, so the same lead always has the same id.

The keys: derive, don't store

I didn't want a new table of API keys to manage. Each key is derived from the account id with an HMAC over a server secret:

const crypto = require('crypto');

const apiKey = (id) =>
  'aicf_' + id + '_' +
  crypto.createHmac('sha256', process.env.SERVER_SECRET)
    .update('api:' + id).digest('hex').slice(0, 24);

// Verify: parse the id out of the key, recompute, compare.
const m = key.match(/^aicf_([a-z0-9]{4,24})_([a-f0-9]{24})$/i);
const valid = m && key === apiKey(m[1]);

The prefix makes keys easy to recognise in logs and secret scanners. The trade off: rotating one customer's key means adding a per-account salt later. For a v1, that is fine.

The default I almost shipped

Zapier's platform UI has a request template that injects auth fields into every request. When you pick API key auth, it helpfully adds your key field in two places:

  • an X-API-KEY header, and
  • a URL query parameter: ?api_key={{bundle.authData.api_key}}

Everything works with both. Tests pass. Nothing warns you.

But a key in a query string ends up in places a header never does: server access logs, proxy logs, analytics tools, error trackers that record URLs. My API only reads the header, so the query parameter added risk and zero function.

The fix took a minute: open Advanced, Request Template, remove the URL param, keep the header. One quirk: the template wouldn't let me delete its last remaining row, so I cleared the key and value fields and saved. Worth checking yours.

Lesson: read every field of a generated request, not just the ones you set yourself.

Small touches that make it feel native

  • Connection label. Zapier lets you template how a connected account appears. {{bundle.inputData.name}} ({{bundle.inputData.site}}) turns "Account #4" into "Bright Smile Dental (brightsmile.com)". Users with several clients will thank you.
  • Help text that says where to find the key. Mine points to the settings page link in the owner's weekly email, plus a docs page.
  • Sample data with friendly labels. Paste a realistic sample object and label each field ("Full Conversation", not conversation). This is what users see when mapping fields, before they have any real data.

What you can't automate

Getting a public listing has requirements that code can't satisfy:

  • The trigger must run at least once with live data in a real Zap.
  • You need at least three different users with live Zaps before Zapier will list it.
  • You provide a test account for their reviewers, plus public API docs and a help article.

Budget time for these. In my case the review requirements took longer than writing the integration.

Wrap up

The working version took an afternoon. The right version needed three extra checks: stable ids, the key out of the URL, and a human look at every default. If you build one, I wrote up our API docs as a reference for the shape of a minimal polling API.

Happy to answer questions in the comments.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.