SalesOS

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 UPDATE as 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 UPDATE of ownerId per record. A refused record keeps its owner and is listed under rejected; 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: CREATE for each new account, contact and deal, then an UPDATE of the lead. Any rejection stops the whole conversion.
  • Close won / close lost: an UPDATE of the deal; the close fields themselves can't be patched.
  • CRM migrations and the CSV importer: CREATE / UPDATE per 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).

curl -X POST https://api.salesos.org/v1/before-save-hooks \
  -H "x-api-key: $SALESOS_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "Credit check",
    "objectType": "OPPORTUNITY",
    "operations": ["CREATE", "UPDATE"],
    "url": "https://hooks.acme.com/salesos/credit",
    "timeoutMs": 3000,
    "failureMode": "FAIL_CLOSED"
  }'

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.

{
  "id": "inv_2b9d4c3fa1e8b7d6c5a4f3e26f1c0a5e",
  "type": "before_save",
  "hookId": "cmhook123",
  "object": "OPPORTUNITY",
  "operation": "UPDATE",
  "organizationId": "cmorg123",
  "occurredAt": "2026-10-02T09:15:00.000Z",
  "actor": { "userId": "cmuser456" },
  "recordId": "cmopp789",
  "record": {
    "id": "cmopp789",
    "name": "Woodgrove — advisor CRM",
    "accountId": "cmacc001",
    "stage": "PROPOSAL_PRICE_QUOTE",
    "amount": 340000,
    "closeDate": "2026-11-01T00:00:00.000Z",
    "ownerId": "cmuser456"
  },
  "previous": {
    "id": "cmopp789",
    "stage": "VALUE_PROPOSITION",
    "amount": 210000
  },
  "changedFields": ["stage", "amount"]
}

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:

{ "allow": true }
{ "allow": false, "message": "Credit limit exceeded for Woodgrove Financial." }
{ "allow": true, "patch": { "nextStep": "Finance review", "probability": 55 } }
  • Deny stops the save with 400 BEFORE_SAVE_HOOK_REJECTED; your message (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 with 503 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 the X-SalesOS-Invocation header 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 }.

export default async function (input: {
  operation: string;
  record: { amount?: number; accountId?: string };
}) {
  if ((input.record.amount ?? 0) > 1_000_000)
    return { allow: false, message: 'Deals over 1M need finance sign-off first.' };
  return { allow: true, patch: { nextStep: 'Finance review' } };
}
  • 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_OPEN saves, FAIL_CLOSED stops the save with 503 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 trigger BEFORE_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:

{
  "id": "asv_…",
  "type": "after_save",
  "triggerId": "…",
  "object": "OPPORTUNITY",
  "operation": "UPDATE",
  "organizationId": "org_…",
  "occurredAt": "2026-10-06T09:30:00.000Z",
  "actor": { "userId": "usr_…" },
  "recordId": "opp_…",
  "record": { "id": "opp_…", "stage": "CLOSED_WON", "amount": 120000 },
  "previous": { "id": "opp_…", "stage": "NEGOTIATION", "amount": 120000 },
  "changedFields": ["stage", "isClosed", "isWon"]
}

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):

curl -X POST https://api.salesos.org/v1/after-save-triggers \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "name": "Sync won deals", "objectType": "OPPORTUNITY",
        "operations": ["UPDATE"], "functionId": "fn_…", "timeoutMs": 5000 }'

POST /v1/after-save-triggers/:id/test runs the function with a sample record (its own salesos.* writes are real).

Test, log and replay

POST /v1/before-save-hooks/{id}/test    {"operation": "UPDATE", "record": {...}, "previous": {...}}
GET  /v1/before-save-hooks/{id}/invocations?status=REJECTED&page=1&limit=20
GET  /v1/before-save-hooks/{id}/invocations/{invocationId}
POST /v1/before-save-hooks/{id}/invocations/{invocationId}/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.

On this page