← Docs

API & options reference

Every endpoint, flag, environment variable, and fault the Fidelic emulator supports.

The emulator answers a subset of the Salesforce REST API at http://localhost:<port> (default :8080). Point any client that speaks Salesforce REST at it — curl, your integration code, a test suite — instead of a live org or sandbox.

Everything documented here is implemented today. Where behaviour intentionally differs from a real org, it’s called out.


Starting the server

# minimal — built-in Account/Contact only, empty in-memory store
./sfemu --addr :8080

# with your org's metadata (objects) and Apex (triggers/classes)
SFEMU_APEX_SRC=mdapi ./sfemu --addr :8080 --metadata-dir mdapi/objects

On startup it logs its version, store, and how many triggers loaded / were EXECUTE-classified:

sfemu 4734cdd listening on :8080 (store: sqlite ":memory:", client-secret: false, metadata: "mdapi/objects", hooks-file: "")

./sfemu version prints the build SHA on its own — quote it in any bug report so it’s traceable to an exact build.

Two builds ship in the bundle. The default sfemu runs your triggers through approximated hooks. The apexexec build additionally runs EXECUTE-classified triggers through the real interpreter (SFEMU_APEX_SRC is only read there). Both answer the identical REST surface below — the execution path is invisible to callers, and faults fire the same way regardless.


CLI flags

flagdefaultmeaning
--addr:8080listen address
--metadata-dir$SFEMU_METADATA_DIRdirectory of Salesforce .object XML — teaches the emulator your objects/fields
--hooks-file$SFEMU_HOOKS_FILEdeclarative hooks JSON loaded at startup
--db:memory:SQLite DSN — :memory: (wiped on restart) or a file path (persists)
versionsubcommand: print build SHA and exit

Environment variables

vareffect
SFEMU_METADATA_DIRsame as --metadata-dir
SFEMU_HOOKS_FILEsame as --hooks-file
SFEMU_APEX_SRC(apexexec build) directory of Apex to classify + run for EXECUTE objects
SFEMU_CLIENT_SECRETif set, the token endpoint requires this client_secret; unset ⇒ accept any credentials
SFEMU_LICENSE_KEYlicense key checked at startup (stub)
SFEMU_LATENCY_MSadd N ms latency to every /services/data/ response
SFEMU_FAULT_LOCK_ON_UPDATEarm UNABLE_TO_LOCK_ROW on updates — see Fault injection
SFEMU_FAULT_LIMIT_AFTERafter N calls, return REQUEST_LIMIT_EXCEEDED (<0 = off, the default)

Boolean flags accept any value Go’s strconv.ParseBool understands — 1, t, true, TRUE. Anything unset or unrecognized is off.


Authentication

Auth realism is deliberately light in v0 — enough to test a client’s token flow, not to enforce security.

POST /services/oauth2/token

Only grant_type=client_credentials is supported.

curl -sX POST localhost:8080/services/oauth2/token \
  -d grant_type=client_credentials -d client_id=x -d client_secret=y
{
  "access_token": "00Demu.00D...",
  "instance_url": "http://localhost:8080",
  "token_type": "Bearer",
  "issued_at": 1753166400000
}
  • If SFEMU_CLIENT_SECRET is set, a mismatched client_secret400 invalid_client.
  • Any other grant_type400 unsupported_grant_type.

Bearer tokens on data requests

  • No Authorization header → allowed. You can hit the data API without a token.
  • If you do send Authorization: Bearer <t>, it must be a token this instance issued, otherwise 401 INVALID_SESSION_ID. (So a token from a restarted instance is rejected.)

Data API (/services/data/)

Path shape: /services/data/{version}/{sobjects|query}/...

{version} must be v<major>.<minor> with major ≥ 20 (e.g. v62.0). Anything else → 404. Examples below use v62.0.

Create — POST /sobjects/{object}

curl -sX POST localhost:8080/services/data/v62.0/sobjects/Account \
  -H 'Content-Type: application/json' -d '{"Name":"Acme"}'
{ "id": "001...", "success": true, "errors": [] }

Runs your before/after-insert logic (hooks or interpreter), required-field and field-type validation, then persists.

Retrieve — GET /sobjects/{object}/{id}

curl -s localhost:8080/services/data/v62.0/sobjects/Account/001...
# projection: only the fields you ask for (+ attributes)
curl -s 'localhost:8080/services/data/v62.0/sobjects/Account/001...?fields=Id,Name'

