Creating Salesforce Leads via API with OAuth Client Credentials
Server-to-server lead creation without a user login: an External Client App, one token request and one POST.
Your website has a contact form and sales wants every submission in Salesforce as a Lead. No one should have to log in for that to happen. Here's the full setup, from the app in Salesforce Setup to the first Lead created with curl.
TL;DR
- Use the OAuth 2.0 client credentials flow: your server swaps a client ID and secret for an access token, with no user login and no refresh token.
- Since Spring '26, creating new Connected Apps is turned off by default in every org. Create an External Client App instead.
- The token runs as a Run As user you pick in the app's policies. Give that user only the access it needs, ideally a dedicated integration user.
- Ask for the token at your org's My Domain URL (
https://yourcompany.my.salesforce.com), notlogin.salesforce.com. - Create the Lead with
POST /services/data/vXX.X/sobjects/Lead/, using an API version your org lists.LastNameandCompanyare required.
The problem
Sales wants every website enquiry in Salesforce, and they want it without anyone copying rows from an inbox. The usual first attempts go wrong in a few familiar ways:
- Following a tutorial that opens a browser login and a consent screen. That works once on your laptop, then breaks on a server where nobody is there to click "Allow".
- Storing an admin's username and password and logging in with them. It works until the password changes, MFA is turned on, or the admin leaves.
- Going to Setup > App Manager to create a Connected App and finding the option is gone. Since Spring '26 that's expected, not a missing permission.
Why it happened
Most Salesforce OAuth examples are written for a person logging in: a browser redirect, a consent screen and a refresh token. A form handler on a server has no person to log in, so those flows don't fit.
The client credentials flow is the server-to-server version. Think of it as a staff badge for a machine. The badge (client ID and secret) gets you a day pass (the access token), and the day pass carries the permissions of one named employee (the Run As user). Lose the badge and anyone can get a day pass, so it lives in your server's secrets and nowhere else.
The fix
One External Client App with the client credentials flow turned on, a dedicated integration user as the Run As user, and a server that asks for a token, creates the Lead, and asks again when the token expires. It's simpler than the JWT bearer flow, which needs a certificate. It's also safer than the username-and-password flow, which Salesforce discourages and blocks by default in newer orgs.
Steps
Create an integration user. In Setup, create a user with the Salesforce Integration license and the Salesforce API Only System Integrations profile. Assign it the Salesforce API Integration permission set license, then a permission set that can create Leads (Read and Create on the Lead object) and nothing else.
It worked if the user appears under Setup > Users as active.
Create the External Client App. In Setup, search for External Client App Manager and click New External Client App. Fill in the name and contact email, then under API (Enable OAuth Settings):
- Check Enable OAuth.
- Set a callback URL. This flow doesn't use it, but the form asks for one, so
https://localhost/callbackis fine. - Add the Manage user data via APIs (api) scope.
- Check Enable Client Credentials Flow.
Save it.
Set the Run As user. Open the app's Policies tab and click Edit. Under OAuth Policies, check Enable Client Credentials Flow and enter the integration user's username in Run As (Username). Save.
It worked if the policy shows the username. Without it, the token request fails with
no client credentials user enabled.Copy the consumer key and secret. On the app's Settings tab, open OAuth Settings and click Consumer Key and Secret. Salesforce sends a verification code to your email. Put the values in your server's environment, never in code:
export SF_DOMAIN='https://yourcompany.my.salesforce.com' export SF_CLIENT_ID='paste the consumer key' export SF_CLIENT_SECRET='paste the consumer secret'Get a token.
curl -s "$SF_DOMAIN/services/oauth2/token" \ -d grant_type=client_credentials \ -d client_id="$SF_CLIENT_ID" \ -d client_secret="$SF_CLIENT_SECRET" | jqIt worked if the JSON has an
access_tokenand aninstance_url. There's norefresh_token. When the token expires, you ask for a new one the same way.Check which API versions your org has.
TOKEN=$(curl -s "$SF_DOMAIN/services/oauth2/token" \ -d grant_type=client_credentials \ -d client_id="$SF_CLIENT_ID" \ -d client_secret="$SF_CLIENT_SECRET" | jq -r .access_token) export SF_API_VERSION=$(curl -s "$SF_DOMAIN/services/data/" -H "Authorization: Bearer $TOKEN" | jq -r '.[-1].version') echo "$SF_API_VERSION"It worked if it prints a version number such as
66.0. Orgs on the Winter '27 release show68.0. Pin this number in your config; don't look it up on every request.Create the Lead.
curl -s "$SF_DOMAIN/services/data/v$SF_API_VERSION/sobjects/Lead/" \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"FirstName":"Ana","LastName":"Reyes","Company":"Example Co","Email":"ana@example.com","LeadSource":"Web"}' | jqIt worked if you get back
"success": trueand anidstarting with00Q. Open$SF_DOMAIN/<that id>in a browser to see the Lead.Do the same from your app. In Node.js 18 or later, with the same environment variables:
let cached = null; async function getToken() { if (cached) return cached; const res = await fetch(`${process.env.SF_DOMAIN}/services/oauth2/token`, { method: "POST", body: new URLSearchParams({ grant_type: "client_credentials", client_id: process.env.SF_CLIENT_ID, client_secret: process.env.SF_CLIENT_SECRET, }), }); if (!res.ok) throw new Error(`Salesforce token: ${res.status} ${await res.text()}`); cached = (await res.json()).access_token; return cached; } export async function createLead(lead, retried = false) { const res = await fetch(`${process.env.SF_DOMAIN}/services/data/v${process.env.SF_API_VERSION}/sobjects/Lead/`, { method: "POST", headers: { Authorization: `Bearer ${await getToken()}`, "Content-Type": "application/json" }, body: JSON.stringify(lead), }); // An expired token gives a 401: get a fresh one and try once more if (res.status === 401 && !retried) { cached = null; return createLead(lead, true); } if (!res.ok) throw new Error(`Salesforce lead: ${res.status} ${await res.text()}`); return (await res.json()).id; }
How it works
sequenceDiagram
participant Form as Website form
participant App as Your server
participant Auth as Salesforce /oauth2/token
participant API as Salesforce REST API
Form->>App: POST /contact (name, email, company)
App->>Auth: grant_type=client_credentials, client_id, client_secret
Auth-->>App: access_token (runs as the integration user)
App->>API: POST /sobjects/Lead (Bearer token)
API-->>App: 201 { id: "00Q...", success: true }
App-->>Form: thanks, we'll be in touch
Note over App,API: token expired? API returns 401,<br/>the server gets a new token and retries once
Common errors
| Error | Usually means |
|---|---|
invalid_client / invalid client credentials |
The key or secret is wrong, or has a stray space. Copy them again. |
invalid_grant / no client credentials user enabled |
Client credentials isn't turned on in the app's Policies, or there's no Run As user. |
invalid_grant / request not supported on this domain |
You called login.salesforce.com. Use your My Domain URL. |
401 INVALID_SESSION_ID |
The token expired or was revoked. Get a new one. |
400 REQUIRED_FIELD_MISSING |
LastName or Company is missing. |
403 or INSUFFICIENT_ACCESS |
The Run As user can't create Leads. Check its permission set. |
DUPLICATES_DETECTED |
A duplicate rule blocked it. Decide with sales whether to update the existing Lead instead. |
New apps can also take a few minutes before their first token works. If every setting looks right, wait and try again.
How to avoid it next time
- Use a dedicated integration user with only the permissions the integration needs, never an admin's account.
- Keep the consumer secret in your secrets store, rotate it on a schedule, and rotate it at once if it leaks. Anyone with the key and secret can get a token.
- Pin the API version in one place, and check Salesforce's release notes before raising it.
- Log the Salesforce error body. The message almost always names the field or setting that's wrong.
- Don't block the form on Salesforce. Queue the Lead and retry if Salesforce is slow or down.
Tools used
- Salesforce External Client Apps: the app that holds the OAuth settings
- OAuth 2.0 Client Credentials Flow (Salesforce): Salesforce's guide to the flow
- Salesforce REST API: the
sobjects/Leadendpoint - curl: sends the token and Lead requests
- jq: pulls fields out of the JSON responses
- Node.js: runs the same calls from an app with built-in
fetch
Comments
Comments and votes open again when the site is back online.