Skip to content
Who Sees What

‹ API overview

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

  1. Request anatomy
  2. Response anatomy
  3. Errors
  4. Credentials
  5. Reusable clients: Node and Apex
  6. Endpoint index
  7. Orgs: list, connect, poll a connection
  8. Templates: list, run
  9. Slot search: users, objects, records
  10. Reports: export
  11. Scheduled report exports: list, create, deliveries, webhook verification
  12. Cross-org compare
  13. Admin setup
  14. Appendix A: template catalog
  15. Appendix B: slot value formats
  16. 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.

HeaderRequiredValue
AuthorizationyesBearer <credential>. See Credentials.
Content-Typeon POSTapplication/json.
x-correlation-idnoYour 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.

  • GET endpoints take every parameter as a query string value (?orgId=...&q=...). URL-encode values.
  • POST endpoints take a JSON object body. The body is capped at 64 KiB (65,536 bytes); a larger body is refused with 413 and the connection is closed.
  • A path parameter (:templateId in /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:

HeaderMeaning
x-correlation-idThe 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-AfterOnly on 429 and some 503 responses: the number of seconds to wait before retrying.
Strict-Transport-SecuritySet 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

HTTPerror.codeWhenMessage
400invalid_inputA parameter failed the endpoint’s validation.Names the problem; the fixed strings are listed per endpoint below.
401unauthorizedNo 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).
403forbiddenThe 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.
404not_foundNo such route.no route for <METHOD> <path>
405method_not_allowedThe path exists under a different method.<METHOD> not allowed on <path>
413invalid_inputThe JSON body exceeded 64 KiB.request body too large
429rate_limitedMore 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
502upstream_errorThe backend call failed, timed out, or the server’s own output failed validation. Not your request’s fault.Varies.
503auth_unavailableCredential 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 orgId against that scope and refuses any other org with 403 Not authorized for org <orgId>.
  • Edition is captured at creation and re-checked live. If the org later drops below Enterprise, calls fail with 403 naming the edition; if the credential was created when the org held a higher entitlement than it holds now, the template list refuses with This 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 pathWhat it doesReads your org?
GET /v1/whoseeswhat/orgsList the orgs in the credential’s scope and whether each is connected.no
POST /v1/whoseeswhat/connect/orgStart connecting a Salesforce org (device code flow).no
POST /v1/whoseeswhat/connect/org/statusPoll a connection attempt.no
GET /v1/whoseeswhat/templatesList the audit templates (questions) and their slots.no
POST /v1/whoseeswhat/templates/:templateId/runsRun a template and get the explained answer.yes
GET /v1/whoseeswhat/usersSearch users to fill a {subject} slot.yes
GET /v1/whoseeswhat/objectsSearch objects to fill a {target} object slot.yes
GET /v1/whoseeswhat/recordsSearch records by name to fill a {target} record slot.yes
POST /v1/whoseeswhat/reportsRun a template and get the result as CSV, XLSX, PDF, or JSON.yes
GET /v1/whoseeswhat/schedulesList scheduled report exports.no
POST /v1/whoseeswhat/schedulesCreate a scheduled report export (email or signed webhook).no
POST /v1/whoseeswhat/compare/orgsCompare one org’s access model against another.yes, both
POST /v1/whoseeswhat/admin/auditor-permission-setInstall 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" }
  ]
}
FieldTypeNotes
orgs[].orgIdstringThe 18 character org id to pass as orgId everywhere else.
orgs[].connectedbooleanfalse means Who Sees What holds no Salesforce session for it; run the connect flow.
orgs[].instanceUrlstring or nullThe org’s My Domain URL, when connected.
orgs[].environmentstring or nullproduction 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:

FieldTypeRequiredNotes
environment"production" or "sandbox"noWhich 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."
}
FieldTypeNotes
statusstringawaiting_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.
userCodestringShow this to the human.
verificationUristringWhere the human enters the code.
verificationUriCompletestringSame URL with the code pre-filled, when Salesforce supplies it.
expiresInSecondsnumberHow long the code is valid.
pollAfterSecondsnumberMinimum seconds between status polls (Salesforce’s interval; 5 when not given).
handlestringPass to the status endpoint.
messagestringAlways 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:

