<?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: WeInc</title>
    <description>The latest articles on DEV Community by WeInc (@weinc).</description>
    <link>https://dev.to/weinc</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%2F4208261%2F8d52710f-e529-47ef-9109-0ddb0ec8aae8.png</url>
      <title>DEV Community: WeInc</title>
      <link>https://dev.to/weinc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/weinc"/>
    <language>en</language>
    <item>
      <title>Sending website form entries to Asana, Pipedrive and other tools: OAuth lessons</title>
      <dc:creator>WeInc</dc:creator>
      <pubDate>Mon, 02 Nov 2026 11:17:23 +0000</pubDate>
      <link>https://dev.to/weinc/sending-website-form-entries-to-asana-pipedrive-and-other-tools-oauth-lessons-2457</link>
      <guid>https://dev.to/weinc/sending-website-form-entries-to-asana-pipedrive-and-other-tools-oauth-lessons-2457</guid>
      <description>&lt;p&gt;Every website form eventually gets the same request: "Can the entries go straight into our CRM?" Then the next one asks for Asana. Then Linear. Then the email list.&lt;/p&gt;

&lt;p&gt;Each vendor has an OAuth flow, and each one looks standard until you build it. These are the lessons from wiring form entries into Asana, Pipedrive, Linear and Kit (formerly ConvertKit). Most of them apply to any OAuth integration where your server acts on a user's behalf long after they clicked "Connect".&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with one small spec per vendor
&lt;/h2&gt;

