SalesOS

Custom actions

Register your own HTTPS endpoint as a named action and call it from workflows, the assistant and record pages, with signed requests, schemas, retries and a full invocation log.

A custom action is an HTTPS endpoint you run, registered in SalesOS under a stable apiName with an input schema and an output schema. SalesOS never runs your code; it signs a POST to your endpoint, validates what you send back, and uses the result:

  • as a workflow step (Automations and Cross-object workflows): record fields fill the inputs, and outputs are written back to the record;
  • as an assistant tool, when you expose it with a description;
  • from a Run action button on Lead, Contact, Account and Deal pages, when you flag it for manual use;
  • from agents, which propose it for a person to approve.

Manage actions under Settings → Developer → Custom actions, or with /v1/custom-actions (org owners and admins; API access is part of the Enterprise plan, and of every developer org).

Register an action

curl -X POST https://api.salesos.org/v1/custom-actions \
  -H "x-api-key: $SALESOS_KEY" -H "Content-Type: application/json" \
  -d '{
    "apiName": "enrich_company",
    "label": "Enrich company",
    "url": "https://hooks.acme.com/salesos/enrich",
    "kind": "READ",
    "idempotent": true,
    "maxRetries": 2,
    "timeoutMs": 10000,
    "objectTypes": ["ACCOUNT"],
    "inputSchema": {
      "type": "object",
      "properties": { "domain": { "type": "string" } },
      "required": ["domain"]
    },
    "outputSchema": {
      "type": "object",
      "properties": { "industry": { "type": "string" }, "employees": { "type": "integer" } },
      "required": ["industry"]
    },
    "inputMapping": { "domain": "domain" },
    "outputMapping": { "industry": "industry", "employees": "numberOfEmployees" },
    "allowManual": true,
    "exposeAsTool": true,
    "toolDescription": "Look up a company's industry and size from its web domain."
  }'

The response includes the action's signing secret once (secret). Store it in your endpoint's configuration; POST /v1/custom-actions/{id}/rotate-secret issues a new one.

FieldMeaning
apiNameLowercase letters, digits and _, 2–48 characters. Never changes; it is how workflows and change sets refer to the action.
kindREAD (looks something up, changes nothing) or WRITE. Write actions always need the user's confirmation in the assistant and an approver for agents.
idempotent, maxRetriesSafe-to-repeat actions are retried (up to 3 times) on timeouts, network errors, 429 and 5xx, with the same Idempotency-Key. Other actions are called once.
timeoutMsDefault 10 000, maximum 30 000. All attempts share one deadline of at most 45 s.
objectTypesLEAD, CONTACT, ACCOUNT, OPPORTUNITY, TASK or custom:<object api name>. Where the Run action button appears, and what a workflow step may run on.
inputMapping{ "input": "recordField" }, a dotted path into the record (including customFields.<name>), or { "input": { "value": … } } for a constant.
outputMapping{ "output": "recordField" }. Written back after a record run.

Schemas

The input and output schemas are JSON Schema objects of named fields — the shape the field picker in Settings produces: string (optionally with format date, date-time, email or uri, or an enum), number, integer, boolean, and arrays of strings. Inputs are validated (and lightly coerced: "42" → 42) before anything is sent; unknown input keys are refused. The response must match the output schema; extra keys are ignored.

The request you receive

POST /salesos/enrich HTTP/1.1
Content-Type: application/json
User-Agent: SalesOS-Extensions/1.0
X-SalesOS-Action: enrich_company
X-SalesOS-Invocation: inv_6f1c0a5e2b9d4c3fa1e8b7d6c5a4f3e2
X-SalesOS-Attempt: 1
Idempotency-Key: inv_6f1c0a5e2b9d4c3fa1e8b7d6c5a4f3e2
X-SalesOS-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
  "id": "inv_6f1c0a5e2b9d4c3fa1e8b7d6c5a4f3e2",
  "type": "custom_action",
  "action": "enrich_company",
  "organizationId": "cmorg123",
  "occurredAt": "2026-10-02T09:15:00.000Z",
  "source": "WORKFLOW",
  "actor": { "userId": "cmuser456" },
  "record": { "object": "ACCOUNT", "id": "cmacc789" },
  "input": { "domain": "northwind.example" }
}

source is MANUAL, WORKFLOW, COPILOT, AGENT, TEST or REPLAY. The invocation id is stable across retries of one call; dedupe on it (or on Idempotency-Key).

Your response

Reply 2xx with JSON, either the outputs themselves or wrapped in output:

{ "output": { "industry": "Retail", "employees": 1200 } }

Anything else — another status, a body that isn't JSON or doesn't match the output schema, a timeout — fails the invocation; nothing is written back. Only 307/308 redirects are followed (at most two), each to a target that passes the same checks.

Verifying the signature

The scheme is the same as webhooks, with the action's own secret: v1 is the hex HMAC-SHA256 of "<t>.<raw body>". Reject requests whose t is more than 300 seconds from your clock.

