# Commissa REST API, MCP & Webhooks (v3.0)

Commissa ships a JSON REST API and signed webhooks so deals can arrive from the system that
already knows about them — a CRM, an order form, a nightly export — and so payout figures can be
pulled into payroll instead of retyped from a PDF.

Money is **integer cents** everywhere in this API. No floats, no currency strings to parse.

## Authentication

Create a key in **API & Webhooks**. A key belongs to a user and can do exactly what that user can
do in the app. Send it on every call:

    Authorization: Bearer cmk_...

The key is shown once at creation and stored as a SHA-256 hash. Revoke any time.

`/api/*` is CSRF-exempt by design — the key is the credential, and browsers never send it
implicitly.

## Endpoints

| Method & path | What it does |
|---|---|
| `GET  /api/ping` | Verify a key |
| `GET  /api/deals` | Newest 50. `?rep_id=`, `?status=`, `?category=`, `?from=`, `?to=` |
| `POST /api/deals` | Record a deal |
| `GET  /api/deals/{id}` | One deal |
| `POST /api/deals/{id}/paid` | Record the day the customer paid: `{"paid_on": "2026-04-02"}` (`""` clears it). With "Hold commission until the customer pays" on, this releases the deal into the period of that date (3.3) |
| `GET  /api/reps` | The rep list |
| `GET  /api/periods` | Pay periods |
| `GET  /api/periods/{id}/statement?rep={id}` | One rep's statement, penny-exact |
| `GET  /api/periods/{id}/payouts` | Every active rep's net for the period, one call |
| `GET  /api/openapi.json` | OpenAPI 3 spec (no auth) |
| `GET  /healthz` | `{"ok":true,"app":"commissa","version":"2.0.0"}` (no auth) |

### Record a deal

    curl -X POST https://your-install/api/deals \
      -H "Authorization: Bearer cmk_..." -H "Content-Type: application/json" \
      -d '{"rep_id": 3, "amount": "4500.00", "deal_date": "2026-03-04",
           "product": "Enterprise annual", "category": "software", "status": "won"}'

`amount` is decimal in, integer cents out. `status` is `won` or `pending`; only **won** deals
reach a statement. Validation is not re-implemented for the API — the request is passed through
`_deal_from_post()` and `_deal_refusal()`, the exact pair the browser form uses, so a rule can
never apply in the UI and not over HTTP:

    {"error": "unprocessable", "detail": "Choose which rep closed the deal."}

### Statements are the core's arithmetic, not the API's

`/api/periods/{id}/statement` returns whatever `Commission::payout()` computed. **Nothing is
re-added, re-rounded or re-derived in the API layer** — the JSON, the HTML statement, the CSV and
the PDF are the same numbers to the penny.

That matters most under a **tiered** scheme. Per-deal commission is defined as the *delta of the
cumulative function*, so the deal lines telescope to the whole-period figure exactly rather than
drifting a cent at each band boundary. A worked case, and one the suite asserts:

> 5% up to 100,000.00, then 8%. Three deals of 40,000.00 → volume 120,000.00.
> 5% of 100,000.00 = 5,000.00, plus 8% of 20,000.00 = 1,600.00 → **gross 6,600.00**, and the three
> line commissions sum to exactly that.

Because a tiered deal can straddle two bands, a line carries a human `rate_label` ("5% → 8%")
rather than a single rate:

    {"statement": {
       "gross_cents": 660000, "adj_total_cents": -25000, "net_cents": 635000,
       "volume_cents": 12000000, "deal_count": 3,
       "rule": {"kind": "tiered", "name": "Tiered", "effective_from": "2026-01-01"},
       "lines": [{"deal_id": 1, "amount_cents": 4000000, "rate_label": "5%",
                  "commission_cents": 200000, "running_volume_cents": 4000000,
                  "running_commission_cents": 200000}, ...],
       "adjustments": [{"kind": "clawback", "amount_cents": -25000, "reason": "Returned order"}]}}

Adjustments are **signed** and move `net_cents` only — `gross_cents` stays the computed
commission, so a bonus or clawback never hides inside the rate.

## MCP (Model Context Protocol) — new in 3.0

Commissa speaks MCP over stateless streamable HTTP at **`POST /mcp`**. Authentication is the same
revocable API key as the REST API (`Authorization: Bearer <key>`), and a key carries its owner's
role, so an agent can never do more than the person whose key it holds.

`GET /mcp` answers **405** with `Allow: POST` — deliberately without the key check, so a probing
client is told "right endpoint, wrong verb" rather than a bare 401 that reads as "no MCP here".

