{"slug": "building-a-zapier-integration-for-a-tiny-saas-the-api-key-default-i-almost", "title": "Building a Zapier Integration for a Tiny SaaS: The API Key Default I Almost Shipped", "summary": "A developer running a small SaaS that places an AI assistant on small business websites built a public Zapier integration in an afternoon, then spent most of a day correcting it, including removing a default request template that placed customers' API keys in query strings where they would land in access logs, proxy logs, analytics and error trackers. The writeup details stable lead IDs for Zapier's polling deduplication, HMAC-derived API keys computed from the account ID and a server secret, and the review requirements that took longer than the code itself.", "body_md": "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?\"\n\nThe 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.\n\nZapier's polling triggers need surprisingly little from you:\n\n```\nGET /api/agent?zapier=me\nX-API-Key: aicf_abc123_0123456789abcdef01234567\n\n200 OK\n{ \"ok\": true, \"id\": \"abc123\", \"name\": \"Bright Smile Dental\", \"site\": \"brightsmile.com\" }\nGET /api/agent?zapier=leads\nX-API-Key: aicf_abc123_0123456789abcdef01234567\n\n200 OK\n[\n  {\n    \"id\": \"abc123-1793812345678\",\n    \"created_at\": \"2026-11-05T09:30:12.000Z\",\n    \"name\": \"Sara Khan\",\n    \"email\": \"sara@example.com\",\n    \"message\": \"Do you do teeth whitening on Saturdays?\",\n    \"conversation\": \"Visitor: ...\\nAssistant: ...\"\n  }\n]\n```\n\nThe 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.\n\nI 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:\n\n``` js\nconst crypto = require('crypto');\n\nconst apiKey = (id) =>\n  'aicf_' + id + '_' +\n  crypto.createHmac('sha256', process.env.SERVER_SECRET)\n    .update('api:' + id).digest('hex').slice(0, 24);\n\n// Verify: parse the id out of the key, recompute, compare.\nconst m = key.match(/^aicf_([a-z0-9]{4,24})_([a-f0-9]{24})$/i);\nconst valid = m && key === apiKey(m[1]);\n```\n\nThe 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.\n\nZapier'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:\n\n`X-API-KEY` header, and`?api_key={{bundle.authData.api_key}}`\nEverything works with both. Tests pass. Nothing warns you.\n\nBut 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.\n\nThe 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.\n\n**Lesson:** read every field of a generated request, not just the ones you set yourself.\n\n`{{bundle.inputData.name}} ({{bundle.inputData.site}})` turns \"Account #4\" into \"Bright Smile Dental (brightsmile.com)\". Users with several clients will thank you.`conversation`). This is what users see when mapping fields, before they have any real data.\nGetting a public listing has requirements that code can't satisfy:\n\nBudget time for these. In my case the review requirements took longer than writing the integration.\n\nThe 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](https://aicofounderhq.com/developers) as a reference for the shape of a minimal polling API.\n\nHappy to answer questions in the comments.", "url": "https://wpnews.pro/news/building-a-zapier-integration-for-a-tiny-saas-the-api-key-default-i-almost", "canonical_source": "https://dev.to/mohsin_azam/building-a-zapier-integration-for-a-tiny-saas-the-api-key-default-i-almost-shipped-me1", "published_at": "2026-11-05 17:11:30+00:00", "updated_at": "2026-11-05 17:19:26.972193+00:00", "lang": "en", "topics": ["ai-agents", "ai-products", "developer-tools", "ai-tools"], "entities": ["Zapier", "Google Sheets", "Slack", "Bright Smile Dental"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/building-a-zapier-integration-for-a-tiny-saas-the-api-key-default-i-almost", "markdown": "https://wpnews.pro/news/building-a-zapier-integration-for-a-tiny-saas-the-api-key-default-i-almost.md", "text": "https://wpnews.pro/news/building-a-zapier-integration-for-a-tiny-saas-the-api-key-default-i-almost.txt", "jsonld": "https://wpnews.pro/news/building-a-zapier-integration-for-a-tiny-saas-the-api-key-default-i-almost.jsonld"}}