# Connect v1 contract

> The CoSuite Connect v1 contract for partners. Request signing, error envelope, tenant, entitlement, member, usage and customer endpoints, SSO and versioning rules.

Source: https://cosuite.io/en/developers/contract

This page is the complete server-to-server contract between CoSuite and your app. Contract id: `cosuite-connect/1.0`, current package version **1.7**. The goal in one sentence: **your app can change internally as much as it wants; as long as it follows this contract, nothing on either side breaks.**

## Principles {#principles}

1. **Your app is an independent product.** It keeps its own database, users and UI. CoSuite never reaches into it; it only calls the endpoints on this page.
2. **Who owns what:**

   | Topic | Owner |
   |---|---|
   | Business (tenant) identity, module purchases, prices, invoices | CoSuite |
   | Entitlements: which module is on, limits, end date | CoSuite (your app enforces them) |
   | Your app's own data (patients, bookings, files…) | Your app |
   | The user's session inside your app | Your app (CoSuite only proves the identity) |

3. **Separate namespace.** All contract endpoints live under `/api/cosuite/v1/*` on your base URL. Your existing public API is not affected.
4. **Additive only within v1.** Fields, endpoints and enum values are never removed, renamed or given a new meaning (see [versioning](#versioning)).
5. **Ignore what you don't know.** Both sides silently ignore unknown JSON fields, module keys and event types. This is what makes additions safe.
6. **Your outage does not stop CoSuite.** Every call CoSuite makes has a timeout, retries and a queue (see [reliability](#reliability)).

## Identifiers {#identifiers}

| Term | Meaning |
|---|---|
| app key | Your app's key, lowercase, 2–24 characters (`^[a-z][a-z0-9]{1,23}$`), for example `dent`. It appears in the manifest field `asist` and as the SSO audience. It never changes. |
| `tenantId` | The business's id in CoSuite. **Opaque string**, up to 64 characters (`[A-Za-z0-9_.:-]`). Do not rely on its format. |
| `localTenantId` | Your id for the same business (organization, workspace, clinic…). Opaque, generated by you. |
| `userId` | CoSuite user id. Opaque, up to 64 characters. |
| `module` | Module key `<appKey>.<module>`, for example `dent.treatment`. `core.*` is reserved for CoSuite. |

Keep the `tenantId ↔ localTenantId` mapping in your own table (suggested name: `cosuite_links`). A `tenantId` can be linked only once.

## Transport and signing {#signing}

All server-to-server calls use HTTPS and JSON (`application/json; charset=utf-8`), UTF-8 text and ISO 8601 UTC timestamps (`2026-10-26T09:00:00Z`).

### Credentials {#credentials}

CoSuite issues one **key pair** per app and environment: a `keyId` (for example `csk_dent_test`) and a 32-byte random `secret`. Both sides store the secret encrypted and never write it to logs. The same secret signs calls in both directions (CoSuite → your app and your app → CoSuite).

### Signature {#signature}

Every request carries four headers:

| Header | Value |
|---|---|
| `X-CoSuite-Key` | `keyId` |
| `X-CoSuite-Timestamp` | Unix time in seconds |
| `X-CoSuite-Request-Id` | Unique per request (UUID; 8–128 characters of `[A-Za-z0-9_-]`). **Stays the same** when a request is retried. |
| `X-CoSuite-Signature` | `v1=` + hex(HMAC-SHA256(secret, canonical string)) |

The canonical string is four parts joined with `|`:

```text
{METHOD}|{pathAndQuery}|{timestamp}|{hex(sha256(body))}
```

- `METHOD` in upper case (`PUT`).
- `pathAndQuery` exactly as in the request line, without host: `/api/cosuite/v1/tenants/abc/entitlements?x=1`. If a reverse proxy rewrites the path, use the raw path the client sent.
- For an empty body, use the SHA-256 of the empty string (`e3b0c442…b855`).
- Hash the **raw body bytes** as received; parse the JSON only after verifying.

The verifying side:

1. Rejects a timestamp more than **±300 seconds** away from its clock with `401 signature_expired`.
2. Compares the signature in constant time; on mismatch `401 invalid_signature`. An unknown `keyId` is `401 unknown_key`.
3. Remembers `X-CoSuite-Request-Id` for **10 minutes** on write requests: the same id with a **different body** is `409 request_id_reused`; the same id with the same body returns the first response again.

Accept request bodies up to 256 KB. Test vectors and ready-made functions for Node, .NET, PHP and Python are on the [SDK page](https://cosuite.io/en/developers/sdk#signature-snippets).

### Key rotation {#key-rotation}

During a rotation, two secrets can be valid for the same `keyId`. The verifying side tries both. The old secret is removed at the latest 7 days later.

## Responses and errors {#errors}

Success:

```json
{ "ok": true, "data": { } }
```

Error:

```json
{ "ok": false, "error": { "code": "tenant_not_found", "message": "…", "retryable": false }, "requestId": "…" }
```

- `code` is stable and meant for machines; `message` is for humans (English is enough).
- When `retryable` is `true`, CoSuite retries with the same `X-CoSuite-Request-Id`.
- Return **5xx for server errors** on contract endpoints, never 4xx: CoSuite decides whether to retry based on the status.

| HTTP | `code` | Retry? |
|---|---|---|
| 400 | `invalid_request` (+ `details`: field errors) | no |
| 401 | `invalid_signature`, `signature_expired`, `unknown_key` | no |
| 403 | `subscription_suspended` | no |
| 404 | `tenant_not_found`, `member_not_found`, `not_found` | no |
| 409 | `tenant_already_linked`, `member_conflict`, `stale_version`, `request_id_reused`, `invalid_link_code` | no |
| 413 | `payload_too_large` | no |
| 422 | `unsupported_module`, `limit_unsupported`, `capability_unsupported` | no |
| 429 | `rate_limited` (+ `Retry-After` header) | yes |
| 500/502/503 | `internal`, `unavailable` | yes |

## Endpoints your app provides {#endpoints}

All under `{baseUrl}/api/cosuite/v1`, all protected by the [signature](#signature), including `/health`.

| Method | Path | Purpose |
|---|---|---|
| GET | `/manifest` | Describe your app and modules |
| GET | `/health` | Liveness |
| POST | `/tenants` | Open or link a business |
| GET | `/tenants/{tenantId}` | Link status |
| PATCH | `/tenants/{tenantId}` | Name, language, currency, time zone or country changed |
| DELETE | `/tenants/{tenantId}` | Close the link (data deletion is scheduled) |
| PUT | `/tenants/{tenantId}/entitlements` | Write the entitlement (full document) |
| GET | `/tenants/{tenantId}/entitlements` | The last entitlement you applied |
| PUT | `/tenants/{tenantId}/members/{userId}` | Add or update a member |
| DELETE | `/tenants/{tenantId}/members/{userId}` | Remove a member |
| GET | `/tenants/{tenantId}/members` | Linked members |
| GET | `/tenants/{tenantId}/usage?period=YYYY-MM` | Usage metrics |
| GET | `/tenants/{tenantId}/summary` | Short cards for the CoSuite dashboard |
| GET | `/tenants/{tenantId}/customers?cursor=&limit=` | Customers, paginated (capability `customers`) |
| GET/POST | `/sso` | Browser sign-in (token, not signature; see [SSO](#sso)) |

Path parameters are URL-encoded; decode them before use.

### GET /manifest {#manifest}

```json
{
  "contract": "cosuite-connect/1.0",
  "asist": "dent",
  "name": "DentCloud",
  "version": "2026.11.1",
  "baseUrl": "https://app.dentcloud.example",
  "locales": ["en", "tr", "az"],
  "modules": [
    {
      "key": "dent.treatment",
      "name": { "en": "Patients and treatment plans", "tr": "Hasta ve tedavi planı", "az": "Xəstə və müalicə planı" },
      "limits": [
        { "key": "chairs", "unit": "count", "name": { "en": "Chairs", "tr": "Koltuk", "az": "Kreslo" } },
        { "key": "smsPerMonth", "unit": "count_per_month" }
      ],
      "usage": ["smsSent", "storageBytes"]
    }
  ],
  "capabilities": ["sso", "members", "usage", "summary"]
}
```

- `asist` is your app key; `baseUrl` is the root your contract endpoints hang under.
- CoSuite validates its catalog against the manifest: a module that is not in your manifest cannot be sold.
- CoSuite never calls an endpoint whose capability is missing from `capabilities` (`sso`, `members`, `usage`, `summary`, `customers`, `link`). For example, an app without `members` never receives member calls.
- Modules can describe themselves in much more detail (features, API, webhooks, markets). See [module definition](https://cosuite.io/en/developers/module-definition).

### GET /health {#health}

```json
{ "ok": true, "data": { "status": "ok", "version": "2026.11.1" } }
```

`status` is `ok` or `degraded`. Answer quickly; CoSuite uses a 3-second timeout.

### POST /tenants: open or link {#tenants-create}

Request:

```json
{
  "tenantId": "org_8f2c",
  "name": "Smile Dental Clinic",
  "locale": "en",
  "currency": "USD",
  "timeZone": "Europe/Istanbul",
  "country": "TR",
  "owner": { "userId": "usr_1a", "email": "owner@smile.example", "name": "Deniz Kaya", "locale": "en" },
  "link": null,
  "test": false
}
```

- **Idempotent:** calling again for the same `tenantId` returns `200` with the same `localTenantId`; no new account is created.
- `link` is set when the business **already has an account** in your app: `{ "code": "<one-time link code>" }`. See [linking an existing account](#link).
- Tenants with `test: true` come from the conformance test and sandbox. Do not bill them, keep them out of your reports and delete them **immediately** on `DELETE`.

Response `201` (new) or `200` (already linked):

```json
{ "ok": true, "data": { "localTenantId": "b7c1", "status": "linked", "created": true } }
```

`GET /tenants/{tenantId}` returns the same shape (`status` is `linked` or `deleting`, optionally with `entitlementVersion`). `PATCH /tenants/{tenantId}` carries any of `name`, `locale`, `currency`, `timeZone`, `country` and returns the same shape. An unknown `tenantId` is `404 tenant_not_found`.

### Linking an existing account (capability `link`) {#link}

If a business already uses your app with its own subscription, moving to CoSuite must not create a new empty account; the existing one is linked.

1. In your app, the account owner or admin clicks **"Connect to CoSuite"**. You generate and show a **one-time code valid for at most 30 minutes** (suggested format `LNK-XXXXXXXXXXXX`, 8–200 characters), bound to that account only.
2. The user enters the code in CoSuite (Subscription › "Link your existing account"). CoSuite adds the module to the subscription and sends `POST /tenants` with `link: { code }`.
3. You verify the code (exists, not expired, not used), link the **existing** account to this `tenantId` without creating a new one, consume the code and return `200 { localTenantId, status: "linked", created: false }`. An invalid or expired code is `409 invalid_link_code`, and **no account is created**; CoSuite tells the user and retries with a new code.
4. After linking:
   - The account's data and its existing members stay as they are; CoSuite members are added through the [member endpoints](#members).
   - Billing moves to CoSuite once an entitlement with `billing.managedBy = "cosuite"` arrives: stop your own recurring charge, do not charge the next period and hide your payment screens. Refunds for an already paid period are settled between you and CoSuite.
   - If a limit in the entitlement is below current usage, do not delete data; the excess becomes read-only.

Apps that do not declare `link` never see the "Link your existing account" option. Even so, **every** app must reject an unknown code with `409 invalid_link_code`; the conformance test checks this. The `tenant.linked_by_user` event is optional and informational.

### PUT /tenants/{tenantId}/entitlements {#entitlements}

CoSuite always sends the **full document**, never a diff:

```json
{
  "version": 42,
  "status": "active",
  "validUntil": "2026-11-26T00:00:00Z",
  "graceUntil": "2026-12-03T00:00:00Z",
  "modules": [
    { "key": "dent.treatment", "limits": { "chairs": 4, "smsPerMonth": 500 } },
    { "key": "dent.imaging" }
  ],
  "billing": { "managedBy": "cosuite" },
  "issuedAt": "2026-10-26T09:00:00Z"
}
```

Rules:

- **`version` only goes up.** If the incoming version is less than or equal to the one you applied, do not apply it and still return `200` with `applied: false`. This makes late, repeated or out-of-order calls harmless.
- A module that is not in `modules` is **off**.
- A limit that is not given uses your default for that module.
- Ignore unknown module keys and list them in `ignored` in the response.
- `status` and what your app does:

  | `status` | Your app |
  |---|---|
  | `trial`, `active` | Works normally |
  | `past_due` | Works normally, shows a "payment pending" notice |
  | `suspended` | **Read-only**: data is visible, writes return `403 subscription_suspended` |
  | `canceled` | Sign-in is closed; data is kept until `DELETE` arrives |

- When `billing.managedBy` is `"cosuite"`, **do not show your own payment screens, upgrade buttons or trial counters** to this business. This prevents double billing. Derive your internal plan fields from this document.

Response:

```json
{ "ok": true, "data": { "applied": true, "version": 42, "ignored": [] } }
```

`GET /tenants/{tenantId}/entitlements` returns the last document you applied (or `null`). CoSuite uses it for its nightly reconciliation.

### Members {#members}

`PUT /tenants/{tenantId}/members/{userId}`:

```json
{ "email": "ayse@smile.example", "name": "Ayşe Demir", "locale": "tr", "role": "admin" }
```

- `role` is one of `owner`, `admin`, `member`, `viewer`. Map it to your own role model.
- A person works in one business. If your app allows an email in only one account and that email already belongs to another account, return `409 member_conflict`; never move accounts on your own. CoSuite marks the member as "could not connect" and shows it to the admin.
- People are never deleted, only removed from the business (`DELETE`). Close their sessions in your app. An unknown member is `404 member_not_found`.
- `GET /tenants/{tenantId}/members` returns `{ "members": [ { "userId", "email", "name", "locale", "role", "localUserId"? } ] }`.

### GET /tenants/{tenantId}/usage {#usage}

```json
{ "ok": true, "data": { "period": "2026-10", "metrics": { "smsSent": 212, "storageBytes": 734003200 }, "asOf": "2026-10-26T08:00:00Z" } }
```

Metric keys come from the module's `usage` list in your manifest. Once a month has closed, its values must not change. An invalid `period` is `400 invalid_request`.

### GET /tenants/{tenantId}/summary {#summary}

Cards for the business's CoSuite home page. The language comes from the `Accept-Language` header.

```json
{ "ok": true, "data": { "cards": [
  { "key": "today", "title": "Appointments today", "value": "18", "trend": null, "url": "https://app.dentcloud.example/calendar" }
] } }
```

- At most 6 cards.
- If the response does not arrive within **1 second**, CoSuite skips the cards. Serve them from a cache, never run heavy queries here.

### Customers (capability `customers`) {#customers}

Your customers appear in CoSuite's customer list with your app's badge, a source filter and a link to the record in your app. Direction is **your app → CoSuite**: you own the record and it is edited in your app; CoSuite shows these fields as locked. Customers created in CoSuite are not sent to you.

**1. Paginated list:** `GET /tenants/{tenantId}/customers?limit=200&cursor=…`

```json
{ "ok": true, "data": {
  "customers": [
    { "id": "cus_8x2", "kind": "person", "name": "Ayşe Demir", "email": "ayse@example.com", "phone": "+905321234567",
      "taxNumber": null, "city": "Istanbul", "country": "TR", "url": "https://app.dentcloud.example/patients/cus_8x2",
      "updatedAt": "2026-10-26T09:12:00Z" }
  ],
  "nextCursor": "eyJ1IjoiMjAyNi0xMC0yNlQwOToxMjowMFoiLCJpIjoiY3VzXzh4MiJ9"
} }
```

- `limit` is 1–200 (default 200); outside that range, or with a cursor you cannot decode, return `400 invalid_request`.
- The order must be stable (suggestion: `updatedAt, id` ascending, with the cursor carrying both). The last page has `nextCursor: null`.
- Deleted customers are not listed.
- CoSuite calls this when the link is created (initial import) and every night (drift check).

**2. Events**, from your outbox when a customer is created, changed or deleted (see [events](#events)):

```json
{ "id": "evt_01", "type": "customer.upserted", "tenantId": "org_8f2c", "occurredAt": "2026-10-26T09:12:00Z", "data": { "customer": { } } }
```

```json
{ "id": "evt_02", "type": "customer.deleted", "tenantId": "org_8f2c", "occurredAt": "2026-10-26T09:30:00Z", "data": { "customerId": "cus_8x2", "deletedAt": "2026-10-26T09:30:00Z" } }
```

`data.customer` has exactly the shape of a list item.

| Field | Rule |
|---|---|
| `id` | Your own id, never changes (1–64, `[A-Za-z0-9_.:-]`) |
| `kind` | `person` or `company`; unknown values count as `person` |
| `name` | Required, 1–200 characters |
| `email`, `phone`, `taxNumber`, `city`, `country`, `url` | Optional. `phone` in E.164 (`+905…`), `country` ISO 3166 two letters, `url` under your `baseUrl`. **An invalid value does not drop the record; it is treated as empty.** |
| `updatedAt` | Required. CoSuite ignores updates older than what it has, so out-of-order events are safe. |

In CoSuite, a customer with the same email or phone in the same business is matched instead of duplicated. A customer deleted in your app is not deleted in CoSuite; only the link is removed (it may have invoices).

### DELETE /tenants/{tenantId} {#tenants-delete}

- The link closes and sign-in stops.
- Keep the data for **30 days**, then delete it. CoSuite may send `{ "retentionDays": 0–90 }` in the body to change this. If `POST /tenants` arrives for the same `tenantId` within that window, reopen the account.
- `test: true` tenants are deleted immediately.
- Response: `200 { "deleted": true }`. When the data is finally deleted, send the `tenant.data_deleted` event.

**Accounts linked with a code** (`created: false`, see [link](#link)) are **detached, not deleted**:

- The link and the CoSuite member mappings are removed; the account, its data and its users stay in your app and sign-in stays open.
- Billing returns to you (the `managedBy: "cosuite"` effect ends); the plan follows your own rules.
- `retentionDays` is ignored and `tenant.data_deleted` is **not** sent.
- Response: `200 { "deleted": false, "detached": true }`. CoSuite closes the link immediately.
- Linking the same `tenantId` again requires a new link code; `POST /tenants` without a code opens a new account.
- A linked account never counts as a test account, even if the request says `test: true`.

## Endpoints CoSuite provides {#cosuite-endpoints}

Base URL `https://api.cosuite.io/connect/v1`. Same [signature](#signature), signed with your own `keyId`.

| Method | Path | Purpose |
|---|---|---|
| GET | `/tenants/{tenantId}/entitlements` | Fetch the latest entitlement when in doubt |
| POST | `/events` | Send an event to CoSuite |
| GET | `/jwks.json` | Public keys for verifying SSO tokens (public, unsigned) |

### POST /events {#events}

```json
{ "id": "evt_01J9", "type": "usage.threshold_reached", "tenantId": "org_8f2c", "occurredAt": "2026-10-26T09:00:00Z", "data": { "metric": "smsPerMonth", "percent": 80 } }
```

| Event | When |
|---|---|
| `tenant.linked_by_user` | A user clicked "Connect to CoSuite" in your app (`data.linkCode`) |
| `usage.threshold_reached` | 80% or 100% of a limit is used |
| `member.left` | A member deleted their own account in your app |
| `tenant.data_deleted` | The retention period ended and the data was deleted |
| `customer.upserted` | A customer was created or changed (`data.customer`; capability `customers`) |
| `customer.deleted` | A customer was deleted (`data.customerId`, `data.deletedAt`) |

- Send events from an **outbox**: write the event to your database first, then deliver it. Retry with increasing intervals; mark it dead if it cannot be delivered within 24 hours.
- Use the event `id` as `X-CoSuite-Request-Id`. CoSuite de-duplicates by `id`, so delivering twice is harmless.

## Single sign-on (SSO) {#sso}

When a user clicks "Open" for your app in CoSuite:

1. CoSuite issues a short-lived **JWT** and the browser posts it with an auto-submitted form to `POST {baseUrl}/api/cosuite/v1/sso`. The token never appears in the address bar or in logs.
2. The token is signed with **ES256** (asymmetric). Verify it with CoSuite's public keys at `https://api.cosuite.io/connect/v1/jwks.json`. Cache the keys for at least one hour; on an unknown `kid`, refetch at most once a minute. Because there is no shared secret, no app can mint sign-in tokens for another app.
3. Token claims:

   ```json
   { "iss": "https://cosuite.io", "aud": "dent", "sub": "usr_1a", "tid": "org_8f2c",
     "email": "owner@smile.example", "name": "Deniz Kaya", "locale": "en", "role": "owner",
     "redirect": "/calendar", "iat": 1761469200, "exp": 1761469260, "jti": "4f1c…" }
   ```

4. Check that:
   - the signature is valid and the algorithm is ES256;
   - `iss` is `https://cosuite.io` and `aud` is your app key;
   - `exp` has not passed (lifetime at most **60 seconds**, `exp - iat ≤ 60`; allow ±30 seconds of clock skew);
   - `jti` has not been used before (single use; remember it for 2 minutes);
   - `tid` is a linked tenant.
5. If the member does not exist yet but the tenant is linked, create the member on the spot (same rules as [members](#members)). Then start your own session and redirect to `redirect`. `redirect` may only be a path on your own domain: it starts with `/` and not with `//` or `/\`.
   - You may also establish the session through your own landing page (for example a one-time ticket plus your own identity provider). Then the first redirect carries the `redirect` path and the landing page goes there after the session is set. Carry the ticket in the URL fragment (`#…`) so it never reaches server logs.
6. On any error, show your own sign-in page with a link back to CoSuite. Never show the raw error to the user.

## Language, currency, time {#locale}

- Languages are codes such as `en`, `tr`, `az`. If you do not support a language, fall back to the closest one you support; never fail.
- Currency (`USD`, `TRY`, `EUR`, `AZN`…) is informational. **Pricing and collection belong to CoSuite**; do not show prices to a business connected through CoSuite.
- Time zones are IANA names (`Europe/Istanbul`, `Asia/Baku`).

## Reliability (CoSuite's promise) {#reliability}

- CoSuite's calls to your app time out after 10 seconds; `summary` after 1 second; `health` after 3 seconds.
- Write calls (`tenants`, `entitlements`, `members`) are sent from CoSuite's outbox. If your app is down, the sale and payment still complete; the call is retried after 1 min, 5 min, 30 min, 2 h and 6 h, with the same request id.
- After 5 consecutive failures CoSuite stops calling your app for 2 minutes (circuit breaker), then sends a single probe.
- **Nightly reconciliation:** every night CoSuite reads `GET …/entitlements` for every linked tenant and resends the current document if your `version` is behind.

## Versioning: the "don't break us" list {#versioning}

**Allowed within v1:**

- Adding an endpoint (and its capability in the manifest).
- Adding a field to a response.
- Adding an **optional** field to a request.
- Adding a module, limit, usage metric, event type or error code.

**Careful:** the fixed lists CoSuite **sends** to you (`status`, `role`, `billing.managedBy`) are not extended within v1, because an older app would not recognize a new value.

**Not allowed within v1:**

- Removing or renaming a field, endpoint, enum value or error code.
- Changing a field's type or meaning (for example turning a monthly limit into a daily one).
- Making an optional field required.
- Changing the HTTP status of a successful response.
- Changing authentication or the signature format.
- Renaming a module key. Add a new key instead; the old one lives for at least 6 months.

**If a breaking change is ever needed**, `/api/cosuite/v2` opens **next to** v1; v1 is not switched off. The shutdown date of v1 is announced at least **6 months** in advance, via a `deprecated` field in the manifest (`{ "sunset": "<ISO date>", "message": "…" }`) and a `Deprecation` response header.

The same rules apply to you: once your module is live, treat its keys, limits, scopes and webhook types as permanent. CoSuite enforces its side with an automated contract check in the SDK; your side is enforced by the [conformance test](https://cosuite.io/en/developers/sdk#conformance).