```
POST /mcp   {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}
POST /mcp   {"jsonrpc":"2.0","id":2,"method":"tools/list"}
POST /mcp   {"jsonrpc":"2.0","id":3,"method":"tools/call",
             "params":{"name":"statement_preview","arguments":{"period_id":1,"rep_id":1}}}
```

| Tool | Writes? | What it does |
|---|---|---|
| `list_reps` | no | Reps and their active flag |
| `rep_detail` | no | One rep, their deals, and the commission rule that applies today |
| `record_deal` | **yes** | Records a deal. Only "won" deals earn commission; `paid_on` (optional, 3.3) is the day the customer paid |
| `statement_preview` | no | A rep's payout for a period: deals, rate or tier applied, adjustments, net |
| `period_report` | no | Every rep's payout for a period, with totals |
| `list_disputes` | no | Disputes, newest first, optionally filtered by status |

**Deciding a dispute is deliberately NOT a tool.** Upholding one moves money; that stays a human
decision on the Disputes screen, where the decide-once rule and the audit trail live.

**Argument hygiene.** Tool arguments take the same scalar guard as the REST body — a value may not
be an array or object unless the tool allowlists it, unknown arguments are refused by name, and an
out-of-range status is refused with the list of what is accepted (`won`, `pending`).

**One computation.** `statement_preview` and `period_report` return `Commission::payout()` and
`Commission::periodSummary()` — the same functions behind the HTML statement, the CSV and the PDF.
Verified live: gross, adjustments, net and volume are identical across all five surfaces.

### Connect it to an assistant

Mint the key in the app first: **Settings → API keys**. Choose **Read only** when the assistant should
answer questions but never change anything — the endpoint then lists only the read tools and refuses the
rest by name, so a careless prompt cannot write. Full access behaves as before.

Every client needs the same three facts, and nothing in the handshake is vendor-specific:

| | |
|---|---|
| **The address** | `https://your-install/mcp` |
| **The key** | header `Authorization: Bearer apk_xxxx` |
| **The transport** | MCP over streamable HTTP, stateless |

**Claude** — one command, or the same URL and header as a custom connector in the desktop and web apps:

```sh
claude mcp add --transport http commissa https://your-install/mcp \
  --header "Authorization: Bearer apk_xxxx"
```

**ChatGPT and the OpenAI API** — one entry in the Responses API's `tools` array (in ChatGPT itself, the
same URL and key go in as a connector):

```json
{
  "type": "mcp",
  "server_label": "commissa",
  "server_url": "https://your-install/mcp",
  "authorization": "apk_xxxx",
  "require_approval": "never"
}
```

