<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mohsin Azam</title>
    <description>The latest articles on DEV Community by Mohsin Azam (@mohsin_azam).</description>
    <link>https://dev.to/mohsin_azam</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4229041%2F85978b1d-788f-4ac2-b50d-efd2c9223b91.png</url>
      <title>DEV Community: Mohsin Azam</title>
      <link>https://dev.to/mohsin_azam</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mohsin_azam"/>
    <language>en</language>
    <item>
      <title>Building a Zapier Integration for a Tiny SaaS: The API Key Default I Almost Shipped</title>
      <dc:creator>Mohsin Azam</dc:creator>
      <pubDate>Thu, 05 Nov 2026 17:11:30 +0000</pubDate>
      <link>https://dev.to/mohsin_azam/building-a-zapier-integration-for-a-tiny-saas-the-api-key-default-i-almost-shipped-me1</link>
      <guid>https://dev.to/mohsin_azam/building-a-zapier-integration-for-a-tiny-saas-the-api-key-default-i-almost-shipped-me1</guid>
      <description>&lt;p&gt;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?"&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API surface: two endpoints
&lt;/h2&gt;

&lt;p&gt;Zapier's polling triggers need surprisingly little from you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An endpoint to &lt;strong&gt;test the connection&lt;/strong&gt;, so Zapier can confirm a key is valid when a user connects.&lt;/li&gt;
&lt;li&gt;An endpoint that returns &lt;strong&gt;recent items as an array&lt;/strong&gt;, newest first. Zapier polls it every few minutes and fires a Zap for anything it hasn't seen.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/agent?zapier=me
X-API-Key: aicf_abc123_0123456789abcdef01234567

200 OK
{ "ok": true, "id": "abc123", "name": "Bright Smile Dental", "site": "brightsmile.com" }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;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": "sara@example.com",
    "message": "Do you do teeth whitening on Saturdays?",
    "conversation": "Visitor: ...\nAssistant: ..."
  }
]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one thing that matters most in the second response is &lt;code&gt;id&lt;/code&gt;. &lt;strong&gt;Zapier deduplicates polling results by id.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The keys: derive, don't store
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aicf_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SERVER_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Verify: parse the id out of the key, recompute, compare.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^aicf_&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]{4,24})&lt;/span&gt;&lt;span class="sr"&gt;_&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;a-f0-9&lt;/span&gt;&lt;span class="se"&gt;]{24})&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default I almost shipped
&lt;/h2&gt;

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

&lt;ul&gt;
&lt;li&gt;an &lt;code&gt;X-API-KEY&lt;/code&gt; header, and&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;URL query parameter&lt;/strong&gt;: &lt;code&gt;?api_key={{bundle.authData.api_key}}&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything works with both. Tests pass. Nothing warns you.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The fix took a minute: open &lt;strong&gt;Advanced, Request Template&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; read every field of a generated request, not just the ones you set yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small touches that make it feel native
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  What you can't automate
&lt;/h2&gt;

&lt;p&gt;Getting a public listing has requirements that code can't satisfy:&lt;/p&gt;

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

&lt;p&gt;Budget time for these. In my case the review requirements took longer than writing the integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap up
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://aicofounderhq.com/developers" rel="noopener noreferrer"&gt;API docs&lt;/a&gt; as a reference for the shape of a minimal polling API.&lt;/p&gt;

&lt;p&gt;Happy to answer questions in the comments.&lt;/p&gt;

</description>
      <category>zapier</category>
      <category>api</category>
      <category>security</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