Unknown record → 404. Unknown field in ?fields=400 INVALID_FIELD.

Update — PATCH /sobjects/{object}/{id}

curl -sX PATCH localhost:8080/services/data/v62.0/sobjects/Account/001... \
  -H 'Content-Type: application/json' -d '{"Rating":"Hot"}'

204 No Content on success. Updating a missing id → 404 INVALID_CROSS_REFERENCE_KEY (distinct from GET’s NOT_FOUND — a real-org quirk the emulator reproduces).

Delete — DELETE /sobjects/{object}/{id}

204 on success; missing id → 404 INVALID_CROSS_REFERENCE_KEY.

Upsert — PATCH /sobjects/{object}/{externalIdField}/{value}

Matches on the external-id field: updates if found (200), creates if not (201). Unknown external-id field → 400.

Query — GET /query?q=<SOQL>

curl -s --get localhost:8080/services/data/v62.0/query \
  --data-urlencode "q=SELECT Id, Name FROM Account WHERE Name != null"
{ "totalSize": 1, "done": true, "records": [ { "attributes": {...}, "Id": "001...", "Name": "Acme" } ] }

Unknown sObject → 400 INVALID_TYPE; unknown column → 400 INVALID_FIELD; unparseable SOQL → 400 MALFORMED_QUERY.

Describe

requestreturns
GET /sobjectsglobal describe — every known object (name, label, keyPrefix)
GET /sobjects/{object}basic info + empty recentItems
GET /sobjects/{object}/describefield list (name, label, type), keyPrefix, queryable

Objects come from your metadata. Only Account and Contact exist out of the box; every other object — standard or custom — is learned from --metadata-dir. A trigger targeting an object not in your metadata won’t have anything to run against.


Fault injection

The whole point: make a real org’s un-reproducible failures happen on demand, so your retry/error-handling paths finally have something to catch. Faults are transport-level — checked before any trigger runs — so they fire identically whether an object is EXECUTE-classified (interpreter) or hooks-approximated.

Fault catalog

errorCodeHTTPfires on
UNABLE_TO_LOCK_ROW400armed op (default: update)
REQUEST_LIMIT_EXCEEDED403any data call, after N

Declarative (env vars, re-applied on reset)

varbehaviour
SFEMU_FAULT_LOCK_ON_UPDATE=1next update returns UNABLE_TO_LOCK_ROW (armed Once)
SFEMU_FAULT_LIMIT_AFTER=Nfirst N data calls succeed, then every call returns REQUEST_LIMIT_EXCEEDED
SFEMU_LATENCY_MS=NN ms added to every data response
SFEMU_FAULT_LOCK_ON_UPDATE=1 SFEMU_APEX_SRC=mdapi \
  ./sfemu --addr :8080 --metadata-dir mdapi/objects
# every PATCH -> [{"errorCode":"UNABLE_TO_LOCK_ROW", ...}]

Runtime (arm/clear without restart)

POST /__emulator__/faults arms one fault; DELETE clears all. Body:

fieldtypedefaultmeaning
errorCodestringone of the catalog above
triggerstring"*"op to fire on: insert / update / delete, or "*" for any data call
afterNint0let N calls through first
oncebooltruefire once then disarm, vs. every matching call
latencyMsintset global latency (can be sent alone)
# fire UNABLE_TO_LOCK_ROW on the next delete, once
curl -sX POST localhost:8080/__emulator__/faults \
  -d '{"errorCode":"UNABLE_TO_LOCK_ROW","trigger":"delete"}'

# fail every call after the 5th, until cleared
curl -sX POST localhost:8080/__emulator__/faults \
  -d '{"errorCode":"REQUEST_LIMIT_EXCEEDED","afterN":5,"once":false}'

# clear everything
curl -sX DELETE localhost:8080/__emulator__/faults

Control endpoints (/__emulator__/)