FieldTypeRequiredNotes
handlestringyesThe 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)." }
FieldTypeNotes
statusstringpending, connected, expired, or error.
orgId, orgNamestringPresent on connected.
inCallerScopebooleanPresent 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.
messagestringAlways 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 }
    ]
  }
]
FieldTypeNotes
templateIdstringwsw-001 through wsw-018. Pass it to run, export, and create a schedule.
familyA B C D E F GEvaluation family: A deny diagnosis, B grant diagnosis, C comparison, D population, E privilege scan, F simulation, G temporal.
questionPatternstringThe question with {token} placeholders.
accessDimensionstringOne of record_read, record_edit, field_read, field_edit, object_read, object_edit, object_access, system_perm.
directionstringOne of deny, grant, population, comparison, privilege, simulation, temporal.
tiertrial paid pro lynceonThe 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.
comingSoonboolean, optionalWhen true, running it returns a fixed coming-soon answer instead of an evaluation. Today only wsw-018.
slots[]arrayOne 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:

FieldTypeRequiredNotes
orgIdstringyesMust be in the credential’s scope.
slotsobject of string valueswhen the template has required slotsKeys 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": []
}
FieldTypePresentNotes
templateIdstringalwaysEcho of the template.
familystringalwaysAs in the catalog.
questionstringalwaysThe pattern with slot values filled in (names resolved where possible).
verdictnone read edit fullalwaysThe headline access level. none is also the value of every short-circuit answer listed below.
summarystringalwaysOne-line human answer. Always read it.
bindingFactorstringfamilies A, B, CThe single deciding layer.
fullPathstring arrayfamilies A, B, CEvery blocker (deny) or every grant (grant), in order.
evidencearray of {label, value}alwaysNamed metadata backing the answer (empty on a short-circuit).
populationarrayfamily DOne row per user: { userId, name, isActive, canSee, accessLevel?, bindingFactor?, fullPath: [], profileName? }.
countsobjectfamily D{ canSee, total, inactive? }.
notesstring arrayalwaysExtra context; short-circuit answers put the detail here (for example required slots: subject (user), target (record)).
caveatsstring arrayalwaysCorrectness caveats. The evaluator never asserts a wrong reason; when it cannot be sure, it says so here.
comingSoonbooleancoming-soon templatestrue 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):

  1. Unknown templateId: 400 invalid_input, unknown template "<id>".
  2. The credential’s edition is below Enterprise: 200, summary Running audits via the API or MCP requires the WSW Enterprise edition., notes ["your edition tier: <tier>"].
  3. orgId outside the credential’s scope: 403 forbidden, Not authorized for org <orgId>.
  4. 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).
  5. A required slot missing: 200, summary Missing required slot(s): <token>, <token>., notes ["required slots: subject (user), target (record)"].
  6. The template’s own tier is above the credential’s: 200, summary This template requires the <tier> tier.
  7. The template is comingSoon: 200, the placeholder answer with comingSoon: true and the caveat Cross-org compare is coming soon; it is built and in final acceptance. (for wsw-018).
  8. No Salesforce session for the org: 200, summary No connected Salesforce org for <orgId>. Connect an org in whoseeswhat first.
  9. 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: 502 upstream_error with the fixed message The 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');
}

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>

ParameterRequiredNotes
orgIdyes
qnoSubstring 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>

ParameterRequiredNotes
orgIdyes
qnoCase-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>

ParameterRequiredNotes
orgIdyes
objectyesAn object API name: letters, digits, and underscores only.
qnoSubstring 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:

FieldTypeRequiredNotes
templateIdstringyesFrom the catalog.
orgIdstringyesIn scope.
slotsobject of stringsper templateSame as run a template.
formatcsv xlsx pdf jsonyesChecked first, before anything else.
deliveryauto or s3noauto (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-..." }
FieldNotes
contentTypetext/csv; charset=utf-8, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/pdf, or application/json.
dataBase64 of the file bytes.
reportThe answer object, same shape as run a template returns.
urlDownload 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.
bytesFile size.

Errors:

  • 400 invalid_input unknown format "<value>" (checked first).
  • 400 invalid_input unknown template "<value>".
  • 403 forbidden for org scope and live edition, exactly as run a template.
  • Everything else from run a template’s ordered list arrives as a 200 rendered in the requested format; the JSON format carries the same summary and notes fields, so classify a JSON report with the same classifyAnswer helper 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>

ParameterRequired
orgIdyes

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"
    }
  ]
}
FieldNotes
idA 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.
enabledA schedule the server disabled (for example one created before the missing-slot check) shows false.
nextRunAt, createdAtISO 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:

