> ## Documentation Index
> Fetch the complete documentation index at: https://docs.truagents.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Salesforce relay

> Host a small HTTPS service that receives TruAgents hot-lead webhooks and upserts Salesforce Leads.

## Summary

A relay is a small web service between TruAgents and Salesforce. TruAgents sends the hot lead to the relay. The relay logs in to Salesforce and creates or updates the Lead.

Simple version: TruAgents only knows the relay's URL and a token that opens the relay. The Salesforce credentials stay in your relay's hosting, never in TruAgents.

Why teams pick a relay:

* No third-party automation tool sees your leads.
* The relay answers TruAgents *after* Salesforce responds, so a Salesforce outage makes TruAgents retry automatically.
* The Lead is upserted on the TruAgents contact id, so retries and repeat messages never create duplicates.

This guide is step 2 of [Send hot leads to Salesforce](/automations/hot-leads-to-salesforce). Build the TruAgents workflow first.

## Who this is for

A developer who can host a Node.js service, working with a Salesforce admin.

## What you need

* A host that gives the service a public `https://` URL, for example a container platform, a serverless function with an HTTPS URL, or a VM behind a load balancer. TruAgents doesn't deliver to `http://`, private, or internal addresses.
* Node.js 18 or later. The relay has no dependencies.
* A Salesforce admin for the one-time Salesforce setup.

## Step 1: Salesforce setup (admin, one time)

<Steps>
  <Step title="Add the external id field">
    In **Setup** → **Object Manager** → **Lead** → **Fields & Relationships**, click **New**. Choose **Text**, length 64, label **TruAgents Contact Id**. Check that the API name is `TruAgents_Contact_Id__c`, and tick **Unique** and **External ID**.
  </Step>

  <Step title="Add the Lead Source value">
    In **Object Manager** → **Lead** → **Fields & Relationships** → **Lead Source**, add the picklist value `TruAgents`. If you'd rather use an existing value, change `LeadSource` in the relay code.
  </Step>

  <Step title="Create an app for the client credentials flow">
    In **Setup**, create a Connected App in **App Manager**, or an External Client App in **External Client App Manager** if your org uses those. Then:

    * Enable OAuth. Enter any callback URL, for example `https://login.salesforce.com/services/oauth2/callback`. The relay doesn't use it.
    * Add the OAuth scope **Manage user data via APIs (api)**.
    * Enable **Client Credentials Flow**.
    * In the app's policies, set the **Run As** user for the client credentials flow. Use an integration user that can create and edit Leads and can edit `TruAgents_Contact_Id__c`.
  </Step>

  <Step title="Hand over the credentials">
    Copy the app's **Consumer Key** and **Consumer Secret**, plus your **My Domain** URL (for example `https://acme.my.salesforce.com`). The client credentials flow requires the My Domain URL, not `login.salesforce.com`. Send these to the developer through your password manager, never by email or chat.
  </Step>
</Steps>

## Step 2: deploy the relay (developer)

Save this as `salesforce-relay.mjs`:

```js salesforce-relay.mjs theme={null}
// TruAgents "Send webhook" -> Salesforce Lead relay. Node 18+, no dependencies.
//
// Required environment:
//   RELAY_TOKEN       dedicated token TruAgents sends in the X-Relay-Token header
//   SF_LOGIN_URL      your My Domain URL, e.g. https://acme.my.salesforce.com
//   SF_CLIENT_ID      consumer key of the app with the Client Credentials flow
//   SF_CLIENT_SECRET  consumer secret of that app
// Optional:
//   SF_API_VERSION    default v62.0
//   PORT              default 8080

import http from "node:http";
import { timingSafeEqual } from "node:crypto";

function required(name) {
  const value = process.env[name];
  if (!value) throw new Error(`missing env ${name}`);
  return value;
}

const RELAY_TOKEN = Buffer.from(required("RELAY_TOKEN"));
const SF_LOGIN_URL = required("SF_LOGIN_URL").replace(/\/$/, "");
const SF_CLIENT_ID = required("SF_CLIENT_ID");
const SF_CLIENT_SECRET = required("SF_CLIENT_SECRET");
const SF_API_VERSION = process.env.SF_API_VERSION || "v62.0";
const PORT = Number(process.env.PORT || 8080);
const MAX_BODY_BYTES = 64 * 1024;

let cachedToken = null;

async function salesforceToken() {
  if (cachedToken) return cachedToken;
  const res = await fetch(`${SF_LOGIN_URL}/services/oauth2/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "client_credentials",
      client_id: SF_CLIENT_ID,
      client_secret: SF_CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`salesforce auth failed: ${res.status} ${await res.text()}`);
  const body = await res.json();
  cachedToken = { accessToken: body.access_token, instanceUrl: body.instance_url };
  return cachedToken;
}