**Every other client spells the same three facts differently — copy the shape from its own
documentation, not from here.** VS Code is the clearest example of why: its
[configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
(read 6 September 2026) puts servers in `.vscode/mcp.json` under a **`"servers"` object** — *"an
object that maps server names to their configurations"*. Pasted as-is,
a block written for another client will not load there. The id, the URL and the token are what travel; the JSON around
them belongs to whichever client you are configuring.

**A local model, n8n, or your own code** — n8n's **MCP Client** node takes the URL and the same
`Authorization: Bearer` header; a model running on your own machine reaches it through any MCP client, so
nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:

```sh
curl -X POST https://your-install/mcp \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet:
Commissa implements the protocol, not an integration with a particular vendor.

## Webhooks

Add receiver URLs in **API & Webhooks**. Event:

- `deal.recorded` — a deal was entered, by the form, the API or the AI tool. A CSV import records its rows without sending an event per row.

Every delivery is signed:

    X-Commissa-Event: deal.recorded
    X-Commissa-Signature: sha256=<hmac_sha256(body, your endpoint secret)>

Verify by recomputing the HMAC over the raw body with the endpoint's secret. Deliveries are logged
(newest 200 per endpoint) with the response code, so "did it fire?" is answerable from the admin
page.

Delivery is best-effort with a short timeout: a slow receiver must never block a deal entry.

## Documented limits

- **There is no `period.closed` event.** From 3.1.4 a period can be closed in the app: its
  statements are then stored as they stood, `GET /api/periods` reports `closed` and `closed_at`,
  and `GET /api/periods/{id}/statement` and `/payouts` return the stored statements. An open
  period is still computed live from the deals inside it. No webhook fires on a close.
- **Deals are create-and-read over the API.** Editing and deleting stay in the UI — changing a
  recorded deal changes a paid commission, and that should have someone's eyes on it.
- **Reps, rules, periods and adjustments are read-only over the API** (adjustments are visible on
  a statement). A commission scheme is a contract; it is not set by an integration.
- **The CSV import never creates a rep.** A row naming someone absent from the rep list is skipped
  and reported — inventing a salesperson from a typo'd column would silently reroute a payout.

## New in 3.2: fingerprints and statement links

`GET /api/periods/{id}/statement?rep=<id>` now also returns `"frozen": true|false` and `"fingerprint"`: for a
closed period, three groups of four hex characters computed from the figures that were paid (each deal,
each adjustment, the net); `null` while the period is open. The same fingerprint is printed on the
statement and in the rep's statement email.

Statement links (`/s/<token>`) are the rep's own read of one statement, made from the statement page or
sent with the statement email. They are browser pages, not API routes: no key opens them, and no API
route or MCP tool makes, lists or switches one off. A question a rep sends from their link arrives as an
ordinary dispute (`list_disputes`), raised by `rep: <name> (statement link)`.

### New in 3.2 (pass B): quotas, attainment, the payroll ID

- `GET /api/periods/{id}/statement?rep=<id>` also returns `"quota_cents"` (the rep's quota for the period, or
  `null`), `"attainment_bp"` (won volume ÷ quota in basis points, 10000 = 100%, or `null` without a quota) and
  `"quota_missing"` (true when the rep is on a quota scheme and no quota is set, so the first band's rate applied
  to all their sales). `rule.kind` can be `"quota"`.
- `GET /api/periods/{id}/payouts` gives each row `"attainment_bp"`.
- Reps carry `"payroll_id"` (the payroll system's employee number, `""` when not set).
- The MCP tools `statement_preview` and `period_report` return the same fields; `rep_detail` and `list_reps` return
  `payroll_id`.
- Quotas, payout approvals and the payroll file are browser actions only: no API route or MCP tool sets a quota,
  approves payouts or closes a period.
- Statement lines carry `full_amount_cents` and `share_bp` for a split deal (`amount_cents` is that rep's share); both are `null` when the deal is not split. Splits are set on the deal form, not over the API.


## Connect it to Zapier, Make or n8n

Commissa talks to other tools without a plugin: it sends a webhook (an HTTPS message) the moment something happens, and it accepts calls to the API above. Zapier, Make and n8n can use both.

### Before you start

- In Commissa, open **API & Webhooks**. Mint an API key (choose **Read only** if the other tool only reads), and add a webhook when you have its URL (below).
- Use your install's full address, for example `https://commissa.yourcompany.com`.

### When something happens in Commissa: send it to another tool

1. In the other tool, create the trigger that gives you a URL to receive webhooks:
   - **Zapier:** the "Webhooks by Zapier" app, trigger **Catch Hook** (Webhooks by Zapier needs a paid Zapier plan).
   - **Make:** module **Webhooks → Custom webhook**.
   - **n8n:** the **Webhook** node.
2. Copy that URL into Commissa under **API & Webhooks**, and choose the event or events below.
3. Make the event happen once in Commissa, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `deal.recorded` | A deal was entered, by the form, the API or the AI tool. A CSV import records its rows without sending an event per row. |

Every delivery carries `X-Commissa-Signature`, an HMAC-SHA256 signature over the raw body. Zapier and Make accept the delivery without checking it, so keep the webhook URL private. In n8n you can check it in a Code node before using the data.

### When something happens elsewhere: act in Commissa

- **Zapier:** "Webhooks by Zapier", action **POST** (or **Custom Request**).
- **Make:** module **HTTP → Make a request**.
- **n8n:** the **HTTP Request** node.

Send the request to your install's address plus the path from the Endpoints table above, with the header `Authorization: Bearer <your API key>` and a JSON body. A read-only key cannot write.

The endpoints that change something:

- `POST /api/deals`: Record a deal
- `POST /api/deals/{id}/paid`: Record the day the customer paid: `{"paid_on": "2026-04-02"}` (`""` clears it). With "Hold commission until the customer pays" on, this releases the deal into the period of that date (3.3)

### Example recipes

- **deal.recorded → a row in Google Sheets:** webhook trigger on `deal.recorded` (A deal was entered, by the form, the API or the AI tool. A CSV import records its rows without sending an event per row), then the Google Sheets "Create row" action with the fields you want.
- **deal.recorded → a message in Slack or Teams:** webhook trigger on `deal.recorded` (A deal was entered, by the form, the API or the AI tool. A CSV import records its rows without sending an event per row), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Commissa:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/deals` (Record a deal).

These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Commissa's own automated tests check its API.
