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.
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".
Start with one small spec per vendor
The temptation is a big if (vendor === "pipedrive") chain. It falls apart by the third vendor. What worked better was a single generic flow plus a small description of each vendor:
type VendorSpec = {
id: string;
authorizeUrl: string;
tokenUrl: string;
clientAuth: "basic" | "body"; // where client_id/secret go on token calls
bodyFormat: "form" | "json";
authorizeParams?: Record<string, string>; // scope and friends
defaultExpiresIn?: number | null; // when the reply has no expires_in
apiBase?(tokenReply: unknown): string | null; // per-account API host
revoke?(tokens: Tokens): Promise<void>;
};
Every quirk below became one field in that spec. Adding a vendor became a short object, not a new code path.
The per-vendor quirks
Pipedrive: the API host comes from the token reply. Pipedrive returns an api_domain with the tokens, something like https://yourcompany.pipedrive.com. 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.
Pipedrive also expects client credentials as HTTP Basic auth on token calls, not in the body:
const basic = "Basic " + Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
await fetch("https://oauth.pipedrive.com/oauth/token", {
method: "POST",
headers: { authorization: basic, "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri }),
});
Send them in the body instead and you get a generic auth error that does not say why.
Asana: tokens last an hour, and ask for the narrow scope. 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, tasks:write is enough. Ask for the smallest scope that does the job; it makes the consent screen less alarming and the app review shorter.
Linear: scopes are comma separated. Most vendors want scopes separated by spaces. Linear wants commas: read,issues:create. Space-separated scopes do not fail loudly, you just end up with less access than you expected. issues:create is the narrowest scope that can create issues, and actor=user makes issues appear as created by the person who connected.
Kit: refresh tokens are single use. 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:
- Persist the new tokens before doing anything else with them.
- Avoid two parallel refreshes for the same connection. The loser will present a refresh token that has already been consumed.
Missing expires_in. 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.
Refresh before expiry, and once more after a 401
Two rules cover nearly all token failures:
- Refresh early. If the token expires within the next few minutes, refresh before the call. A five-minute margin handles clock skew and slow requests.
- Retry once after a 401. 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.
async function callWithToken(userId: string, doCall: (token: string) => Promise<Response>) {
let token = await getToken(userId); // refreshes if expiring soon
if (!token) return { ok: false, reason: "not_connected" };
let res = await doCall(token);
if (res.status === 401) {
token = await getToken(userId, { force: true }); // one forced refresh
if (!token) return { ok: false, reason: "reconnect_needed" };
res = await doCall(token);
}
return { ok: res.ok, status: res.status };
}
function needsRefresh(expiresAt: string | null, now = Date.now()) {
return !!expiresAt && Date.parse(expiresAt) - 5 * 60_000 <= now;
}
When the vendor refuses the refresh itself (an invalid_grant, usually), mark the connection as revoked and tell the user to reconnect. Retrying in a loop only gets you rate limited.
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.
Seal tokens at rest
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.
A few practical points:
- Encrypt both tokens, not just the refresh token. A one-hour access token is still enough to read a CRM.
- Never log them. Log the vendor, the user ID and the HTTP status.
- Store non-secret metadata (account name, API host, expiry) in plain columns so you can debug without decrypting anything.
Disconnect means stop sending
When a user disconnects, three things should happen:
- Revoke at the vendor 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.
- Delete or mark the stored tokens as revoked.
- Disable every form destination that used this connection. 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.
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.
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.
Installs that start on the vendor's side
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 state value first, and without the user being signed in to your product.
Plan for it:
- Accept a callback that arrives without your usual
state, but only for vendors you have marked as marketplace-installable. Everywhere else, a missing or mismatchedstatemust still fail. - 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.
- Expect reviewers to test this path. A marketplace review will click "Install" from the listing, not from your settings page.
Things I would do from day one
- One generic flow, one spec per vendor.
- Tests for each spec using a fake
fetch: check the header shape, the body format, and the scope string. - A five-minute refresh margin and a single retry on 401.
- Encrypted tokens, plain metadata.
- Disconnect disables destinations, and failed refresh flags them.
- Keep every form entry in your own database first, then forward it. If an integration breaks, nothing is lost and you can resend.
That last one matters more than any OAuth detail. The CRM is a destination, not the store of record.
These notes come from building form integrations for WeInc (we.inc), 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.
Top comments (0)