# Partner API

!!! note
    **Partner API (for software platforms).** These endpoints are for software companies that resell or embed TruTec inside their own product for their own customers. A normal contractor workspace key cannot call them. Every endpoint on this page needs a key with the **Partner** permission, which TruTec grants to approved partners. Other keys get `403` `API key lacks partner management permission`.

If you are a contractor building tools for your own business, you do not need this page. Use your workspace key with the rest of the API.

## How partner access works

A partner key belongs to the partner's own TruTec account. It can act on two kinds of customer workspaces:

| Kind | `partner_access_type` | How it is created | What the partner can do |
|---|---|---|---|
| **Child workspace** | `child` | `POST /v1/partner/firms` | Everything: plans, billing, keys, settings, data |
| **Linked workspace** | `linked` | The customer approves a link to their existing TruTec workspace | Read and write data, bill sessions. Plans, keys, billing portal and measurement settings stay with the customer |

### Choosing the workspace on each call

When a partner key calls any other endpoint in this API (jobs, measurements, webhooks, exports and so on), it must say which customer workspace to use. Pass one of these, in the query string or JSON body:

| Parameter | Meaning |
|---|---|
| `firm_id` | The TruTec workspace id |
| `linked_workspace_token` | The token returned by the link flow for a linked workspace |
| `partner_user_id` | Your user id, if that user has been linked to a TruTec workspace |

Without one, the call fails with `400` `linked_workspace_token, firm_id, or partner_user_id is required when using a partner API key`. A workspace you are not connected to returns `404` `Firm not found`.

## Dev keys and sandbox workspaces

Partners can test without touching live customers or live billing.

- A **sandbox workspace** (`is_sandbox: true`) is a separate test workspace. Its data never mixes with live workspaces. Its name starts with `[DEV-<your account id>] `.
- A **dev key** (`is_dev: true`) is a partner key that can only reach sandbox workspaces.

What a dev key **can** do:

- Create workspaces. Every workspace it creates is a sandbox workspace, and that workspace's own key is also a dev key.
- List, read and manage sandbox workspaces, and call the rest of the API against them (with `firm_id`).
- Run billing flows in Stripe **test mode**: checkout pages accept Stripe test cards, and no real money moves. If test mode is not available, plan changes are applied directly with no checkout.
- Create more dev keys.

What a dev key **cannot** do:

- See or touch a live workspace. Any live `firm_id` returns `403` `Dev key can only access sandbox firms`. `GET /v1/partner/firms` and the embed workspace picker hide live workspaces.
- Create live workspaces or live keys.
- Charge real money. Sandbox workspaces never create card charges for sessions or overage.
- Delete records. Dev keys do not have the Delete records permission.

Be aware:

- Partner-wide measurement defaults (`PUT /v1/partner/measurement-defaults`) are shared by your whole account. A dev key that changes them changes the defaults for live workspaces you create later. With `apply_to_existing`, a dev key only updates sandbox workspaces.
- A dev key has a usage limit (default 1,000 billable sessions). After that, its calls return `429`.
- Sandbox sessions have a second limit for your whole account: 500 new sessions in any 30 days, across all your sandbox workspaces and keys. After that, new sessions return `429` `sandbox_limit_reached` until older ones age out. Refunding a sandbox session does not free a slot. Need more for testing? Contact TruTec.
- `GET /v1/partner/health` tells you which kind of key you are using.

A live partner key can also reach sandbox workspaces. You can create one with `"sandbox": true` on `POST /v1/partner/firms`. Sessions there count toward the same 30-day sandbox limit.

!!! note
    Dev keys and sandbox workspaces exist only for partner accounts. A contractor's own workspace keys (created in **Settings → API keys**) always act on that live workspace. To test safely, ask TruTec for a separate test workspace and use its keys.

### Check your key

`GET /v1/partner/health`

**Permission:** Partner

```bash
curl https://trutec.ai/api/v1/partner/health \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{"ok": true, "stripe_mode": "test", "key_type": "dev"}
```

