Merit AC
Setup guide Same contract as the Python example

Node setup

Merit AC's ingestion API is plain JSON over HTTPS, so a Node proxy follows the exact same shape as the Python reference — issue each employee their own key, forward the call, read the token usage off the response, and fire a usage event at Merit AC before returning.

The request that matters

const usage = upstreamResponse.usage;
const costUsd = price(model, usage.input_tokens, usage.output_tokens);

await fetch(`${MERIT_API_BASE}/ingest/usage`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${process.env.MERIT_INGEST_TOKEN}`,
  },
  body: JSON.stringify({
    source_system: "anthropic_api",
    external_id: externalIdForKey(proxyKey),
    tool: "anthropic_api",
    model,
    cost_usd: Number(costUsd.toFixed(6)),
    tokens_in: usage.input_tokens,
    tokens_out: usage.output_tokens,
  }),
});

Fire this after the response streams back to the caller (or via a queue) so a slow Merit AC ingest call never adds latency to the actual request — same note as the Python reference in backend/proxy_example.py.

Fields Merit AC expects

source_system, external_id, tool, and cost_usd are required; model, tokens_in, tokens_out, and occurred_at are optional. external_id must already exist in an IdentityMapping for your organization — map it once:

await fetch(`${MERIT_API_BASE}/admin/identity-mapping`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${dashboardJwt}`,
  },
  body: JSON.stringify({
    email: "priya@yourcompany.com",
    source_system: "anthropic_api",
    external_id: "proxy-key-priya-abc123",
  }),
});

using the ingest_token from GET /admin/org. An unmapped external_id gets a 422 on purpose — that's a shadow-AI candidate, not something to silently drop.

Outcomes and quality signals

These don't flow through the proxy — they come from webhooks on the systems where the work actually lands:

// PR merged
await fetch(`${MERIT_API_BASE}/ingest/outcome`, {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${MERIT_INGEST_TOKEN}` },
  body: JSON.stringify({
    source_system: "anthropic_api",
    external_id: "proxy-key-priya-abc123",
    source: "github",
    outcome_type: "pr_merged",
    external_ref: pullRequest.html_url,
  }),
});

// PR reverted -- a quality signal, not an outcome
await fetch(`${MERIT_API_BASE}/ingest/quality-signal`, {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${MERIT_INGEST_TOKEN}` },
  body: JSON.stringify({
    source_system: "anthropic_api",
    external_id: "proxy-key-priya-abc123",
    signal_type: "pr_reverted",
    external_ref: pullRequest.html_url,
  }),
});

Verifying it worked

Usage events land immediately, but the dashboard reads PersonScore, written by the nightly scoring job. Trigger it on demand to see today's events reflected without waiting:

await fetch(`${MERIT_API_BASE}/admin/recompute-scores`, {
  method: "POST",
  headers: { Authorization: `Bearer ${dashboardJwt}` },
});
const breakdown = await fetch(`${MERIT_API_BASE}/api/tool-breakdown`, {
  headers: { Authorization: `Bearer ${dashboardJwt}` },
}).then((r) => r.json());

If your tool shows up with the right spend, the wiring is correct.