# Partner API

> For approved integrators: create client workspaces, issue each client a restricted API key, and read the usage and statements you re-invoice from, all over the API.

The Partner API is for integrators who run Subsidia for several client organisations from one account. It is not the general developer API: it manages the **client workspaces you own**, not your own data. Everything here is also available in the app (Partner space); each step exists over the API so a client can be provisioned without a browser.

For the bigger picture (architecture, one key per client, what to hand to a client's legal team) read [Building on Subsidia for your clients](https://dev.subsidia.protypa.fr/docs/integrators.md).

## Two gates

Every `/partner/*` route is behind both:

1. **The account must be partner-enabled** by Protypa. Until then these routes answer `403` with `code: "PARTNER_NOT_ENABLED"`. Contact Protypa to join the programme.
2. **A key must carry the `partner` scope explicitly.** A key without it is refused with `403 scope_denied`, even on a partner-enabled account, and so is a legacy full-access key created before scopes existed (otherwise the desktop app's device key could create clients). A signed-in session in the app does not need a scope.

Create the partner key in the Subsidia console with the `partner` scope only: it manages your fleet and nothing else.

> **WARNING: Keep the partner key out of client software**
> The partner key can create workspaces, mint keys and read every client's statement. Keep it on your own back end. What you hand to a client's application is a **client key** (below): restricted, ceilinged, and unable to carry the `partner` scope.

## The model

**A client workspace is owned by you. Its usage counts against your account: one bill from Protypa to you, one bill from you to each client.**

Flow: Protypa -> Your partner account -> Client workspace -> Client key -> Client's application

A client workspace is separate from the others: every read is scoped to it, and a dossier can be reserved to named people. You own it, so you can reach its documents, its access journal and its keys. Every member of a client workspace sees a notice that you run it, and a client administrator can acknowledge it; the acknowledgement date is visible to you in the client list (`managedAckAt`).

## Billing model

| Direction | How it is computed | Where you see it |
| --- | --- | --- |
| **Protypa to you (wholesale)** | Per active seat across all your client workspaces, plus questions consumed that month, priced on a marginal volume grid (each tier at its own rate, like income tax), with an optional monthly platform minimum. Questions are weighted by model class. Verifier checks are counted as **controls**, apart from questions. | `GET /partner/wholesale` (the live grid is in the response), `GET /partner/invoices` |
| **You to your client (resale)** | Whatever you set per client: a rate per 100 questions, an optional price per seat, and optional questions included per seat. Subsidia never bills your client; you do. | `GET /partner/clients`, `GET /partner/clients/:id/statement`, `GET /partner/usage.csv` |
| **Margin** | Your resale total minus the wholesale price for the same seats and questions. | `marginCents` in a client statement |

Amounts are in euro cents, excluding tax. An invoice left unpaid past the grace period (7 days) suspends your clients' access (nothing is deleted) until it is paid. Customer-facing copy talks about questions, not tokens: a question is a unit of model usage with a fixed ceiling per answer, see [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md).

## Endpoints

### GET /partner/status

Is this account a partner?

The only partner route open to a key that has the scope on a non-partner account. Use it to check your setup before anything else.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/partner/status" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/status', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/partner/status",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: Whether the programme is enabled.

```json
{ "enabled": true }
```

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 403 | `scope_denied` | The key does not carry the `partner` scope. |

### GET /partner/clients

List your client workspaces with this month usage.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/partner/clients" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/partner/clients",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: One entry per client, oldest first, and totals. `monthAmountCents` is `null` while a client has no resale rate; `totals.monthAmountCents` is `null` unless every client has one.

```json
{
  "clients": [
    {
      "id": "ws_9d41",
      "name": "Cabinet Durand",
      "createdAt": "2026-08-12T09:00:00.000Z",
      "members": 4,
      "pendingInvitations": 1,
      "apiKeys": 2,
      "monthCalls": 812,
      "monthQuestions": 640,
      "monthCostUsd": 0.91,
      "lastActivityAt": "2026-10-09T07:58:00.000Z",
      "ratePer100Cents": 500,
      "seatRateCents": 1900,
      "includedQuestionsPerSeat": 400,
      "seats": 5,
      "monthAmountCents": 9500,
      "managedAckAt": "2026-08-14T13:20:00.000Z",
      "suspendedAt": null,
      "unpaidMonths": []
    }
  ],
  "totals": { "clients": 1, "monthCalls": 812, "monthQuestions": 640, "monthCostUsd": 0.91, "monthAmountCents": 9500 }
}
```

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 403 | `PARTNER_NOT_ENABLED` | The account is not a partner. |
| 403 | `scope_denied` | The key does not carry the `partner` scope. |

### POST /partner/clients

Create a client workspace.

Creates a workspace owned by you. With `adminEmail`, that person is invited as the client's administrator; without it the workspace exists but nobody there can sign in yet (your own API keys still work through the keys you mint).

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `name` | `string` | yes |  | 1 to 80 characters. |
| `adminEmail` | `string` | no |  | Email of the client manager to invite as administrator. |
| `ratePer100Cents` | `integer` | no |  | Your resale rate per 100 questions, in euro cents (0 to 1,000,000), or `null`. |

#### Request examples

_curl_

```bash
curl -X POST "https://api.subsidia.protypa.fr/partner/clients" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n  -H "Content-Type: application/json" \n  -d '{
  "name": "Cabinet Durand",
  "adminEmail": "direction@cabinet-durand.example",
  "ratePer100Cents": 500
}'
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    "name": "Cabinet Durand",
    "adminEmail": "direction@cabinet-durand.example",
    "ratePer100Cents": 500
  }),
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr" + "/partner/clients",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={
      "name": "Cabinet Durand",
      "adminEmail": "direction@cabinet-durand.example",
      "ratePer100Cents": 500
    },
)
print(res.status_code, res.json())
```

#### Responses

**201**: The client, and the invitation if one was sent.

```json
{
  "client": { "id": "ws_9d41", "name": "Cabinet Durand", "createdAt": "2026-10-09T09:00:00.000Z" },
  "invitation": { "email": "direction@cabinet-durand.example", "role": "admin" }
}
```

**400**: No name, or an invalid admin email.

```json
{ "error": "A client needs a name (and a valid admin email, if given)" }
```

### PATCH /partner/clients/:id

Rename a client, set its resale terms, or suspend it.

Send at least one field. Money fields are non-negative whole numbers (euro cents or questions); `null` clears one. `suspended: true` refuses every API key of the client with `403 client_suspended` without revoking any, and `false` restores them: a pause button for a client who has not paid.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The client workspace id returned when the client was created. |

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `name` | `string` | no |  | New name, 1 to 80 characters. |
| `ratePer100Cents` | `integer` | no |  | Your price per 100 questions beyond the included ones. |
| `seatRateCents` | `integer` | no |  | Your price per active seat per month (up to 1,000,000). |
| `includedQuestionsPerSeat` | `integer` | no |  | Questions included per seat before the per-100 rate applies. |
| `suspended` | `boolean` | no |  | Cut off, or restore, all API keys of this client. |

#### Request examples

_curl_

```bash
curl -X PATCH "https://api.subsidia.protypa.fr/partner/clients/$ID" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n  -H "Content-Type: application/json" \n  -d '{
  "seatRateCents": 1900,
  "includedQuestionsPerSeat": 400,
  "ratePer100Cents": 500
}'
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id, {
  method: 'PATCH',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    "seatRateCents": 1900,
    "includedQuestionsPerSeat": 400,
    "ratePer100Cents": 500
  }),
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.patch(
    "https://api.subsidia.protypa.fr" + "/partner/clients/" + id,
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={
      "seatRateCents": 1900,
      "includedQuestionsPerSeat": 400,
      "ratePer100Cents": 500
    },
)
print(res.status_code, res.json())
```

#### Responses

**200**: The updated client.

```json
{ "client": { "id": "ws_9d41", "name": "Cabinet Durand" } }
```

**400**: Nothing valid to update.

```json
{ "error": "Nothing valid to update" }
```

**404**: Not a client of yours.

```json
{ "error": "Client not found" }
```

### POST /partner/clients/:id/keys

Issue an API key to a client application.

A restricted key for the client's own software. It must name at least one capability scope (`engine`, `knowledge`, `reflex`, `light`, `webhooks`, `proof`), may add `readonly`, and can never carry `partner`. Optional ceilings protect you from a runaway integration: `monthlyQuestionLimit` answers `402 API_KEY_BUDGET_EXCEEDED` once reached (counted from usage logs, so it can lag a burst by a few seconds), `ratePerMinute` answers `429` with `Retry-After`.

The raw key is in the response **once**.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The client workspace id returned when the client was created. |

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `name` | `string` | yes |  | 1 to 80 characters, for your own reference. |
| `scopes` | `string[]` | yes |  | Non-empty. At least one capability scope; not `partner`. |
| `monthlyQuestionLimit` | `integer` | no |  | Calendar-month ceiling in questions. Verifier controls do not count. |
| `ratePerMinute` | `integer` | no |  | Requests per minute, up to 100,000. |
| `expiresAt` | `string` | no |  | ISO 8601 date-time after which the key stops working. |

#### Request examples

_curl_

```bash
curl -X POST "https://api.subsidia.protypa.fr/partner/clients/$ID/keys" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n  -H "Content-Type: application/json" \n  -d '{
  "name": "Portail Durand",
  "scopes": [
    "engine",
    "knowledge",
    "proof"
  ],
  "monthlyQuestionLimit": 2000,
  "ratePerMinute": 60
}'
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id + '/keys', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    "name": "Portail Durand",
    "scopes": [
      "engine",
      "knowledge",
      "proof"
    ],
    "monthlyQuestionLimit": 2000,
    "ratePerMinute": 60
  }),
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr" + "/partner/clients/" + id + "/keys",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={
      "name": "Portail Durand",
      "scopes": [
        "engine",
        "knowledge",
        "proof"
      ],
      "monthlyQuestionLimit": 2000,
      "ratePerMinute": 60
    },
)
print(res.status_code, res.json())
```

#### Responses

**201**: The key (`key`) and its settings.

```json
{
  "key": "sk_live_3f9a...c2",
  "id": "key_71ab",
  "name": "Portail Durand",
  "prefix": "sk_live_3f9a1c07",
  "scopes": ["engine", "knowledge", "proof"],
  "monthlyQuestionLimit": 2000,
  "ratePerMinute": 60,
  "expiresAt": null,
  "note": "Store this key now — it will not be shown again."
}
```

**400**: Invalid body or scopes.

```json
{ "error": "a client key cannot carry the partner scope" }
```

**404**: Not a client of yours.

### GET /partner/clients/:id/keys

List the active keys of a client.

Never the secret. `monthQuestions` is the count the ceiling is enforced on, so the gauge and a `402` never disagree.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The client workspace id returned when the client was created. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/partner/clients/$ID/keys" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id + '/keys', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/partner/clients/" + id + "/keys",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: The client and its active keys.

```json
{
  "client": { "id": "ws_9d41", "name": "Cabinet Durand", "createdAt": "2026-08-12T09:00:00.000Z" },
  "keys": [
    {
      "id": "key_71ab",
      "name": "Portail Durand",
      "prefix": "sk_live_3f9a1c07",
      "scopes": ["engine", "knowledge", "proof"],
      "monthlyQuestionLimit": 2000,
      "ratePerMinute": 60,
      "usageCount": 1840,
      "lastUsedAt": "2026-10-09T07:58:00.000Z",
      "createdAt": "2026-08-12T09:10:00.000Z",
      "expiresAt": null,
      "monthQuestions": 640
    }
  ]
}
```

**404**: Not a client of yours.

### DELETE /partner/clients/:id/keys/:keyId

Revoke a client key.

The client application loses access at its next call.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The client workspace id returned when the client was created. |
| `keyId` | `string` | yes |  | The key `id`. |

#### Request examples

_curl_

```bash
curl -X DELETE "https://api.subsidia.protypa.fr/partner/clients/$ID/keys/$KEYID" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id + '/keys/' + keyId, {
  method: 'DELETE',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
console.log(res.status)
```

_Python_

```python
import os, requests

res = requests.delete(
    "https://api.subsidia.protypa.fr" + "/partner/clients/" + id + "/keys/" + keyId,
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code)
```

#### Responses

**204**: Revoked.

**404**: Unknown key or client.

```json
{ "error": "Key not found" }
```

### GET /partner/clients/:id/usage

Monthly usage of a client, oldest first.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The client workspace id returned when the client was created. |

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `months` | `integer` | no | `6` | How many calendar months, 1 to 24, empty months included. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/partner/clients/$ID/usage?months=3" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id + '/usage' + '?months=3', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/partner/clients/" + id + "/usage" + "?months=3",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: Calls, questions and estimated provider cost in USD per month.

```json
{
  "client": { "id": "ws_9d41", "name": "Cabinet Durand", "createdAt": "2026-08-12T09:00:00.000Z" },
  "months": [
    { "month": "2026-08", "calls": 120, "questions": 98, "costUsd": 0.12 },
    { "month": "2026-09", "calls": 701, "questions": 566, "costUsd": 0.77 },
    { "month": "2026-10", "calls": 812, "questions": 640, "costUsd": 0.91 }
  ]
}
```

**404**: Not a client of yours.

### GET /partner/clients/:id/statement

A client monthly statement: what to bill, and your margin.

The document you re-invoice from. It computes the client total at **your** resale terms (seats, included questions, questions beyond them), next to Protypa's wholesale price for the same usage and the resulting margin. `controls` is the number of Verifier checks that month, shown apart from questions. For a closed month, seats are counted at month end; for the running month, as they stand now (`seatsBasis`).

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The client workspace id returned when the client was created. |

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `month` | `string` | no | `current month` | Calendar month as `YYYY-MM`, for example `2026-09`. Anything else is a 400. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/partner/clients/$ID/statement?month=2026-09" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id + '/statement' + '?month=2026-09', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/partner/clients/" + id + "/statement" + "?month=2026-09",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: The statement. Fields that depend on a resale rate you have not set are `null`.

```json
{
  "month": "2026-09",
  "clientName": "Cabinet Durand",
  "issuer": { "name": "Integrator SAS", "email": "billing@integrator.example" },
  "seatsBasis": "month-end",
  "seats": 5,
  "seatRateCents": 1900,
  "seatAmountCents": 9500,
  "questions": 566,
  "controls": 128,
  "includedQuestions": 2000,
  "billableQuestions": 0,
  "ratePer100Cents": 500,
  "questionAmountCents": 0,
  "totalCents": 9500,
  "wholesale": { "seatCents": 1000, "questionCents100": 449, "seatAmountCents": 5000, "questionAmountCents": 2541, "totalCents": 7541 },
  "marginCents": 1959
}
```

**400**: Bad month.

```json
{ "error": "month must look like 2026-08" }
```

**404**: Not a client of yours.

#### Notes

The statement also carries a `usage` breakdown object. The wholesale figures in the example are illustrative: the live grid is returned by `GET /partner/wholesale`.

### GET /partner/clients/:id/payments

Which months a client has paid you.

Your own ledger, newest first: the statement total of each month, whether it is due (finished, with something to collect) and when you marked it paid. Subsidia never bills your client; this only records what you tell it.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The client workspace id returned when the client was created. |

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `months` | `integer` | no | `6` | 1 to 24. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/partner/clients/$ID/payments" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id + '/payments', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/partner/clients/" + id + "/payments",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: `{ months: [...] }`, one entry per month.

**404**: Not a client of yours.

### PUT /partner/clients/:id/payments/:month

Mark a month paid or unpaid.

Not allowed for a month that has not started. Send `amountCents` only when what you received differs from the statement.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The client workspace id returned when the client was created. |
| `month` | `string` | yes |  | `YYYY-MM`. |

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `paid` | `boolean` | yes |  | True to mark paid, false to undo. |
| `amountCents` | `integer` | no |  | Amount actually received, in euro cents. |

#### Request examples

_curl_

```bash
curl -X PUT "https://api.subsidia.protypa.fr/partner/clients/$ID/payments/$MONTH" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n  -H "Content-Type: application/json" \n  -d '{
  "paid": true
}'
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/clients/' + id + '/payments/' + month, {
  method: 'PUT',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    "paid": true
  }),
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.put(
    "https://api.subsidia.protypa.fr" + "/partner/clients/" + id + "/payments/" + month,
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={
      "paid": True
    },
)
print(res.status_code, res.json())
```

#### Responses

**200**: The recorded payment (`payment`).

**400**: Bad month, a future month, or an invalid body.

**404**: Not a client of yours.

### GET /partner/usage.csv

One line per client for a month, to re-invoice from.

Semicolon-separated with a UTF-8 byte-order mark and CRLF line ends, so a French spreadsheet opens it in columns. A total line closes the file. Columns: `mois`, `client`, `appels`, `questions`, `tarif_pour_100_questions_eur`, `montant_ht_eur`, `sieges`, `tarif_par_siege_eur`, `montant_sieges_ht_eur`, `questions_incluses`, `questions_facturables`, `montant_questions_ht_eur`. `montant_ht_eur` is the client total (seats plus billable questions).

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `month` | `string` | no | `previous month` | Calendar month as `YYYY-MM`, for example `2026-09`. Anything else is a 400. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/partner/usage.csv?month=2026-09" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \n  -o clients-2026-09.csv
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/usage.csv' + '?month=2026-09', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const buffer = Buffer.from(await res.arrayBuffer())
await writeFile('clients-2026-09.csv', buffer)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/partner/usage.csv" + "?month=2026-09",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
open('clients-2026-09.csv', 'wb').write(res.content)
```

#### Responses

**200**: `text/csv; charset=utf-8`, attachment `clients-<month>.csv`.

**400**: Bad month.

### GET /partner/wholesale

What this account owes Protypa.

Current seats across every workspace you run and the month's questions, priced on the standing wholesale grid. The response includes the live `grid` (marginal tiers) and the weights per `modelClasses`, so you can forecast. `controls` and `controlAmountCents` show Verifier checks apart from questions.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `month` | `string` | no | `current month` | Calendar month as `YYYY-MM`, for example `2026-09`. Anything else is a 400. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/partner/wholesale?month=2026-10" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/wholesale' + '?month=2026-10', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/partner/wholesale" + "?month=2026-10",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: The wholesale statement (abridged).

```json
{
  "month": "2026-10",
  "seats": 12,
  "seatCents": 1000,
  "seatAmountCents": 12000,
  "questions": 4120,
  "questionCents100": 449,
  "questionAmountCents": 18499,
  "controls": 128,
  "controlAmountCents": 0,
  "minimumCents": 0,
  "totalCents": 30499,
  "tiers": [{ "from": 0, "upTo": 50000, "cents100": 449, "questions": 4120, "amountCents": 18499 }],
  "grid": [{ "upTo": 50000, "cents100": 449 }, { "upTo": null, "cents100": 49 }]
}
```

**400**: Bad month.

#### Notes

Illustrative numbers. The grid is set by Protypa and can change: read it from this endpoint rather than hard-coding it.

### GET /partner/invoices

Your wholesale invoices and what is outstanding.

Every invoice Protypa sent you, the outstanding amount (sent, not yet paid), whether automatic debit is on, whether your clients are currently suspended for non-payment, and the grace period in days.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/partner/invoices" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/invoices', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/partner/invoices",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: Invoices and billing state (the invoice objects are abridged here).

```json
{
  "invoices": [],
  "outstandingCents": 0,
  "autoDebit": true,
  "autoDebitSince": "2026-09-02T10:00:00.000Z",
  "suspended": false,
  "graceDays": 7,
  "platformFeeCents": 0
}
```

### GET /partner/devices

Every paired desktop install across your clients.

A support view: which machines run Subsidia at your clients, their state (`ok`, `attention`, `offline`, `expiring`, `never-seen`, `revoked`) and version, without asking each client. A desktop is paired by signing the app in with your account and choosing the client workspace.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/partner/devices" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/devices', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/partner/devices",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: The fleet.

### DELETE /partner/devices/:id

Remove a paired install.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The device id from `GET /partner/devices`. |

#### Request examples

_curl_

```bash
curl -X DELETE "https://api.subsidia.protypa.fr/partner/devices/$ID" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/devices/' + id, {
  method: 'DELETE',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
console.log(res.status)
```

_Python_

```python
import os, requests

res = requests.delete(
    "https://api.subsidia.protypa.fr" + "/partner/devices/" + id,
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code)
```

#### Responses

**204**: Removed.

**404**: Unknown device.

```json
{ "error": "Device not found" }
```

### Paying Protypa

Paying the wholesale invoice goes through Stripe Checkout, so these routes return a URL to open in a browser rather than a receipt. The two `confirm` routes are what your redirect page calls after Stripe sends the person back; they are idempotent and do not wait for Stripe's own notification.

### POST /partner/invoices/:id/pay

Open a Checkout session for one invoice.

Charges the invoice's exact amount. Refused if the invoice is already paid or void.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The invoice id from `GET /partner/invoices`. |

#### Request examples

_curl_

```bash
curl -X POST "https://api.subsidia.protypa.fr/partner/invoices/$ID/pay" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/invoices/' + id + '/pay', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr" + "/partner/invoices/" + id + "/pay",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: Open `url` in a browser.

```json
{ "sessionId": "cs_live_a1...", "url": "https://checkout.stripe.com/c/pay/cs_live_a1..." }
```

**400**: Already paid, void, or the session could not start. `error` says which.

### GET /partner/invoices/:id/confirm

Confirm a payment after the Stripe redirect.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | The invoice id. |

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `session_id` | `string` | yes |  | The Checkout session id Stripe appended to your return URL. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/partner/invoices/$ID/confirm?session_id=cs_live_a1" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/invoices/' + id + '/confirm' + '?session_id=cs_live_a1', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/partner/invoices/" + id + "/confirm" + "?session_id=cs_live_a1",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: The payment state after verification.

**400**: `session_id` missing, or the payment could not be verified.

### POST /partner/auto-debit

Authorise automatic debit of the monthly invoice.

Opens a Stripe Checkout session in setup mode where you save a card or SEPA account once. Each monthly wholesale invoice is then debited at its exact amount.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Request examples

_curl_

```bash
curl -X POST "https://api.subsidia.protypa.fr/partner/auto-debit" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/auto-debit', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr" + "/partner/auto-debit",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: Open `url` in a browser.

```json
{ "sessionId": "cs_live_b2...", "url": "https://checkout.stripe.com/c/setup/cs_live_b2..." }
```

**400**: The authorisation could not start.

### GET /partner/auto-debit/confirm

Confirm the debit authorisation after the Stripe redirect.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `session_id` | `string` | yes |  | The Checkout session id. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/partner/auto-debit/confirm?session_id=cs_live_b2" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/auto-debit/confirm' + '?session_id=cs_live_b2', {
  method: 'GET',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
const data = await res.json()
console.log(res.status, data)
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr" + "/partner/auto-debit/confirm" + "?session_id=cs_live_b2",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code, res.json())
```

#### Responses

**200**: The authorisation is saved.

**400**: `session_id` missing or not verifiable.

### DELETE /partner/auto-debit

Stop the automatic debit.

Forgets the saved payment method. Invoices are then paid by hand from Checkout.

- **Authentication:** API key with the partner scope, or session
- **Scopes:** `partner`

#### Request examples

_curl_

```bash
curl -X DELETE "https://api.subsidia.protypa.fr/partner/auto-debit" \n  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr' + '/partner/auto-debit', {
  method: 'DELETE',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
})
console.log(res.status)
```

_Python_

```python
import os, requests

res = requests.delete(
    "https://api.subsidia.protypa.fr" + "/partner/auto-debit",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
)
print(res.status_code)
```

#### Responses

**204**: Forgotten.

## Provision a client end to end

**Create, key, check**

_TypeScript_

```typescript
const base = 'https://api.subsidia.protypa.fr'
const headers = {
  Authorization: 'Bearer ' + process.env.SUBSIDIA_PARTNER_KEY, // sk_live_... with the partner scope
  'Content-Type': 'application/json',
}

// 1. A workspace for the client, with its manager invited as administrator
const { client } = await fetch(base + '/partner/clients', {
  method: 'POST',
  headers,
  body: JSON.stringify({ name: 'Cabinet Durand', adminEmail: 'direction@cabinet-durand.example', ratePer100Cents: 500 }),
}).then((r) => r.json())

// 2. A restricted key for the client's own application (shown once)
const { key } = await fetch(base + '/partner/clients/' + client.id + '/keys', {
  method: 'POST',
  headers,
  body: JSON.stringify({ name: 'Portail Durand', scopes: ['engine', 'proof'], monthlyQuestionLimit: 2000, ratePerMinute: 60 }),
}).then((r) => r.json())
// hand "key" to the client's application: it can call the Gateway and the Verifier, nothing else

// 3. At month end
const statement = await fetch(base + '/partner/clients/' + client.id + '/statement?month=2026-09', { headers }).then((r) => r.json())
console.log(statement.totalCents, statement.controls, statement.marginCents)
```

_Python_

```python
import os, requests

base = "https://api.subsidia.protypa.fr"
headers = {"Authorization": "Bearer " + os.environ["SUBSIDIA_PARTNER_KEY"]}  # partner scope

client = requests.post(
    base + "/partner/clients",
    headers=headers,
    json={"name": "Cabinet Durand", "adminEmail": "direction@cabinet-durand.example", "ratePer100Cents": 500},
).json()["client"]

key = requests.post(
    base + "/partner/clients/" + client["id"] + "/keys",
    headers=headers,
    json={"name": "Portail Durand", "scopes": ["engine", "proof"], "monthlyQuestionLimit": 2000, "ratePerMinute": 60},
).json()["key"]  # shown once

statement = requests.get(
    base + "/partner/clients/" + client["id"] + "/statement",
    headers=headers,
    params={"month": "2026-09"},
).json()
print(statement["totalCents"], statement["controls"], statement["marginCents"])
```

## Frequently asked

**Why does my key get scope_denied on a partner account?**

The key was created without the `partner` scope, or before scopes existed. Create a new key and tick `partner`. A client key can never carry it.

**What does suspending a client do?**

Every API key of that workspace answers `403 client_suspended` until you set `suspended: false`. Nothing is revoked or deleted, so it is reversible with no re-issuing of keys.

**Does the client see what I charge?**

No. Statements and rates are yours. The client sees the usage its own keys and members generate.

**Are Verifier checks billed?**

They are counted per client as controls, apart from questions, and appear on statements. At the time of writing no price is attached to them.