`key_type` is `dev` or `live`. `stripe_mode` is `test` for dev keys and `live` otherwise.

### Create a dev key

`POST /v1/partner/dev-key`

**Permission:** Partner

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | string | No | Default `Partner Dev Key` |
| `usage_limit` | integer | No | Max billable sessions. Default 1000 |

```bash
curl -X POST https://trutec.ai/api/v1/partner/dev-key \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"name": "Staging"}'
```

```json
{
  "id": 640,
  "name": "Staging",
  "key_prefix": "sk_Xy7pQ2aB",
  "is_active": true,
  "is_dev": true,
  "usage_count": 0,
  "usage_limit": 1000,
  "usage_remaining": 1000,
  "last_used_at": null,
  "created_at": "2026-10-12T15:00:00",
  "revoked_at": null,
  "key": "sk_Xy7pQ2aB...",
  "mode": "sandbox"
}
```

`key` is shown once. Store it securely.

## Customer workspaces

### Create a workspace

`POST /v1/partner/firms`

**Permission:** Partner

Creates a child workspace and its first API key.

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Company name. Must be unique across TruTec |
| `plan` | string | No | `trial`, `silver`, `gold`, `platinum` or `diamond`. Default `gold` |
| `email` | string | No | Billing email for the Stripe customer |
| `success_url` | string | For paid plans | Where Stripe Checkout sends the customer after paying |
| `cancel_url` | string | For paid plans | Where Stripe Checkout sends the customer if they cancel |
| `sandbox` | boolean | No | `true` creates a sandbox workspace with no Stripe at all |
| `key_name` | string | No | Name for the workspace's API key |
| `usage_limit` | integer | No | Usage limit for the workspace's key. Default unlimited (1000 for sandbox) |
| `measurement_settings` | object | No | Measurement presets for this workspace. See [Measurement presets](#measurement-presets) |

How the plan is set up:

- `trial`: active at once as `trialing`. No checkout. Includes a small number of free sessions.
- Paid plans with a live key: the workspace is `pending` until the customer pays at `checkout_url`.
- Paid plans with a dev key: the same, in Stripe test mode.
- `sandbox: true`: active at once, no Stripe.

```bash
curl -X POST https://trutec.ai/api/v1/partner/firms \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Northgate Paving",
    "plan": "gold",
    "email": "billing@northgatepaving.example",
    "success_url": "https://app.example.com/trutec/success",
    "cancel_url": "https://app.example.com/trutec/cancel"
  }'
```

```json
{
  "firm": {
    "id": 5120,
    "name": "Northgate Paving",
    "is_sandbox": false,
    "partner_access_type": "child",
    "partner_managed": true,
    "linked_direct": false,
    "subscription_plan": "gold",
    "subscription_status": "pending",
    "pending_subscription_plan": null,
    "cancel_requested": false,
    "stripe_customer_id": "cus_Q1w2E3r4T5",
    "api_enabled": true,
    "measurements_used_this_period": 0,
    "measurements_limit": 30,
    "measurements_remaining": 30,
    "total_measurements_ever": 0,
    "current_period_end": null,
    "created_at": "2026-10-12T15:02:11.204512"
  },
  "api_key": {
    "id": 641,
    "name": "Northgate Paving API Key",
    "key_prefix": "sk_9fKs2LmQ",
    "is_active": true,
    "is_dev": false,
    "usage_count": 0,
    "usage_limit": null,
    "usage_remaining": null,
    "last_used_at": null,
    "created_at": "2026-10-12T15:02:11.310021",
    "revoked_at": null,
    "key": "sk_9fKs2LmQ..."
  },
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_live_...",
  "mode": "live"
}
```

`mode` is `live`, `test` (dev key, Stripe test mode) or `sandbox` (no Stripe). The workspace key can read and write jobs and measurements. It cannot delete records or use partner endpoints. It is shown once.

Errors:

- `400` `name is required`, `Invalid plan. Must be one of: ...`, `success_url and cancel_url are required`, `measurement_settings must be an object`
- `409` `A company with this name already exists`. `existing_firm_id` is set when that workspace is already yours
- `500` `Failed to create billing session: ...`

### List workspaces

`GET /v1/partner/firms`

**Permission:** Partner

Your child and linked workspaces, newest first. Dev keys see sandbox workspaces only.

| Name | Type | Required | Description |
|---|---|---|---|
| `limit` | integer | No | Default 50, max 100 |
| `offset` | integer | No | Default 0 |

```bash
curl "https://trutec.ai/api/v1/partner/firms?limit=50" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [{"id": 5120, "name": "Northgate Paving", "partner_access_type": "child", "subscription_plan": "gold", "subscription_status": "active", "...": "..."}],
  "pagination": {"total": 1, "limit": 50, "offset": 0}
}
```

Linked workspaces include a `linked_workspace_token`. Trial workspaces include a `trial` object: `eligible`, `scope`, `limit`, `used`, `remaining`.

### Get a workspace

`GET /v1/partner/firms/{firm_id}`

**Permission:** Partner

Returns the workspace object above plus `api_key`: the workspace's active key (without the secret) for child workspaces, `null` for linked ones.

Errors: `404` `Firm not found`, `403` `Dev key can only access sandbox firms`.

### Rotate a workspace key

`POST /v1/partner/firms/{firm_id}/rotate-key`

**Permission:** Partner

Revokes **every** active key on the child workspace and issues one new key. Not available for linked workspaces.

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | string | No | Name for the new key |
| `usage_limit` | integer | No | Usage limit for the new key |
| `is_dev` | boolean | No | Only allowed for sandbox workspaces. Dev keys always create dev keys |

```bash
curl -X POST https://trutec.ai/api/v1/partner/firms/5120/rotate-key \
  -H "X-API-Key: sk_your_api_key_here"
```

Returns `201` with the key object and `key` (shown once). Errors: `400` `is_dev keys can only be created for sandbox firms`, `400` `is_dev must be a boolean when provided`, `403` for linked workspaces.

## Plans and billing

Plan names are `trial`, `silver`, `gold`, `platinum` and `diamond`. These endpoints manage child workspaces only. Linked workspaces return `403`: the customer manages their own billing in TruTec.

### Change plan

`POST /v1/partner/firms/{firm_id}/change-plan`

**Permission:** Partner

For an `active` paid workspace.

| Name | Type | Required | Description |
|---|---|---|---|
| `plan` | string | Yes | `silver`, `gold`, `platinum` or `diamond` |
| `success_url`, `cancel_url` | string | When a checkout is needed | Needed when moving to or from Silver |

- Between monthly plans (Gold, Platinum, Diamond): applied at once. Upgrades are prorated and invoiced now. Downgrades are not prorated. `checkout_url` is `null`.
- To or from Silver: returns a `checkout_url`. The plan changes after the customer completes checkout. Until then `pending_subscription_plan` shows the new plan.

```bash
curl -X POST https://trutec.ai/api/v1/partner/firms/5120/change-plan \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"plan": "platinum"}'
```

```json
{"firm": {"id": 5120, "subscription_plan": "platinum", "...": "..."}, "checkout_url": null, "previous_plan": "gold"}
```

Errors (`400`): `plan is required`, `Firm is already on the gold plan`, `Firm subscription is not active. Use /reactivate for lapsed firms.`, `Cannot change plan for a trial firm. Use /reactivate to upgrade from trial.`, `Cannot change plan while cancellation is scheduled. ...`, `success_url and cancel_url are required for this plan change`.

### Reactivate or upgrade from trial

`POST /v1/partner/firms/{firm_id}/reactivate`

**Permission:** Partner

Restarts billing for a lapsed workspace, undoes a scheduled cancellation, or moves a trial workspace to a paid plan.

| Name | Type | Required | Description |
|---|---|---|---|
| `plan` | string | For trial workspaces | Paid plan to start. Defaults to the current plan otherwise |
| `success_url`, `cancel_url` | string | When a checkout is needed | Stripe Checkout redirects |

If a cancellation is scheduled on a live subscription, it is undone at once (`checkout_url: null`, `reactivated: true`). Otherwise the response has a `checkout_url` for the customer.

Errors: `400` `Firm subscription is already active`, `400` `plan is required when upgrading a trial firm`, `400` `Invalid plan. ...`.

### Billing portal

`POST /v1/partner/firms/{firm_id}/billing-portal`

**Permission:** Partner

Returns a Stripe billing portal link where the customer can update their card or cancel.

| Name | Type | Required | Description |
|---|---|---|---|
| `return_url` | string | Yes | Where the portal sends the customer back to |

```json
{"portal_url": "https://billing.stripe.com/p/session/...", "firm": {"id": 5120, "...": "..."}}
```

Errors: `400` `return_url is required`, `400` `No Stripe billing account found for this firm`.

### Sessions: how usage is billed

Partner usage is billed per **session**. A session is one takeoff or job in your product, identified by your own `session_id`.

- A session is billed once. Sending the same `session_id` again returns the original charge with `deduplicated: true`.
- Detection calls with a partner key need a `session_id` and bill the session on first use. Inside the embedded editor, the first detection in a session bills it.
- Child workspaces get a few free sessions first (shown in `trial`). Trial-plan workspaces then stop with `402` `trial_exhausted` until they move to a paid plan.
- Silver workspaces pay per session. Monthly plans include a number of reports per period and bill overage per report after that. See [Limits & billing](limits-and-billing.md) for prices.
- Linked workspaces are billed under the customer's own TruTec plan.
- Sandbox workspaces never create card charges. They share a limit of 500 new sessions per 30 days for your account (see [Dev keys and sandbox workspaces](#dev-keys-and-sandbox-workspaces)).

### Charge a session

`POST /v1/partner/firms/{firm_id}/charge`

**Permission:** Partner

Bills one session yourself, for example when your user opens a takeoff.

| Name | Type | Required | Description |
|---|---|---|---|
| `session_id` | string | Yes for trial, Silver and linked workspaces | Your idempotency key for this session |
| `partner_user_id` | string | No | Your user id, for reporting |
| `partner_customer_id` | string | No | Your customer id, for reporting |

```bash
curl -X POST https://trutec.ai/api/v1/partner/firms/5120/charge \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"session_id": "job-88231", "partner_user_id": "u-417", "partner_customer_id": "c-0091"}'
```

```json
{
  "success": true,
  "usage": {"count": 12, "limit": null, "remaining": null},
  "measurement_usage": {"used_this_period": 12, "limit": 30, "total_ever": 47, "partner_access_type": "child", "billing_mode": "partner_session"},
  "billing": {
    "session_id": "job-88231",
    "status": "charged",
    "trial_applied": false,
    "partner_user_id": "u-417",
    "partner_customer_id": "c-0091",
    "deduplicated": false,
    "already_refunded": false,
    "charge_source": "legacy_charge_endpoint",
    "partner_access_type": "child",
    "billing_mode": "partner_session",
    "trial": {"eligible": true, "scope": "firm", "limit": 2, "used": 2, "remaining": 0}
  }
}
```

When a card charge was made, the response also has `overage`: `charged`, `amount_cents`, `payment_intent_id`.

Billing errors carry a machine-readable `reason`:

| Status | `reason` | Meaning |
|---|---|---|
| `400` | `session_id_required` | Send a `session_id` |
| `402` | `trial_exhausted` | Free sessions used up. Upgrade with `/reactivate` |
| `402` | `billing_required`, `missing_payment_method`, `payment_failed` | The customer's payment details need attention |
| `402` | `subscription_inactive` | Linked workspace billing is not active |
| `403` | `plan_required`, `upgrade_required` | Linked workspace plan does not cover this |
| `429` | | `API key usage limit exceeded` |
| `429` | `sandbox_limit_reached` | Your sandbox workspaces used their 500 sessions for the last 30 days. `sandbox_usage` has `used`, `limit` and `window_days` |

### Refund a session

`POST /v1/partner/firms/{firm_id}/refund-charge`

**Permission:** Partner

Reverses a session charge, for example when a takeoff failed. Any card payment for that session is refunded, and usage counters go back down. Refunding the same session twice is safe: the second call returns `already_refunded: true`.

| Name | Type | Required | Description |
|---|---|---|---|
| `session_id` | string | Yes for linked workspaces | The session to refund |
| `feedback` | string | No | Why it was refunded, up to 2,000 characters. Shared with the TruTec team |

```bash
curl -X POST https://trutec.ai/api/v1/partner/firms/5120/refund-charge \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"session_id": "job-88231", "feedback": "Imagery was out of date for this site."}'
```

Returns the same shape as a charge, with `billing.status: "refunded"` and `feedback_received: true` when feedback was saved.

Errors: `404` `Billing session not found`, `409` `Billing session is not charged`, `402` `Payment refund failed: ...`, `400` `No charged usage to refund`.

## Linking an existing TruTec workspace

Use this when your customer already has a TruTec workspace. The customer signs in to TruTec and approves the link. The first link for a workspace must be approved by a workspace owner.

### Start a link

`POST /v1/partner/link/start`

**Permission:** Partner

| Name | Type | Required | Description |
|---|---|---|---|
| `partner_user_id` | string | Yes | Your id for the user who is linking |
| `return_url` | string | Yes | `http(s)` URL to send the user back to |
| `partner_customer_id` | string | No | Your customer id |
| `email_hint` | string | No | Shown to the user on the TruTec page |
| `state` | string | No | Echoed back to `return_url` |

```json
{"link_url": "https://trutec.ai/...", "expires_in": 3600}
```

Send the user to `link_url` (valid for one hour). After approval TruTec redirects to `return_url?status=success&code=...&state=...`.

### Exchange the code

`POST /v1/partner/link/exchange`

**Permission:** Partner

Send `{"code": "..."}` within 10 minutes.

```json
{
  "linked": true,
  "firm": {"id": 4471, "name": "Lakeside Asphalt", "partner_access_type": "linked", "linked_direct": true, "...": "..."},
  "linked_workspace_token": "eyJwYXJ0bmVy...",
  "user": {"id": 9021, "email": "owner@lakeside.example", "username": "lakeside-owner"},
  "partner_user_id": "u-417",
  "partner_customer_id": "c-0091"
}
```

Store `firm.id` or `linked_workspace_token` and pass it on later calls. Errors: `400` `Invalid or expired code`, `404` `Linked workspace not found`.

## Embedding the takeoff editor

Your backend mints a short-lived URL. Your front end loads it in an iframe. Never send your API key to the browser.

### Mint an embed session

`POST /v1/partner/embed-token` or `POST /v1/partner/firms/{firm_id}/embed-token`

**Permission:** Partner

Creates (or resumes) a job in the customer's workspace and returns a signed URL valid for 24 hours. Minting does not bill anything.

| Name | Type | Required | Description |
|---|---|---|---|
| `session_id` | string | Yes | Your stable id for this takeoff. The same id resumes the same job. `external_id` is an alias |
| `firm_id` | integer | No | The workspace. If left out, TruTec works it out (see below) |
| `partner_customer_id` | string | No | Your customer id. Remembered so later sessions find the workspace |
| `partner_user_id` | string | No | Your user id. The job is owned by the linked TruTec user when known |
| `name` | string | No | Job name. Default `Parking Measurement` |
| `address` | string | No | Site address |

Without `firm_id`, the workspace is chosen from: a workspace remembered for this `partner_customer_id`, then the workspace linked to `partner_user_id`, then your only connected workspace.

```bash
curl -X POST https://trutec.ai/api/v1/partner/embed-token \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"session_id": "job-88231", "partner_customer_id": "c-0091", "partner_user_id": "u-417", "name": "Northgate Plaza", "address": "1200 Commerce Dr, Austin, TX"}'
```

```json
{
  "embed_url": "https://trutec.ai/measurements/embed/parking?token=...",
  "expires_in": 86400,
  "job_id": 4812,
  "project_id": 3307,
  "firm_id": 5120,
  "workspace_name": "Northgate Paving",
  "session_id": "job-88231",
  "external_id": "job-88231"
}
```

Mint a new URL each time the user opens the editor. Do not store embed URLs.

Errors:

- `400` `reason: session_id_required`
- `409` `reason: workspace_ambiguous`: nothing was written. The response has `candidates` (`firm_id`, `name`) and a `workspace_picker_url`. Pass `firm_id`, or load `workspace_picker_url` in the iframe so the customer picks once.
- `404` `reason: no_connected_workspace`
- `403` `Dev key can only access sandbox firms`

### Workspace picker

`POST /v1/partner/embed-token/choose-workspace`

**Permission:** None. The picker token in the body is the credential.

The page at `workspace_picker_url` calls this for you. You normally do not call it directly. Body: `token`, `firm_id`. The token works once: a second use returns `410` `picker_token_used`. A customer already mapped to another workspace returns `409` `workspace_already_mapped`; mint with an explicit `firm_id` to move them. On success it returns the same object as minting an embed session.

### Messages from the iframe

The editor talks to your page with `postMessage`. Check `event.origin === "https://trutec.ai"`.

| Message from TruTec | When |
|---|---|
| `trutec:autosave` | The editor saved |
| `trutec:elements` | Reply to `trutec:request-elements` |
| `trutec:measurement` | Reply to `trutec:request-save` |
| `trutec:save-exit` | Reply to `trutec:request-save-exit` |
| `trutec:elements-error` | A request failed (`error`, `request_id`) |

| Message you send | Effect |
|---|---|
| `trutec:request-elements` | Read the current measurements without saving |
| `trutec:request-save` | Save, then reply with `trutec:measurement` |
| `trutec:request-save-exit` | Save, then reply with `trutec:save-exit` |

Include a `request_id` and it is echoed back. Reply messages carry `data` with `project_id`, `measurement_id` and the measurement summary. Read the saved result server-side with `GET /v1/measurements/{id}?firm_id=` ([Measurements](measurements.md)).

## Measurement presets

Presets set how the takeoff editor labels and shows items in your customers' workspaces. Keys: `counter_labels` (object of category to label), `counter_display` and `view_display` (object of category to display mode), `custom_item_defs` (list), `enabled_categories` (list of category keys), `counter_unit_rules` (object of category to `{basis, divisor, label}`), `stall_centerline_enabled` (boolean). Send `null` or empty to clear a key. Invalid categories or modes return `400` with the valid values.

### Get partner defaults

`GET /v1/partner/measurement-defaults`

**Permission:** Partner

Returns `{"measurement_defaults": {...}}`: the presets copied into every workspace you create.

### Set partner defaults

`PUT /v1/partner/measurement-defaults`

**Permission:** Partner

Send one or more preset keys. Add `"apply_to_existing": true` to also update your existing child workspaces now (sandbox ones only, for a dev key).

```bash
curl -X PUT https://trutec.ai/api/v1/partner/measurement-defaults \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"counter_labels": {"handicap": "ADA Spaces"}, "apply_to_existing": true}'
```

```json
{"measurement_defaults": {"counter_labels": {"handicap": "ADA Spaces"}, "...": "..."}, "applied_to_existing_firm_ids": [5120, 5121]}
```

`400` `Provide at least one of: ...` when no preset key is sent.

### Get or update one workspace's presets

`GET /v1/partner/firms/{firm_id}/measurement-settings`
`PATCH /v1/partner/firms/{firm_id}/measurement-settings`

**Permission:** Partner

`GET` returns `{"firm_id": 5120, "measurement_settings": {...}}`. `PATCH` takes the same preset keys and returns the updated presets. `PATCH` is not available for linked workspaces (`403`).