Admin-style, fenced under a double-underscore prefix — zero collision with /services/*. Not part of the Salesforce API; specific to the emulator.

endpointmethodeffect
/__emulator__/resetPOSTwipe all data + runtime faults/hooks, then re-apply the startup baseline (env-var faults, hooks file)
/__emulator__/faultsPOST / DELETEarm one fault (see above) / clear all
/__emulator__/hooksPOST / DELETEregister one declarative hook / clear all
/__emulator__/licenseGETreport license-stub status
/__emulator__/asyncGETlist the async work captured this session (Queueable / @future / Schedulable / Batchable)

reset returns to your configured baseline, not to empty — so a lock fault armed via SFEMU_FAULT_LOCK_ON_UPDATE comes back after a reset. Use it between test cases for a clean, reproducible starting point.

Async log (/__emulator__/async)

Apex async is real but has no honest “later” in a synchronous emulator, so the emulator runs it deterministically at a defined moment and makes the outcome assertable rather than fire-and-forget:

  • Queueable (System.enqueueJob) and @future calls are transactional — enqueued during the triggering DML, run after that transaction commits, each in its own transaction (fresh statics, isolated rollback). A parent rollback un-enqueues them. This is the default (inline) mode; record-only captures them without running.
  • Schedulable (System.schedule) is record-only — cron timing isn’t simulated, so it is captured as scheduled but never run.
  • Calling a @future from inside a @future/Queueable body raises the real platform error (“Future method cannot be called from a future or batch method”), isolated to that job.
  • Batchable (Database.executeBatch) is transactional like Queueable. Its lifecycle is collapsed to a single chunk — start()execute() once over the whole scope → finish(). start()’s Database.getQueryLocator is lazy: the query runs at drain against the committed store, so a rolled-back write is invisible to the scope and a committed one is included. The collapse does not model per-chunk transaction boundaries or Database.Stateful; a batch’s execute() may enqueue a Queueable child (allowed within the chain budget). Database.executeBatch from an already-async context is refused.
curl -s localhost:8080/__emulator__/async
# { "count": 2, "jobs": [
#   { "type": "MakeContact", "method": "execute", "kind": "queueable",   "ran": true },
#   { "type": "Nightly",     "method": "execute", "kind": "schedulable", "ran": false,
#     "note": "scheduled; cron timing not simulated (record-only)" } ] }

Each captured job carries its kind (queueable / future / schedulable / batch), so the same log records the whole async surface.


Error envelopes

DML/query errors use the documented Salesforce array shape:

[ { "message": "unable to obtain exclusive access to this record or 1 records",
    "errorCode": "UNABLE_TO_LOCK_ROW",
    "fields": [] } ]

OAuth errors use the token-endpoint shape:

{ "error": "invalid_client", "error_description": "invalid client credentials" }

Common codes you’ll see: INVALID_CROSS_REFERENCE_KEY (patch/delete missing id), INVALID_TYPE (unknown sObject), INVALID_FIELD (unknown column), MALFORMED_QUERY (bad SOQL), REQUIRED_FIELD_MISSING, JSON_PARSER_ERROR, INVALID_SESSION_ID (unrecognized bearer).


Common workflows

Point real integration code at the emulator

  1. Start with your metadata + triggers loaded.
  2. In your client, swap the instance URL for http://localhost:8080 (auth optional — the client-credentials flow above works if your client insists on a token).
  3. Run your suite. Records persist within the run (--db file.sqlite to persist across restarts).

Test a lock-failure retry path

# terminal 1 — server with locks armed
SFEMU_FAULT_LOCK_ON_UPDATE=1 SFEMU_APEX_SRC=mdapi \
  ./sfemu --addr :8080 --metadata-dir mdapi/objects

# terminal 2
ID=$(curl -sX POST localhost:8080/services/data/v62.0/sobjects/Account \
  -H 'Content-Type: application/json' -d '{"Name":"Locke"}' | jq -r .id)
curl -sX PATCH localhost:8080/services/data/v62.0/sobjects/Account/$ID \
  -H 'Content-Type: application/json' -d '{"Rating":"Hot"}'
# -> UNABLE_TO_LOCK_ROW ; your retry logic now has a real failure to handle

To let updates succeed again: curl -X DELETE localhost:8080/__emulator__/faults (or restart without the env var).

Simulate hitting an API limit mid-batch

curl -sX POST localhost:8080/__emulator__/faults \
  -d '{"errorCode":"REQUEST_LIMIT_EXCEEDED","afterN":10,"once":false}'
# 11th call onward -> 403 REQUEST_LIMIT_EXCEEDED, until you DELETE it

See also: QUICKSTART.md for first-run setup (converting an SFDX project, the coverage report, macOS quarantine).