Before-save hooks
A synchronous call to your endpoint before a record is created, updated or deleted — allow it, reject it with a message, or patch it.
A before-save hook lets your own system take part in a save. Before SalesOS writes a record of the hook's object, it sends your endpoint the proposed record; you answer allow, deny with a message, or allow with a patch to some of its fields. Typical uses: a credit check before a deal moves forward, normalising addresses, or refusing edits to fields your ERP owns.
Hooks run inside the core save path, next to validation rules, so every way
a record is written through the entity services goes through them: the app,
the REST and Bulk APIs, the CSV importer, bulk edits, the assistant, agents and
custom-action write-backs. They cover Leads, Contacts, Accounts, Deals
(OPPORTUNITY), Tasks, Cases (CASE) and custom object records
(custom:<object api name>), on CREATE, UPDATE and DELETE.
The decision can come from your endpoint or from one of your serverless functions; to run code after a save instead, use an after-save trigger.
System writes that don't go through an entity service call hooks (and validation rules) themselves, with these semantics:
- Workflow steps Update field, Assign owner, Add tag and Remove tag:
an
UPDATEas the workflow's creator. A rejection fails the step; a patch is merged into the step's write (the new owner itself can't be patched). - Mass transfer: an
UPDATEofownerIdper record. A refused record keeps its owner and is listed underrejected; the rest move together in one transaction. With hooks on the object a transfer is capped at 200 records per call, and a dry run checks validation rules only. - Lead conversion:
CREATEfor each new account, contact and deal, then anUPDATEof the lead. Any rejection stops the whole conversion. - Close won / close lost: an
UPDATEof the deal; the close fields themselves can't be patched. - CRM migrations and the CSV importer:
CREATE/UPDATEper row; a refused row becomes a row error. A migration dry run checks validation rules only.
Manage hooks under Settings → Developer → Before-save hooks, or with
/v1/before-save-hooks (org owners and admins).
The response carries the hook's signing secret once. Hooks on the same object
run in ascending position, each seeing the record as patched by the ones
before it.
The request you receive
Signed exactly like custom actions
(X-SalesOS-Signature: t=…,v1=…, HMAC-SHA256 of "<t>.<raw body>" with the
hook's secret), with X-SalesOS-Hook: <hook id> and X-SalesOS-Invocation.
record is the record as it would be saved (stored values merged with the
change; for DELETE, the record being deleted). previous is the stored
record on updates and deletes, null on creates. Values use the API's wire
shape: dates as ISO strings, money as numbers. For custom objects, record is
the record's data.
Your response
Reply 200 with one of:
- Deny stops the save with
400 BEFORE_SAVE_HOOK_REJECTED; yourmessage(up to 500 characters) is what the person, the API client or the bulk row sees. - A patch may set only the object's own editable fields: no ids, no
owner, tenant or audit columns, no relation keys (
accountId, …) and no lifecycle stamps. Values must have the column's type (an enum value, a number, an ISO date-time, …). The patch is then checked like a change the person made themselves — field-level security for the acting user, then validation rules on the patched record — and is refused the same way. - A response that is not
{"allow": …}, or a patch that breaks those rules, counts as a failure of the hook.
Timeouts and failure mode
Hooks are on the save path, so they are strict: 3 seconds by default
(250 ms minimum), 5 at most, no retries, no redirects other than 307/308, 64 KB response cap,
and the same URL protection as custom actions. When a hook times out, errors,
is unreachable, or is paused after repeated failures:
FAIL_OPEN(default) — the save goes ahead without the hook's input;FAIL_CLOSED— the save is stopped with503 BEFORE_SAVE_HOOK_UNAVAILABLE.
Either way the invocation is logged, so you can see what was skipped.
Latency
A save with no hook for its object pays nothing measurable. A new or changed hook can take up to 30 seconds to apply to every save. A save with hooks waits for each matching hook in turn, so it takes at least your endpoint's round-trip per hook — keep handlers fast and do slow work asynchronously. Bulk jobs and imports call the hook once per row, so a 200 ms hook adds about 200 ms per row. Invocation logs are written in the background and do not add to the save.
Loops
Calls your endpoint makes back into the API while it handles a hook do not trigger hooks again when either is true:
- the save is for the same record the hook is deciding on;
- the request carries
X-SalesOS-Hook-Origin: <invocation id>with the id from theX-SalesOS-Invocationheader you received (valid for 60 seconds). Send it on every callback.
Other validation — rules, field-level security, permissions — still applies to those saves.
Function-backed hooks
Set functionId instead of url and a serverless function
decides, with no endpoint to host. The contract is the same: the function's
input is the payload above, and it returns { allow, patch, message }.
- The same patch allow-list, field-level security and validation re-check apply; the same loop protection and caching apply (an org without hooks still pays nothing).
- It runs with the hook's timeout (3 seconds by default, 5 at most). If
functions are at capacity and it can't start within a second, or it times
out, crashes or throws, that is a failure:
FAIL_OPENsaves,FAIL_CLOSEDstops the save with503 BEFORE_SAVE_HOOK_UNAVAILABLE. - The invocation shows in the hook's log (with
function:<id>as the URL) and in the function's own log with the triggerBEFORE_SAVE. There is no signing secret to rotate. - The function's
salesos.*writes go through the save path too: hooks on the record being decided are skipped, and a chain of functions stops at depth 3.
After-save triggers
An after-save trigger runs a function right after a record of its object
is saved, synchronously, in the same request. The function's input is:
record is the saved row (for deletes, the last stored row) and previous
the stored row before the change (null on create).
The save has already committed when a trigger runs. A trigger cannot reject
or roll it back; use a before-save hook for that. If the function fails, times
out or can't start because functions are at capacity, the save still succeeds: the failure is in the
function's invocation log (trigger AFTER_SAVE) and the response carries an
X-SalesOS-Save-Warning: after_save_failed trigger=<id> code=<code> invocation=<id>
header, never a 500.
- Triggers on the same object run in ascending
position, each with its own timeout (5 seconds by default, 250 ms minimum, 10 at most), and each one adds its run time to the request. - The function may write further through
salesos.*. Those writes go through the whole save path (hooks, validation rules, workflows, other triggers), but a trigger never re-fires for its own function's writes, and chains stop at depth 3. - They fire wherever before-save hooks do (forms, REST, GraphQL, bulk, imports, workflow field steps, flows, lead conversion, close won and lost), except mass transfer.
Manage them under Settings → Developer → After-save triggers, or with
/v1/after-save-triggers (org owners and admins; Enterprise, with functions):
POST /v1/after-save-triggers/:id/test runs the function with a sample record
(its own salesos.* writes are real).
Test, log and replay
A test sends a sample record and returns the decision, the accepted patch, and the request and response; nothing is saved. A replay re-sends a logged payload the same way. Logs are kept for 30 days.
Developer orgs and change sets
Build and test hooks in a developer org, then promote them with a change set or a metadata deploy. New hooks and after-save triggers arrive turned off, with a new signing secret.
Named credentials
Store an outbound connection once — base URL plus authentication — and call it from functions, custom actions, flows and webhooks without your code ever seeing the secret.
Serverless functions
Write TypeScript or JavaScript that SalesOS runs for you in an isolated sandbox — from workflows, custom actions, events, schedules, HTTP and the save path.