TL;DR: Connecting WordPress to Salesforce is three decisions — how you get a token, which endpoint you call, and who absorbs the failure when Salesforce is slow.
- Use the OAuth 2.0 client credentials flow. It is the server-to-server flow, needs no logged-in user, and the token response tells you which host to call.
- Call the token response's
instance_url, never the login host. This is the single most common first-integration bug. - Upsert by external ID rather than create. Salesforce answers 201 when it inserted and 204 when it updated, so a repeated delivery is harmless.
- The daily API allowance is org-wide and shared across REST, SOAP and Bulk. Your form competes with every other integration in the org.
- Never call Salesforce inline on the request that created the record. Queue it.
/ Auth
How do you authenticate a WordPress site to Salesforce?
Use the OAuth 2.0 client credentials flow. It exists for exactly this shape of integration: a server acting on its own behalf, with no human at a browser to approve anything. You configure an External Client App (or a classic Connected App) in Salesforce Setup, enable the client credentials flow, nominate a run-as user, and you get a consumer key and secret.
Two older approaches still show up in tutorials and both are worth skipping. The username-password flow embeds a password and a security token in your source and is reserved by Salesforce for special scenarios. The web server flow is designed for apps acting on behalf of a signed-in user, which is not what a form submission is.
The token exchange is a single POST. What matters is the response, not the request:
PHP — exchanging client credentials for a token
$res = wp_remote_post( 'https://MyDomain.my.salesforce.com/services/oauth2/token', [ 'timeout' => 15, 'body' => [ 'grant_type' => 'client_credentials', 'client_id' => $consumer_key, 'client_secret' => $consumer_secret, ], ] ); if ( is_wp_error( $res ) ) { return $res; // transport failure — worth retrying } $body = json_decode( wp_remote_retrieve_body( $res ), true ); // Both of these matter. The second one is the part people miss. $token = $body['access_token'] ?? ''; $host = $body['instance_url'] ?? '';
The instance_url in that response is the host every subsequent API call must use. It is not always the domain you authenticated against, it can differ per org, and it can change after a Salesforce migration. Hard-coding your My Domain and ignoring instance_url produces an integration that works in your sandbox and fails in someone else's production org.
Tokens from this flow are short-lived and carry no refresh token — you request a new one when the old one stops working. Treat a 401 as "fetch a fresh token and replay once", and anything still failing after that as a configuration problem rather than something to keep retrying.
/ Endpoint
Which Salesforce endpoint should a WordPress site call?
For one record at a time, the sObject Rows by External ID resource, addressed with PATCH. The path carries the object, the external ID field, and the value:
PATCH /services/data/v64.0/sobjects/Contact/External_Id__c/{value}
Pin an API version deliberately. Salesforce ships three releases a year and each gets its own version number, and a version stays supported for years before retirement — as of Summer '25 the oldest supported version is v31.0. Rather than guessing, ask the org what it supports: a GET to /services/data/ returns the list of available versions, which is the honest way to choose one and the only way to notice an org is older than you assumed.
For volume, the sObject Collections resources take up to 200 records in a single call. That matters more than it sounds, because of the limit arithmetic in the next section.
/ Identity
How do you avoid creating duplicate Salesforce records?
Upsert on a field you control, rather than querying for a match and then deciding. An external ID is a custom field marked External ID and Unique in Salesforce — a WordPress user ID, an order number, an email address — and it turns the whole create-or-update question into one idempotent call.
The response tells you what happened, and the two codes are worth handling separately:
| Response | Meaning | What to do |
|---|---|---|
| 201 Created | No record matched the external ID, so Salesforce inserted one | Store the returned record id if you need it later |
| 204 No Content | A record matched and was updated | Nothing — success with an empty body is not an error |
| 400 duplicate value | The external ID matched more than one record | Fix the field uniqueness in Salesforce; the payload is not the problem |
| 401 Unauthorized | Token expired or invalid | Fetch a fresh token, replay once, then stop |
| 403 REQUEST_LIMIT_EXCEEDED | The org burned its daily API allowance | Back off hard — this is not your integration alone |
The 204 catches people out. A successful update returns an empty body, so code that parses JSON unconditionally throws on the happy path and the delivery gets logged as a failure while Salesforce did exactly what was asked. Check the status before you decode.
The duplicate-value error is the other one worth naming. Salesforce documents a "Duplicate value found: <field> duplicates value on record with id" failure on PATCH upserts, and it appears most often when two deliveries for the same record land within milliseconds of each other. Serialising deliveries per record — which a queue does for free — removes it.
An upsert keyed on a field you own can be delivered twice with no consequence. A create cannot. Everything else about retry design follows from that one choice. — the rule that makes retries safe
/ Limits
What are the Salesforce API limits, and who else spends them?
Salesforce meters API calls per org on a 24-hour rolling basis, not per integration and not per calendar day. Enterprise Edition allows 100,000 calls plus 1,000 per licensed user; Unlimited allows 100,000 plus 5,000 per user. The pool is shared across REST, SOAP, Bulk and Connect — so your WordPress integration is drawing from the same budget as the org's data warehouse sync, its marketing tooling and every admin running a report through an API client.
Work out what your site actually spends before shipping. A form doing one upsert per submission at 200 submissions a day costs 200 calls — trivial. The same form doing a lookup, then an upsert, then a second call to attach a related record costs three times that, and a WooCommerce store firing on four order-status transitions each with a lookup is a different conversation entirely:
- 200 submissions × 1 upsert = 200 calls/day
- 200 submissions × 3 calls (lookup + upsert + related record) = 600 calls/day
- 2,000 orders × 4 status changes × 3 calls = 24,000 calls/day — a quarter of the base allowance for one plugin
That last line is why sObject Collections matters. Batching those 24,000 calls at up to 200 records each brings the same work down to roughly 120 calls, and the difference between those two numbers is the difference between an integration nobody notices and one the Salesforce admin asks you to turn off.
Salesforce treats the daily cap as a soft limit briefly and then enforces it with REQUEST_LIMIT_EXCEEDED. Concurrency is metered separately: production orgs allow 25 simultaneous long-running requests, where "long-running" means 20 seconds or more.
/ Failure
Should the Salesforce call run inside the WordPress request?
No. Whatever created the record in WordPress — a form submission, a checkout, a user registration — is a request with a person waiting at the end of it, and an outbound API call inside it makes that person wait for Salesforce.
The reasoning is not about typical latency, it is about the tail. Salesforce is normally fast. But when it is slow, or the token endpoint is slow, or DNS hiccups, your checkout inherits that latency directly. With a 15-second timeout, a Salesforce incident becomes a 15-second checkout for every customer, and PHP-FPM workers pile up behind it. The correct shape is to record the intent, return immediately, and let a background worker own the delivery — that way a Salesforce outage delays data rather than breaking the site.
Which failures deserve a retry is a question with a precise answer, and getting it wrong is expensive in both directions. A 5xx, a 429 and a connection timeout are all worth repeating. A 400 caused by a bad field name will fail identically forever — retrying it five times just multiplies the API calls you are already rationing.
| Concern | Hand-rolled wp_remote_post | Webhook Actions |
|---|---|---|
| Connector catalogue | None — you write every call | Also none. You point it at an endpoint you name; there is no Salesforce connector to pick from a list |
| Delivery timing | Inline — the visitor waits for Salesforce | Queued — the request returns before the API call runs |
| Retry on 5xx / 429 | One attempt, then the data is gone | Exponential backoff, 5 attempts by default |
| Retry on 4xx | Retries a doomed payload if you loop naively | Marked failed immediately — a bad payload is not retried |
| Evidence of what was sent | Whatever you remembered to error_log() | Per-attempt log with request, response and replay |
| Consumer key and secret | Constants in wp-config or the database | Not these — the vault stores a finished header, not the exchange that mints one |
The honest read of that table: the right-hand column is not a Salesforce integration you can pick off a shelf. There is no connector catalogue — you still decide the endpoint, the payload and the field mapping. What it removes is the queue, the backoff curve and the attempt log, which is the part that takes a week and is the part people skip.
The credential row deserves precision, because it is the one people misread. The plugin's Credentials Vault stores a finished authorization header — a bearer token, basic auth, or a raw value for a header you name — encrypted at rest, referenced from a webhook by id, injected only at dispatch and redacted out of the delivery log. Write-only means the secret goes in and only a masked hint comes back. What it does not do is perform the client credentials exchange, so the consumer key and secret stay with whatever mints the token. The vault is the right home for an API that authenticates with a static token instead, Pipedrive for instance.
Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.
/ Exposure
What does Salesforce not protect you from?
Three things, and none of them show up in a successful test.
The allowance is org-wide, so your bug is everyone's outage. A retry loop with no cap, or a hook that fires on every post save instead of on publish, does not break your integration first. It exhausts the shared daily quota and breaks the org's other integrations, silently, hours later. Cap attempts and know your per-day arithmetic before you ship.
A public form is an unauthenticated write path into your CRM. Anything that turns a form submission into a Salesforce record has handed the internet a way to create records. Without rate limiting and spam filtering in front of it, the cost is not just junk data — it is junk data that consumes the API allowance above. Gate it before the queue, not after.
The run-as user's permissions are the integration's permissions. The client credentials flow acts as a nominated user, and it inherits that user's object and field access in full. Pointing it at an administrator is the fast way to get a working integration and the fast way to give a web-facing code path write access to every object in the org. Create a dedicated integration user with access to the objects you actually touch.
For the specific case of sending form entries rather than platform events, the mechanics differ enough to be worth their own walkthrough — see sending Gravity Forms entries to Salesforce, which covers the licence question and the Lead object. If the destination is HubSpot rather than Salesforce, the HubSpot contacts API has a different rate-limit model worth knowing before you choose.