FieldTypeRequiredNotes
orgIdstringyesIn scope.
templateIdstringyesFrom the catalog.
slotsobject of stringswhen the template has required slotsChecked at creation so the schedule cannot fail every run.
formatcsv xlsx pdf jsonyes
cronstringyesStandard 5-field cron (minute hour day-of-month month day-of-week), evaluated in tz. Must have a next occurrence.
tzstringyesAn IANA zone name, for example America/New_York or UTC.
sinkobjectyes{ "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:

  1. 403 forbidden Creating a scheduled report export via the API or MCP requires the WSW Enterprise edition.
  2. 403 forbidden Not authorized for org <orgId>.
  3. 400 unknown_template
  4. 400 missing_slots: <token>, <token>
  5. 400 invalid_cron (bad syntax, or no next occurrence)
  6. 400 invalid_timezone
  7. 400 invalid_format
  8. 400 invalid_email (an email sink with a malformed address), invalid_webhook_target (a webhook url that is not https, or that resolves to a private, loopback, or link-local address), or invalid_sink (a type other than email or webhook).
  9. 502 upstream_error with the fixed message The 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 reason webhook_sink_unavailable is 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:

HeaderValue
content-typeapplication/json
x-wsw-timestampUnix seconds at signing time.
x-wsw-signaturesha256=<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:

FieldTypeRequiredNotes
orgIdstringyesThe requesting org, in scope.
otherOrgIdstringyesThe 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
  }
}
FieldNotes
idThe comparison run id.
snapshotA, snapshotBIds of the two saved snapshots (orgId and otherOrgId respectively).
summary.addedCount, removedCount, changedCount, totalChangesExact counts over the whole diff.
summary.byEntryKindExact 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.notePresent 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.truncatedtrue 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 forbidden Cross-org compare via the API or MCP requires the WSW Enterprise edition.
  • 403 forbidden Not authorized for org <orgId>.
  • 400 invalid_input otherOrgId is required
  • 400 invalid_input cannot compare an org against itself
  • 403 forbidden This org is not eligible to compare against the requested org. (not the same billing account, or no active seat)
  • 403 forbidden No 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:

FieldTypeRequiredNotes
orgIdstringyesIn scope.
assignToUserstringnoAn 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." }
FieldNotes
installedfalse when the install failed; message then reads Install failed: <reasons>. The connected user needs admin (Customize Application) for this one-time setup.
createdtrue if newly created, false if it existed and was updated.
assigned, alreadyAssignedPresent when assignToUser was given.
messageWithout 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.

templateIdQuestionFamilyDimensionDirectionTierSlots (token: type)
wsw-001Why can’t {subject} see {target}?Arecord_readdenytrialsubject: user; target: record
wsw-002Why can {subject} see {target} but {subject2} can’t?Crecord_readcomparisontrialsubject: user; subject2: user; target: record
wsw-003Why can’t {subject} see the {target} field?Afield_readdenytrialsubject: user; target: field
wsw-004Why can’t {subject} see or edit {target}?Aobject_accessdenytrialsubject: user; target: object or tab_app
wsw-005Why can’t anyone see {target}?Arecord_readdenytrialtarget: record
wsw-006Why CAN {subject} see {target}?Brecord_readgranttrialsubject: user; target: record
wsw-007Who can see this record: {target}?Drecord_readpopulationtrialtarget: record
wsw-008Who can edit {target}?Drecord_editpopulationprotarget: record
wsw-009Who can see this object or field: {target}?Dfield_readpopulationtrialtarget: field or object
wsw-010Who has {permission}?Esystem_permprivilegetrialpermission: system_perm or object_perm
wsw-011If I add {subject} to {target}, what do they gain?Frecord_readsimulationprosubject: user; target: role, group, or profile_permset
wsw-012If I change {target}, who is affected?Frecord_readsimulationprotarget: object
wsw-013What changed in {subject}‘s access to {target} since {since}?Grecord_readtemporalprosubject: user; target: record; since: date or change_event
wsw-014Who holds privileged, MFA-required permissions?Esystem_permprivilegetrialnone
wsw-015Who can edit this object: {target}?Dobject_editpopulationprotarget: object
wsw-016Who can edit this field: {target}?Dfield_editpopulationprotarget: field
wsw-017What changed in this org’s access model since {since}?Gobject_accesstemporalprosince: date
wsw-018How does {org2}‘s access model differ from this org’s?Cobject_accesscomparisonproorg2: 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 typePassExampleWhere to get it
user (subject, subject2)A 15 or 18 character User Id. A name is refused by the evaluator (502 upstream_error).0051a0000012b3rAACGET /users
record (target)A 15 or 18 character record Id.0011a00000AbCdeAACGET /records
object (target)The object API name.Account, Custom_Obj__cGET /objects
field (target)Object.Field API names, split on the first dot, so namespaced names work.Account.AnnualRevenue, ns__Obj__c.ns__Field__cGET /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.00E1a000000AbCdEAMYour 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 PermissionsModifyAllDatathis 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-01your choice
org (org2)Another connected org’s 18 character Id.00D60000000OtherAACGET /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 502 from the per-call deadline or an evaluator refusal. The only unmetered 502 is a server output-validation defect (a response that would have violated the documented schema).