function tokenMatches(header) {
  const given = Buffer.from(header || "");
  return given.length === RELAY_TOKEN.length && timingSafeEqual(given, RELAY_TOKEN);
}

// A Lead requires LastName and Company. TruAgents sends one full name and no
// company, so split the name and take the company from the email domain.
export function toSalesforceLead(event) {
  const { workflow, contact, communication } = event.data;
  const parts = (contact.name || "").trim().split(/\s+/).filter(Boolean);
  const lastName = parts.pop() || contact.email || "Unknown";
  const domain = (contact.email || "").split("@")[1] || "";
  const company = domain ? domain.split(".")[0] : "Unknown";

  return {
    FirstName: parts.join(" "),
    LastName: lastName,
    Company: company,
    Email: contact.email || null,
    Phone: contact.phone || null,
    LeadSource: "TruAgents",
    Rating: "Hot",
    Description: [
      `Hot lead detected by TruAgents workflow "${workflow.name}".`,
      `Channel: ${communication.channel}`,
      `Subject: ${communication.subject || "(none)"}`,
      `Message: ${communication.preview || ""}`,
      communication._links?.self ? `Conversation: ${communication._links.self}` : "",
      workflow._links?.self ? `Workflow run: ${workflow._links.self}` : "",
    ]
      .filter(Boolean)
      .join("\n"),
  };
}

// PATCH on the external id field creates the Lead the first time (201) and
// updates the same Lead after that (204), so retries never duplicate it.
async function upsertLead(event, isRetry = false) {
  const { accessToken, instanceUrl } = await salesforceToken();
  const contactId = encodeURIComponent(event.data.contact.id);
  const url = `${instanceUrl}/services/data/${SF_API_VERSION}/sobjects/Lead/TruAgents_Contact_Id__c/${contactId}`;
  const res = await fetch(url, {
    method: "PATCH",
    headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
    body: JSON.stringify(toSalesforceLead(event)),
  });
  // Salesforce sessions expire; log in again once before giving up.
  if (res.status === 401 && !isRetry) {
    cachedToken = null;
    return upsertLead(event, true);
  }
  return { status: res.status, body: await res.text() };
}

function reply(res, status, body) {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(JSON.stringify(body));
}

async function readJson(req) {
  const chunks = [];
  let size = 0;
  for await (const chunk of req) {
    size += chunk.length;
    if (size > MAX_BODY_BYTES) throw new Error("body too large");
    chunks.push(chunk);
  }
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}

http
  .createServer(async (req, res) => {
    if (req.method !== "POST") return reply(res, 405, { error: "POST only" });
    if (!tokenMatches(req.headers["x-relay-token"])) return reply(res, 401, { error: "bad token" });

    let event;
    try {
      event = await readJson(req);
    } catch {
      return reply(res, 400, { error: "invalid body" });
    }
    if (event.event !== "workflow.step.webhook_sent" || !event.data?.contact?.id) {
      return reply(res, 400, { error: "unexpected payload" });
    }

    try {
      const sf = await upsertLead(event);
      console.log(`event=${event.id} run=${event.data.workflow.run_id} salesforce=${sf.status}`);
      if (sf.status >= 200 && sf.status < 300) return reply(res, 200, { ok: true });
      // 503 asks TruAgents to retry (Salesforce outage, expired login, or API limit).
      // 400 tells TruAgents to stop: Salesforce rejected the data itself.
      const rateLimited =
        sf.status === 429 || (sf.status === 403 && sf.body.includes("REQUEST_LIMIT_EXCEEDED"));
      if (sf.status >= 500 || sf.status === 401 || rateLimited) {
        return reply(res, 503, { error: "salesforce unavailable" });
      }
      console.error(`event=${event.id} salesforce rejected lead: ${sf.body}`);
      return reply(res, 400, { error: "salesforce rejected lead" });
    } catch (err) {
      console.error(`event=${event.id}`, err);
      return reply(res, 503, { error: "relay error" });
    }
  })
  .listen(PORT, () => console.log(`salesforce relay listening on :${PORT}`));