&lt;p&gt;The temptation is a big &lt;code&gt;if (vendor === "pipedrive")&lt;/code&gt; chain. It falls apart by the third vendor. What worked better was a single generic flow plus a small description of each vendor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;VendorSpec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;authorizeUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;tokenUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;clientAuth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;basic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;body&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// where client_id/secret go on token calls&lt;/span&gt;
  &lt;span class="nl"&gt;bodyFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;form&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;authorizeParams&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// scope and friends&lt;/span&gt;
  &lt;span class="nl"&gt;defaultExpiresIn&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// when the reply has no expires_in&lt;/span&gt;
  &lt;span class="nx"&gt;apiBase&lt;/span&gt;&lt;span class="p"&gt;?(&lt;/span&gt;&lt;span class="nx"&gt;tokenReply&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// per-account API host&lt;/span&gt;
  &lt;span class="nx"&gt;revoke&lt;/span&gt;&lt;span class="p"&gt;?(&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Tokens&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every quirk below became one field in that spec. Adding a vendor became a short object, not a new code path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The per-vendor quirks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pipedrive: the API host comes from the token reply.&lt;/strong&gt; Pipedrive returns an &lt;code&gt;api_domain&lt;/code&gt; with the tokens, something like &lt;code&gt;https://yourcompany.pipedrive.com&lt;/code&gt;. API calls go to that host, not a single global one. Store it next to the tokens and read it back on every call. It can also come back on refresh, so update it then too.&lt;/p&gt;

&lt;p&gt;Pipedrive also expects client credentials as HTTP Basic auth on token calls, not in the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;basic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Basic &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;clientSecret&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://oauth.pipedrive.com/oauth/token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;basic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;grant_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authorization_code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;redirect_uri&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send them in the body instead and you get a generic auth error that does not say why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asana: tokens last an hour, and ask for the narrow scope.&lt;/strong&gt; Asana access tokens expire after about an hour, so refreshing is not an edge case, it is the normal path for any form that gets submitted more than an hour after someone connected. For creating tasks from form entries, &lt;code&gt;tasks:write&lt;/code&gt; is enough. Ask for the smallest scope that does the job; it makes the consent screen less alarming and the app review shorter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linear: scopes are comma separated.&lt;/strong&gt; Most vendors want scopes separated by spaces. Linear wants commas: &lt;code&gt;read,issues:create&lt;/code&gt;. Space-separated scopes do not fail loudly, you just end up with less access than you expected. &lt;code&gt;issues:create&lt;/code&gt; is the narrowest scope that can create issues, and &lt;code&gt;actor=user&lt;/code&gt; makes issues appear as created by the person who connected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kit: refresh tokens are single use.&lt;/strong&gt; Kit's v4 OAuth sends client credentials in a JSON body and rotates the refresh token every time you use it. If you refresh and fail to save the new refresh token (a crash, a timeout writing to the database), the connection is dead and the user has to reconnect. Two consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Persist the new tokens before doing anything else with them.&lt;/li&gt;
&lt;li&gt;Avoid two parallel refreshes for the same connection. The loser will present a refresh token that has already been consumed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Missing &lt;code&gt;expires_in&lt;/code&gt;.&lt;/strong&gt; Not every reply includes it. Keep a per-vendor default (Kit's tokens last about two hours) and treat "null" as "does not expire" only when you know that is true for that vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refresh before expiry, and once more after a 401
&lt;/h2&gt;

&lt;p&gt;Two rules cover nearly all token failures:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Refresh early.&lt;/strong&gt; If the token expires within the next few minutes, refresh before the call. A five-minute margin handles clock skew and slow requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry once after a 401.&lt;/strong&gt; Tokens get revoked or expire early for reasons you cannot see. On a 401, force a refresh and retry the request exactly once. If that fails too, stop.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;callWithToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;doCall&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                 &lt;span class="c1"&gt;// refreshes if expiring soon&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not_connected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;doCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;force&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;  &lt;span class="c1"&gt;// one forced refresh&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reconnect_needed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;doCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;needsRefresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the vendor refuses the refresh itself (an &lt;code&gt;invalid_grant&lt;/code&gt;, usually), mark the connection as revoked and tell the user to reconnect. Retrying in a loop only gets you rate limited.&lt;/p&gt;

&lt;p&gt;One detail that bites: some vendors return a new refresh token on refresh and some do not. When the reply has no refresh token, keep the old one. Overwriting it with null quietly breaks the next refresh an hour later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seal tokens at rest
&lt;/h2&gt;

&lt;p&gt;Access and refresh tokens are passwords to someone else's account. Encrypt them before they hit the database, with a key that lives outside the database (an environment secret or a key manager). Authenticated encryption such as AES-256-GCM is the usual choice, because tampering is detected on decrypt.&lt;/p&gt;

&lt;p&gt;A few practical points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Encrypt both tokens, not just the refresh token. A one-hour access token is still enough to read a CRM.&lt;/li&gt;
&lt;li&gt;Never log them. Log the vendor, the user ID and the HTTP status.&lt;/li&gt;
&lt;li&gt;Store non-secret metadata (account name, API host, expiry) in plain columns so you can debug without decrypting anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disconnect means stop sending
&lt;/h2&gt;

&lt;p&gt;When a user disconnects, three things should happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Revoke at the vendor&lt;/strong&gt; if they offer an endpoint. Asana, Linear and Pipedrive all do. Revoking the refresh token usually kills the access token too. Treat a failed revoke as a warning, not a blocker; the user still wants to be disconnected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete or mark the stored tokens&lt;/strong&gt; as revoked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disable every form destination that used this connection.&lt;/strong&gt; This is the one people forget. Otherwise the next form submission tries a dead token, fails, and the entry either vanishes or lands in an error log nobody reads.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Disabling with a clear status ("disabled: connection removed") also lets your UI tell the site owner exactly why entries stopped going to Pipedrive, instead of leaving them to guess.&lt;/p&gt;

&lt;p&gt;The same applies when a refresh fails permanently. Mark the destination as needing reconnection, keep the form entries in your own storage so nothing is lost, and tell the owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installs that start on the vendor's side
&lt;/h2&gt;

&lt;p&gt;Marketplaces like Pipedrive's let users install your app from the vendor's own listing page. That means the OAuth flow can start without your app having created a &lt;code&gt;state&lt;/code&gt; value first, and without the user being signed in to your product.&lt;/p&gt;

&lt;p&gt;Plan for it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accept a callback that arrives without your usual &lt;code&gt;state&lt;/code&gt;, but only for vendors you have marked as marketplace-installable. Everywhere else, a missing or mismatched &lt;code&gt;state&lt;/code&gt; must still fail.&lt;/li&gt;
&lt;li&gt;If the user is not signed in, park the authorization code briefly (it expires fast, so minutes, not hours), send them to sign in or sign up, then finish the exchange.&lt;/li&gt;
&lt;li&gt;Expect reviewers to test this path. A marketplace review will click "Install" from the listing, not from your settings page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Things I would do from day one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One generic flow, one spec per vendor.&lt;/li&gt;
&lt;li&gt;Tests for each spec using a fake &lt;code&gt;fetch&lt;/code&gt;: check the header shape, the body format, and the scope string.&lt;/li&gt;
&lt;li&gt;A five-minute refresh margin and a single retry on 401.&lt;/li&gt;
&lt;li&gt;Encrypted tokens, plain metadata.&lt;/li&gt;
&lt;li&gt;Disconnect disables destinations, and failed refresh flags them.&lt;/li&gt;
&lt;li&gt;Keep every form entry in your own database first, then forward it. If an integration breaks, nothing is lost and you can resend.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one matters more than any OAuth detail. The CRM is a destination, not the store of record.&lt;/p&gt;

&lt;p&gt;These notes come from building form integrations for WeInc (&lt;a href="https://we.inc" rel="noopener noreferrer"&gt;we.inc&lt;/a&gt;), a website builder. The patterns are vendor-neutral, though, and the docs for each provider are worth reading in full before you start: they are short, and every quirk above is in there somewhere.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>softwaredevelopment</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Turning a Shopify store into a full website with Buy Buttons</title>
      <dc:creator>WeInc</dc:creator>
      <pubDate>Mon, 02 Nov 2026 11:17:23 +0000</pubDate>
      <link>https://dev.to/weinc/turning-a-shopify-store-into-a-full-website-with-buy-buttons-3aio</link>
      <guid>https://dev.to/weinc/turning-a-shopify-store-into-a-full-website-with-buy-buttons-3aio</guid>
      <description>&lt;p&gt;A lot of small brands start on Shopify and stay there because it works. Checkout, payments, taxes, inventory and order emails are solved. The part that often feels cramped is everything around the products: the story, the long landing pages, the blog, the booking form, the campaign page that needs a layout no theme section offers.&lt;/p&gt;

&lt;p&gt;You can keep Shopify for what it does well and build the rest of the site however you like, then drop products into it with Buy Buttons. This post walks through how that works under the hood, when it is worth doing, and the things that tend to go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Buy Button actually is
&lt;/h2&gt;

&lt;p&gt;The Buy Button is a small JavaScript library that Shopify hosts on its CDN. It renders a product card (image, title, price, variant picker, add to cart) and a slide-out cart on any web page. When the shopper clicks checkout, they are sent to the normal Shopify checkout for that store.&lt;/p&gt;

&lt;p&gt;Under the hood it talks to the &lt;strong&gt;Storefront API&lt;/strong&gt;, Shopify's public, read-mostly GraphQL API meant for custom storefronts. That is the key idea: your separate site never touches the Admin API from the browser. It only uses a token that is designed to be public.&lt;/p&gt;

&lt;p&gt;A minimal embed looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"product-1"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://sdks.shopifycdn.com/buy-button/latest/buy-button-storefront.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ShopifyBuy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;buildClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;your-store.myshopify.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;storefrontAccessToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;YOUR_STOREFRONT_TOKEN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;ShopifyBuy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onReady&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;product&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1234567890&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// numeric product ID&lt;/span&gt;
      &lt;span class="na"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;product-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two values matter: the store's &lt;code&gt;myshopify.com&lt;/code&gt; domain and a Storefront access token. The product ID is the numeric ID you can see in the admin URL of the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Storefront token comes from
&lt;/h2&gt;

&lt;p&gt;If you are setting this up for one store by hand, the Buy Button sales channel in the Shopify admin will generate the embed code and token for you. That is the simplest route.&lt;/p&gt;

&lt;p&gt;If you are building an app that does this for many stores, you create the token yourself through the Admin GraphQL API, using the store's admin access token on your server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;mutation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;CreateStorefrontToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;StorefrontAccessTokenInput&lt;/span&gt;&lt;span class="p"&gt;!)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;storefrontAccessTokenCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;storefrontAccessToken&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;userErrors&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your app needs the right scopes for this to work. The ones that matter for a Buy Button are the &lt;code&gt;unauthenticated_*&lt;/code&gt; family, which describe what the Storefront token is allowed to do on behalf of anonymous shoppers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;unauthenticated_read_product_listings&lt;/code&gt; so the token can read products and prices.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unauthenticated_read_checkouts&lt;/code&gt; and &lt;code&gt;unauthenticated_write_checkouts&lt;/code&gt; so it can create and read carts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus something like &lt;code&gt;read_products&lt;/code&gt; if your own app needs to list the catalog in an admin screen.&lt;/p&gt;

&lt;p&gt;Notice what is absent: nothing about customers, orders or payments. That is deliberate. The Storefront token sits in page source where anyone can read it, so it should only be able to do what an anonymous shopper on the store could already do.&lt;/p&gt;

&lt;p&gt;Two habits that save trouble later:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reuse, don't multiply.&lt;/strong&gt; A store can only hold a limited number of Storefront tokens. Before creating one, list the existing tokens (&lt;code&gt;shop { storefrontAccessTokens(first: 50) { ... } }&lt;/code&gt;) and reuse yours if it is there. Give it a recognisable title so you can find it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate what you got back.&lt;/strong&gt; Check that the mutation returned a token and no &lt;code&gt;userErrors&lt;/code&gt; before you write it into a page. A silent empty string produces a Buy Button that renders nothing, with no obvious error.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The flow from click to order
&lt;/h2&gt;

&lt;p&gt;The order of events is worth spelling out, because it explains most of the edge cases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your page loads the Buy Button script and fetches product data through the Storefront API.&lt;/li&gt;
&lt;li&gt;The shopper picks a variant and adds it to the cart. The cart lives on Shopify; the script keeps a reference in the browser.&lt;/li&gt;
&lt;li&gt;On checkout, the shopper leaves your domain and lands on the store's Shopify checkout.&lt;/li&gt;
&lt;li&gt;Payment, tax, shipping rates, discount codes and the order confirmation all happen on Shopify, exactly as they would from a theme.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because checkout never runs on your site, you are not handling card data, and your site is not in PCI scope for it. Orders show up in the Shopify admin like any other order, attributed to the Buy Button channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a separate site beats a theme
&lt;/h2&gt;

&lt;p&gt;A theme is still the right answer for most stores. A separate site with Buy Buttons is worth it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Products are a small part of the site.&lt;/strong&gt; A studio, a course creator, a restaurant selling a few jars of sauce, a band selling merch. The site is mostly content, and a full store theme is overkill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need layouts the theme fights.&lt;/strong&gt; Long-form launch pages, interactive explainers, or a design system you already use elsewhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The site already exists.&lt;/strong&gt; Adding commerce to a working marketing site is easier than migrating it into Liquid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You sell in several places.&lt;/strong&gt; The same product can appear on a blog post, a landing page and a partner microsite, all feeding one inventory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is the wrong choice when you rely heavily on theme apps (reviews widgets, bundles, upsell apps). Most of those inject into theme templates and will not appear on your separate site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Embedding the wrong token.&lt;/strong&gt; The Admin API token must never reach the browser. If you see &lt;code&gt;shpat_&lt;/code&gt; style tokens in front-end code, stop. Only the Storefront token belongs in page source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hard-coding product IDs without a fallback.&lt;/strong&gt; If a product is deleted or unpublished from the Buy Button channel, the component renders empty. Keep a plain "View on store" link next to each embed, pointing at &lt;code&gt;https://your-store.myshopify.com/products/handle&lt;/code&gt;. If the script fails, people can still buy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting the sales channel.&lt;/strong&gt; Products must be available to the channel your token belongs to. A product that is active in the store but not published to that channel will not load through the Storefront API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prices that look stale.&lt;/strong&gt; The Buy Button fetches live prices, but any price you also typed into your own copy will not update. Let the component show the price and keep it out of your headings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two domains, two analytics stories.&lt;/strong&gt; The shopper browses on your domain and pays on Shopify's. Set up cross-domain tracking, or accept that your site analytics will see "clicked checkout" and Shopify will see "order placed", and join them by date rather than by session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expiring admin tokens in apps.&lt;/strong&gt; If you are building an app, newer Shopify installs hand you expiring offline tokens (around an hour) plus a refresh token. That only matters for your server-side admin calls, like creating the Storefront token. The Storefront token itself does not expire this way, so pages you already published keep working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Script loading order.&lt;/strong&gt; If your site is a single-page app, the &lt;code&gt;#product-1&lt;/code&gt; node might not exist yet when the script runs. Create components after the element mounts, and clean them up when it unmounts, or you will get duplicate cards on route changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical setup
&lt;/h2&gt;

&lt;p&gt;For a small brand, my default is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Shopify stays the source of truth for products, stock and orders.&lt;/li&gt;
&lt;li&gt;The website is whatever is fastest to edit for the people running it.&lt;/li&gt;
&lt;li&gt;Each product section has a Buy Button plus a plain text link to the product on the Shopify store.&lt;/li&gt;
&lt;li&gt;One Storefront token per store, created once, reused, and scoped to the &lt;code&gt;unauthenticated_*&lt;/code&gt; permissions only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We built this flow into the Shopify app for WeInc (&lt;a href="https://we.inc" rel="noopener noreferrer"&gt;we.inc&lt;/a&gt;), where a merchant installs it, the app creates the Storefront token, and the generated site gets Buy Buttons for the store's products. The same pattern works fine by hand with the Buy Button channel and a static site, which is a good way to test it before committing to any tooling.&lt;/p&gt;

&lt;p&gt;The main thing to remember: Shopify keeps the money part, your site keeps the story. As long as the token you embed can only do what an anonymous shopper can do, the split is safe and simple.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
