API reference
This page is the complete, self-contained reference for the Who Sees What REST API. It covers every endpoint under /v1/whoseeswhat: the request structure and payload, every parameter and option, the response structure and payload, every error each call can return, and a Node and an Apex example for every call that shows how to make it and how to process what comes back. Field names, types, limits, and messages are taken from the service’s own schemas and code, not inferred. For the narrative overview (what the API is, how tiers work, how to get a credential), see the API overview. For the same operations as MCP tools, see the MCP server page.
Changed November 3, 2026. Every endpoint now has a Node and an Apex example, the request and response anatomy is documented in full, and the page adds reusable clients for both languages, retry handling, webhook signature verification, and the complete template and slot catalog.
Contents
- Request anatomy
- Response anatomy
- Errors
- Credentials
- Reusable clients: Node and Apex
- Endpoint index
- Orgs: list, connect, poll a connection
- Templates: list, run
- Slot search: users, objects, records
- Reports: export
- Scheduled report exports: list, create, deliveries, webhook verification
- Cross-org compare
- Admin setup
- Appendix A: template catalog
- Appendix B: slot value formats
- Appendix C: other Digadop APIs on this host
Request anatomy
Host and scheme. https://whoseeswhat.com. HTTPS is required; a plain http:// request is refused with 403 forbidden. Every Who Sees What route is under /v1/whoseeswhat.
Headers.
| Header | Required | Value |
|---|---|---|
Authorization | yes | Bearer <credential>. See Credentials. |
Content-Type | on POST | application/json. |
x-correlation-id | no | Your own id for the call, 8 to 128 characters from A-Z a-z 0-9 . _ : -. If it matches that shape the server adopts it for its audit and usage records and echoes it back; if it does not match, the server mints its own id instead and echoes that. |
Where parameters go.
GETendpoints take every parameter as a query string value (?orgId=...&q=...). URL-encode values.POSTendpoints take a JSON object body. The body is capped at 64 KiB (65,536 bytes); a larger body is refused with413and the connection is closed.- A path parameter (
:templateIdin/templates/:templateId/runs) is taken from the URL and overrides the same key if you also put it in the body. - Path and query values arrive as strings. The server keeps them as strings unless the endpoint’s schema rejects the string and accepts the JSON-parsed value, so
orgId=00D...stays a string and you never need to quote or escape a value beyond URL-encoding.
Routing. A trailing slash is tolerated (/templates/ matches /templates). An unknown path returns 404 not_found with the message no route for <METHOD> <path>. A known path called with the wrong method returns 405 method_not_allowed with <METHOD> not allowed on <path>. Routing happens before authentication, so a bad path 404s even without a token; everything else requires a valid token first.
Response anatomy
Success is always 200. Every successful call on this page, including the two that create something (a schedule, a connection attempt), returns 200 with Content-Type: application/json and the endpoint’s result object as the body. There is no 201, no 204, and no empty success body.
Headers on every response, success or error:
| Header | Meaning |
|---|---|
x-correlation-id | The id for this call (yours if you sent a valid one, otherwise minted). Log it; it is the key support uses to find your call in the audit and usage records. |
Retry-After | Only on 429 and some 503 responses: the number of seconds to wait before retrying. |
Strict-Transport-Security | Set on every gateway response. |
Error envelope. Every error is a JSON object with one key, error, whose value has a stable machine code and a human message:
{ "error": { "code": "forbidden", "message": "Not authorized for org 00D000000000000EAA." } }
Branch on code and the HTTP status; show message to a human. Messages on this page are quoted exactly as the server sends them where they are fixed strings; a <placeholder> marks a value the server fills in.
Per-call deadline. A call that outlives the server’s per-call executor deadline (20 seconds by default) is answered 502 upstream_error with the fixed message The upstream service failed to handle the request., and it is metered like any other call that reached the evaluator; the cause is written to the server’s audit record under your correlation id, never to the wire. A large population or comparison question on a big org can hit it; prefer a report export delivered as a signed link, or a scheduled export, for the heaviest questions.
A 200 is not always a completed audit. Three endpoints (run a template, export a report, and install the auditor permission set) answer 200 with a plain-language result for conditions you might expect to be errors: a missing slot value, an org that is not connected, a template that is coming soon. Each endpoint section below lists its ordered behavior. Read the body, not only the status.
Errors
| HTTP | error.code | When | Message |
|---|---|---|---|
| 400 | invalid_input | A parameter failed the endpoint’s validation. | Names the problem; the fixed strings are listed per endpoint below. |
| 401 | unauthorized | No Authorization header, or a token that is malformed, expired, revoked, or signed by an unknown key. | missing or invalid credential when the header is absent; otherwise the fixed string authentication failed (the verifier’s reason stays on the server’s audit record). |
| 403 | forbidden | The token is valid but not entitled: the org is below Enterprise, the orgId is outside the token’s scope, the request is plain http, or a product rule refused the call. | Listed per endpoint. |
| 404 | not_found | No such route. | no route for <METHOD> <path> |
| 405 | method_not_allowed | The path exists under a different method. | <METHOD> not allowed on <path> |
| 413 | invalid_input | The JSON body exceeded 64 KiB. | request body too large |
| 429 | rate_limited | More than 120 requests per minute from one credential (or one IP when unauthenticated), or more than 600 per minute across all callers on the serving instance. Carries Retry-After. | too many requests |
| 502 | upstream_error | The backend call failed, timed out, or the server’s own output failed validation. Not your request’s fault. | Varies. |
| 503 | auth_unavailable | Credential verification could not answer (the revocation store or key service was unreachable). Retry with backoff; do not create a new credential. | Fixed server text. |
A 401 after about an hour of successful calls is the credential expiring (see next section), not a change in your code.
Credentials
A credential is created in the Who Sees What app (organization menu, API access, Create credential, optional label up to 200 characters) by a user with Manage Users or Modify All Data on an Enterprise org. The app shows the token once, with its expiresAt. Facts your integration must plan around:
- It expires one hour after creation. There is no refresh call. An unattended job needs a fresh credential before each run, created by a person in the app, or a short-lived job started right after creation. The credential list in the app shows each token’s label, created time, and last-used time, never the token itself.
- Revocation (the app’s Revoke button) takes effect immediately on the server instance that handled the revoke and within 30 seconds on every other instance.
- Scope is the single org the credential was created in. Every org-scoped call checks
orgIdagainst that scope and refuses any other org with403Not authorized for org <orgId>. - Edition is captured at creation and re-checked live. If the org later drops below Enterprise, calls fail with
403naming the edition; if the credential was created when the org held a higher entitlement than it holds now, the template list refuses withThis credential was minted with a higher entitlement than the org currently has. Mint a new one. - The same credential works for this REST API and for MCP. The two surfaces share one authorization, metering, and audit path.
Reusable clients: Node and Apex
Every endpoint example below assumes one of these two helpers so the examples stay short. Each one sends the bearer token, sets a correlation id you can grep for later, parses the JSON body, turns the error envelope into a typed exception, and retries 429, 502, and 503.
Node (18 or later; native fetch, no dependencies)
// wsw.mjs
import { randomUUID } from 'node:crypto';
const BASE = process.env.WSW_BASE ?? 'https://whoseeswhat.com';
export class WswError extends Error {
constructor(status, code, message, correlationId) {
super(`${status} ${code}: ${message}`);
this.status = status; // HTTP status
this.code = code; // error.code from the envelope
this.correlationId = correlationId;
}
}
const RETRY_STATUSES = new Set([429, 502, 503]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* Call one endpoint. `path` is relative to /v1/whoseeswhat, e.g. '/templates'.
* GET: pass `query` (an object). POST: pass `body` (an object).
* Resolves with the parsed JSON on 200; throws WswError otherwise.
*/
export async function wsw(token, method, path, { query, body, correlationId, retries = 3 } = {}) {
const url = new URL(`/v1/whoseeswhat${path}`, BASE);
for (const [k, v] of Object.entries(query ?? {})) if (v != null) url.searchParams.set(k, String(v));
const cid = correlationId ?? `myapp-${randomUUID()}`; // 8 to 128 chars of [A-Za-z0-9._:-]
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, {
method,
headers: {
authorization: `Bearer ${token}`,
'x-correlation-id': cid,
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const echoedCid = res.headers.get('x-correlation-id') ?? cid;
const json = await res.json().catch(() => null);
if (res.ok) return json; // every success is 200
const code = json?.error?.code ?? 'unknown';
const message = json?.error?.message ?? res.statusText;
if (RETRY_STATUSES.has(res.status) && attempt < retries) {
const retryAfter = Number(res.headers.get('retry-after'));
await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt);
continue;
}
throw new WswError(res.status, code, message, echoedCid);
}
}
Usage pattern for every example below:
import { wsw, WswError } from './wsw.mjs';
const token = process.env.WSW_TOKEN;
try {
const templates = await wsw(token, 'GET', '/templates');
} catch (e) {
if (e instanceof WswError && e.status === 401) { /* credential expired or revoked: create a new one */ }
else throw e;
}
Apex
Before any callout, add https://whoseeswhat.com as a Remote Site Setting, or create a Named Credential for it and use callout:<name> as the endpoint prefix. Apex callouts allow up to 120 seconds per request (setTimeout(120000)), 100 callouts per transaction, and a 6 MB heap in synchronous context (12 MB asynchronous), so request large reports as a signed link rather than inline.
public with sharing class WhoSeesWhatClient {
public class WswException extends Exception {
public Integer status;
public String code;
public String correlationId;
}
private static final String BASE = 'https://whoseeswhat.com/v1/whoseeswhat';
private final String token;
public WhoSeesWhatClient(String token) { this.token = token; }
/** GET with query params. Returns the parsed JSON (a Map or a List, per endpoint). */
public Object get(String path, Map<String, String> query) {
String qs = '';
if (query != null) {
List<String> parts = new List<String>();
for (String k : query.keySet()) {
if (query.get(k) != null) parts.add(k + '=' + EncodingUtil.urlEncode(query.get(k), 'UTF-8'));
}
if (!parts.isEmpty()) qs = '?' + String.join(parts, '&');
}
return send('GET', path + qs, null);
}
/** POST a JSON body. Returns the parsed JSON. */
public Object post(String path, Map<String, Object> body) {
return send('POST', path, JSON.serialize(body));
}
private Object send(String method, String pathAndQuery, String body) {
HttpRequest req = new HttpRequest();
req.setEndpoint(BASE + pathAndQuery);
req.setMethod(method);
req.setTimeout(120000);
req.setHeader('Authorization', 'Bearer ' + token);
req.setHeader('x-correlation-id', 'sfdc-' + String.valueOf(Crypto.getRandomLong()).replace('-', ''));
if (body != null) {
req.setHeader('Content-Type', 'application/json');
req.setBody(body);
}
HttpResponse res = new Http().send(req);
String cid = res.getHeader('x-correlation-id');
if (res.getStatusCode() == 200) {
return JSON.deserializeUntyped(res.getBody());
}
WswException ex = new WswException();
ex.status = res.getStatusCode();
ex.code = 'unknown';
ex.correlationId = cid;
String message = res.getStatus();
try {
Map<String, Object> env = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
Map<String, Object> err = (Map<String, Object>) env.get('error');
ex.code = (String) err.get('code');
message = (String) err.get('message');
} catch (Exception ignore) {
// a non JSON body (a proxy error page): keep the HTTP status text as the message
}
ex.setMessage(ex.status + ' ' + ex.code + ': ' + message + ' (correlation ' + cid + ')');
throw ex;
}
}
Apex cannot sleep, so a 429 or 503 is retried by re-enqueuing the work: catch WswException where status is 429, 502, or 503, read the Retry-After value if you need it, and System.enqueueJob a Queueable (or schedule a one-off Schedulable) to try again. Unit tests use Test.setMock(HttpCalloutMock.class, ...) returning a 200 body in the shape documented for the endpoint.
Endpoint index
| Method and path | What it does | Reads your org? |
|---|---|---|
GET /v1/whoseeswhat/orgs | List the orgs in the credential’s scope and whether each is connected. | no |
POST /v1/whoseeswhat/connect/org | Start connecting a Salesforce org (device code flow). | no |
POST /v1/whoseeswhat/connect/org/status | Poll a connection attempt. | no |
GET /v1/whoseeswhat/templates | List the audit templates (questions) and their slots. | no |
POST /v1/whoseeswhat/templates/:templateId/runs | Run a template and get the explained answer. | yes |
GET /v1/whoseeswhat/users | Search users to fill a {subject} slot. | yes |
GET /v1/whoseeswhat/objects | Search objects to fill a {target} object slot. | yes |
GET /v1/whoseeswhat/records | Search records by name to fill a {target} record slot. | yes |
POST /v1/whoseeswhat/reports | Run a template and get the result as CSV, XLSX, PDF, or JSON. | yes |
GET /v1/whoseeswhat/schedules | List scheduled report exports. | no |
POST /v1/whoseeswhat/schedules | Create a scheduled report export (email or signed webhook). | no |
POST /v1/whoseeswhat/compare/orgs | Compare one org’s access model against another. | yes, both |
POST /v1/whoseeswhat/admin/auditor-permission-set | Install the least-privilege auditor permission set. | writes |
Every endpoint requires the whoseeswhat entitlement (Who Sees What Enterprise). Every org-scoped endpoint checks orgId against the credential’s scope.
Orgs
List connected orgs
GET /v1/whoseeswhat/orgs
Returns the Salesforce orgs this credential may act on, each marked connected or not. Call this first to get a valid orgId rather than guessing one. No parameters.
Response 200:
{
"orgs": [
{ "orgId": "00D000000000000EAA", "connected": true, "instanceUrl": "https://yourorg.my.salesforce.com", "environment": "production" }
]
}
| Field | Type | Notes |
|---|---|---|
orgs[].orgId | string | The 18 character org id to pass as orgId everywhere else. |
orgs[].connected | boolean | false means Who Sees What holds no Salesforce session for it; run the connect flow. |
orgs[].instanceUrl | string or null | The org’s My Domain URL, when connected. |
orgs[].environment | string or null | production or sandbox, when known. |
Errors: only the shared 401, 429, 503. This endpoint has no edition or org check of its own.
Node:
const { orgs } = await wsw(token, 'GET', '/orgs');
const org = orgs.find((o) => o.connected);
if (!org) throw new Error('No connected org: run the connect flow first.');
console.log(org.orgId, org.instanceUrl);
Apex:
WhoSeesWhatClient wsw = new WhoSeesWhatClient(wswToken);
Map<String, Object> body = (Map<String, Object>) wsw.get('/orgs', null);
String orgId;
for (Object o : (List<Object>) body.get('orgs')) {
Map<String, Object> org = (Map<String, Object>) o;
if (org.get('connected') == true) { orgId = (String) org.get('orgId'); break; }
}
System.debug('orgId=' + orgId);
Connect a new org
POST /v1/whoseeswhat/connect/org
Starts a device code flow so a script or agent can connect a Salesforce org without first visiting the Who Sees What web app. A human still approves the OAuth consent in a browser; this call only gets you the code and URL to hand them.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
environment | "production" or "sandbox" | no | Which Salesforce login to use. Defaults to production; any other value is refused 400 invalid_input. |
Response 200, flow started:
{
"status": "awaiting_authorization",
"userCode": "ABCD-1234",
"verificationUri": "https://login.salesforce.com/setup/connect",
"verificationUriComplete": "https://login.salesforce.com/setup/connect?user_code=ABCD-1234",
"expiresInSeconds": 600,
"pollAfterSeconds": 5,
"handle": "7c0f1e2d-3b4a-4c5d-8e9f-0a1b2c3d4e5f",
"message": "Tell the user: open https://login.salesforce.com/setup/connect and enter the code ABCD-1234 to approve. Then call whoseeswhat_connect_org_status with this handle until it reports connected."
}
| Field | Type | Notes |
|---|---|---|
status | string | awaiting_authorization when the flow started; device_flow_disabled when the Salesforce connected app does not allow the device flow; error otherwise. Only awaiting_authorization carries the fields below. |
userCode | string | Show this to the human. |
verificationUri | string | Where the human enters the code. |
verificationUriComplete | string | Same URL with the code pre-filled, when Salesforce supplies it. |
expiresInSeconds | number | How long the code is valid. |
pollAfterSeconds | number | Minimum seconds between status polls (Salesforce’s interval; 5 when not given). |
handle | string | Pass to the status endpoint. |
message | string | Always present; on error it is the only explanation. Error texts include The gateway is not configured for Salesforce OAuth (no client id)., Could not reach Salesforce to start the connection. Try again shortly., and Salesforce declined to start the connection: <reason>. |
Errors: the shared 400 (a non-string environment), 401, 429, 503. A Salesforce-side failure is reported in the 200 body’s status and message, not as an HTTP error.
Node:
const start = await wsw(token, 'POST', '/connect/org', { body: { environment: 'production' } });
if (start.status !== 'awaiting_authorization') throw new Error(start.message);
console.log(`Open ${start.verificationUriComplete ?? start.verificationUri} and enter ${start.userCode}`);
const handle = start.handle;
Apex:
Map<String, Object> start = (Map<String, Object>) wsw.post('/connect/org',
new Map<String, Object>{ 'environment' => 'production' });
if (start.get('status') != 'awaiting_authorization') throw new WhoSeesWhatClient.WswException((String) start.get('message'));
String handle = (String) start.get('handle');
System.debug('Open ' + start.get('verificationUri') + ' and enter ' + start.get('userCode'));
Poll a connect attempt
POST /v1/whoseeswhat/connect/org/status
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
handle | string | yes | The handle from connect a new org. |
Response 200:
{ "status": "connected", "orgId": "00D000000000000EAA", "orgName": "Acme Corp", "inCallerScope": true, "message": "Connected org 00D000000000000EAA (Acme Corp). You can now run audits against it with whoseeswhat_run_template (orgId=00D000000000000EAA)." }
| Field | Type | Notes |
|---|---|---|
status | string | pending, connected, expired, or error. |
orgId, orgName | string | Present on connected. |
inCallerScope | boolean | Present on connected. false means the org connected but your credential is not scoped to it, so audits against it are refused until a credential created in that org is used. |
message | string | Always present. Fixed texts: Not approved yet. Ask the user to finish approving, then check again. (pending), Could not reach Salesforce to check status. Try again shortly. (pending), The user declined the connection. (error), This connection request expired. Start over with whoseeswhat_connect_org. (expired), Invalid connection handle. Start over with whoseeswhat_connect_org. (error). |
Poll no faster than pollAfterSeconds until status leaves pending. The handle is single-use per attempt: on expired or error, start a new flow.
Node:
let status;
do {
await new Promise((r) => setTimeout(r, start.pollAfterSeconds * 1000));
status = await wsw(token, 'POST', '/connect/org/status', { body: { handle } });
} while (status.status === 'pending');
if (status.status !== 'connected') throw new Error(status.message);
console.log('connected', status.orgId, 'in scope:', status.inCallerScope);
Apex: the same request and response; poll from a Queueable that re-enqueues itself, or a scheduled job, since Apex cannot block.
public class WswConnectPoller implements Queueable, Database.AllowsCallouts {
private final String token; private final String handle;
public WswConnectPoller(String token, String handle) { this.token = token; this.handle = handle; }
public void execute(QueueableContext ctx) {
Map<String, Object> st = (Map<String, Object>) new WhoSeesWhatClient(token)
.post('/connect/org/status', new Map<String, Object>{ 'handle' => handle });
String status = (String) st.get('status');
if (status == 'pending') {
System.enqueueJob(new WswConnectPoller(token, handle)); // try again on the next job
} else if (status == 'connected') {
System.debug('connected ' + st.get('orgId') + ' inCallerScope=' + st.get('inCallerScope'));
} else {
System.debug(LoggingLevel.ERROR, (String) st.get('message'));
}
}
}
Templates
List audit templates
GET /v1/whoseeswhat/templates
Returns the catalog of audit templates (parameterized questions) and the slots each one takes. No parameters. The full catalog is reproduced in Appendix A.
Response 200: a JSON array (not an object) of template summaries.
[
{
"templateId": "wsw-001",
"family": "A",
"questionPattern": "Why can't {subject} see {target}?",
"accessDimension": "record_read",
"direction": "deny",
"tier": "trial",
"slots": [
{ "token": "subject", "type": "user", "pickerSource": "User lookup", "required": true },
{ "token": "target", "type": "record", "pickerSource": "record picker (Id or recent)", "required": true }
]
}
]
| Field | Type | Notes |
|---|---|---|
templateId | string | wsw-001 through wsw-018. Pass it to run, export, and create a schedule. |
family | A B C D E F G | Evaluation family: A deny diagnosis, B grant diagnosis, C comparison, D population, E privilege scan, F simulation, G temporal. |
questionPattern | string | The question with {token} placeholders. |
accessDimension | string | One of record_read, record_edit, field_read, field_edit, object_read, object_edit, object_access, system_perm. |
direction | string | One of deny, grant, population, comparison, privilege, simulation, temporal. |
tier | trial paid pro lynceon | The minimum edition to run this template. trial is the free tier, paid is Professional, pro is Enterprise. Because API access itself requires Enterprise, every template in today’s catalog is runnable. |
comingSoon | boolean, optional | When true, running it returns a fixed coming-soon answer instead of an evaluation. Today only wsw-018. |
slots[] | array | One entry per input: token (the key to use under slots when you run it), type (a string or an array of allowed types), pickerSource (a hint for building a picker), required (boolean), and an optional default. |
Errors: 403 forbidden with This credential was minted with a higher entitlement than the org currently has. Mint a new one. when the org’s live edition is below what the credential was created with; 403 forbidden when no org in the credential’s scope is on Enterprise.
Node:
const templates = await wsw(token, 'GET', '/templates');
const runnable = templates.filter((t) => !t.comingSoon);
for (const t of runnable) {
console.log(t.templateId, t.questionPattern, 'slots:', t.slots.map((s) => `${s.token}${s.required ? '' : '?'}`).join(', '));
}
Apex:
List<Object> templates = (List<Object>) wsw.get('/templates', null);
for (Object o : templates) {
Map<String, Object> t = (Map<String, Object>) o;
if (t.get('comingSoon') == true) continue;
List<String> tokens = new List<String>();
for (Object s : (List<Object>) t.get('slots')) tokens.add((String) ((Map<String, Object>) s).get('token'));
System.debug(t.get('templateId') + ' ' + t.get('questionPattern') + ' slots=' + String.join(tokens, ','));
}
Run a template
POST /v1/whoseeswhat/templates/:templateId/runs
Runs one audit template against a connected org and returns the explained answer in the same response. This is the core operation; reports and schedules render or re-run the same evaluation.
Path parameter: templateId, from the catalog.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
orgId | string | yes | Must be in the credential’s scope. |
slots | object of string values | when the template has required slots | Keys are slot tokens from the catalog entry; values are strings in the formats in Appendix B. A missing, null, or blank value counts as missing. |
{ "orgId": "00D000000000000EAA", "slots": { "subject": "0051a0000012b3rAAC", "target": "0011a00000AbCdeAAC" } }
Response 200, the answer object:
{
"templateId": "wsw-001",
"family": "A",
"question": "Why can't Jane Doe see Acme Corp?",
"verdict": "none",
"summary": "Jane Doe cannot see Acme Corp: the org-wide default for Account is Private and no sharing reaches her.",
"bindingFactor": "Org-Wide Default: Private",
"fullPath": ["Org-Wide Default: Private", "Role Hierarchy: not above the owner", "Sharing Rules: none match"],
"evidence": [{ "label": "Org-Wide Default (Account)", "value": "Private" }],
"notes": [],
"caveats": []
}
| Field | Type | Present | Notes |
|---|---|---|---|
templateId | string | always | Echo of the template. |
family | string | always | As in the catalog. |
question | string | always | The pattern with slot values filled in (names resolved where possible). |
verdict | none read edit full | always | The headline access level. none is also the value of every short-circuit answer listed below. |
summary | string | always | One-line human answer. Always read it. |
bindingFactor | string | families A, B, C | The single deciding layer. |
fullPath | string array | families A, B, C | Every blocker (deny) or every grant (grant), in order. |
evidence | array of {label, value} | always | Named metadata backing the answer (empty on a short-circuit). |
population | array | family D | One row per user: { userId, name, isActive, canSee, accessLevel?, bindingFactor?, fullPath: [], profileName? }. |
counts | object | family D | { canSee, total, inactive? }. |
notes | string array | always | Extra context; short-circuit answers put the detail here (for example required slots: subject (user), target (record)). |
caveats | string array | always | Correctness caveats. The evaluator never asserts a wrong reason; when it cannot be sure, it says so here. |
comingSoon | boolean | coming-soon templates | true on the placeholder answer. |
Behavior, in the order the server checks it. Most of these are 200 with verdict: "none", evidence: [], caveats: [], and the reason in summary (and notes):
- Unknown
templateId: 400invalid_input,unknown template "<id>". - The credential’s edition is below Enterprise: 200,
summaryRunning audits via the API or MCP requires the WSW Enterprise edition.,notes["your edition tier: <tier>"]. orgIdoutside the credential’s scope: 403forbidden,Not authorized for org <orgId>.- The org’s live edition (re-checked, cached for up to 300 seconds) is below Enterprise: 403
forbidden,API access requires the WSW Enterprise edition (entitlement_required: the org is no longer entitled to Enterprise). - A required slot missing: 200,
summaryMissing required slot(s): <token>, <token>.,notes["required slots: subject (user), target (record)"]. - The template’s own
tieris above the credential’s: 200,summaryThis template requires the <tier> tier. - The template is
comingSoon: 200, the placeholder answer withcomingSoon: trueand the caveatCross-org compare is coming soon; it is built and in final acceptance.(forwsw-018). - No Salesforce session for the org: 200,
summaryNo connected Salesforce org for <orgId>. Connect an org in whoseeswhat first. - Otherwise the evaluator runs live against your org and returns the full answer. A slot value the evaluator cannot use (a
{subject}that is not a User Id, an unknown{permission}, a{since}that is not a date) makes the evaluation fail: 502upstream_errorwith the fixed messageThe upstream service failed to handle the request.The specific reason (for example that a name matched no user, or more than one) is written to the server’s audit record under your correlation id, not to the response, so use the slot search endpoints to get Ids first.
A very large comparison answer (wsw-018) is bounded on this endpoint to its first 500 rows with the exact totals stated in notes; the report export of the same question is not bounded.
Node: run, then classify the answer before trusting it.
function classifyAnswer(a) {
if (a.comingSoon) return 'coming-soon';
if (a.summary.startsWith('Missing required slot')) return 'missing-slots';
if (a.summary.startsWith('No connected Salesforce org')) return 'not-connected';
if (a.summary.includes('requires the')) return 'edition'; // tier or template tier
return 'evaluated';
}
const answer = await wsw(token, 'POST', '/templates/wsw-001/runs', {
body: { orgId, slots: { subject: '0051a0000012b3rAAC', target: '0011a00000AbCdeAAC' } },
});
const kind = classifyAnswer(answer);
if (kind !== 'evaluated') throw new Error(`${kind}: ${answer.summary} ${answer.notes.join(' ')}`);
console.log(answer.verdict, answer.summary);
for (const step of answer.fullPath ?? []) console.log(' -', step);
if (answer.population) {
const canSee = answer.population.filter((u) => u.canSee && u.isActive);
console.log(`${answer.counts.canSee} of ${answer.counts.total} can see it; ${canSee.length} active`);
}
Apex:
Map<String, Object> answer = (Map<String, Object>) wsw.post('/templates/wsw-001/runs', new Map<String, Object>{
'orgId' => orgId,
'slots' => new Map<String, Object>{ 'subject' => '0051a0000012b3rAAC', 'target' => '0011a00000AbCdeAAC' }
});
String summary = (String) answer.get('summary');
if (answer.get('comingSoon') == true
|| summary.startsWith('Missing required slot')
|| summary.startsWith('No connected Salesforce org')
|| summary.contains('requires the')) {
throw new WhoSeesWhatClient.WswException('Not evaluated: ' + summary);
}
System.debug(answer.get('verdict') + ': ' + summary);
if (answer.containsKey('fullPath')) {
for (Object step : (List<Object>) answer.get('fullPath')) System.debug(' - ' + step);
}
if (answer.containsKey('population')) {
Map<String, Object> counts = (Map<String, Object>) answer.get('counts');
for (Object o : (List<Object>) answer.get('population')) {
Map<String, Object> u = (Map<String, Object>) o;
if (u.get('canSee') == true) System.debug(u.get('name') + ' ' + u.get('accessLevel'));
}
System.debug(counts.get('canSee') + ' of ' + counts.get('total') + ' can see it');
}
Slot search
Three lookup endpoints that return values you can pass straight into slots. All three take orgId (required, in scope) as a query parameter, and all three return [] rather than an error when the org has no Salesforce session.
Search users
GET /v1/whoseeswhat/users?orgId=<orgId>&q=<text>
| Parameter | Required | Notes |
|---|---|---|
orgId | yes | |
q | no | Substring match on Name or Username. When omitted, returns active users only. |
Response 200: an array of up to 25 users.
[{ "id": "0051a0000012b3rAAC", "name": "Jane Doe", "username": "jane@example.com", "isActive": true }]
Pass id as a {subject} or {subject2} slot value.
Errors: 403 forbidden Not authorized for org <orgId>.; 400 invalid_input when orgId is missing.
Node:
const users = await wsw(token, 'GET', '/users', { query: { orgId, q: 'jane' } });
if (users.length !== 1) throw new Error(`expected one match for "jane", got ${users.length}`);
const subject = users[0].id;
Apex:
List<Object> users = (List<Object>) wsw.get('/users', new Map<String, String>{ 'orgId' => orgId, 'q' => 'jane' });
if (users.size() != 1) throw new WhoSeesWhatClient.WswException('expected one match, got ' + users.size());
String subject = (String) ((Map<String, Object>) users[0]).get('id');
Search objects
GET /v1/whoseeswhat/objects?orgId=<orgId>&q=<text>
| Parameter | Required | Notes |
|---|---|---|
orgId | yes | |
q | no | Case-insensitive substring match on API name or label. |
Response 200: an array of up to 50 objects.
[{ "apiName": "Account", "label": "Account" }]
Pass apiName as an object {target}, or apiName.FieldName as a field {target}.
Node:
const objects = await wsw(token, 'GET', '/objects', { query: { orgId, q: 'acc' } });
const account = objects.find((o) => o.apiName === 'Account');
Apex:
List<Object> objects = (List<Object>) wsw.get('/objects', new Map<String, String>{ 'orgId' => orgId, 'q' => 'acc' });
Search records
GET /v1/whoseeswhat/records?orgId=<orgId>&object=<apiName>&q=<text>
| Parameter | Required | Notes |
|---|---|---|
orgId | yes | |
object | yes | An object API name: letters, digits, and underscores only. |
q | no | Substring match on the object’s Name field. |
Response 200: an array of up to 25 records. Returns [] when the object has no queryable Name field.
[{ "id": "0011a00000AbCdeAAC", "name": "Acme Corp" }]
Pass id as a record {target}.
Errors: 400 invalid_input invalid object name "<value>" when object contains anything other than letters, digits, and underscores (this is an injection guard, not only a format check); 403 out of scope.
Node:
const records = await wsw(token, 'GET', '/records', { query: { orgId, object: 'Account', q: 'Acme' } });
const target = records[0]?.id;
Apex:
List<Object> records = (List<Object>) wsw.get('/records',
new Map<String, String>{ 'orgId' => orgId, 'object' => 'Account', 'q' => 'Acme' });
String target = records.isEmpty() ? null : (String) ((Map<String, Object>) records[0]).get('id');
Reports
Export a report
POST /v1/whoseeswhat/reports
Runs a template and returns the result as a file: CSV, XLSX, PDF, or JSON, inline or as a signed download link. It runs the same ordered checks as run a template, and every short-circuit answer (missing slots, not connected, and so on) is rendered in the requested format too, so a CSV export of an unconnected org is a one-line CSV saying so.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
templateId | string | yes | From the catalog. |
orgId | string | yes | In scope. |
slots | object of strings | per template | Same as run a template. |
format | csv xlsx pdf json | yes | Checked first, before anything else. |
delivery | auto or s3 | no | auto (default) returns the file inline when it is 1 MiB (1,048,576 bytes) or smaller and a signed link when larger. s3 always returns a signed link. |
{ "templateId": "wsw-007", "orgId": "00D000000000000EAA", "slots": { "target": "0011a00000AbCdeAAC" }, "format": "xlsx" }
Response 200, one of three envelopes. Detect the shape by which keys are present.
Inline binary (csv, xlsx, pdf, at or under 1 MiB):
{ "ok": true, "format": "xlsx", "contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "encoding": "base64", "data": "<base64>", "bytes": 48213, "correlationId": "myapp-..." }
Inline JSON (format: "json", never base64):
{ "ok": true, "format": "json", "report": { "templateId": "wsw-007", "verdict": "read", "summary": "...", "population": [] }, "correlationId": "myapp-..." }
Signed link (any format over 1 MiB, or delivery: "s3"):
{ "ok": true, "format": "pdf", "url": "https://<bucket>.s3.amazonaws.com/...", "expiresAt": "2026-11-03T13:30:00.000Z", "bytes": 2400000, "s3Key": "reports/00D000000000000EAA/....pdf", "correlationId": "myapp-..." }
| Field | Notes |
|---|---|
contentType | text/csv; charset=utf-8, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/pdf, or application/json. |
data | Base64 of the file bytes. |
report | The answer object, same shape as run a template returns. |
url | Download it with a plain GET, no auth header. Valid for 15 minutes (900 seconds) from issuance; expiresAt is the exact instant. Request the export again after that. |
bytes | File size. |
Errors:
400 invalid_inputunknown format "<value>"(checked first).400 invalid_inputunknown template "<value>".403 forbiddenfor org scope and live edition, exactly as run a template.- Everything else from run a template’s ordered list arrives as a
200rendered in the requested format; the JSON format carries the samesummaryandnotesfields, so classify a JSON report with the sameclassifyAnswerhelper used for a run.
Node:
import { writeFile } from 'node:fs/promises';
const out = await wsw(token, 'POST', '/reports', {
body: { templateId: 'wsw-007', orgId, slots: { target }, format: 'xlsx' },
});
if ('data' in out) {
await writeFile('who-can-see.xlsx', Buffer.from(out.data, 'base64'));
} else if ('report' in out) {
console.log(out.report.summary);
} else {
const file = await fetch(out.url); // no Authorization header on the signed link
if (!file.ok) throw new Error(`download failed ${file.status}`);
await writeFile('who-can-see.xlsx', Buffer.from(await file.arrayBuffer()));
}
Apex: keep the inline path under the heap limit; ask for delivery: 's3' when a report may be large, then download the link in a second callout and store it as a file.
Map<String, Object> out = (Map<String, Object>) wsw.post('/reports', new Map<String, Object>{
'templateId' => 'wsw-007', 'orgId' => orgId,
'slots' => new Map<String, Object>{ 'target' => target }, 'format' => 'csv'
});
Blob fileBody;
if (out.containsKey('data')) {
fileBody = EncodingUtil.base64Decode((String) out.get('data'));
} else if (out.containsKey('report')) {
System.debug(((Map<String, Object>) out.get('report')).get('summary'));
} else {
HttpRequest dl = new HttpRequest();
dl.setEndpoint((String) out.get('url')); // the bucket host needs its own Remote Site Setting
dl.setMethod('GET');
dl.setTimeout(120000);
HttpResponse f = new Http().send(dl);
if (f.getStatusCode() != 200) throw new WhoSeesWhatClient.WswException('download failed ' + f.getStatusCode());
fileBody = f.getBodyAsBlob();
}
if (fileBody != null) {
insert new ContentVersion(Title = 'Who can see ' + target, PathOnClient = 'who-can-see.csv', VersionData = fileBody);
}
Scheduled report exports
A scheduled report export re-runs a template on a cron schedule and delivers the rendered result by email or to a webhook you host, signed so you can verify it came from Who Sees What.
List scheduled report exports
GET /v1/whoseeswhat/schedules?orgId=<orgId>
| Parameter | Required |
|---|---|
orgId | yes |
Response 200:
{
"schedules": [
{
"id": "7c0f1e2d-3b4a-4c5d-8e9f-0a1b2c3d4e5f",
"templateId": "wsw-007",
"slots": { "target": "0011a00000AbCdeAAC" },
"format": "csv",
"cron": "0 6 * * 1",
"tz": "America/New_York",
"sink": { "type": "email", "to": "security@example.com" },
"enabled": true,
"nextRunAt": "2026-11-09T11:00:00.000Z",
"createdAt": "2026-11-03T12:00:00.000Z"
}
]
}
| Field | Notes |
|---|---|
id | A UUID. |
sink | { "type": "email", "to": "<address>" } or { "type": "webhook", "url": "<https URL>" }. A webhook sink also carries "secret": "[sealed]", a fixed placeholder; the value itself is never returned. |
enabled | A schedule the server disabled (for example one created before the missing-slot check) shows false. |
nextRunAt, createdAt | ISO 8601, UTC. |
Responses may also carry a jobKind field that is not part of the published shape; ignore it.
Errors: 403 forbidden Listing scheduled report exports via the API or MCP requires the WSW Enterprise edition.; 403 forbidden Not authorized for org <orgId>.
Node:
const { schedules } = await wsw(token, 'GET', '/schedules', { query: { orgId } });
for (const s of schedules) console.log(s.id, s.templateId, s.cron, s.tz, s.sink.type, s.enabled ? 'on' : 'OFF', s.nextRunAt);
Apex:
Map<String, Object> body = (Map<String, Object>) wsw.get('/schedules', new Map<String, String>{ 'orgId' => orgId });
for (Object o : (List<Object>) body.get('schedules')) {
Map<String, Object> s = (Map<String, Object>) o;
System.debug(s.get('id') + ' ' + s.get('templateId') + ' ' + s.get('cron') + ' next ' + s.get('nextRunAt'));
}
Create a scheduled report export
POST /v1/whoseeswhat/schedules
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
orgId | string | yes | In scope. |
templateId | string | yes | From the catalog. |
slots | object of strings | when the template has required slots | Checked at creation so the schedule cannot fail every run. |
format | csv xlsx pdf json | yes | |
cron | string | yes | Standard 5-field cron (minute hour day-of-month month day-of-week), evaluated in tz. Must have a next occurrence. |
tz | string | yes | An IANA zone name, for example America/New_York or UTC. |
sink | object | yes | { "type": "email", "to": "<address>" }, or { "type": "webhook", "url": "<https URL>", "secret": "<your HMAC secret>" }. The secret is sealed at rest; every later read shows it as the placeholder [sealed], never the value. |
{
"orgId": "00D000000000000EAA", "templateId": "wsw-007", "slots": { "target": "0011a00000AbCdeAAC" },
"format": "json", "cron": "0 6 * * 1", "tz": "America/New_York",
"sink": { "type": "webhook", "url": "https://hooks.example.com/wsw", "secret": "a-long-random-string" }
}
Response 200: { "schedule": { ... } }, the same shape as one entry of the list response.
Errors, in the order checked. Input mistakes are 400 invalid_input with the message being exactly the code shown:
403 forbiddenCreating a scheduled report export via the API or MCP requires the WSW Enterprise edition.403 forbiddenNot authorized for org <orgId>.400unknown_template400missing_slots: <token>, <token>400invalid_cron(bad syntax, or no next occurrence)400invalid_timezone400invalid_format400invalid_email(an email sink with a malformed address),invalid_webhook_target(a webhookurlthat is nothttps, or that resolves to a private, loopback, or link-local address), orinvalid_sink(atypeother thanemailorwebhook).502 upstream_errorwith the fixed messageThe upstream service failed to handle the request.when webhook secret sealing is temporarily unavailable on the server (a server configuration gap, not your mistake; the reasonwebhook_sink_unavailableis on the server’s audit record only): retry later.
Node:
try {
const { schedule } = await wsw(token, 'POST', '/schedules', {
body: {
orgId, templateId: 'wsw-007', slots: { target }, format: 'json',
cron: '0 6 * * 1', tz: 'America/New_York',
sink: { type: 'webhook', url: 'https://hooks.example.com/wsw', secret: process.env.WSW_WEBHOOK_SECRET },
},
});
console.log('created', schedule.id, 'next run', schedule.nextRunAt);
} catch (e) {
if (e instanceof WswError && e.status === 400) console.error('fix the request:', e.message); // e.g. "400 invalid_input: invalid_cron"
else throw e;
}
Apex:
Map<String, Object> created = (Map<String, Object>) wsw.post('/schedules', new Map<String, Object>{
'orgId' => orgId, 'templateId' => 'wsw-007',
'slots' => new Map<String, Object>{ 'target' => target },
'format' => 'csv', 'cron' => '0 6 * * 1', 'tz' => 'America/New_York',
'sink' => new Map<String, Object>{ 'type' => 'email', 'to' => 'security@example.com' }
});
Map<String, Object> schedule = (Map<String, Object>) created.get('schedule');
System.debug('created ' + schedule.get('id') + ' next ' + schedule.get('nextRunAt'));
What a delivery looks like
On each cron occurrence in tz, the server runs the template exactly as run a template does, renders the result in format, stores the artifact, and delivers it.
Email sink. From reports@whoseeswhat.com, subject Your WhoSeesWhat scheduled report, plain-text body: the run’s one-line summary, a blank line, then a signed download link to the artifact that is valid for 15 minutes.
Webhook sink. One POST to your url:
| Header | Value |
|---|---|
content-type | application/json |
x-wsw-timestamp | Unix seconds at signing time. |
x-wsw-signature | sha256=<hex>, where hex is HMAC-SHA256 with your secret over the UTF-8 bytes of <x-wsw-timestamp>.<raw request body>. |
The body is the rendered JSON report itself (the same object POST /reports returns under report) when format is json and the artifact is 256 KiB (262,144 bytes) or smaller. Otherwise the body is:
{ "artifactUrl": "https://<bucket>.s3.amazonaws.com/...", "summary": "12 of 40 users can see Acme Corp." }
artifactUrl is a signed link valid for 15 minutes. Your endpoint must answer a 2xx within 10 seconds; redirects are not followed and count as failure. Compute the signature over the raw bytes you received, before any JSON parsing or re-serialization, and compare with a constant-time comparison. Reject a timestamp more than a few minutes from your clock to defeat replay.
Node receiver (bare http, no framework):
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.WSW_WEBHOOK_SECRET;
function verify(rawBody, timestamp, signature) {
if (!timestamp || !signature?.startsWith('sha256=')) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // 5 minute replay window
const expected = createHmac('sha256', SECRET).update(`${timestamp}.`).update(rawBody).digest('hex');
const given = signature.slice('sha256='.length);
const expectedBuf = Buffer.from(expected, 'hex');
const givenBuf = Buffer.from(given, 'hex'); // a non-hex header decodes shorter: compare the buffers, not the strings
return expectedBuf.length === givenBuf.length && timingSafeEqual(expectedBuf, givenBuf);
}
createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
try {
const raw = Buffer.concat(chunks);
if (!verify(raw, req.headers['x-wsw-timestamp'], req.headers['x-wsw-signature'])) {
res.writeHead(401).end();
return;
}
const payload = JSON.parse(raw.toString('utf8'));
if (payload.artifactUrl) console.log('fetch within 15 min:', payload.artifactUrl, payload.summary);
else console.log('inline report:', payload.summary);
res.writeHead(204).end(); // answer fast; process after
} catch (err) {
console.error('webhook rejected', err); // a bad body or header must not crash the receiver
if (!res.headersSent) res.writeHead(400).end();
}
});
}).listen(8080);
If you use Express, mount the route with express.raw({ type: 'application/json' }) so req.body is the raw Buffer; express.json() would re-serialize and break the signature.
Apex receiver. Expose an Apex REST class through a Site or Experience Cloud guest user (the webhook carries no Salesforce credential), store the secret in a Custom Metadata or Custom Setting, and verify with Crypto.generateMac:
@RestResource(urlMapping='/wsw/webhook')
global with sharing class WswWebhook {
@HttpPost
global static void receive() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;
Blob raw = req.requestBody; // the raw bytes, exactly as sent
String ts = req.headers.get('x-wsw-timestamp');
String sig = req.headers.get('x-wsw-signature');
String secret = Wsw_Settings__c.getOrgDefaults().Webhook_Secret__c;
Boolean ok = ts != null && sig != null && sig.startsWith('sha256=');
if (ok) {
Long skew = Math.abs(DateTime.now().getTime() / 1000 - Long.valueOf(ts));
ok = skew <= 300;
}
if (ok) {
Blob signedInput = Blob.valueOf(ts + '.');
// HMAC over (ts + '.' + body bytes): concatenate the two byte strings before signing
String hexBody = EncodingUtil.convertToHex(raw);
Blob combined = EncodingUtil.convertFromHex(EncodingUtil.convertToHex(signedInput) + hexBody);
String expected = 'sha256=' + EncodingUtil.convertToHex(Crypto.generateMac('hmacSHA256', combined, Blob.valueOf(secret)));
ok = expected == sig; // Apex String equality is constant time enough here; the secret never leaves the org
}
if (!ok) { res.statusCode = 401; return; }
Map<String, Object> payload = (Map<String, Object>) JSON.deserializeUntyped(raw.toString());
if (payload.containsKey('artifactUrl')) {
// enqueue a Queueable to download artifactUrl within 15 minutes; do not call out here
System.enqueueJob(new WswArtifactFetch((String) payload.get('artifactUrl'), (String) payload.get('summary')));
} else {
System.debug('inline report: ' + payload.get('summary'));
}
res.statusCode = 204;
}
}
// The Queueable the receiver enqueues: downloads the signed artifact link outside the inbound request.
// Add the artifact host (https://whoseeswhat.com and its storage host) to Remote Site Settings or a Named Credential.
public class WswArtifactFetch implements Queueable, Database.AllowsCallouts {
private final String artifactUrl;
private final String summary;
public WswArtifactFetch(String artifactUrl, String summary) {
this.artifactUrl = artifactUrl;
this.summary = summary;
}
public void execute(QueueableContext ctx) {
HttpRequest req = new HttpRequest();
req.setEndpoint(artifactUrl); // the link is pre-signed; no Authorization header
req.setMethod('GET');
req.setTimeout(120000);
HttpResponse res = new Http().send(req);
if (res.getStatusCode() != 200) {
throw new CalloutException('artifact fetch failed: HTTP ' + res.getStatusCode());
}
// res.getBodyAsBlob() is the report (JSON or CSV, by the schedule's format); store it where your process needs it
System.debug(summary + ' (' + res.getBodyAsBlob().size() + ' bytes)');
}
}
Cross-org compare
Compare two orgs
POST /v1/whoseeswhat/compare/orgs
Compares the access model of one connected org against another and reports what was added, removed, or changed. Both orgs must hold active seats under the same billing account (checked live on every call); orgId must be in the credential’s scope, otherOrgId need not be (its snapshot is only read).
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
orgId | string | yes | The requesting org, in scope. |
otherOrgId | string | yes | The org to compare against. |
Response 200:
{
"id": "9e1f6c2a-...",
"otherOrgId": "00D60000000OtherAAC",
"snapshotA": "snap-...",
"snapshotB": "snap-...",
"summary": {
"addedCount": 3, "removedCount": 1, "changedCount": 5, "totalChanges": 9,
"byEntryKind": { "PermissionSet": { "added": 2, "removed": 0, "changed": 1 }, "Profile": { "added": 1, "removed": 1, "changed": 4 } },
"changes": [
{ "change": "added", "entryKind": "PermissionSet", "key": "Sales_Manager", "after": { "assigneeCount": 4 } },
{ "change": "changed", "entryKind": "Profile", "key": "Standard User", "before": { "viewAllData": false }, "after": { "viewAllData": true } }
],
"truncated": false
}
}
| Field | Notes |
|---|---|
id | The comparison run id. |
snapshotA, snapshotB | Ids of the two saved snapshots (orgId and otherOrgId respectively). |
summary.addedCount, removedCount, changedCount, totalChanges | Exact counts over the whole diff. |
summary.byEntryKind | Exact per-kind counts. |
summary.changes[] | { change: "added" or "removed" or "changed", entryKind, key, before?, after? }. On this endpoint changes is a sample of at most 50 entries, spread across entry kinds, with long before and after values clipped. |
summary.changesShown, summary.note | Present when the diff is larger than the sample: note reads Showing <n> of <total> changed entries, sampled across entry kinds. Exact counts are in addedCount, removedCount, changedCount and byEntryKind. ... |
summary.truncated | true when the underlying diff itself was cut short (a very large org pair). |
Use the counts for totals and the sample for examples; the complete change list is available in the Who Sees What app’s Compare view for the same run.
Errors:
403 forbiddenCross-org compare via the API or MCP requires the WSW Enterprise edition.403 forbiddenNot authorized for org <orgId>.400 invalid_inputotherOrgId is required400 invalid_inputcannot compare an org against itself403 forbiddenThis org is not eligible to compare against the requested org.(not the same billing account, or no active seat)403 forbiddenNo connected Salesforce session for org <otherOrgId>.then the same for<orgId>.
Node:
const cmp = await wsw(token, 'POST', '/compare/orgs', { body: { orgId, otherOrgId } });
const s = cmp.summary;
console.log(`${s.totalChanges} differences (+${s.addedCount} -${s.removedCount} ~${s.changedCount})`);
for (const [kind, c] of Object.entries(s.byEntryKind)) console.log(kind, c);
if (s.note) console.log(s.note);
for (const ch of s.changes) console.log(ch.change, ch.entryKind, ch.key);
Apex:
Map<String, Object> cmp = (Map<String, Object>) wsw.post('/compare/orgs',
new Map<String, Object>{ 'orgId' => orgId, 'otherOrgId' => otherOrgId });
Map<String, Object> s = (Map<String, Object>) cmp.get('summary');
System.debug(s.get('totalChanges') + ' differences');
for (Object o : (List<Object>) s.get('changes')) {
Map<String, Object> ch = (Map<String, Object>) o;
System.debug(ch.get('change') + ' ' + ch.get('entryKind') + ' ' + ch.get('key'));
}
Admin setup
Install the auditor permission set
POST /v1/whoseeswhat/admin/auditor-permission-set
Installs or updates the least-privilege Who Sees What auditor permission set in the connected org and optionally assigns it to an existing user, so you can then connect Who Sees What as that user instead of an admin. This is the one endpoint that writes to your Salesforce org’s configuration. The connected user needs Customize Application.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
orgId | string | yes | In scope. |
assignToUser | string | no | An existing user’s 15 or 18 character Id, or their username. Never creates a user. |
Response 200, always; read the booleans and message:
{ "permissionSet": "WSW_Auditor", "installed": true, "created": false, "assigned": true, "alreadyAssigned": false, "message": "Permission set updated and assigned to jane@example.com. Now connect Who Sees What as that user to run least-privilege." }
| Field | Notes |
|---|---|
installed | false when the install failed; message then reads Install failed: <reasons>. The connected user needs admin (Customize Application) for this one-time setup. |
created | true if newly created, false if it existed and was updated. |
assigned, alreadyAssigned | Present when assignToUser was given. |
message | Without assignToUser: Permission set <created or updated>. To finish, assign it to your integration user (re-run with assignToUser), then connect Who Sees What as that user. With it: Permission set <created or updated> and <assigned to or already assigned to> <user>. Now connect Who Sees What as that user to run least-privilege. |
Not connected: 200 with installed: false and a message saying to connect the org first.
Errors: 400 invalid_input No user found with username "<value>". Pass an existing user's Id or username.; 403 for scope and edition as elsewhere.
Node:
const setup = await wsw(token, 'POST', '/admin/auditor-permission-set', { body: { orgId, assignToUser: 'audit.bot@example.com' } });
if (!setup.installed) throw new Error(setup.message);
console.log(setup.message);
Apex:
Map<String, Object> setup = (Map<String, Object>) wsw.post('/admin/auditor-permission-set',
new Map<String, Object>{ 'orgId' => orgId, 'assignToUser' => 'audit.bot@example.com' });
if (setup.get('installed') != true) throw new WhoSeesWhatClient.WswException((String) setup.get('message'));
System.debug(setup.get('message'));
Appendix A: template catalog
The catalog GET /templates returns today. tier trial and pro are both runnable on Enterprise. Slot types tell you which value format to pass.
| templateId | Question | Family | Dimension | Direction | Tier | Slots (token: type) |
|---|---|---|---|---|---|---|
| wsw-001 | Why can’t {subject} see {target}? | A | record_read | deny | trial | subject: user; target: record |
| wsw-002 | Why can {subject} see {target} but {subject2} can’t? | C | record_read | comparison | trial | subject: user; subject2: user; target: record |
| wsw-003 | Why can’t {subject} see the {target} field? | A | field_read | deny | trial | subject: user; target: field |
| wsw-004 | Why can’t {subject} see or edit {target}? | A | object_access | deny | trial | subject: user; target: object or tab_app |
| wsw-005 | Why can’t anyone see {target}? | A | record_read | deny | trial | target: record |
| wsw-006 | Why CAN {subject} see {target}? | B | record_read | grant | trial | subject: user; target: record |
| wsw-007 | Who can see this record: {target}? | D | record_read | population | trial | target: record |
| wsw-008 | Who can edit {target}? | D | record_edit | population | pro | target: record |
| wsw-009 | Who can see this object or field: {target}? | D | field_read | population | trial | target: field or object |
| wsw-010 | Who has {permission}? | E | system_perm | privilege | trial | permission: system_perm or object_perm |
| wsw-011 | If I add {subject} to {target}, what do they gain? | F | record_read | simulation | pro | subject: user; target: role, group, or profile_permset |
| wsw-012 | If I change {target}, who is affected? | F | record_read | simulation | pro | target: object |
| wsw-013 | What changed in {subject}‘s access to {target} since {since}? | G | record_read | temporal | pro | subject: user; target: record; since: date or change_event |
| wsw-014 | Who holds privileged, MFA-required permissions? | E | system_perm | privilege | trial | none |
| wsw-015 | Who can edit this object: {target}? | D | object_edit | population | pro | target: object |
| wsw-016 | Who can edit this field: {target}? | D | field_edit | population | pro | target: field |
| wsw-017 | What changed in this org’s access model since {since}? | G | object_access | temporal | pro | since: date |
| wsw-018 | How does {org2}‘s access model differ from this org’s? | C | object_access | comparison | pro | org2: org (coming soon) |
Appendix B: slot value formats
Every slot value is a string. The evaluator does not resolve names; pass identifiers, and use the slot search endpoints to find them.
| Slot type | Pass | Example | Where to get it |
|---|---|---|---|
user (subject, subject2) | A 15 or 18 character User Id. A name is refused by the evaluator (502 upstream_error). | 0051a0000012b3rAAC | GET /users |
record (target) | A 15 or 18 character record Id. | 0011a00000AbCdeAAC | GET /records |
object (target) | The object API name. | Account, Custom_Obj__c | GET /objects |
field (target) | Object.Field API names, split on the first dot, so namespaced names work. | Account.AnnualRevenue, ns__Obj__c.ns__Field__c | GET /objects for the object; your org’s field API names |
role, group, profile_permset (target on wsw-011) | The Id of the Role, Public Group, Profile, or Permission Set. | 00E1a000000AbCdEAM | Your org’s setup data |
system_perm (permission) | One of these labels, case-insensitive, or its raw Permissions* field name: View All Data, Modify All Data, Export Reports, Manage Users, Author Apex, Customize Application, Manage Sharing, API Enabled, Manage Reports in Public Folders. Anything else is refused by the evaluator (502 upstream_error). | Modify All Data or PermissionsModifyAllData | this table |
date (since) | YYYY-MM-DD, optionally with a time (YYYY-MM-DDTHH:MM:SSZ). Anything else is refused by the evaluator (502 upstream_error). | 2026-10-01 | your choice |
org (org2) | Another connected org’s 18 character Id. | 00D60000000OtherAAC | GET /orgs |
A value that fails evaluator parsing surfaces as 502 upstream_error with the fixed message The upstream service failed to handle the request. (the specific reason is written to the server’s audit record under your correlation id); a missing required slot surfaces as the 200 Missing required slot(s) answer.
Appendix C: other Digadop APIs on this host
The same gateway, host, bearer credential, conventions, error envelope, and rate limits also serve three Digadop Help audience surfaces for an Enterprise credential: /v1/digadop-knowledge (knowledge articles and their audience), /v1/digadop-cms (CMS content and its audience), and /v1/digadop-experience-cloud (Experience Cloud members, records, and guest visibility). They are documented with Digadop Help, not here; they are listed so a 404 on a path outside /v1/whoseeswhat is not mistaken for a Who Sees What problem.
Notes
- Who Sees What scoping is enforced by the backend: a call never exceeds the human caller’s own Salesforce access. Install the auditor permission set is the setup call that writes to your org: it writes the named permission set and, if asked, assigns it to an existing user.
- Who Sees What keeps its own records: every call, including a refused one, adds an audit record and a usage record in Who Sees What’s own systems. A report delivered as a signed link, and a comparison’s two snapshots, are stored in Who Sees What’s own storage. None of this changes your Salesforce org.
- Every call that reaches the evaluator is metered against your plan, including one that ends in a
502from the per-call deadline or an evaluator refusal. The only unmetered502is a server output-validation defect (a response that would have violated the documented schema).