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.
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
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.
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) Separate namespace. All contract endpoints live under
/api/cosuite/v1/*on your base URL. Your existing public API is not affected.Additive only within v1. Fields, endpoints and enum values are never removed, renamed or given a new meaning (see versioning).
Ignore what you don't know. Both sides silently ignore unknown JSON fields, module keys and event types. This is what makes additions safe.
Your outage does not stop CoSuite. Every call CoSuite makes has a timeout, retries and a queue (see reliability).
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
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
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
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 |:
{METHOD}|{pathAndQuery}|{timestamp}|{hex(sha256(body))}
METHODin upper case (PUT).pathAndQueryexactly 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:
- Rejects a timestamp more than ±300 seconds away from its clock with
401 signature_expired. - Compares the signature in constant time; on mismatch
401 invalid_signature. An unknownkeyIdis401 unknown_key. - Remembers
X-CoSuite-Request-Idfor 10 minutes on write requests: the same id with a different body is409 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.
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
Success:
{ "ok": true, "data": { } }
Error:
{ "ok": false, "error": { "code": "tenant_not_found", "message": "…", "retryable": false }, "requestId": "…" }
codeis stable and meant for machines;messageis for humans (English is enough).- When
retryableistrue, CoSuite retries with the sameX-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
All under {baseUrl}/api/cosuite/v1, all protected by the 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) |
Path parameters are URL-encoded; decode them before use.
GET /manifest
{
"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"]
}
asistis your app key;baseUrlis 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 withoutmembersnever receives member calls. - Modules can describe themselves in much more detail (features, API, webhooks, markets). See module definition.
GET /health
{ "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
Request:
{
"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
tenantIdreturns200with the samelocalTenantId; no new account is created. linkis set when the business already has an account in your app:{ "code": "<one-time link code>" }. See linking an existing account.- Tenants with
test: truecome from the conformance test and sandbox. Do not bill them, keep them out of your reports and delete them immediately onDELETE.
Response 201 (new) or 200 (already linked):
{ "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)
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.
- 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. - The user enters the code in CoSuite (Subscription › "Link your existing account"). CoSuite adds the module to the subscription and sends
POST /tenantswithlink: { code }. - You verify the code (exists, not expired, not used), link the existing account to this
tenantIdwithout creating a new one, consume the code and return200 { localTenantId, status: "linked", created: false }. An invalid or expired code is409 invalid_link_code, and no account is created; CoSuite tells the user and retries with a new code. - After linking:
- The account's data and its existing members stay as they are; CoSuite members are added through the member endpoints.
- 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
CoSuite always sends the full document, never a diff:
{
"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:
versiononly goes up. If the incoming version is less than or equal to the one you applied, do not apply it and still return200withapplied: false. This makes late, repeated or out-of-order calls harmless.A module that is not in
modulesis off.A limit that is not given uses your default for that module.
Ignore unknown module keys and list them in
ignoredin the response.statusand what your app does:statusYour app trial,activeWorks normally past_dueWorks normally, shows a "payment pending" notice suspendedRead-only: data is visible, writes return 403 subscription_suspendedcanceledSign-in is closed; data is kept until DELETEarrivesWhen
billing.managedByis"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:
{ "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
PUT /tenants/{tenantId}/members/{userId}:
{ "email": "ayse@smile.example", "name": "Ayşe Demir", "locale": "tr", "role": "admin" }
roleis one ofowner,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 is404 member_not_found. GET /tenants/{tenantId}/membersreturns{ "members": [ { "userId", "email", "name", "locale", "role", "localUserId"? } ] }.
GET /tenants/{tenantId}/usage
{ "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
Cards for the business's CoSuite home page. The language comes from the Accept-Language header.
{ "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)
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=…
{ "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"
} }
limitis 1–200 (default 200); outside that range, or with a cursor you cannot decode, return400 invalid_request.- The order must be stable (suggestion:
updatedAt, idascending, with the cursor carrying both). The last page hasnextCursor: 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):
{ "id": "evt_01", "type": "customer.upserted", "tenantId": "org_8f2c", "occurredAt": "2026-10-26T09:12:00Z", "data": { "customer": { } } }
{ "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}
- 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. IfPOST /tenantsarrives for the sametenantIdwithin that window, reopen the account. test: truetenants are deleted immediately.- Response:
200 { "deleted": true }. When the data is finally deleted, send thetenant.data_deletedevent.
Accounts linked with a code (created: false, see 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. retentionDaysis ignored andtenant.data_deletedis not sent.- Response:
200 { "deleted": false, "detached": true }. CoSuite closes the link immediately. - Linking the same
tenantIdagain requires a new link code;POST /tenantswithout 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
Base URL https://api.cosuite.io/connect/v1. Same 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
{ "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
idasX-CoSuite-Request-Id. CoSuite de-duplicates byid, so delivering twice is harmless.
Single sign-on (SSO)
When a user clicks "Open" for your app in CoSuite:
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.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 unknownkid, refetch at most once a minute. Because there is no shared secret, no app can mint sign-in tokens for another app.Token claims:
{ "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…" }Check that:
- the signature is valid and the algorithm is ES256;
issishttps://cosuite.ioandaudis your app key;exphas not passed (lifetime at most 60 seconds,exp - iat ≤ 60; allow ±30 seconds of clock skew);jtihas not been used before (single use; remember it for 2 minutes);tidis a linked tenant.
If the member does not exist yet but the tenant is linked, create the member on the spot (same rules as members). Then start your own session and redirect to
redirect.redirectmay 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
redirectpath and the landing page goes there after the session is set. Carry the ticket in the URL fragment (#…) so it never reaches server logs.
- 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
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
- 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)
- CoSuite's calls to your app time out after 10 seconds;
summaryafter 1 second;healthafter 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 …/entitlementsfor every linked tenant and resends the current document if yourversionis behind.
Versioning: the "don't break us" list
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.