Gateway & customer API
How customers call your module's API through api.cosuite.io with their CoSuite key, and how your app verifies and serves those requests (contract 1.8).
Customers call your module's API at https://api.cosuite.io/v1/apps/{app}/{path} with their own CoSuite API key. CoSuite checks the key, the subscription and the scope, then forwards the request to your app with a Connect signature and the calling business in X-CoSuite-Tenant. Your app never sees the customer's key, and the customer never sees yours.
Customer software ──Bearer cs_api_…──▶ api.cosuite.io/v1/apps/dent/patients
│ key · subscription · route · scope · rate limit
▼
{server}/patients ◀── Connect signature + X-CoSuite-Tenant
For your app: serving gateway requests
The gateway only forwards operations that exist in your module's OpenAPI 3.1 document, and only when the customer's key holds a scope the operation requires.
Address. Requests go to servers[0].url of your OpenAPI document when it is on the same origin as your app's base URL; otherwise to the base URL. Path and query are appended unchanged: GET /v1/apps/dent/patients?page=2 → GET {server}/patients?page=2.
Headers you receive (in addition to the Connect signature headers):
| Header | Meaning |
|---|---|
X-CoSuite-Gateway: 1 |
The request comes from the gateway, not from a Connect call. |
X-CoSuite-Tenant |
The business's tenantId (the same id as in POST /tenants). Return only this business's data. |
X-CoSuite-Scopes |
The key's scopes valid for this request, space separated. |
X-CoSuite-Api-Key-Id |
Id of the customer's CoSuite key (not the key itself), for your logs and limits. |
Content-Type, Accept, Accept-Language, If-Match, If-None-Match |
Passed through from the customer. |
The customer's Authorization header is never forwarded.
Your obligations:
- Verify the signature and reject replayed request ids.
verifyGatewayRequestdoes both. - For an unknown or unlinked
tenantId, answer404withtenant_not_foundand return no data. - Answer within 30 seconds, at most 10 MB, and never with a redirect (3xx is treated as an error).
- The gateway already checked the operation's scope; you may narrow further using
X-CoSuite-Scopes.
import { verifyGatewayRequest, MemoryReplayStore } from "@cosuite/connect";
const replay = new MemoryReplayStore(); // use Redis or your database with several processes
export async function GET(req: Request) {
const url = new URL(req.url);
const body = await req.text();
const v = await verifyGatewayRequest({
method: req.method,
pathAndQuery: url.pathname + url.search,
body,
headers: req.headers,
secretsFor: (keyId) => (keyId === process.env.COSUITE_KEY_ID ? [process.env.COSUITE_SECRET!] : undefined),
replay,
});
if (!v.ok) return Response.json({ ok: false, error: { code: "unauthorized", message: v.reason, retryable: false } }, { status: 401 });
const account = await findAccountByCoSuiteTenant(v.context.tenantId);
if (!account) return Response.json({ ok: false, error: { code: "tenant_not_found", message: "tenant not found", retryable: false } }, { status: 404 });
return Response.json({ items: await listPatients(account.id) });
}
In other languages, verify the signature exactly as for Connect calls and read the headers above.
Conformance. For every module with an api, the conformance test calls up to three parameterless GET operations:
- With the test business: an answer is expected.
- With an unknown business:
404 tenant_not_foundis expected. - Without a signature:
401is expected.
For customers: calling the API
- In the CoSuite panel, open Management › API keys and create a key. Choose the app and only the scopes you need; set an expiry if you can. The key is shown once.
- Send it as a bearer token:
curl -H "Authorization: Bearer $COSUITE_API_KEY" \
"https://api.cosuite.io/v1/apps/dent/patients?page=2"
The key works only for the app it was created for, only with the scopes you chose, and only while the module is in your subscription. When you drop the module its scopes stop working; revoked or expired keys fail immediately. Every call is recorded in the API audit log (key, endpoint, scope, status, duration — never bodies) and kept for 12 months.
Limits:
- Request body: at most 2 MB.
- 600 requests per minute per business, 300 per minute per key.
- An app that fails 5 times in a row is paused for 60 seconds.
Webhooks
Your app declares the events a module emits in the manifest (modules[].webhooks[], see module definition) and sends each occurrence to CoSuite like any other event: POST /connect/v1/events with tenantId, a unique id, the declared type and data. CoSuite forwards it to the businesses that subscribed and does not keep the body after delivery.
Customers subscribe in Management › API keys › Webhooks: they pick the app, the events and an https address, and receive a signing secret once. Each delivery is a POST with the JSON body { "id", "type", "app", "occurredAt", "data" } and these headers:
| Header | Meaning |
|---|---|
X-CoSuite-Event |
Event type, e.g. treatment.completed |
X-CoSuite-Delivery |
Delivery id (the same on every retry) |
X-CoSuite-Timestamp |
Unix seconds |
X-CoSuite-Signature |
v1= + hex HMAC-SHA256 of {timestamp}.{raw body} with the secret |
Verify the signature, accept timestamps within 5 minutes, and answer 2xx within 10 seconds. Failed deliveries are retried for up to 24 hours (after 1, 5, 15, 60, 180, 360 and 720 minutes). Use the event id to ignore duplicates.
Errors
The app's own responses (status and body) are returned unchanged. Errors produced by the gateway use the Connect envelope { "ok": false, "error": { "code", "message", "retryable" } }:
| Status | Code | When |
|---|---|---|
| 401 | unauthorized |
Missing, invalid, revoked or expired key |
| 403 | forbidden |
The key belongs to another app |
| 403 | not_subscribed |
None of the key's scopes are in the current subscription |
| 403 | insufficient_scope |
The operation needs a scope the key doesn't have |
| 404 | app_not_found · route_not_found |
Unknown app, or the path isn't in the app's API |
| 405 | method_not_allowed |
The path exists, the method doesn't |
| 409 | not_linked |
The business hasn't been linked to the app yet (open it once from CoSuite) |
| 413 | payload_too_large |
Body over 2 MB |
| 429 | rate_limited |
Rate limit exceeded; see Retry-After |
| 502 | bad_gateway |
The app couldn't be reached, redirected, or answered over 10 MB |
| 503 | app_unavailable |
The app is paused after repeated failures; see Retry-After |
| 504 | upstream_timeout |
The app didn't answer within 30 seconds |
Every gateway response carries X-CoSuite-Request-Id; quote it when you contact support.