```

Set these environment variables in your host's secret store:

| Variable | Value |
| - | - |
| `RELAY_TOKEN` | A new random value, for example from `openssl rand -hex 32`. It only opens the relay. |
| `SF_LOGIN_URL` | The My Domain URL from step 1. |
| `SF_CLIENT_ID` | The Consumer Key. |
| `SF_CLIENT_SECRET` | The Consumer Secret. |
| `SF_API_VERSION` | Optional. Defaults to `v62.0`. |
| `PORT` | Optional. Defaults to `8080`. Most hosts set it for you. |

Start it with `node salesforce-relay.mjs`. If a required variable is missing, the relay refuses to start and names the variable.

### How the relay answers TruAgents

The relay's status code tells TruAgents what to do next:

| Situation | Relay returns | TruAgents then |
| - | - | - |
| Salesforce created (201) or updated (204) the Lead | `200` | Marks the step **Succeeded**. |
| Salesforce login expired | Logs in again once and retries, then answers as for the result | — |
| Salesforce is down (`5xx`), login still fails, or Salesforce is rate-limiting (`429`, or `403` with `REQUEST_LIMIT_EXCEEDED`) | `503` | Retries about every 5 to 10 minutes, for up to 24 hours. |
| Salesforce rejects the Lead (for example a validation rule or a missing picklist value) | `400` | Marks the step **Failed**. No retry, because the same data would fail again. |
| Wrong or missing `X-Relay-Token` | `401` | Marks the step **Failed**. |
| Body isn't a TruAgents event | `400` | Marks the step **Failed**. |

Every request is logged with the TruAgents event `id`, the run id, and the Salesforce status. When Salesforce rejects a Lead, its error message is logged too.

## Step 3: test the relay before connecting TruAgents

Save the example body from the [Webhooks reference](/developers/webhooks#example-body) as `payload.json`, then:

```bash theme={null}
# Expect 200 {"ok":true}, and a new Lead in Salesforce
curl -i -X POST https://relay.example.com/ \
  -H 'Content-Type: application/json' \
  -H "X-Relay-Token: $RELAY_TOKEN" \
  --data-binary @payload.json

# Send it again: expect 200, and the same Lead updated, not a second one
curl -i -X POST https://relay.example.com/ \
  -H 'Content-Type: application/json' \
  -H "X-Relay-Token: $RELAY_TOKEN" \
  --data-binary @payload.json

# Wrong token: expect 401
curl -i -X POST https://relay.example.com/ \
  -H 'Content-Type: application/json' \
  -H 'X-Relay-Token: wrong' \
  --data-binary @payload.json
```

Replace `https://relay.example.com/` with your relay's URL. Delete the test Lead afterwards.

## Step 4: connect TruAgents

1. Open the workflow and click the **Send webhook** step.
2. **Endpoint URL**: the relay's `https://` URL.
3. **Headers**: click **Add header**. Name `X-Relay-Token`, value the `RELAY_TOKEN`.
4. Click **Save workflow**, then continue with [Step 3: test and go live](/automations/hot-leads-to-salesforce#step-3-test-and-go-live).

<Warning>
  Anyone in your organization who can open the workflow can read the `X-Relay-Token` value. That's why it must only open the relay. Never put the Salesforce Consumer Secret or a Salesforce password in a header. Rotate `RELAY_TOKEN` when people with workflow access leave, and update the header at the same time.
</Warning>

## Running it

* **Alerts.** Alert on any `400` or `401` the relay returns: those leads don't reach Salesforce and TruAgents won't retry them. Also alert on repeated `503`s.
* **Personal data.** The relay handles names, emails, phone numbers, and message previews. Host it where your company's data policies allow, and keep log retention short.
* **Changing the mapping.** Edit `toSalesforceLead`. To also create a Task for each hot message, add a second Salesforce call after the upsert.

## Related pages

* [Send hot leads to Salesforce](/automations/hot-leads-to-salesforce)
* [Webhooks reference](/developers/webhooks)
* [Zapier guide](/automations/salesforce-zapier)
* [Make guide](/automations/salesforce-make)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.