import crypto from 'node:crypto';
import express from 'express';

const app = express();
app.post('/salesos/enrich', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verify(raw, req.header('X-SalesOS-Signature') ?? '', process.env.SALESOS_ACTION_SECRET!)) {
    return res.status(401).end();
  }
  const { input } = JSON.parse(raw);
  res.json({ output: { industry: lookupIndustry(input.domain) } });
});

function verify(rawBody: string, header: string, secret: string) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return header
    .split(',')
    .filter((p) => p.startsWith('v1='))
    .some((p) => {
      const sig = Buffer.from(p.slice(3), 'hex');
      const exp = Buffer.from(expected, 'hex');
      return sig.length === exp.length && crypto.timingSafeEqual(sig, exp);
    });
}
import hmac, hashlib, json, os, time
from flask import Flask, request, abort, jsonify

app = Flask(__name__)
SECRET = os.environ["SALESOS_ACTION_SECRET"]

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    items = [p.split("=", 1) for p in header.split(",") if "=" in p]
    t = int(dict(items).get("t", 0))
    if abs(time.time() - t) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(k == "v1" and hmac.compare_digest(v, expected) for k, v in items)

@app.post("/salesos/enrich")
def enrich():
    if not verify(request.get_data(), request.headers.get("X-SalesOS-Signature", ""), SECRET):
        abort(401)
    payload = json.loads(request.get_data())
    return jsonify(output={"industry": lookup_industry(payload["input"]["domain"])})

Calling through a named credential

Instead of a raw url, an action can target a named credential and a path under it:

{ "apiName": "enrich_company", "namedCredentialId": "nc_…", "calloutPath": "/v1/enrich", "…": "…" }

The request, signature, schemas, retries and log are the same; SalesOS also adds the credential's authentication (an API key header, bearer token, basic auth or an OAuth 2.0 client-credentials token), keeps redirects under the credential's base URL, and logs the call on the credential too. Rotating the partner's key then means updating one credential, not every action.

Running a function instead

Set functionId (one of your serverless functions) instead of url, and the action runs your code on SalesOS with no endpoint to host. The schemas, workflow step, assistant tool, Run action button and invocation log work unchanged; the function's input is the validated input and its return value is checked against the output schema.

Where actions run

Workflow step

Add a Custom action step in Automations or Cross-object workflows. The step's config is { "actionApiName": "enrich_company" }, optionally with its own inputMapping / outputMapping (otherwise the action's apply). Outputs are written as the workflow's creator, through the same services as the REST API: validation rules, before-save hooks, field-level security, ownership, the audit trail, workflows and webhooks all apply, and a rejected write marks the step failed with the reason. A step's write-back never re-triggers the workflow it belongs to, and chained write-backs stop after three workflows.

Assistant tool

With exposeAsTool, the assistant sees the action as a tool: read_custom_<apiName> for READ actions and custom_action_<apiName> for WRITE actions, described by toolDescription and typed by the input schema. Write actions are always held until the user confirms in a later message, whatever the model says. Inputs and outputs pass through the AI trust layer like any other tool: personal data is masked before it reaches the model and restored before your endpoint is called, and the output is fenced as untrusted data.

Run action button

With allowManual, people who can see a record of one of the action's objectTypes get a Run action button. Mapped inputs are filled from the record; the person types the rest. POST /v1/custom-actions/{id}/run { "objectType", "recordId", "input" } does the same over the API.

Agents

Agents propose a CUSTOM_ACTION with { "actionApiName", "input", "objectType", "recordId" }; every such proposal waits in the approvals inbox, and runs as the approver.

Protection SalesOS applies

  • https only, no credentials in the URL, and no internal hostnames or private, internal or reserved addresses. The check is made on the address actually connected to, at connect time and on every redirect.
  • Responses over 256 KB are cut off and fail.
  • After repeated consecutive failures, calls to an action are paused briefly (logged as CIRCUIT_OPEN) before SalesOS tries it again.
  • Inactive actions are refused (tests and replays still work).

Invocation log, test and replay

Every call is logged with its request headers and body, the response status and the first 4 KB of the body, latency, attempts and outcome, for 30 days. The outcome is SUCCEEDED, FAILED, TIMEOUT, BLOCKED (the URL failed the checks above), REJECTED or CIRCUIT_OPEN.

POST /v1/custom-actions/{id}/test                 {"input": {...}}
GET  /v1/custom-actions/{id}/invocations?status=FAILED&page=1&limit=20
GET  /v1/custom-actions/{id}/invocations/{invocationId}
POST /v1/custom-actions/{id}/invocations/{invocationId}/replay

A replay sends the same input again under a new invocation id and does not write back to the record.

Developer orgs

Build and test actions in a developer org: a separate org with sample data, where API access, webhooks, OAuth apps, custom actions and hooks are available on any plan and email only reaches you. Promote what you built with a change set.

On this page