From e7a90875c3d6f06cd34218b717face30cc6fdda1 Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Thu, 5 Nov 2026 07:54:18 -0600 Subject: [PATCH 1/3] Scheduling: customers' own OAuth apps Scale and Enterprise customers can connect hosts' calendars through their own Google, Microsoft or Zoom app, so the consent screen shows their brand. - scheduling_oauth_app: one per environment and provider, with the secret encrypted and covered by reencryptWebhookSecrets. Connections and consents are pinned to the app that issued their tokens, so adding an app never disturbs hosts already connected. - A customer's page on their own domain can be the redirect URI and forward to the existing callback, since Google verifies redirect domains as the app owner's. completion_url says where the browser lands afterwards. - A refused client secret (invalid_client, deleted_client) is now transient, so it's one failing app with one webhook and one owner email rather than every host marked needs_reauth. This also covers coss.com's own apps. - Zoom deauthorization events are per app (/scheduling/zoom/events/:appId) and scoped to that app's connections, so a customer holding their own event token can't reach other organizations'. - Plan gate schedulingOauthApps on creating only; rotating and deleting always work. Self-serve downgrades are refused while apps exist. - API, SDK, dashboard page, docs, and the hosted page's consent copy. The migration is generated after merging main. --- .changeset/scheduling-oauth-apps.md | 6 + AGENTS.md | 9 +- apps/api/openapi.json | 10401 +++++++++------- apps/api/src/middleware/request-log.ts | 5 +- apps/api/src/routes/scheduling-callbacks.ts | 25 +- apps/api/src/routes/scheduling-client.ts | 13 +- apps/api/src/routes/scheduling.ts | 168 + apps/api/src/webhook-events.ts | 2 + apps/api/test/agent-surface-exemptions.ts | 8 + apps/api/test/app.test.ts | 2 + apps/api/test/contract-types.ts | 13 +- apps/api/test/scheduling-oauth-apps.test.ts | 415 + apps/api/test/sdk-contract.test.ts | 38 + .../src/app/h/[sessionId]/calendars.tsx | 33 +- .../scheduling/src/app/h/[sessionId]/types.ts | 3 + .../content/docs/scheduling/calendars.mdx | 3 +- apps/site/content/docs/scheduling/meta.json | 1 + .../content/docs/scheduling/oauth-apps.mdx | 182 + apps/site/content/docs/scheduling/zoom.mdx | 6 +- apps/site/src/lib/changelog.ts | 6 + apps/site/src/lib/sdk-samples.ts | 20 + .../[envSlug]/scheduling/actions.ts | 93 + .../[envSlug]/scheduling/hosts/page.tsx | 19 +- .../oauth-apps/oauth-app-actions.tsx | 275 + .../[envSlug]/scheduling/oauth-apps/page.tsx | 189 + .../scheduling/scheduling-actions.tsx | 14 + apps/web/src/lib/billing-preflight.ts | 9 +- apps/web/src/lib/navigation.ts | 1 + apps/worker/src/jobs.ts | 10 + packages/billing/src/catalog/entitlements.ts | 2 + packages/billing/src/catalog/plans.ts | 4 + packages/cli/src/commands/open.ts | 1 + packages/dashboard-routes/src/index.ts | 1 + packages/db/src/id.ts | 1 + packages/db/src/schema/scheduling.ts | 74 + packages/email/src/test-event-schemas.ts | 3 + packages/email/src/test-events.ts | 8 + packages/scheduling/AGENTS.md | 39 + .../scheduling/src/calendar/connections.ts | 147 +- .../src/calendar/delegation-notifications.ts | 86 +- .../scheduling/src/calendar/delegation.ts | 2 + .../src/calendar/oauth-app-state.ts | 190 + .../scheduling/src/calendar/oauth-apps.ts | 520 + packages/scheduling/src/calendar/oauth.ts | 29 +- packages/scheduling/src/calendar/zoom.ts | 9 +- packages/scheduling/src/errors.ts | 6 + packages/scheduling/src/index.ts | 1 + packages/scheduling/src/schemas.ts | 217 + packages/scheduling/src/settings.ts | 18 + packages/scheduling/src/testing.ts | 23 + packages/scheduling/src/webhook-types.ts | 3 + packages/scheduling/test/oauth-apps.test.ts | 762 ++ packages/scheduling/test/reencrypt.test.ts | 3 +- packages/sdk/src/core/routes.generated.ts | 2 + packages/sdk/src/index.ts | 1 + packages/sdk/src/resources/scheduling.ts | 73 + packages/sdk/src/types/scheduling.ts | 102 +- packages/sdk/src/types/webhook.ts | 4 +- packages/sdk/src/webhook-event-types.ts | 2 + packages/sdk/test/diagnose-products.test.ts | 4 + packages/webhooks/AGENTS.md | 2 +- packages/webhooks/src/reencrypt.ts | 73 + tooling/sdkgen/src/methods.ts | 6 + 63 files changed, 9976 insertions(+), 4411 deletions(-) create mode 100644 .changeset/scheduling-oauth-apps.md create mode 100644 apps/api/test/scheduling-oauth-apps.test.ts create mode 100644 apps/site/content/docs/scheduling/oauth-apps.mdx create mode 100644 apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/oauth-apps/oauth-app-actions.tsx create mode 100644 apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/oauth-apps/page.tsx create mode 100644 packages/scheduling/src/calendar/oauth-app-state.ts create mode 100644 packages/scheduling/src/calendar/oauth-apps.ts create mode 100644 packages/scheduling/test/oauth-apps.test.ts diff --git a/.changeset/scheduling-oauth-apps.md b/.changeset/scheduling-oauth-apps.md new file mode 100644 index 000000000..191eb1346 --- /dev/null +++ b/.changeset/scheduling-oauth-apps.md @@ -0,0 +1,6 @@ +--- +"coss": minor +"coss-cli": patch +--- + +Scheduling's own OAuth apps: `coss.scheduling.oauthApps` connects hosts' calendars through your own Google, Microsoft or Zoom app, so the consent screen shows your brand. Calendar connections gain `oauth_app`, settings gain `privacy_url`, and there are `scheduling.oauth_app.failing` and `.restored` webhooks. `coss open scheduling-oauth-apps` opens its dashboard page. diff --git a/AGENTS.md b/AGENTS.md index be22afd39..ce01ed53a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -269,8 +269,8 @@ variables (`infra/test` enforces the list): a hash that differs between laptops | `LOG_RETENTION_DAYS` | worker: days of environment logs kept (1-31, default 30); whole expired day partitions are dropped hourly | | `SCHEDULING_DATA_KEYS` | api, web, worker: comma-separated 32-byte base64 keys, newest first, encrypting attendees' details and answers. Unset in production → Scheduling is off (503 `scheduling_unavailable`); a fixed development key otherwise | | `SCHEDULING_PUBLIC_URL` | api, web, worker: origin of the hosted scheduling pages (`apps/scheduling`). Default `http://localhost:3005` outside production; unset in production → sessions have no `url` and emails no manage link | -| `GOOGLE_CALENDAR_CLIENT_ID`, `GOOGLE_CALENDAR_CLIENT_SECRET`, `MICROSOFT_CALENDAR_CLIENT_ID`, `MICROSOFT_CALENDAR_CLIENT_SECRET` | api, worker: Scheduling's OAuth apps for calendar access, separate from sign-in's. Redirect URIs `${API_PUBLIC_URL}/scheduling/oauth/{google,microsoft}/callback`. Each pair together, or that provider can't be connected | -| `ZOOM_CLIENT_ID`, `ZOOM_CLIENT_SECRET`, `ZOOM_WEBHOOK_SECRET_TOKEN` | api, worker: Scheduling's Zoom app, for meetings on hosts' accounts. Redirect URI `${API_PUBLIC_URL}/scheduling/oauth/zoom/callback`, deauthorization events at `${API_PUBLIC_URL}/scheduling/zoom/events`, verified with the token. The pair together, or Zoom can't be connected | +| `GOOGLE_CALENDAR_CLIENT_ID`, `GOOGLE_CALENDAR_CLIENT_SECRET`, `MICROSOFT_CALENDAR_CLIENT_ID`, `MICROSOFT_CALENDAR_CLIENT_SECRET` | api, worker: Scheduling's OAuth apps for calendar access, separate from sign-in's. Redirect URIs `${API_PUBLIC_URL}/scheduling/oauth/{google,microsoft}/callback`. Each pair together, or that provider can't be connected except through an environment's own OAuth app | +| `ZOOM_CLIENT_ID`, `ZOOM_CLIENT_SECRET`, `ZOOM_WEBHOOK_SECRET_TOKEN` | api, worker: Scheduling's Zoom app, for meetings on hosts' accounts. Redirect URI `${API_PUBLIC_URL}/scheduling/oauth/zoom/callback`, deauthorization events at `${API_PUBLIC_URL}/scheduling/zoom/events`, verified with the token. The pair together, or Zoom can't be connected except through an environment's own OAuth app | | `SMS_PROVIDER` | api, web, worker: provider of live environments' SMS and of new numbers (`fake` default, or `twilio`). Production with `fake` refuses to buy numbers or submit registrations, so nothing live is sent; the api only needs the name (callbacks are verified with each subaccount's token). Shape in `@coss/sms/env` | | `RISK_PHONE_LOOKUP`, `RISK_PHONE_SIM_SWAP` | api: risk's phone checks in live environments (`off` default: live checks answer 503; `twilio`: Twilio Lookup's line type and carrier with the parent account's `TWILIO_ACCOUNT_SID`/`TWILIO_AUTH_TOKEN`, paid per lookup — Twilio's DPA purpose must cover it first). `RISK_PHONE_SIM_SWAP=true` also asks for SIM swaps (only after Twilio's carrier approval and adding them back to the DPA: the phone check data and Annex 3's Twilio row). Sandbox environments use fixtures | | `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_PRIMARY_CUSTOMER_PROFILE_SID`, `SMS_SUBACCOUNT_PREFIX` | web + worker with `SMS_PROVIDER=twilio` (the first two also api, with `RISK_PHONE_LOOKUP=twilio`): Cal.com's parent Twilio account (shared ISV), the approved primary customer profile organizations' 10DLC brands are registered under, and the prefix of the subaccounts coss.com creates (`-`). Twilio calls back `${API_PUBLIC_URL}/sms/twilio/*` | @@ -534,7 +534,7 @@ notification emails) and, if there is useful advice, to `ADVICE` (`packages/repu across orgs like `authorizeLiveSender`), `receiveEmail`/`routeRecipients` (receiving; from the envelope recipients), `recordSmsEvent`/`ingestSmsEvents` (from the `sms_id` of the callback URL), `receiveSms` (from the receiving number), `handleStatusCallback`/`handleInboundCallback` (from the callback's subaccount), `authorizeLiveSmsSender`, `ingestDmarcReports` (from the report address's token), `reencryptWebhookSecrets`, `reencryptSchedulingData` (operator scripts), `authenticateHookPortalSession` (from the session), `ingestSupportEmails` (one environment, across senders) and the rest of `@coss/support`'s worker functions, `submitForm`/`getHostedForm` (from the form id of `/f/:id`), `confirmFormSubmission`/`findConfirmation` (from the verification token), and the worker scans `runDueWebhookDeliveries`, `runDueLogDrainDeliveries`, `runDueHookDeliveries`, `parkHeldHookDeliveries`, `runDueHookRecoveries`, the Hooks purges, `recheckDueDomains`, `recheckDueTrackingDomains`, `recheckDueReceivingDomains`, `reconcileDedicatedIpPools`, `sweepStaleClaims`, `sweepUnclaimedEmails`, `runDueReputationEvaluations`, `runDueSubaccountEvaluations`, `runDueOrganizationDeletions`, `runDueOrganizationLogos`, `runDueSubaccountDeletions`, `runDueDmarcNotifications`, `runDueMigrations`, `EmailService.dispatchDue`, `unclaimedEmailBacklog`, `unclaimedSmsBacklog` (the `dispatch-backlog` loop: counts only), `SmsService.dispatchDue`, `sweepStaleSmsClaims`, `sweepUnclaimedSms`, `advanceDueSmsRegistrations`, `chargeDueSmsFees`, `chargeDueInboxFees`, `recordDueVerifySmsUsage` (bills each organization for its codes from coss.com's shared numbers, reading coss.com's messages), - `authenticateSchedulingSession` (from the token), `handleCalendarPush`, `completeCalendarAuthorization` (from the push's channel and the consent's state), `runDueCalendarDelegations`, `handleZoomDeauthorization` (from Zoom's signed event), `refreshIdleZoomConnections`, `runDueCalendarSyncs`, `runDueCalendarWrites`, `renewDueCalendarChannels`, `checkHostEditedEvents`, `runDueSchedulingNotifications`, `expireDueBookings`, + `authenticateSchedulingSession` (from the token), `handleCalendarPush`, `completeCalendarAuthorization` (from the push's channel and the consent's state), `runDueCalendarDelegations`, `handleZoomDeauthorization` (from Zoom's signed event), `zoomOauthAppForEvents` (read only to check that signature), `warnOfExpiringOauthAppSecrets`, `refreshIdleZoomConnections`, `runDueCalendarSyncs`, `runDueCalendarWrites`, `renewDueCalendarChannels`, `checkHostEditedEvents`, `runDueSchedulingNotifications`, `expireDueBookings`, `runDueFormDeliveries`, `runDueFormStoreCleanup` (and the dispatch jobs, from the job's `organizationId`); `@coss/sms/admin` is platform-level like `@coss/email/admin`. Server actions take only slugs + target ids and derive the org from `requireOrgPermission`; every Better Auth organization call passes `organizationId` @@ -796,7 +796,8 @@ notification emails) and, if there is useful advice, to `ADVICE` (`packages/repu **Not built yet** (don't assume these exist; delete a line when it ships): OAuth for the hosted MCP server (it takes an API key in a header) and `COSS_MCP_TOOLSETS` there, these parts of Support: on-call paging (so 24x7 can't be sold yet), a holiday calendar, automatic SLA credits, a support role narrower than platform admin, satisfaction ratings, AI drafts, presence, iOS screens, a public API, MCP and CLI tools, -these parts of Scheduling: public booking pages, built-in payments, customers' own OAuth apps, routing forms, a workflow builder, waitlists, travel schedules, imports from Cal.com Platform, +these parts of Scheduling: public booking pages, built-in payments, routing forms, a workflow builder, waitlists, travel schedules, imports from Cal.com Platform, +these parts of Scheduling's own OAuth apps: callback domains served by coss.com (a customer's own page forwards instead), more than one app per provider per environment, and managing them in the iOS app, MCP and CLI, these parts of calendar delegation: automatic Microsoft secret rotation, coss.com-owned or keyless (`signJwt`) service accounts, certificate credentials, account-level Zoom, excluding one host while `match_host_email` is on, and delegation in the iOS app, MCP and CLI, customers granting staff temporary access in HIPAA mode, Verify's voice and WhatsApp channels, locales other than English, custom message templates, diff --git a/apps/api/openapi.json b/apps/api/openapi.json index b5d5bb5e9..df445d972 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -4113,6 +4113,86 @@ } ] }, + "SchedulingOauthAppWebhookEvent": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The event id." + }, + "object": { + "type": "string", + "enum": [ + "event" + ] + }, + "api_version": { + "type": "string", + "description": "The API version this payload is rendered in: the endpoint's." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the event was created." + }, + "type": { + "type": "string", + "enum": [ + "scheduling.oauth_app.failing", + "scheduling.oauth_app.restored" + ] + }, + "data": { + "type": "object", + "properties": { + "oauth_app_id": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "code": { + "type": [ + "string", + "null" + ], + "description": "What the provider said, when it refused." + }, + "occurred_at": { + "type": "string" + } + }, + "required": [ + "oauth_app_id", + "provider", + "code", + "occurred_at" + ] + } + }, + "required": [ + "id", + "object", + "api_version", + "created_at", + "type", + "data" + ], + "examples": [ + { + "id": "whev_01kd2m9a0b1c2d3e4f5g6h7j8m", + "object": "event", + "type": "scheduling.oauth_app.failing", + "created_at": "2027-01-01T10:00:00.000Z", + "data": { + "oauth_app_id": "calapp_01kd2m9a0b1c2d3e4f5g6h7j8m", + "provider": "google", + "code": "app_refused", + "occurred_at": "2027-01-01T10:00:00.000Z" + } + } + ] + }, "Environment": { "type": "object", "properties": { @@ -9681,10 +9761,12 @@ "scheduling.calendar.needs_reauth", "scheduling.calendar.disconnected", "scheduling.calendar_delegation.failing", - "scheduling.calendar_delegation.restored" + "scheduling.calendar_delegation.restored", + "scheduling.oauth_app.failing", + "scheduling.oauth_app.restored" ] }, - "maxItems": 88, + "maxItems": 90, "description": "Event types delivered to this endpoint. An empty array means every event type, including ones added later. Defaults to every event type." }, "api_version": { @@ -10001,10 +10083,12 @@ "scheduling.calendar.needs_reauth", "scheduling.calendar.disconnected", "scheduling.calendar_delegation.failing", - "scheduling.calendar_delegation.restored" + "scheduling.calendar_delegation.restored", + "scheduling.oauth_app.failing", + "scheduling.oauth_app.restored" ] }, - "maxItems": 88, + "maxItems": 90, "description": "Event types delivered to this endpoint. An empty array means every event type, including ones added later." }, "api_version": { @@ -10574,10 +10658,12 @@ "scheduling.calendar.needs_reauth", "scheduling.calendar.disconnected", "scheduling.calendar_delegation.failing", - "scheduling.calendar_delegation.restored" + "scheduling.calendar_delegation.restored", + "scheduling.oauth_app.failing", + "scheduling.oauth_app.restored" ] }, - "maxItems": 88, + "maxItems": 90, "description": "Event types to receive. Defaults to every event type." }, "api_version": { @@ -10909,6 +10995,9 @@ }, { "$ref": "#/components/schemas/SchedulingCalendarDelegationWebhookEvent" + }, + { + "$ref": "#/components/schemas/SchedulingOauthAppWebhookEvent" } ], "description": "The event, as an endpoint on the request's API version receives it. Its `webhook-id` header is `event.id`." @@ -11374,7 +11463,9 @@ "scheduling.calendar.needs_reauth", "scheduling.calendar.disconnected", "scheduling.calendar_delegation.failing", - "scheduling.calendar_delegation.restored" + "scheduling.calendar_delegation.restored", + "scheduling.oauth_app.failing", + "scheduling.oauth_app.restored" ], "description": "The event type. `domain.*`, `account.*`, `subaccount.*` and `billing.*` events only reach live environments, so they can't be tested in a sandbox one." }, @@ -22142,6 +22233,13 @@ ], "description": "`needs_reauth`: access was lost, and this host isn't offered until it's back." }, + "oauth_app": { + "type": [ + "string", + "null" + ], + "description": "Your own OAuth app this connection's access came through. `null`: coss.com's, or no consent was involved." + }, "delegation": { "type": [ "string", @@ -22198,6 +22296,7 @@ "provider", "account", "status", + "oauth_app", "delegation", "calendars", "created_at" @@ -22211,6 +22310,7 @@ "account": "ana@example.com", "status": "active", "delegation": null, + "oauth_app": null, "calendars": [ { "id": "cal_01kd2m9a0b1c2d3e4f5g6h7j8m", @@ -22324,6 +22424,7 @@ "account": "ana@example.com", "status": "active", "delegation": null, + "oauth_app": null, "calendars": [ { "id": "cal_01kd2m9a0b1c2d3e4f5g6h7j8m", @@ -22757,6 +22858,458 @@ } ] }, + "SchedulingOauthApp": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "scheduling.oauth_app" + ] + }, + "id": { + "type": "string" + }, + "provider": { + "type": "string", + "enum": [ + "google", + "microsoft", + "zoom" + ] + }, + "client_id": { + "type": "string" + }, + "tenant_id": { + "type": [ + "string", + "null" + ] + }, + "redirect_uri": { + "type": [ + "string", + "null" + ] + }, + "coss_callback_url": { + "type": "string", + "description": "Register this with the provider if `redirect_uri` isn't set. If it is, that page forwards `code` and `state` here." + }, + "completion_url": { + "type": [ + "string", + "null" + ] + }, + "zoom_event_url": { + "type": [ + "string", + "null" + ], + "description": "Zoom only: the Event notification endpoint URL for your app's deauthorization events." + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "What to enable on your app at the provider." + }, + "status": { + "type": "string", + "enum": [ + "active", + "failing" + ], + "description": "`failing`: the provider refused the app's credentials. New consents are refused, and calendars connected through it can't be read until they work again." + }, + "last_error_code": { + "type": [ + "string", + "null" + ] + }, + "last_error_at": { + "type": [ + "string", + "null" + ] + }, + "secret_expires_at": { + "type": [ + "string", + "null" + ] + }, + "verified_at": { + "type": [ + "string", + "null" + ], + "description": "When a consent first completed through this app, which proves its secret." + }, + "connections": { + "type": "integer", + "description": "Calendar connections using this app. It can't be deleted while there are any." + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "object", + "id", + "provider", + "client_id", + "tenant_id", + "redirect_uri", + "coss_callback_url", + "completion_url", + "zoom_event_url", + "scopes", + "status", + "last_error_code", + "last_error_at", + "secret_expires_at", + "verified_at", + "connections", + "created_at", + "updated_at" + ], + "examples": [ + { + "object": "scheduling.oauth_app", + "id": "calapp_01kd2m9a0b1c2d3e4f5g6h7j8m", + "provider": "google", + "client_id": "1234567890-abc.apps.googleusercontent.com", + "tenant_id": null, + "redirect_uri": "https://example.com/oauth/google", + "coss_callback_url": "https://api.coss.com/scheduling/oauth/google/callback", + "completion_url": "https://example.com/calendar-connected", + "zoom_event_url": null, + "scopes": [ + "https://www.googleapis.com/auth/calendar.events.freebusy" + ], + "status": "active", + "last_error_code": null, + "last_error_at": null, + "secret_expires_at": null, + "verified_at": "2027-01-01T10:00:00.000Z", + "connections": 12, + "created_at": "2027-01-01T10:00:00.000Z", + "updated_at": "2027-01-01T10:00:00.000Z" + } + ] + }, + "CreateOauthAppRequest": { + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "google" + ] + }, + "client_id": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "pattern": "^\\S+$", + "description": "The app's client ID at the provider." + }, + "client_secret": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "The app's client secret. Never returned." + }, + "redirect_uri": { + "type": "string", + "maxLength": 2000, + "description": "A page on your own domain, registered with the provider, that forwards `code` and `state` to `coss_callback_url`. Google requires this for an external app, since it has to verify the redirect's domain as yours. Leave it out to use `coss_callback_url` directly." + }, + "completion_url": { + "type": "string", + "maxLength": 2000, + "description": "Where the browser lands after a consent that didn't name a `return_url`, with `coss_calendar` set to `connected`, `denied` or `invalid`. Leave it out for a plain page saying what happened." + }, + "secret_expires_at": { + "type": "string", + "format": "date-time", + "description": "When the client secret expires. Owners and admins are warned 14 days before." + } + }, + "required": [ + "provider", + "client_id", + "client_secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "microsoft" + ] + }, + "client_id": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "pattern": "^\\S+$", + "description": "The app's client ID at the provider." + }, + "client_secret": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "The app's client secret. Never returned." + }, + "tenant_id": { + "type": "string", + "pattern": "^[a-zA-Z0-9](?:[a-zA-Z0-9.-]{0,253})$", + "description": "The Directory (tenant) ID, for an app that's only for your own tenant. Leave it out for a multi-tenant app." + }, + "redirect_uri": { + "type": "string", + "maxLength": 2000, + "description": "A page on your own domain, registered with the provider, that forwards `code` and `state` to `coss_callback_url`. Google requires this for an external app, since it has to verify the redirect's domain as yours. Leave it out to use `coss_callback_url` directly." + }, + "completion_url": { + "type": "string", + "maxLength": 2000, + "description": "Where the browser lands after a consent that didn't name a `return_url`, with `coss_calendar` set to `connected`, `denied` or `invalid`. Leave it out for a plain page saying what happened." + }, + "secret_expires_at": { + "type": "string", + "format": "date-time", + "description": "When the client secret expires. Owners and admins are warned 14 days before." + } + }, + "required": [ + "provider", + "client_id", + "client_secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "zoom" + ] + }, + "client_id": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "pattern": "^\\S+$", + "description": "The app's client ID at the provider." + }, + "client_secret": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "The app's client secret. Never returned." + }, + "webhook_secret": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "The Secret Token of your Zoom app, which signs its events at `zoom_event_url`. Never returned." + }, + "redirect_uri": { + "type": "string", + "maxLength": 2000, + "description": "A page on your own domain, registered with the provider, that forwards `code` and `state` to `coss_callback_url`. Google requires this for an external app, since it has to verify the redirect's domain as yours. Leave it out to use `coss_callback_url` directly." + }, + "completion_url": { + "type": "string", + "maxLength": 2000, + "description": "Where the browser lands after a consent that didn't name a `return_url`, with `coss_calendar` set to `connected`, `denied` or `invalid`. Leave it out for a plain page saying what happened." + }, + "secret_expires_at": { + "type": "string", + "format": "date-time", + "description": "When the client secret expires. Owners and admins are warned 14 days before." + } + }, + "required": [ + "provider", + "client_id", + "client_secret" + ], + "additionalProperties": false + } + ], + "examples": [ + { + "provider": "google", + "client_id": "1234567890-abc.apps.googleusercontent.com", + "client_secret": "GOCSPX-abcdefghijklmnopqrstuvwxyz", + "redirect_uri": "https://example.com/oauth/google", + "completion_url": "https://example.com/calendar-connected" + } + ] + }, + "SchedulingOauthAppList": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SchedulingOauthApp" + } + }, + "has_more": { + "type": "boolean" + } + }, + "required": [ + "object", + "data", + "has_more" + ], + "examples": [ + { + "object": "list", + "data": [ + { + "object": "scheduling.oauth_app", + "id": "calapp_01kd2m9a0b1c2d3e4f5g6h7j8m", + "provider": "google", + "client_id": "1234567890-abc.apps.googleusercontent.com", + "tenant_id": null, + "redirect_uri": "https://example.com/oauth/google", + "coss_callback_url": "https://api.coss.com/scheduling/oauth/google/callback", + "completion_url": "https://example.com/calendar-connected", + "zoom_event_url": null, + "scopes": [ + "https://www.googleapis.com/auth/calendar.events.freebusy" + ], + "status": "active", + "last_error_code": null, + "last_error_at": null, + "secret_expires_at": null, + "verified_at": "2027-01-01T10:00:00.000Z", + "connections": 12, + "created_at": "2027-01-01T10:00:00.000Z", + "updated_at": "2027-01-01T10:00:00.000Z" + } + ], + "has_more": false + } + ] + }, + "UpdateOauthAppRequest": { + "type": "object", + "properties": { + "client_secret": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "A new client secret. It's tried on a connection before it replaces the old one." + }, + "tenant_id": { + "type": [ + "string", + "null" + ], + "pattern": "^[a-zA-Z0-9](?:[a-zA-Z0-9.-]{0,253})$", + "description": "The Directory (tenant) ID, for an app that's only for your own tenant. Leave it out for a multi-tenant app." + }, + "webhook_secret": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 1000, + "description": "The Secret Token of your Zoom app, which signs its events at `zoom_event_url`. Never returned." + }, + "redirect_uri": { + "type": [ + "string", + "null" + ], + "maxLength": 2000, + "description": "A page on your own domain, registered with the provider, that forwards `code` and `state` to `coss_callback_url`. Google requires this for an external app, since it has to verify the redirect's domain as yours. Leave it out to use `coss_callback_url` directly." + }, + "completion_url": { + "type": [ + "string", + "null" + ], + "maxLength": 2000, + "description": "Where the browser lands after a consent that didn't name a `return_url`, with `coss_calendar` set to `connected`, `denied` or `invalid`. Leave it out for a plain page saying what happened." + }, + "secret_expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the client secret expires. Owners and admins are warned 14 days before." + } + }, + "additionalProperties": false, + "examples": [ + { + "client_secret": "GOCSPX-abcdefghijklmnopqrstuvwxyz" + } + ] + }, + "DeletedSchedulingOauthApp": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "scheduling.oauth_app" + ] + }, + "id": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "object", + "id", + "deleted" + ], + "examples": [ + { + "object": "scheduling.oauth_app", + "id": "calapp_01kd2m9a0b1c2d3e4f5g6h7j8m", + "deleted": true + } + ] + }, "SetSimulatedBusyRequest": { "type": "object", "properties": { @@ -23036,6 +23589,13 @@ ], "description": "At the top of the hosted pages, and of emails sent from `email_from`." }, + "privacy_url": { + "type": [ + "string", + "null" + ], + "description": "Your privacy policy, linked on a host's page beside what connecting a calendar does, once this environment has its own OAuth app and a `brand_name`." + }, "email_from": { "type": [ "string", @@ -23089,6 +23649,7 @@ "brand_name", "accent_color", "logo", + "privacy_url", "email_from", "sms_from", "calendar_failure_mode", @@ -23103,6 +23664,7 @@ "brand_name": "Acme Health", "accent_color": null, "logo": null, + "privacy_url": null, "email_from": "appointments@example.com", "sms_from": null, "calendar_failure_mode": "hide", @@ -23161,6 +23723,15 @@ "additionalProperties": false, "description": "Your logo, a PNG or JPEG. `null` removes it." }, + "privacy_url": { + "type": [ + "string", + "null" + ], + "maxLength": 2000, + "format": "uri", + "description": "Your privacy policy, as an https URL, for hosts connecting a calendar through your own OAuth app. `null` removes it." + }, "email_from": { "type": [ "string", @@ -50211,32 +50782,1525 @@ } } }, - "/v1/suppressions/import": { - "post": { - "operationId": "importSuppressions", + "/v1/suppressions/import": { + "post": { + "operationId": "importSuppressions", + "tags": [ + "Suppressions" + ], + "summary": "Import suppressions in bulk", + "description": "Adds up to 1000 entries at once, with reason `import`. Each entry is validated on its own: invalid ones are skipped and listed in `invalid`, entries already on the list are left unchanged and counted in `existing`. Importing again is safe.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportSuppressionsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "What the import did.", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Coss-Version": { + "description": "The API version this response is rendered in.", + "schema": { + "type": "string", + "examples": [ + "2026-10-10" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuppressionImport" + } + } + } + }, + "400": { + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "The API key is missing, invalid, revoked or expired (`authentication_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "`subaccount` names no sub-account of the API key's environment (`subaccount_not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "The request body is larger than 5 MB (`payload_too_large`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "The body is not a list of 1-1000 `{ address, scope? }` objects (`validation_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Unexpected server error (`api_error` / `internal_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] + } + }, + "/v1/suppressions/export": { + "get": { + "operationId": "exportSuppressions", + "tags": [ + "Suppressions" + ], + "summary": "Export the suppression list as CSV", + "description": "The whole suppression list of the API key's environment (optionally filtered), oldest first, as CSV with the columns `address,scope,reason,created_at,email_id,id`. The file can be imported again as is. Values a spreadsheet would run as a formula are prefixed with `'`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Export a sub-account's own list (its id or your uid) instead of the environment's.", + "examples": [ + "user_8812" + ] + }, + "required": false, + "description": "Export a sub-account's own list (its id or your uid) instead of the environment's.", + "name": "subaccount", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "*" + ] + }, + { + "type": "string", + "enum": [ + "stream:marketing" + ] + }, + { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,256}$" + } + ], + "description": "Only entries with this scope." + }, + "required": false, + "description": "Only entries with this scope.", + "name": "scope", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "bounce", + "complaint", + "manual", + "import", + "unsubscribe" + ], + "description": "Only entries with this reason.", + "examples": [ + "bounce" + ] + }, + "required": false, + "description": "Only entries with this reason.", + "name": "reason", + "in": "query" + }, + { + "$ref": "#/components/parameters/CossVersion" + } + ], + "responses": { + "200": { + "description": "The CSV file.", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Coss-Version": { + "description": "The API version this response is rendered in.", + "schema": { + "type": "string", + "examples": [ + "2026-10-10" + ] + } + } + }, + "content": { + "text/csv": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "The API key is missing, invalid, revoked or expired (`authentication_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "`subaccount` names no sub-account of the API key's environment (`subaccount_not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "A query parameter failed validation (`validation_error`, see `issues`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Unexpected server error (`api_error` / `internal_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/suppressions/{id}": { + "get": { + "operationId": "getSuppression", + "tags": [ + "Suppressions" + ], + "summary": "Retrieve a suppression", + "description": "One entry of the suppression list of the API key's environment.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "The suppression's id.", + "examples": [ + "sup_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + }, + "required": true, + "description": "The suppression's id.", + "name": "id", + "in": "path" + }, + { + "$ref": "#/components/parameters/CossVersion" + } + ], + "responses": { + "200": { + "description": "The entry.", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Coss-Version": { + "description": "The API version this response is rendered in.", + "schema": { + "type": "string", + "examples": [ + "2026-10-10" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Suppression" + } + } + } + }, + "400": { + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "The API key is missing, invalid, revoked or expired (`authentication_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No suppression with this id exists in the API key's environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Unexpected server error (`api_error` / `internal_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "deleteSuppression", + "tags": [ + "Suppressions" + ], + "summary": "Remove a suppression", + "description": "Removes the entry, so later sends reach the address again (unless another entry still matches). Entries added for bounces and complaints can be removed too.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "The suppression's id.", + "examples": [ + "sup_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + }, + "required": true, + "description": "The suppression's id.", + "name": "id", + "in": "path" + }, + { + "$ref": "#/components/parameters/CossVersion" + } + ], + "responses": { + "200": { + "description": "The entry was removed.", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Coss-Version": { + "description": "The API version this response is rendered in.", + "schema": { + "type": "string", + "examples": [ + "2026-10-10" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletedSuppression" + } + } + } + }, + "400": { + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "The API key is missing, invalid, revoked or expired (`authentication_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No suppression with this id exists in the API key's environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Unexpected server error (`api_error` / `internal_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/subaccounts": { + "post": { + "operationId": "createSubaccount", + "tags": [ + "Sub-accounts" + ], + "summary": "Create a sub-account", + "description": "Creates a sub-account: one of your users, who gets their own domains, suppressions, quotas and reputation. Give it a `uid` (e.g. your user's id) to address it by in every path and send. Sub-accounts belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox ones, a `coss_live_…` key only production ones.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSubaccountRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The sub-account.", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Coss-Version": { + "description": "The API version this response is rendered in.", + "schema": { + "type": "string", + "examples": [ + "2026-10-10" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Subaccount" + } + } + } + }, + "400": { + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "The API key is missing, invalid, revoked or expired (`authentication_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "402": { + "description": "Live sub-accounts need a plan with sub-accounts (`plan_feature_unavailable`), up to its limit (`plan_limit_reached`). (`billing_error`). Never retried: it needs a plan change or a payment.", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "The request body is larger than 5 MB (`payload_too_large`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "The body failed validation (`validation_error`), the `uid` is invalid (`invalid_uid`) or taken (`uid_taken`), the quotas don't fit together (`invalid_quotas`), or the sandbox environment has the most sub-accounts it can (100: `too_many_subaccounts`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Unexpected server error (`api_error` / `internal_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] + }, + "get": { + "operationId": "listSubaccounts", + "tags": [ + "Sub-accounts" + ], + "summary": "List sub-accounts", + "description": "The environment's sub-accounts, newest first. Sub-accounts belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox ones, a `coss_live_…` key only production ones.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "1-100, default 50." + }, + "required": false, + "description": "1-100, default 50.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "The last `id` of the previous page.", + "examples": [ + "sub_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + }, + "required": false, + "description": "The last `id` of the previous page.", + "name": "after", + "in": "query" + }, + { + "$ref": "#/components/parameters/CossVersion" + } + ], + "responses": { + "200": { + "description": "A page of sub-accounts.", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Coss-Version": { + "description": "The API version this response is rendered in.", + "schema": { + "type": "string", + "examples": [ + "2026-10-10" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubaccountList" + } + } + } + }, + "400": { + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "The API key is missing, invalid, revoked or expired (`authentication_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Unexpected server error (`api_error` / `internal_error`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/subaccounts/{subaccount}": { + "get": { + "operationId": "getSubaccount", "tags": [ - "Suppressions" + "Sub-accounts" ], - "summary": "Import suppressions in bulk", - "description": "Adds up to 1000 entries at once, with reason `import`. Each entry is validated on its own: invalid ones are skipped and listed in `invalid`, entries already on the list are left unchanged and counted in `existing`. Importing again is safe.", + "summary": "Retrieve a sub-account", + "description": "A sub-account of the API key's environment, by its id or uid, with its quotas and reputation.", "security": [ { "bearerAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ImportSuppressionsRequest" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", + "examples": [ + "user_8812" + ] + }, + "required": true, + "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", + "name": "subaccount", + "in": "path" + }, + { + "$ref": "#/components/parameters/CossVersion" } - }, + ], "responses": { "200": { - "description": "What the import did.", + "description": "The sub-account.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -50260,13 +52324,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuppressionImport" + "$ref": "#/components/schemas/Subaccount" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -50329,70 +52393,7 @@ } }, "404": { - "description": "`subaccount` names no sub-account of the API key's environment (`subaccount_not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "413": { - "description": "The request body is larger than 5 MB (`payload_too_large`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The body is not a list of 1-1000 `{ address, scope? }` objects (`validation_error`).", + "description": "No sub-account with this id or uid exists in the API key's environment (`subaccount_not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -50481,22 +52482,15 @@ } } } - }, - "parameters": [ - { - "$ref": "#/components/parameters/CossVersion" - } - ] - } - }, - "/v1/suppressions/export": { - "get": { - "operationId": "exportSuppressions", + } + }, + "patch": { + "operationId": "updateSubaccount", "tags": [ - "Suppressions" + "Sub-accounts" ], - "summary": "Export the suppression list as CSV", - "description": "The whole suppression list of the API key's environment (optionally filtered), oldest first, as CSV with the columns `address,scope,reason,created_at,email_id,id`. The file can be imported again as is. Values a spreadsheet would run as a formula are prefixed with `'`.", + "summary": "Update a sub-account", + "description": "Changes the name, uid, metadata, status or quotas. Fields left out keep their value; `status: \"disabled\"` refuses its sends until it is `active` again.", "security": [ { "bearerAuth": [] @@ -50506,72 +52500,33 @@ { "schema": { "type": "string", - "minLength": 1, - "maxLength": 256, - "description": "Export a sub-account's own list (its id or your uid) instead of the environment's.", + "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", "examples": [ "user_8812" ] }, - "required": false, - "description": "Export a sub-account's own list (its id or your uid) instead of the environment's.", + "required": true, + "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", "name": "subaccount", - "in": "query" - }, - { - "schema": { - "anyOf": [ - { - "type": "string", - "enum": [ - "*" - ] - }, - { - "type": "string", - "enum": [ - "stream:marketing" - ] - }, - { - "type": "string", - "pattern": "^[A-Za-z0-9_-]{1,256}$" - } - ], - "description": "Only entries with this scope." - }, - "required": false, - "description": "Only entries with this scope.", - "name": "scope", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "bounce", - "complaint", - "manual", - "import", - "unsubscribe" - ], - "description": "Only entries with this reason.", - "examples": [ - "bounce" - ] - }, - "required": false, - "description": "Only entries with this reason.", - "name": "reason", - "in": "query" + "in": "path" }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSubaccountRequest" + } + } + } + }, "responses": { "200": { - "description": "The CSV file.", + "description": "The sub-account.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -50593,15 +52548,15 @@ } }, "content": { - "text/csv": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Subaccount" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -50664,7 +52619,49 @@ } }, "404": { - "description": "`subaccount` names no sub-account of the API key's environment (`subaccount_not_found`).", + "description": "No sub-account with this id or uid exists in the API key's environment (`subaccount_not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "The request body is larger than 5 MB (`payload_too_large`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -50685,7 +52682,7 @@ } }, "422": { - "description": "A query parameter failed validation (`validation_error`, see `issues`).", + "description": "The body failed validation (`validation_error`), the uid is invalid (`invalid_uid`) or taken (`uid_taken`), the quotas don't fit together (`invalid_quotas`), or it is being deleted (`subaccount_deleting`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -50775,16 +52772,14 @@ } } } - } - }, - "/v1/suppressions/{id}": { - "get": { - "operationId": "getSuppression", + }, + "delete": { + "operationId": "deleteSubaccount", "tags": [ - "Suppressions" + "Sub-accounts" ], - "summary": "Retrieve a suppression", - "description": "One entry of the suppression list of the API key's environment.", + "summary": "Delete a sub-account", + "description": "Starts deleting the sub-account: its sends are refused at once, then its domains are released and it is removed with its suppressions (`status` is `deleting` until then). Its emails stay, with its id. Repeating the call returns it as it is.", "security": [ { "bearerAuth": [] @@ -50794,14 +52789,14 @@ { "schema": { "type": "string", - "description": "The suppression's id.", + "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", "examples": [ - "sup_01k7c8m4q2f3e9v6t5r1n0p8yz" + "user_8812" ] }, "required": true, - "description": "The suppression's id.", - "name": "id", + "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", + "name": "subaccount", "in": "path" }, { @@ -50809,8 +52804,8 @@ } ], "responses": { - "200": { - "description": "The entry.", + "202": { + "description": "The sub-account, being deleted.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -50834,7 +52829,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Suppression" + "$ref": "#/components/schemas/Subaccount" } } } @@ -50903,7 +52898,7 @@ } }, "404": { - "description": "No suppression with this id exists in the API key's environment (`not_found`).", + "description": "No sub-account with this id or uid exists in the API key's environment (`subaccount_not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -50993,14 +52988,16 @@ } } } - }, - "delete": { - "operationId": "deleteSuppression", + } + }, + "/v1/subaccounts/{subaccount}/reinstate": { + "post": { + "operationId": "reinstateSubaccount", "tags": [ - "Suppressions" + "Sub-accounts" ], - "summary": "Remove a suppression", - "description": "Removes the entry, so later sends reach the address again (unless another entry still matches). Entries added for bounces and complaints can be removed too.", + "summary": "Reinstate a sub-account", + "description": "Lifts the sub-account's reputation pause (or warning) once you fixed what caused it: its rates are measured again from now on. A pause is never lifted on its own. Repeating the call returns it as it is. Your organization's own reputation still counts its sending.", "security": [ { "bearerAuth": [] @@ -51010,14 +53007,14 @@ { "schema": { "type": "string", - "description": "The suppression's id.", + "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", "examples": [ - "sup_01k7c8m4q2f3e9v6t5r1n0p8yz" + "user_8812" ] }, "required": true, - "description": "The suppression's id.", - "name": "id", + "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", + "name": "subaccount", "in": "path" }, { @@ -51026,7 +53023,7 @@ ], "responses": { "200": { - "description": "The entry was removed.", + "description": "The sub-account.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -51050,7 +53047,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeletedSuppression" + "$ref": "#/components/schemas/Subaccount" } } } @@ -51119,7 +53116,7 @@ } }, "404": { - "description": "No suppression with this id exists in the API key's environment (`not_found`).", + "description": "No sub-account with this id or uid exists in the API key's environment (`subaccount_not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -51211,32 +53208,68 @@ } } }, - "/v1/subaccounts": { - "post": { - "operationId": "createSubaccount", + "/v1/subaccounts/{subaccount}/stats": { + "get": { + "operationId": "getSubaccountStats", "tags": [ "Sub-accounts" ], - "summary": "Create a sub-account", - "description": "Creates a sub-account: one of your users, who gets their own domains, suppressions, quotas and reputation. Give it a `uid` (e.g. your user's id) to address it by in every path and send. Sub-accounts belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox ones, a `coss_live_…` key only production ones.", + "summary": "Retrieve a sub-account's stats", + "description": "The sub-account's sending per UTC day: recipients, sent, delivered, bounced, complained, failed and suppressed, counted from its emails as they are now (a later bounce moves its day's numbers). Emails are kept 45 days, so earlier days are left out.", "security": [ { "bearerAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateSubaccountRequest" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", + "examples": [ + "user_8812" + ] + }, + "required": true, + "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", + "name": "subaccount", + "in": "path" + }, + { + "schema": { + "type": "string", + "format": "date", + "description": "First day (UTC, `YYYY-MM-DD`); default 30 days before `to`.", + "examples": [ + "2026-10-01" + ] + }, + "required": false, + "description": "First day (UTC, `YYYY-MM-DD`); default 30 days before `to`.", + "name": "from", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date", + "description": "Last day (UTC, `YYYY-MM-DD`), included; default today.", + "examples": [ + "2026-10-15" + ] + }, + "required": false, + "description": "Last day (UTC, `YYYY-MM-DD`), included; default today.", + "name": "to", + "in": "query" + }, + { + "$ref": "#/components/parameters/CossVersion" } - }, + ], "responses": { "200": { - "description": "The sub-account.", + "description": "The sub-account's stats.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -51260,13 +53293,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Subaccount" + "$ref": "#/components/schemas/SubaccountStats" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -51307,27 +53340,6 @@ } } }, - "402": { - "description": "Live sub-accounts need a plan with sub-accounts (`plan_feature_unavailable`), up to its limit (`plan_limit_reached`). (`billing_error`). Never retried: it needs a plan change or a payment.", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -51349,29 +53361,8 @@ } } }, - "413": { - "description": "The request body is larger than 5 MB (`payload_too_large`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "404": { + "description": "No sub-account with this id or uid exists in the API key's environment (`subaccount_not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -51392,7 +53383,7 @@ } }, "422": { - "description": "The body failed validation (`validation_error`), the `uid` is invalid (`invalid_uid`) or taken (`uid_taken`), the quotas don't fit together (`invalid_quotas`), or the sandbox environment has the most sub-accounts it can (100: `too_many_subaccounts`).", + "description": "A query parameter failed validation (`validation_error`), or `from` is after `to` (`invalid_range`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -51481,58 +53472,55 @@ } } } - }, - "parameters": [ - { - "$ref": "#/components/parameters/CossVersion" - } - ] - }, - "get": { - "operationId": "listSubaccounts", + } + } + }, + "/v1/sms": { + "post": { + "operationId": "sendSms", "tags": [ - "Sub-accounts" + "SMS" ], - "summary": "List sub-accounts", - "description": "The environment's sub-accounts, newest first. Sub-accounts belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox ones, a `coss_live_…` key only production ones.", + "summary": "Send an SMS", + "description": "Queues a text message from the API key's environment and returns it with `status: \"queued\"`; it is handed to the carrier moments later. Follow it with `GET /v1/sms/{id}` or the `sms.sent`, `sms.delivered`, `sms.undelivered` and `sms.failed` webhooks. Live (`coss_live_…`) keys send `from` one of the organization's numbers whose carrier registration is approved, to the countries the organization allows (US and Canada unless changed in the dashboard). Sandbox (`coss_test_…`) keys accept any `from`, deliver nothing and answer the SMS simulator's numbers (`+15005550006` delivered, `+15005550001` invalid, `+15005550004` filtered by the carrier, `+15005550009` opted out, `+15005550008` replies). A number on the environment's SMS suppression list (it replied STOP, or it can't receive SMS) is skipped: the message is `failed` with `suppressed`. Segments count against the organization's send quota, and each is billed by the destination country.", "security": [ { "bearerAuth": [] } ], "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "description": "1-100, default 50." - }, - "required": false, - "description": "1-100, default 50.", - "name": "limit", - "in": "query" - }, { "schema": { "type": "string", - "description": "The last `id` of the previous page.", + "minLength": 1, + "maxLength": 256, + "description": "Makes retries safe: a repeated request with the same key and body returns the original message (with its current status) instead of sending again, for 24 hours. 1-256 characters.", "examples": [ - "sub_01k7c8m4q2f3e9v6t5r1n0p8yz" + "welcome-user-123" ] }, "required": false, - "description": "The last `id` of the previous page.", - "name": "after", - "in": "query" + "description": "Makes retries safe: a repeated request with the same key and body returns the original message (with its current status) instead of sending again, for 24 hours. 1-256 characters.", + "name": "Idempotency-Key", + "in": "header" }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendSmsRequest" + } + } + } + }, "responses": { - "200": { - "description": "A page of sub-accounts.", + "202": { + "description": "The message, accepted (`queued` or `scheduled`; `failed` when its number is suppressed). A replay returns the original message with its current status.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -51543,6 +53531,15 @@ ] } }, + "Idempotent-Replayed": { + "description": "`true` when this response replays an earlier request with the same `Idempotency-Key`.", + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, "Coss-Version": { "description": "The API version this response is rendered in.", "schema": { @@ -51556,13 +53553,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubaccountList" + "$ref": "#/components/schemas/Sms" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`) or the `Idempotency-Key` header is invalid (`invalid_idempotency_key`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -51603,8 +53600,119 @@ } } }, + "402": { + "description": "Live sending is paused for an overdue invoice or the spend limit (`billing_hold`) or a used-up budget (`budget_exceeded`), the plan doesn't include SMS or the destination country isn't priced (`plan_feature_unavailable`) (`billing_error`). Never retried: it needs a plan change or a payment.", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { - "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", + "description": "The API key lacks the permission this endpoint needs (`insufficient_permissions`), its organization is suspended (`organization_suspended`), or live sending is suspended (`account_suspended`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Requests with this `Idempotency-Key` are racing for an expired key (`request_in_progress`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Retry-After": { + "description": "Seconds to wait.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "The request body is larger than 5 MB (`payload_too_large`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "The body failed validation (`validation_error`, see `issues`), `to` isn't a valid number (`invalid_phone_number`), `from` isn't one of the organization's numbers (`sender_not_found`) or can't send yet (`sender_not_ready`), the destination country isn't allowed (`country_not_allowed`), or the `Idempotency-Key` was already used with a different body (`idempotency_key_reused`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -51625,7 +53733,7 @@ } }, "429": { - "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", + "description": "Too many requests with this API key (`rate_limited`), or the message's segments exceed the send quota or too many messages went to this number in the last hour (`quota_exceeded`). Retry after `Retry-After` seconds.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -51637,7 +53745,7 @@ } }, "Retry-After": { - "description": "Seconds to wait before retrying.", + "description": "Seconds to wait.", "schema": { "type": "integer" } @@ -51694,16 +53802,14 @@ } } } - } - }, - "/v1/subaccounts/{subaccount}": { + }, "get": { - "operationId": "getSubaccount", + "operationId": "listSms", "tags": [ - "Sub-accounts" + "SMS" ], - "summary": "Retrieve a sub-account", - "description": "A sub-account of the API key's environment, by its id or uid, with its quotas and reputation.", + "summary": "List SMS", + "description": "Messages sent from the API key's environment, newest first. Pass the last `id` as `after` to get the next page while `has_more` is true.", "security": [ { "bearerAuth": [] @@ -51713,15 +53819,68 @@ { "schema": { "type": "string", - "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", - "examples": [ - "user_8812" - ] + "pattern": "^\\+[1-9]\\d{6,14}$", + "description": "Only messages to this number." }, - "required": true, - "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", - "name": "subaccount", - "in": "path" + "required": false, + "description": "Only messages to this number.", + "name": "to", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Only messages in these statuses, comma-separated: `failed`." + }, + "required": false, + "description": "Only messages in these statuses, comma-separated: `failed`.", + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only messages created at or after this time." + }, + "required": false, + "description": "Only messages created at or after this time.", + "name": "created_after", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only messages created before this time." + }, + "required": false, + "description": "Only messages created before this time.", + "name": "created_before", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "1-100, default 20." + }, + "required": false, + "description": "1-100, default 20.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "maxLength": 64, + "description": "Cursor: the `id` of the last item of the previous page." + }, + "required": false, + "description": "Cursor: the `id` of the last item of the previous page.", + "name": "after", + "in": "query" }, { "$ref": "#/components/parameters/CossVersion" @@ -51729,7 +53888,7 @@ ], "responses": { "200": { - "description": "The sub-account.", + "description": "A page of messages.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -51753,7 +53912,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Subaccount" + "$ref": "#/components/schemas/SmsList" } } } @@ -51821,8 +53980,8 @@ } } }, - "404": { - "description": "No sub-account with this id or uid exists in the API key's environment (`subaccount_not_found`).", + "422": { + "description": "A query parameter failed validation (`validation_error`, see `issues`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -51912,50 +54071,34 @@ } } } - }, - "patch": { - "operationId": "updateSubaccount", + } + }, + "/v1/sms/suppressions": { + "post": { + "operationId": "createSmsSuppression", "tags": [ - "Sub-accounts" + "SMS" ], - "summary": "Update a sub-account", - "description": "Changes the name, uid, metadata, status or quotas. Fields left out keep their value; `status: \"disabled\"` refuses its sends until it is `active` again.", + "summary": "Add a number to the SMS suppression list", + "description": "Later sends from the API key's environment to the number are skipped. Adding a number already on the list returns its entry with status 200.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", - "examples": [ - "user_8812" - ] - }, - "required": true, - "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", - "name": "subaccount", - "in": "path" - }, - { - "$ref": "#/components/parameters/CossVersion" - } - ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateSubaccountRequest" + "$ref": "#/components/schemas/CreateSmsSuppressionRequest" } } } }, "responses": { "200": { - "description": "The sub-account.", + "description": "The number already had an entry.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -51979,13 +54122,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Subaccount" + "$ref": "#/components/schemas/SmsSuppression" } } } }, - "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "201": { + "description": "The new entry.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -51995,18 +54138,27 @@ "req_01k7c8m4q2f3e9v6t5r1n0p8yz" ] } + }, + "Coss-Version": { + "description": "The API version this response is rendered in.", + "schema": { + "type": "string", + "examples": [ + "2026-10-10" + ] + } } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/SmsSuppression" } } } }, - "401": { - "description": "The API key is missing, invalid, revoked or expired (`authentication_error`).", + "400": { + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -52026,8 +54178,8 @@ } } }, - "403": { - "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", + "401": { + "description": "The API key is missing, invalid, revoked or expired (`authentication_error`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -52047,8 +54199,8 @@ } } }, - "404": { - "description": "No sub-account with this id or uid exists in the API key's environment (`subaccount_not_found`).", + "403": { + "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -52111,7 +54263,7 @@ } }, "422": { - "description": "The body failed validation (`validation_error`), the uid is invalid (`invalid_uid`) or taken (`uid_taken`), the quotas don't fit together (`invalid_quotas`), or it is being deleted (`subaccount_deleting`).", + "description": "The body failed validation (`validation_error`) or the number isn't valid (`invalid_phone_number`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -52200,15 +54352,20 @@ } } } - } + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] }, - "delete": { - "operationId": "deleteSubaccount", + "get": { + "operationId": "listSmsSuppressions", "tags": [ - "Sub-accounts" + "SMS" ], - "summary": "Delete a sub-account", - "description": "Starts deleting the sub-account: its sends are refused at once, then its domains are released and it is removed with its suppressions (`status` is `deleting` until then). Its emails stay, with its id. Repeating the call returns it as it is.", + "summary": "List suppressed numbers", + "description": "The SMS suppression list of the API key's environment, newest first: opt-outs, invalid numbers and numbers you added.", "security": [ { "bearerAuth": [] @@ -52218,23 +54375,44 @@ { "schema": { "type": "string", - "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", - "examples": [ - "user_8812" - ] + "pattern": "^\\+[1-9]\\d{6,14}$", + "description": "Only this number's entry." }, - "required": true, - "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", - "name": "subaccount", - "in": "path" + "required": false, + "description": "Only this number's entry.", + "name": "phone_number", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "1-100, default 20." + }, + "required": false, + "description": "1-100, default 20.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "maxLength": 64, + "description": "Cursor: the `id` of the last item of the previous page." + }, + "required": false, + "description": "Cursor: the `id` of the last item of the previous page.", + "name": "after", + "in": "query" }, { "$ref": "#/components/parameters/CossVersion" } ], "responses": { - "202": { - "description": "The sub-account, being deleted.", + "200": { + "description": "A page of entries.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -52258,7 +54436,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Subaccount" + "$ref": "#/components/schemas/SmsSuppressionList" } } } @@ -52326,8 +54504,8 @@ } } }, - "404": { - "description": "No sub-account with this id or uid exists in the API key's environment (`subaccount_not_found`).", + "422": { + "description": "A query parameter failed validation (`validation_error`, see `issues`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -52419,14 +54597,14 @@ } } }, - "/v1/subaccounts/{subaccount}/reinstate": { - "post": { - "operationId": "reinstateSubaccount", + "/v1/sms/suppressions/check": { + "get": { + "operationId": "checkSmsSuppression", "tags": [ - "Sub-accounts" + "SMS" ], - "summary": "Reinstate a sub-account", - "description": "Lifts the sub-account's reputation pause (or warning) once you fixed what caused it: its rates are measured again from now on. A pause is never lifted on its own. Repeating the call returns it as it is. Your organization's own reputation still counts its sending.", + "summary": "Check whether a number is suppressed", + "description": "Whether a phone number is on the SMS suppression list of the API key's environment, with its entry if it is.", "security": [ { "bearerAuth": [] @@ -52436,15 +54614,11 @@ { "schema": { "type": "string", - "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", - "examples": [ - "user_8812" - ] + "pattern": "^\\+[1-9]\\d{6,14}$" }, "required": true, - "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", - "name": "subaccount", - "in": "path" + "name": "phone_number", + "in": "query" }, { "$ref": "#/components/parameters/CossVersion" @@ -52452,7 +54626,7 @@ ], "responses": { "200": { - "description": "The sub-account.", + "description": "The result.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -52476,7 +54650,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Subaccount" + "$ref": "#/components/schemas/SmsSuppressionCheck" } } } @@ -52544,8 +54718,8 @@ } } }, - "404": { - "description": "No sub-account with this id or uid exists in the API key's environment (`subaccount_not_found`).", + "422": { + "description": "A query parameter failed validation (`validation_error`, see `issues`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -52637,14 +54811,14 @@ } } }, - "/v1/subaccounts/{subaccount}/stats": { + "/v1/sms/suppressions/{id}": { "get": { - "operationId": "getSubaccountStats", + "operationId": "getSmsSuppression", "tags": [ - "Sub-accounts" + "SMS" ], - "summary": "Retrieve a sub-account's stats", - "description": "The sub-account's sending per UTC day: recipients, sent, delivered, bounced, complained, failed and suppressed, counted from its emails as they are now (a later bounce moves its day's numbers). Emails are kept 45 days, so earlier days are left out.", + "summary": "Retrieve an SMS suppression", + "description": "One entry of the SMS suppression list of the API key's environment.", "security": [ { "bearerAuth": [] @@ -52654,51 +54828,23 @@ { "schema": { "type": "string", - "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", + "description": "The entry's id.", "examples": [ - "user_8812" + "smssup_01k7c8m4q2f3e9v6t5r1n0p8yz" ] }, "required": true, - "description": "The sub-account's `id` (`sub_…`) or your `uid` for it.", - "name": "subaccount", + "description": "The entry's id.", + "name": "id", "in": "path" }, - { - "schema": { - "type": "string", - "format": "date", - "description": "First day (UTC, `YYYY-MM-DD`); default 30 days before `to`.", - "examples": [ - "2026-10-01" - ] - }, - "required": false, - "description": "First day (UTC, `YYYY-MM-DD`); default 30 days before `to`.", - "name": "from", - "in": "query" - }, - { - "schema": { - "type": "string", - "format": "date", - "description": "Last day (UTC, `YYYY-MM-DD`), included; default today.", - "examples": [ - "2026-10-15" - ] - }, - "required": false, - "description": "Last day (UTC, `YYYY-MM-DD`), included; default today.", - "name": "to", - "in": "query" - }, { "$ref": "#/components/parameters/CossVersion" } ], "responses": { "200": { - "description": "The sub-account's stats.", + "description": "The entry.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -52722,7 +54868,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubaccountStats" + "$ref": "#/components/schemas/SmsSuppression" } } } @@ -52791,28 +54937,7 @@ } }, "404": { - "description": "No sub-account with this id or uid exists in the API key's environment (`subaccount_not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "A query parameter failed validation (`validation_error`), or `from` is after `to` (`invalid_range`).", + "description": "No entry with this id exists in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -52902,16 +55027,14 @@ } } } - } - }, - "/v1/sms": { - "post": { - "operationId": "sendSms", + }, + "delete": { + "operationId": "deleteSmsSuppression", "tags": [ "SMS" ], - "summary": "Send an SMS", - "description": "Queues a text message from the API key's environment and returns it with `status: \"queued\"`; it is handed to the carrier moments later. Follow it with `GET /v1/sms/{id}` or the `sms.sent`, `sms.delivered`, `sms.undelivered` and `sms.failed` webhooks. Live (`coss_live_…`) keys send `from` one of the organization's numbers whose carrier registration is approved, to the countries the organization allows (US and Canada unless changed in the dashboard). Sandbox (`coss_test_…`) keys accept any `from`, deliver nothing and answer the SMS simulator's numbers (`+15005550006` delivered, `+15005550001` invalid, `+15005550004` filtered by the carrier, `+15005550009` opted out, `+15005550008` replies). A number on the environment's SMS suppression list (it replied STOP, or it can't receive SMS) is skipped: the message is `failed` with `suppressed`. Segments count against the organization's send quota, and each is billed by the destination country.", + "summary": "Remove a number from the SMS suppression list", + "description": "Sends to the number go out again. Remove an opt-out only when the person asked to receive messages again (they can also reply START).", "security": [ { "bearerAuth": [] @@ -52921,35 +55044,23 @@ { "schema": { "type": "string", - "minLength": 1, - "maxLength": 256, - "description": "Makes retries safe: a repeated request with the same key and body returns the original message (with its current status) instead of sending again, for 24 hours. 1-256 characters.", + "description": "The entry's id.", "examples": [ - "welcome-user-123" + "smssup_01k7c8m4q2f3e9v6t5r1n0p8yz" ] }, - "required": false, - "description": "Makes retries safe: a repeated request with the same key and body returns the original message (with its current status) instead of sending again, for 24 hours. 1-256 characters.", - "name": "Idempotency-Key", - "in": "header" + "required": true, + "description": "The entry's id.", + "name": "id", + "in": "path" }, { "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SendSmsRequest" - } - } - } - }, "responses": { - "202": { - "description": "The message, accepted (`queued` or `scheduled`; `failed` when its number is suppressed). A replay returns the original message with its current status.", + "200": { + "description": "The entry was removed.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -52960,15 +55071,6 @@ ] } }, - "Idempotent-Replayed": { - "description": "`true` when this response replays an earlier request with the same `Idempotency-Key`.", - "schema": { - "type": "string", - "enum": [ - "true" - ] - } - }, "Coss-Version": { "description": "The API version this response is rendered in.", "schema": { @@ -52982,13 +55084,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Sms" + "$ref": "#/components/schemas/DeletedSmsSuppression" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`) or the `Idempotency-Key` header is invalid (`invalid_idempotency_key`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -53029,98 +55131,8 @@ } } }, - "402": { - "description": "Live sending is paused for an overdue invoice or the spend limit (`billing_hold`) or a used-up budget (`budget_exceeded`), the plan doesn't include SMS or the destination country isn't priced (`plan_feature_unavailable`) (`billing_error`). Never retried: it needs a plan change or a payment.", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "403": { - "description": "The API key lacks the permission this endpoint needs (`insufficient_permissions`), its organization is suspended (`organization_suspended`), or live sending is suspended (`account_suspended`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Requests with this `Idempotency-Key` are racing for an expired key (`request_in_progress`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - }, - "Retry-After": { - "description": "Seconds to wait.", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "413": { - "description": "The request body is larger than 5 MB (`payload_too_large`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -53140,8 +55152,8 @@ } } }, - "422": { - "description": "The body failed validation (`validation_error`, see `issues`), `to` isn't a valid number (`invalid_phone_number`), `from` isn't one of the organization's numbers (`sender_not_found`) or can't send yet (`sender_not_ready`), the destination country isn't allowed (`country_not_allowed`), or the `Idempotency-Key` was already used with a different body (`idempotency_key_reused`).", + "404": { + "description": "No entry with this id exists in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -53162,7 +55174,7 @@ } }, "429": { - "description": "Too many requests with this API key (`rate_limited`), or the message's segments exceed the send quota or too many messages went to this number in the last hour (`quota_exceeded`). Retry after `Retry-After` seconds.", + "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -53174,7 +55186,7 @@ } }, "Retry-After": { - "description": "Seconds to wait.", + "description": "Seconds to wait before retrying.", "schema": { "type": "integer" } @@ -53231,14 +55243,16 @@ } } } - }, + } + }, + "/v1/sms/received": { "get": { - "operationId": "listSms", + "operationId": "listReceivedSms", "tags": [ "SMS" ], - "summary": "List SMS", - "description": "Messages sent from the API key's environment, newest first. Pass the last `id` as `after` to get the next page while `has_more` is true.", + "summary": "List received SMS", + "description": "Messages sent to the organization's numbers whose replies go to the API key's environment, newest first.", "security": [ { "bearerAuth": [] @@ -53249,42 +55263,39 @@ "schema": { "type": "string", "pattern": "^\\+[1-9]\\d{6,14}$", - "description": "Only messages to this number." + "description": "Only messages from this number." }, "required": false, - "description": "Only messages to this number.", - "name": "to", + "description": "Only messages from this number.", + "name": "from", "in": "query" }, { "schema": { "type": "string", - "description": "Only messages in these statuses, comma-separated: `failed`." + "pattern": "^\\+[1-9]\\d{6,14}$", + "description": "Only messages to this number of yours." }, "required": false, - "description": "Only messages in these statuses, comma-separated: `failed`.", - "name": "status", + "description": "Only messages to this number of yours.", + "name": "to", "in": "query" }, { "schema": { "type": "string", - "format": "date-time", - "description": "Only messages created at or after this time." + "format": "date-time" }, "required": false, - "description": "Only messages created at or after this time.", "name": "created_after", "in": "query" }, { "schema": { "type": "string", - "format": "date-time", - "description": "Only messages created before this time." + "format": "date-time" }, "required": false, - "description": "Only messages created before this time.", "name": "created_before", "in": "query" }, @@ -53317,7 +55328,7 @@ ], "responses": { "200": { - "description": "A page of messages.", + "description": "A page of received messages.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -53341,7 +55352,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SmsList" + "$ref": "#/components/schemas/ReceivedSmsList" } } } @@ -53502,62 +55513,40 @@ } } }, - "/v1/sms/suppressions": { - "post": { - "operationId": "createSmsSuppression", + "/v1/sms/received/{id}": { + "get": { + "operationId": "getReceivedSms", "tags": [ "SMS" ], - "summary": "Add a number to the SMS suppression list", - "description": "Later sends from the API key's environment to the number are skipped. Adding a number already on the list returns its entry with status 200.", + "summary": "Retrieve a received SMS", + "description": "One message received on a number of the organization and routed to the API key's environment.", "security": [ { "bearerAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateSmsSuppressionRequest" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "description": "The received message's id.", + "examples": [ + "smsin_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + }, + "required": true, + "description": "The received message's id.", + "name": "id", + "in": "path" + }, + { + "$ref": "#/components/parameters/CossVersion" } - }, + ], "responses": { "200": { - "description": "The number already had an entry.", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - }, - "Coss-Version": { - "description": "The API version this response is rendered in.", - "schema": { - "type": "string", - "examples": [ - "2026-10-10" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SmsSuppression" - } - } - } - }, - "201": { - "description": "The new entry.", + "description": "The received message.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -53581,13 +55570,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SmsSuppression" + "$ref": "#/components/schemas/ReceivedSms" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -53649,50 +55638,8 @@ } } }, - "413": { - "description": "The request body is larger than 5 MB (`payload_too_large`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The body failed validation (`validation_error`) or the number isn't valid (`invalid_phone_number`).", + "404": { + "description": "No received message with this id exists in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -53781,67 +55728,25 @@ } } } - }, - "parameters": [ - { - "$ref": "#/components/parameters/CossVersion" - } - ] - }, + } + } + }, + "/v1/sms/numbers": { "get": { - "operationId": "listSmsSuppressions", + "operationId": "listSmsNumbers", "tags": [ "SMS" ], - "summary": "List suppressed numbers", - "description": "The SMS suppression list of the API key's environment, newest first: opt-outs, invalid numbers and numbers you added.", + "summary": "List the organization's phone numbers", + "description": "Every number of the organization, whether it can send live messages now (its carrier registration is approved) and whether its replies arrive in the API key's environment. Numbers are bought and registered in the dashboard.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^\\+[1-9]\\d{6,14}$", - "description": "Only this number's entry." - }, - "required": false, - "description": "Only this number's entry.", - "name": "phone_number", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "description": "1-100, default 20." - }, - "required": false, - "description": "1-100, default 20.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "maxLength": 64, - "description": "Cursor: the `id` of the last item of the previous page." - }, - "required": false, - "description": "Cursor: the `id` of the last item of the previous page.", - "name": "after", - "in": "query" - }, - { - "$ref": "#/components/parameters/CossVersion" - } - ], "responses": { "200": { - "description": "A page of entries.", + "description": "The numbers.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -53865,7 +55770,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SmsSuppressionList" + "$ref": "#/components/schemas/SmsNumberList" } } } @@ -53933,27 +55838,6 @@ } } }, - "422": { - "description": "A query parameter failed validation (`validation_error`, see `issues`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -54023,17 +55907,22 @@ } } } - } + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] } }, - "/v1/sms/suppressions/check": { + "/v1/sms/{id}": { "get": { - "operationId": "checkSmsSuppression", + "operationId": "getSms", "tags": [ "SMS" ], - "summary": "Check whether a number is suppressed", - "description": "Whether a phone number is on the SMS suppression list of the API key's environment, with its entry if it is.", + "summary": "Retrieve an SMS", + "description": "One message sent from the API key's environment, with its current status.", "security": [ { "bearerAuth": [] @@ -54043,11 +55932,15 @@ { "schema": { "type": "string", - "pattern": "^\\+[1-9]\\d{6,14}$" + "description": "The message's id.", + "examples": [ + "sms_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] }, "required": true, - "name": "phone_number", - "in": "query" + "description": "The message's id.", + "name": "id", + "in": "path" }, { "$ref": "#/components/parameters/CossVersion" @@ -54055,7 +55948,7 @@ ], "responses": { "200": { - "description": "The result.", + "description": "The message.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -54079,7 +55972,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SmsSuppressionCheck" + "$ref": "#/components/schemas/Sms" } } } @@ -54147,8 +56040,8 @@ } } }, - "422": { - "description": "A query parameter failed validation (`validation_error`, see `issues`).", + "404": { + "description": "No message with this id exists in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -54240,14 +56133,14 @@ } } }, - "/v1/sms/suppressions/{id}": { + "/v1/sms/{id}/events": { "get": { - "operationId": "getSmsSuppression", + "operationId": "listSmsEvents", "tags": [ "SMS" ], - "summary": "Retrieve an SMS suppression", - "description": "One entry of the SMS suppression list of the API key's environment.", + "summary": "List an SMS's events", + "description": "Everything that happened to the message, oldest first.", "security": [ { "bearerAuth": [] @@ -54257,13 +56150,13 @@ { "schema": { "type": "string", - "description": "The entry's id.", + "description": "The message's id.", "examples": [ - "smssup_01k7c8m4q2f3e9v6t5r1n0p8yz" + "sms_01k7c8m4q2f3e9v6t5r1n0p8yz" ] }, "required": true, - "description": "The entry's id.", + "description": "The message's id.", "name": "id", "in": "path" }, @@ -54273,7 +56166,7 @@ ], "responses": { "200": { - "description": "The entry.", + "description": "The events.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -54297,7 +56190,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SmsSuppression" + "$ref": "#/components/schemas/SmsEventList" } } } @@ -54366,7 +56259,7 @@ } }, "404": { - "description": "No entry with this id exists in the API key's environment (`not_found`).", + "description": "No message with this id exists in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -54456,14 +56349,16 @@ } } } - }, - "delete": { - "operationId": "deleteSmsSuppression", + } + }, + "/v1/sms/{id}/cancel": { + "post": { + "operationId": "cancelSms", "tags": [ "SMS" ], - "summary": "Remove a number from the SMS suppression list", - "description": "Sends to the number go out again. Remove an opt-out only when the person asked to receive messages again (they can also reply START).", + "summary": "Cancel a scheduled SMS", + "description": "Cancels a message scheduled with `scheduled_at` before it is sent. Canceling a canceled message returns it as is.", "security": [ { "bearerAuth": [] @@ -54473,13 +56368,13 @@ { "schema": { "type": "string", - "description": "The entry's id.", + "description": "The message's id.", "examples": [ - "smssup_01k7c8m4q2f3e9v6t5r1n0p8yz" + "sms_01k7c8m4q2f3e9v6t5r1n0p8yz" ] }, "required": true, - "description": "The entry's id.", + "description": "The message's id.", "name": "id", "in": "path" }, @@ -54489,7 +56384,7 @@ ], "responses": { "200": { - "description": "The entry was removed.", + "description": "The canceled message.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -54513,7 +56408,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeletedSmsSuppression" + "$ref": "#/components/schemas/Sms" } } } @@ -54582,7 +56477,28 @@ } }, "404": { - "description": "No entry with this id exists in the API key's environment (`not_found`).", + "description": "No message with this id exists in the API key's environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "The message isn't scheduled (`sms_not_cancelable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -54674,90 +56590,32 @@ } } }, - "/v1/sms/received": { - "get": { - "operationId": "listReceivedSms", + "/v1/sign/documents": { + "post": { + "operationId": "uploadSignDocument", "tags": [ - "SMS" + "Sign documents" ], - "summary": "List received SMS", - "description": "Messages sent to the organization's numbers whose replies go to the API key's environment, newest first.", + "summary": "Upload a document", + "description": "Uploads a PDF (base64, at most 25 MB and 500 pages, not password-protected) for signature requests to use. The response lists each page's size and the text anchors found in it (`{{signature:signer_1}}`), which fields can be placed by. Requests can also take PDFs inline.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^\\+[1-9]\\d{6,14}$", - "description": "Only messages from this number." - }, - "required": false, - "description": "Only messages from this number.", - "name": "from", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^\\+[1-9]\\d{6,14}$", - "description": "Only messages to this number of yours." - }, - "required": false, - "description": "Only messages to this number of yours.", - "name": "to", - "in": "query" - }, - { - "schema": { - "type": "string", - "format": "date-time" - }, - "required": false, - "name": "created_after", - "in": "query" - }, - { - "schema": { - "type": "string", - "format": "date-time" - }, - "required": false, - "name": "created_before", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "description": "1-100, default 20." - }, - "required": false, - "description": "1-100, default 20.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "maxLength": 64, - "description": "Cursor: the `id` of the last item of the previous page." - }, - "required": false, - "description": "Cursor: the `id` of the last item of the previous page.", - "name": "after", - "in": "query" - }, - { - "$ref": "#/components/parameters/CossVersion" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadSignDocumentRequest" + } + } } - ], + }, "responses": { - "200": { - "description": "A page of received messages.", + "201": { + "description": "The uploaded document.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -54781,13 +56639,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReceivedSmsList" + "$ref": "#/components/schemas/SignDocument" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -54849,8 +56707,50 @@ } } }, + "413": { + "description": "The request body is larger than 40 MB (`payload_too_large`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "A query parameter failed validation (`validation_error`, see `issues`).", + "description": "The body failed validation (`validation_error`, see `issues`), or the file isn't a PDF coss.com can read (`invalid_document`: damaged, encrypted or too many pages).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -54919,7 +56819,7 @@ } }, "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "description": "Sign is temporarily unavailable (`sign_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -54939,35 +56839,48 @@ } } } - } - } - }, - "/v1/sms/received/{id}": { + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] + }, "get": { - "operationId": "getReceivedSms", + "operationId": "listSignDocuments", "tags": [ - "SMS" + "Sign documents" ], - "summary": "Retrieve a received SMS", - "description": "One message received on a number of the organization and routed to the API key's environment.", + "summary": "List documents", + "description": "Documents uploaded to the API key's environment, newest first. Pass the last `id` as `after` to get the next page while `has_more` is true.", "security": [ { "bearerAuth": [] } ], "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "1-100, default 20." + }, + "required": false, + "description": "1-100, default 20.", + "name": "limit", + "in": "query" + }, { "schema": { "type": "string", - "description": "The received message's id.", - "examples": [ - "smsin_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] + "maxLength": 64, + "description": "Cursor: the `id` of the last item of the previous page." }, - "required": true, - "description": "The received message's id.", - "name": "id", - "in": "path" + "required": false, + "description": "Cursor: the `id` of the last item of the previous page.", + "name": "after", + "in": "query" }, { "$ref": "#/components/parameters/CossVersion" @@ -54975,7 +56888,7 @@ ], "responses": { "200": { - "description": "The received message.", + "description": "A page of documents.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -54999,7 +56912,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReceivedSms" + "$ref": "#/components/schemas/SignDocumentList" } } } @@ -55067,8 +56980,8 @@ } } }, - "404": { - "description": "No received message with this id exists in the API key's environment (`not_found`).", + "422": { + "description": "A query parameter failed validation (`validation_error`, see `issues`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -55160,22 +57073,40 @@ } } }, - "/v1/sms/numbers": { + "/v1/sign/documents/{id}": { "get": { - "operationId": "listSmsNumbers", + "operationId": "getSignDocument", "tags": [ - "SMS" + "Sign documents" ], - "summary": "List the organization's phone numbers", - "description": "Every number of the organization, whether it can send live messages now (its carrier registration is approved) and whether its replies arrive in the API key's environment. Numbers are bought and registered in the dashboard.", + "summary": "Retrieve a document", + "description": "An uploaded document's details: its hash, page sizes and text anchors. Its bytes are never returned here.", "security": [ { "bearerAuth": [] } ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "The document's id.", + "examples": [ + "sdoc_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + }, + "required": true, + "description": "The document's id.", + "name": "id", + "in": "path" + }, + { + "$ref": "#/components/parameters/CossVersion" + } + ], "responses": { "200": { - "description": "The numbers.", + "description": "The document.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -55199,7 +57130,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SmsNumberList" + "$ref": "#/components/schemas/SignDocument" } } } @@ -55267,6 +57198,27 @@ } } }, + "404": { + "description": "No document with this id exists in the API key's environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -55336,22 +57288,15 @@ } } } - }, - "parameters": [ - { - "$ref": "#/components/parameters/CossVersion" - } - ] - } - }, - "/v1/sms/{id}": { - "get": { - "operationId": "getSms", + } + }, + "delete": { + "operationId": "deleteSignDocument", "tags": [ - "SMS" + "Sign documents" ], - "summary": "Retrieve an SMS", - "description": "One message sent from the API key's environment, with its current status.", + "summary": "Delete a document", + "description": "Deletes an uploaded document and its file. Documents a request uses can't be deleted.", "security": [ { "bearerAuth": [] @@ -55361,13 +57306,13 @@ { "schema": { "type": "string", - "description": "The message's id.", + "description": "The document's id.", "examples": [ - "sms_01k7c8m4q2f3e9v6t5r1n0p8yz" + "sdoc_01k7c8m4q2f3e9v6t5r1n0p8yz" ] }, "required": true, - "description": "The message's id.", + "description": "The document's id.", "name": "id", "in": "path" }, @@ -55377,7 +57322,7 @@ ], "responses": { "200": { - "description": "The message.", + "description": "The document was deleted.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -55401,7 +57346,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Sms" + "$ref": "#/components/schemas/DeletedSignDocument" } } } @@ -55470,7 +57415,28 @@ } }, "404": { - "description": "No message with this id exists in the API key's environment (`not_found`).", + "description": "No document with this id exists in the API key's environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "A signature request uses the document (`document_in_use`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -55539,7 +57505,7 @@ } }, "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "description": "Sign is temporarily unavailable (`sign_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -55562,14 +57528,14 @@ } } }, - "/v1/sms/{id}/events": { - "get": { - "operationId": "listSmsEvents", + "/v1/sign/requests": { + "post": { + "operationId": "createSignRequest", "tags": [ - "SMS" + "Signature requests" ], - "summary": "List an SMS's events", - "description": "Everything that happened to the message, oldest first.", + "summary": "Create a signature request", + "description": "Asks one or more signers to sign one or more PDFs. Give documents by id (uploaded) or inline, and each signer's fields by position (`page`, `x`, `y`, `width`, `height` in PDF points from the page's top left) or by text anchor (`{{signature:signer_1}}`). Every signer needs a `signature` field. Sent at once unless `send` is `false` (a `draft`). Each signer signs by email (`delivery: \"email\"`, the default) or in your app (`\"embedded\"`).", "security": [ { "bearerAuth": [] @@ -55579,23 +57545,35 @@ { "schema": { "type": "string", - "description": "The message's id.", + "minLength": 1, + "maxLength": 256, + "description": "Makes retries safe: a repeated request with the same key and body returns the original request (in its current state) instead of creating another, for 24 hours. 1-256 characters.", "examples": [ - "sms_01k7c8m4q2f3e9v6t5r1n0p8yz" + "welcome-user-123" ] }, - "required": true, - "description": "The message's id.", - "name": "id", - "in": "path" + "required": false, + "description": "Makes retries safe: a repeated request with the same key and body returns the original request (in its current state) instead of creating another, for 24 hours. 1-256 characters.", + "name": "Idempotency-Key", + "in": "header" }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSignRequestRequest" + } + } + } + }, "responses": { - "200": { - "description": "The events.", + "201": { + "description": "The signature request (`sent`, or `draft`). A replay returns the original request in its current state.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -55606,6 +57584,15 @@ ] } }, + "Idempotent-Replayed": { + "description": "`true` when this response replays an earlier request with the same `Idempotency-Key`.", + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, "Coss-Version": { "description": "The API version this response is rendered in.", "schema": { @@ -55619,13 +57606,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SmsEventList" + "$ref": "#/components/schemas/SignRequest" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`) or the `Idempotency-Key` header is invalid (`invalid_idempotency_key`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -55687,8 +57674,71 @@ } } }, - "404": { - "description": "No message with this id exists in the API key's environment (`not_found`).", + "409": { + "description": "Requests with this `Idempotency-Key` are racing for an expired key (`request_in_progress`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "The request body is larger than 40 MB (`payload_too_large`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "The body failed validation (`validation_error`, see `issues`), a document isn't a PDF coss.com can read (`invalid_document`) or isn't in the environment (`not_found`), a field doesn't fit on its page or names an anchor no document has (`invalid_fields`), a signer is listed twice (`invalid_signers`), `expires_at` is out of range (`invalid_expiry`), or the `Idempotency-Key` was already used with a different body (`idempotency_key_reused`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -55757,7 +57807,7 @@ } }, "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "description": "Sign is temporarily unavailable (`sign_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -55778,16 +57828,14 @@ } } } - } - }, - "/v1/sms/{id}/cancel": { - "post": { - "operationId": "cancelSms", + }, + "get": { + "operationId": "listSignRequests", "tags": [ - "SMS" + "Signature requests" ], - "summary": "Cancel a scheduled SMS", - "description": "Cancels a message scheduled with `scheduled_at` before it is sent. Canceling a canceled message returns it as is.", + "summary": "List signature requests", + "description": "Signature requests of the API key's environment, newest first. Pass the last `id` as `after` to get the next page while `has_more` is true.", "security": [ { "bearerAuth": [] @@ -55797,15 +57845,35 @@ { "schema": { "type": "string", - "description": "The message's id.", - "examples": [ - "sms_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] + "description": "Only requests in these statuses, comma-separated: `sent,draft`." }, - "required": true, - "description": "The message's id.", - "name": "id", - "in": "path" + "required": false, + "description": "Only requests in these statuses, comma-separated: `sent,draft`.", + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "1-100, default 20." + }, + "required": false, + "description": "1-100, default 20.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "maxLength": 64, + "description": "Cursor: the `id` of the last item of the previous page." + }, + "required": false, + "description": "Cursor: the `id` of the last item of the previous page.", + "name": "after", + "in": "query" }, { "$ref": "#/components/parameters/CossVersion" @@ -55813,7 +57881,7 @@ ], "responses": { "200": { - "description": "The canceled message.", + "description": "A page of signature requests.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -55837,7 +57905,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Sms" + "$ref": "#/components/schemas/SignRequestList" } } } @@ -55905,29 +57973,8 @@ } } }, - "404": { - "description": "No message with this id exists in the API key's environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "422": { - "description": "The message isn't scheduled (`sms_not_cancelable`).", + "description": "A query parameter failed validation (`validation_error`, see `issues`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -56019,32 +58066,40 @@ } } }, - "/v1/sign/documents": { - "post": { - "operationId": "uploadSignDocument", + "/v1/sign/requests/{id}": { + "get": { + "operationId": "getSignRequest", "tags": [ - "Sign documents" + "Signature requests" ], - "summary": "Upload a document", - "description": "Uploads a PDF (base64, at most 25 MB and 500 pages, not password-protected) for signature requests to use. The response lists each page's size and the text anchors found in it (`{{signature:signer_1}}`), which fields can be placed by. Requests can also take PDFs inline.", + "summary": "Retrieve a signature request", + "description": "A signature request with its documents, signers and fields, and `signed_document` once it's sealed.", "security": [ { "bearerAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UploadSignDocumentRequest" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "description": "The signature request's id.", + "examples": [ + "sreq_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + }, + "required": true, + "description": "The signature request's id.", + "name": "id", + "in": "path" + }, + { + "$ref": "#/components/parameters/CossVersion" } - }, + ], "responses": { - "201": { - "description": "The uploaded document.", + "200": { + "description": "The signature request.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -56068,13 +58123,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignDocument" + "$ref": "#/components/schemas/SignRequest" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -56136,50 +58191,8 @@ } } }, - "413": { - "description": "The request body is larger than 40 MB (`payload_too_large`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The body failed validation (`validation_error`, see `issues`), or the file isn't a PDF coss.com can read (`invalid_document`: damaged, encrypted or too many pages).", + "404": { + "description": "No signature request with this id exists in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -56248,7 +58261,7 @@ } }, "503": { - "description": "Sign is temporarily unavailable (`sign_unavailable`).", + "description": "The API key could not be verified right now; retry (`service_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -56268,48 +58281,33 @@ } } } - }, - "parameters": [ - { - "$ref": "#/components/parameters/CossVersion" - } - ] + } }, - "get": { - "operationId": "listSignDocuments", + "delete": { + "operationId": "deleteSignRequest", "tags": [ - "Sign documents" + "Signature requests" ], - "summary": "List documents", - "description": "Documents uploaded to the API key's environment, newest first. Pass the last `id` as `after` to get the next page while `has_more` is true.", + "summary": "Delete a signature request", + "description": "Deletes a request that isn't `sent` (cancel it first) with its signers, fields, audit trail, signatures and signed document, for good. Its documents stay (delete them on their own). Keep the signed document first if you need it: it is your record.", "security": [ { "bearerAuth": [] } ], "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "description": "1-100, default 20." - }, - "required": false, - "description": "1-100, default 20.", - "name": "limit", - "in": "query" - }, { "schema": { "type": "string", - "maxLength": 64, - "description": "Cursor: the `id` of the last item of the previous page." + "description": "The signature request's id.", + "examples": [ + "sreq_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] }, - "required": false, - "description": "Cursor: the `id` of the last item of the previous page.", - "name": "after", - "in": "query" + "required": true, + "description": "The signature request's id.", + "name": "id", + "in": "path" }, { "$ref": "#/components/parameters/CossVersion" @@ -56317,7 +58315,7 @@ ], "responses": { "200": { - "description": "A page of documents.", + "description": "The deleted request.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -56341,7 +58339,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignDocumentList" + "$ref": "#/components/schemas/DeletedSignRequest" } } } @@ -56409,8 +58407,29 @@ } } }, + "404": { + "description": "No signature request with this id exists in the API key's environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "A query parameter failed validation (`validation_error`, see `issues`).", + "description": "The request is `sent`: cancel it first (`request_not_deletable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -56479,7 +58498,7 @@ } }, "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "description": "Sign is temporarily unavailable (`sign_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -56502,14 +58521,14 @@ } } }, - "/v1/sign/documents/{id}": { - "get": { - "operationId": "getSignDocument", + "/v1/sign/requests/{id}/send": { + "post": { + "operationId": "sendSignRequest", "tags": [ - "Sign documents" + "Signature requests" ], - "summary": "Retrieve a document", - "description": "An uploaded document's details: its hash, page sizes and text anchors. Its bytes are never returned here.", + "summary": "Send a draft signature request", + "description": "Sends a `draft`. An already sent request is returned as is.", "security": [ { "bearerAuth": [] @@ -56519,13 +58538,13 @@ { "schema": { "type": "string", - "description": "The document's id.", + "description": "The signature request's id.", "examples": [ - "sdoc_01k7c8m4q2f3e9v6t5r1n0p8yz" + "sreq_01k7c8m4q2f3e9v6t5r1n0p8yz" ] }, "required": true, - "description": "The document's id.", + "description": "The signature request's id.", "name": "id", "in": "path" }, @@ -56535,7 +58554,7 @@ ], "responses": { "200": { - "description": "The document.", + "description": "The sent request.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -56559,7 +58578,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignDocument" + "$ref": "#/components/schemas/SignRequest" } } } @@ -56628,7 +58647,28 @@ } }, "404": { - "description": "No document with this id exists in the API key's environment (`not_found`).", + "description": "No signature request with this id exists in the API key's environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "The request isn't a draft (`request_not_draft`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -56718,14 +58758,16 @@ } } } - }, - "delete": { - "operationId": "deleteSignDocument", + } + }, + "/v1/sign/requests/{id}/cancel": { + "post": { + "operationId": "cancelSignRequest", "tags": [ - "Sign documents" + "Signature requests" ], - "summary": "Delete a document", - "description": "Deletes an uploaded document and its file. Documents a request uses can't be deleted.", + "summary": "Cancel a signature request", + "description": "Cancels a `draft` or `sent` request: its signers can no longer sign. An already canceled request is returned as is.", "security": [ { "bearerAuth": [] @@ -56735,13 +58777,13 @@ { "schema": { "type": "string", - "description": "The document's id.", + "description": "The signature request's id.", "examples": [ - "sdoc_01k7c8m4q2f3e9v6t5r1n0p8yz" + "sreq_01k7c8m4q2f3e9v6t5r1n0p8yz" ] }, "required": true, - "description": "The document's id.", + "description": "The signature request's id.", "name": "id", "in": "path" }, @@ -56751,7 +58793,7 @@ ], "responses": { "200": { - "description": "The document was deleted.", + "description": "The canceled request.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -56775,7 +58817,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeletedSignDocument" + "$ref": "#/components/schemas/SignRequest" } } } @@ -56844,7 +58886,7 @@ } }, "404": { - "description": "No document with this id exists in the API key's environment (`not_found`).", + "description": "No signature request with this id exists in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -56865,7 +58907,7 @@ } }, "422": { - "description": "A signature request uses the document (`document_in_use`).", + "description": "The request is already completed, declined or expired (`request_not_cancelable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -56934,7 +58976,7 @@ } }, "503": { - "description": "Sign is temporarily unavailable (`sign_unavailable`).", + "description": "The API key could not be verified right now; retry (`service_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -56957,14 +58999,14 @@ } } }, - "/v1/sign/requests": { - "post": { - "operationId": "createSignRequest", + "/v1/sign/requests/{id}/audit_trail": { + "get": { + "operationId": "listSignRequestEvents", "tags": [ "Signature requests" ], - "summary": "Create a signature request", - "description": "Asks one or more signers to sign one or more PDFs. Give documents by id (uploaded) or inline, and each signer's fields by position (`page`, `x`, `y`, `width`, `height` in PDF points from the page's top left) or by text anchor (`{{signature:signer_1}}`). Every signer needs a `signature` field. Sent at once unless `send` is `false` (a `draft`). Each signer signs by email (`delivery: \"email\"`, the default) or in your app (`\"embedded\"`).", + "summary": "List a signature request's audit trail", + "description": "Everything that happened to the request, oldest first: who did it, from which address, the documents' SHA-256 when it mattered, and a hash chain (`previous_hash`, `hash`) that shows the trail is complete and unchanged.", "security": [ { "bearerAuth": [] @@ -56974,35 +59016,23 @@ { "schema": { "type": "string", - "minLength": 1, - "maxLength": 256, - "description": "Makes retries safe: a repeated request with the same key and body returns the original request (in its current state) instead of creating another, for 24 hours. 1-256 characters.", + "description": "The signature request's id.", "examples": [ - "welcome-user-123" + "sreq_01k7c8m4q2f3e9v6t5r1n0p8yz" ] }, - "required": false, - "description": "Makes retries safe: a repeated request with the same key and body returns the original request (in its current state) instead of creating another, for 24 hours. 1-256 characters.", - "name": "Idempotency-Key", - "in": "header" + "required": true, + "description": "The signature request's id.", + "name": "id", + "in": "path" }, { "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateSignRequestRequest" - } - } - } - }, "responses": { - "201": { - "description": "The signature request (`sent`, or `draft`). A replay returns the original request in its current state.", + "200": { + "description": "The audit trail.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -57013,15 +59043,6 @@ ] } }, - "Idempotent-Replayed": { - "description": "`true` when this response replays an earlier request with the same `Idempotency-Key`.", - "schema": { - "type": "string", - "enum": [ - "true" - ] - } - }, "Coss-Version": { "description": "The API version this response is rendered in.", "schema": { @@ -57035,13 +59056,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignRequest" + "$ref": "#/components/schemas/SignEventList" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`) or the `Idempotency-Key` header is invalid (`invalid_idempotency_key`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -57103,71 +59124,8 @@ } } }, - "409": { - "description": "Requests with this `Idempotency-Key` are racing for an expired key (`request_in_progress`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "413": { - "description": "The request body is larger than 40 MB (`payload_too_large`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The body failed validation (`validation_error`, see `issues`), a document isn't a PDF coss.com can read (`invalid_document`) or isn't in the environment (`not_found`), a field doesn't fit on its page or names an anchor no document has (`invalid_fields`), a signer is listed twice (`invalid_signers`), `expires_at` is out of range (`invalid_expiry`), or the `Idempotency-Key` was already used with a different body (`idempotency_key_reused`).", + "404": { + "description": "No signature request with this id exists in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -57236,7 +59194,7 @@ } }, "503": { - "description": "Sign is temporarily unavailable (`sign_unavailable`).", + "description": "The API key could not be verified right now; retry (`service_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -57257,14 +59215,16 @@ } } } - }, - "get": { - "operationId": "listSignRequests", + } + }, + "/v1/sign/requests/{id}/remind": { + "post": { + "operationId": "remindSignRequest", "tags": [ "Signature requests" ], - "summary": "List signature requests", - "description": "Signature requests of the API key's environment, newest first. Pass the last `id` as `after` to get the next page while `has_more` is true.", + "summary": "Remind signers", + "description": "Emails a reminder, with a fresh link, to every email signer of a sent request who hasn't signed and whose turn it is, unless they were emailed in the last hour. Embedded signers are yours to remind. Sent within seconds; each is a `reminded` event in the audit trail.", "security": [ { "bearerAuth": [] @@ -57274,35 +59234,15 @@ { "schema": { "type": "string", - "description": "Only requests in these statuses, comma-separated: `sent,draft`." - }, - "required": false, - "description": "Only requests in these statuses, comma-separated: `sent,draft`.", - "name": "status", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "description": "1-100, default 20." - }, - "required": false, - "description": "1-100, default 20.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "maxLength": 64, - "description": "Cursor: the `id` of the last item of the previous page." + "description": "The signature request's id.", + "examples": [ + "sreq_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] }, - "required": false, - "description": "Cursor: the `id` of the last item of the previous page.", - "name": "after", - "in": "query" + "required": true, + "description": "The signature request's id.", + "name": "id", + "in": "path" }, { "$ref": "#/components/parameters/CossVersion" @@ -57310,7 +59250,7 @@ ], "responses": { "200": { - "description": "A page of signature requests.", + "description": "The request.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -57334,7 +59274,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignRequestList" + "$ref": "#/components/schemas/SignRequest" } } } @@ -57402,8 +59342,29 @@ } } }, + "404": { + "description": "No signature request with this id exists in the API key's environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "A query parameter failed validation (`validation_error`, see `issues`).", + "description": "The request isn't `sent`, or nobody can be reminded now (`reminder_not_due`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -57495,14 +59456,14 @@ } } }, - "/v1/sign/requests/{id}": { + "/v1/sign/requests/{id}/signed_document": { "get": { - "operationId": "getSignRequest", + "operationId": "getSignedDocument", "tags": [ "Signature requests" ], - "summary": "Retrieve a signature request", - "description": "A signature request with its documents, signers and fields, and `signed_document` once it's sealed.", + "summary": "Download the signed document", + "description": "The completed request's sealed PDF: every document with the signatures and values filled in, then a certificate of completion (signers, how they were identified, what they agreed to, the documents' SHA-256 and the audit trail). coss.com's seal makes any later change visible in PDF viewers. Ready once `signed_document` is set, which the `sign_request.completed` webhook announces.", "security": [ { "bearerAuth": [] @@ -57528,7 +59489,7 @@ ], "responses": { "200": { - "description": "The signature request.", + "description": "The sealed PDF.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -57550,9 +59511,10 @@ } }, "content": { - "application/json": { + "application/pdf": { "schema": { - "$ref": "#/components/schemas/SignRequest" + "type": "string", + "format": "binary" } } } @@ -57641,6 +59603,27 @@ } } }, + "422": { + "description": "The request isn't completed, or its document is still being sealed (`signed_document_not_ready`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -57690,7 +59673,7 @@ } }, "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "description": "Sign is temporarily unavailable (`sign_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -57711,14 +59694,16 @@ } } } - }, - "delete": { - "operationId": "deleteSignRequest", + } + }, + "/v1/sign/requests/{id}/signers/{signer_id}/sessions": { + "post": { + "operationId": "createSignSession", "tags": [ "Signature requests" ], - "summary": "Delete a signature request", - "description": "Deletes a request that isn't `sent` (cancel it first) with its signers, fields, audit trail, signatures and signed document, for good. Its documents stay (delete them on their own). Keep the signed document first if you need it: it is your record.", + "summary": "Create a signing session", + "description": "A short-lived link to the signing page for one `embedded` signer of a sent request. Your app vouches for who the signer is (the audit trail records `customer_asserted`), so create it only for that signed-in user, when they open the page that shows it. Embed the `url` in an iframe from an origin in your Sign settings' `allowed_origins` (`mountSigning` from `coss/sign` does it), or redirect to it. Each call creates a new session.", "security": [ { "bearerAuth": [] @@ -57738,13 +59723,36 @@ "name": "id", "in": "path" }, + { + "schema": { + "type": "string", + "description": "The signer's id (`signers[].id` of the request).", + "examples": [ + "ssgn_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + }, + "required": true, + "description": "The signer's id (`signers[].id` of the request).", + "name": "signer_id", + "in": "path" + }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSignSessionRequest" + } + } + } + }, "responses": { - "200": { - "description": "The deleted request.", + "201": { + "description": "The session, with its link.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -57768,7 +59776,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeletedSignRequest" + "$ref": "#/components/schemas/SignSession" } } } @@ -57837,7 +59845,7 @@ } }, "404": { - "description": "No signature request with this id exists in the API key's environment (`not_found`).", + "description": "No such signer on a signature request in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -57858,7 +59866,7 @@ } }, "422": { - "description": "The request is `sent`: cancel it first (`request_not_deletable`).", + "description": "The signer signs by email (`signer_not_embedded`), already signed or declined (`signer_finished`), or the request isn't `sent` or has expired (`request_not_signable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -57950,40 +59958,22 @@ } } }, - "/v1/sign/requests/{id}/send": { - "post": { - "operationId": "sendSignRequest", + "/v1/sign/settings": { + "get": { + "operationId": "getSignSettings", "tags": [ - "Signature requests" + "Sign settings" ], - "summary": "Send a draft signature request", - "description": "Sends a `draft`. An already sent request is returned as is.", + "summary": "Get the Sign settings", + "description": "The API key's environment's settings: which sites may embed the signing page, how it looks, and the consent text signers accept.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The signature request's id.", - "examples": [ - "sreq_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - }, - "required": true, - "description": "The signature request's id.", - "name": "id", - "in": "path" - }, - { - "$ref": "#/components/parameters/CossVersion" - } - ], "responses": { "200": { - "description": "The sent request.", + "description": "The settings.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -58007,7 +59997,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignRequest" + "$ref": "#/components/schemas/SignSettings" } } } @@ -58075,48 +60065,6 @@ } } }, - "404": { - "description": "No signature request with this id exists in the API key's environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The request isn't a draft (`request_not_draft`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -58186,43 +60134,38 @@ } } } - } - } - }, - "/v1/sign/requests/{id}/cancel": { - "post": { - "operationId": "cancelSignRequest", + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] + }, + "patch": { + "operationId": "updateSignSettings", "tags": [ - "Signature requests" + "Sign settings" ], - "summary": "Cancel a signature request", - "description": "Cancels a `draft` or `sent` request: its signers can no longer sign. An already canceled request is returned as is.", + "summary": "Update the Sign settings", + "description": "Changes the fields given; the others keep their values.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The signature request's id.", - "examples": [ - "sreq_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - }, - "required": true, - "description": "The signature request's id.", - "name": "id", - "in": "path" - }, - { - "$ref": "#/components/parameters/CossVersion" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSignSettingsRequest" + } + } } - ], + }, "responses": { "200": { - "description": "The canceled request.", + "description": "The settings.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -58246,7 +60189,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignRequest" + "$ref": "#/components/schemas/SignSettings" } } } @@ -58314,29 +60257,8 @@ } } }, - "404": { - "description": "No signature request with this id exists in the API key's environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "422": { - "description": "The request is already completed, declined or expired (`request_not_cancelable`).", + "description": "The body failed validation (`validation_error`), or an origin isn't `https://host[:port]` (`invalid_origin`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -58425,43 +60347,40 @@ } } } - } + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] } }, - "/v1/sign/requests/{id}/audit_trail": { - "get": { - "operationId": "listSignRequestEvents", + "/v1/sign/templates": { + "post": { + "operationId": "createSignTemplate", "tags": [ - "Signature requests" + "Sign templates" ], - "summary": "List a signature request's audit trail", - "description": "Everything that happened to the request, oldest first: who did it, from which address, the documents' SHA-256 when it mattered, and a hash chain (`previous_hash`, `hash`) that shows the trail is complete and unchanged.", + "summary": "Create a template", + "description": "Documents with fields for roles (`client`, `company`) instead of named signers, made into requests by naming a signer per role. Fields by position or text anchor (resolved now). Can start empty and be built in the embedded builder (create a builder session).", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The signature request's id.", - "examples": [ - "sreq_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - }, - "required": true, - "description": "The signature request's id.", - "name": "id", - "in": "path" - }, - { - "$ref": "#/components/parameters/CossVersion" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSignTemplateRequest" + } + } } - ], + }, "responses": { - "200": { - "description": "The audit trail.", + "201": { + "description": "The template.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -58485,13 +60404,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignEventList" + "$ref": "#/components/schemas/SignTemplate" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -58553,8 +60472,29 @@ } } }, - "404": { - "description": "No signature request with this id exists in the API key's environment (`not_found`).", + "413": { + "description": "The request body is larger than 40 MB (`payload_too_large`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "The body failed validation (`validation_error`), a document isn't a PDF coss.com can read (`invalid_document`) or isn't in the environment (`not_found`), a field doesn't fit or names an unknown role or anchor (`invalid_fields`), or a role is listed twice (`invalid_signers`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -58623,7 +60563,7 @@ } }, "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "description": "Sign is temporarily unavailable (`sign_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -58643,35 +60583,48 @@ } } } - } - } - }, - "/v1/sign/requests/{id}/remind": { - "post": { - "operationId": "remindSignRequest", + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] + }, + "get": { + "operationId": "listSignTemplates", "tags": [ - "Signature requests" + "Sign templates" ], - "summary": "Remind signers", - "description": "Emails a reminder, with a fresh link, to every email signer of a sent request who hasn't signed and whose turn it is, unless they were emailed in the last hour. Embedded signers are yours to remind. Sent within seconds; each is a `reminded` event in the audit trail.", + "summary": "List templates", + "description": "Newest first. Pass the last `id` as `after` while `has_more` is true.", "security": [ { "bearerAuth": [] } ], "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "1-100, default 20." + }, + "required": false, + "description": "1-100, default 20.", + "name": "limit", + "in": "query" + }, { "schema": { "type": "string", - "description": "The signature request's id.", - "examples": [ - "sreq_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] + "maxLength": 64, + "description": "Cursor: the `id` of the last item of the previous page." }, - "required": true, - "description": "The signature request's id.", - "name": "id", - "in": "path" + "required": false, + "description": "Cursor: the `id` of the last item of the previous page.", + "name": "after", + "in": "query" }, { "$ref": "#/components/parameters/CossVersion" @@ -58679,7 +60632,7 @@ ], "responses": { "200": { - "description": "The request.", + "description": "A page of templates.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -58703,7 +60656,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignRequest" + "$ref": "#/components/schemas/SignTemplateList" } } } @@ -58771,29 +60724,8 @@ } } }, - "404": { - "description": "No signature request with this id exists in the API key's environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "422": { - "description": "The request isn't `sent`, or nobody can be reminded now (`reminder_not_due`).", + "description": "A query parameter failed validation (`validation_error`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -58885,14 +60817,14 @@ } } }, - "/v1/sign/requests/{id}/signed_document": { + "/v1/sign/templates/{id}": { "get": { - "operationId": "getSignedDocument", + "operationId": "getSignTemplate", "tags": [ - "Signature requests" + "Sign templates" ], - "summary": "Download the signed document", - "description": "The completed request's sealed PDF: every document with the signatures and values filled in, then a certificate of completion (signers, how they were identified, what they agreed to, the documents' SHA-256 and the audit trail). coss.com's seal makes any later change visible in PDF viewers. Ready once `signed_document` is set, which the `sign_request.completed` webhook announces.", + "summary": "Retrieve a template", + "description": "A template with its documents, roles and fields. `ready` says whether requests can be made from it.", "security": [ { "bearerAuth": [] @@ -58902,13 +60834,13 @@ { "schema": { "type": "string", - "description": "The signature request's id.", + "description": "The template's id.", "examples": [ - "sreq_01k7c8m4q2f3e9v6t5r1n0p8yz" + "stpl_01k7c8m4q2f3e9v6t5r1n0p8yz" ] }, "required": true, - "description": "The signature request's id.", + "description": "The template's id.", "name": "id", "in": "path" }, @@ -58918,7 +60850,7 @@ ], "responses": { "200": { - "description": "The sealed PDF.", + "description": "The template.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -58940,10 +60872,9 @@ } }, "content": { - "application/pdf": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/SignTemplate" } } } @@ -59012,28 +60943,7 @@ } }, "404": { - "description": "No signature request with this id exists in the API key's environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The request isn't completed, or its document is still being sealed (`signed_document_not_ready`).", + "description": "No template with this id exists in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -59102,7 +61012,7 @@ } }, "503": { - "description": "Sign is temporarily unavailable (`sign_unavailable`).", + "description": "The API key could not be verified right now; retry (`service_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -59123,16 +61033,14 @@ } } } - } - }, - "/v1/sign/requests/{id}/signers/{signer_id}/sessions": { - "post": { - "operationId": "createSignSession", + }, + "patch": { + "operationId": "updateSignTemplate", "tags": [ - "Signature requests" + "Sign templates" ], - "summary": "Create a signing session", - "description": "A short-lived link to the signing page for one `embedded` signer of a sent request. Your app vouches for who the signer is (the audit trail records `customer_asserted`), so create it only for that signed-in user, when they open the page that shows it. Embed the `url` in an iframe from an origin in your Sign settings' `allowed_origins` (`mountSigning` from `coss/sign` does it), or redirect to it. Each call creates a new session.", + "summary": "Update a template", + "description": "Changes the fields given. `documents`, `roles` and `fields` each replace the whole list. Requests already made from it don't change.", "security": [ { "bearerAuth": [] @@ -59142,46 +61050,33 @@ { "schema": { "type": "string", - "description": "The signature request's id.", + "description": "The template's id.", "examples": [ - "sreq_01k7c8m4q2f3e9v6t5r1n0p8yz" + "stpl_01k7c8m4q2f3e9v6t5r1n0p8yz" ] }, "required": true, - "description": "The signature request's id.", + "description": "The template's id.", "name": "id", "in": "path" }, - { - "schema": { - "type": "string", - "description": "The signer's id (`signers[].id` of the request).", - "examples": [ - "ssgn_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - }, - "required": true, - "description": "The signer's id (`signers[].id` of the request).", - "name": "signer_id", - "in": "path" - }, { "$ref": "#/components/parameters/CossVersion" } ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateSignSessionRequest" + "$ref": "#/components/schemas/UpdateSignTemplateRequest" } } } }, "responses": { - "201": { - "description": "The session, with its link.", + "200": { + "description": "The template.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -59205,13 +61100,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignSession" + "$ref": "#/components/schemas/SignTemplate" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -59274,7 +61169,28 @@ } }, "404": { - "description": "No such signer on a signature request in the API key's environment (`not_found`).", + "description": "No template with this id exists in the API key's environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "The request body is larger than 40 MB (`payload_too_large`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -59295,7 +61211,7 @@ } }, "422": { - "description": "The signer signs by email (`signer_not_embedded`), already signed or declined (`signer_finished`), or the request isn't `sent` or has expired (`request_not_signable`).", + "description": "The body failed validation (`validation_error`), a document isn't a PDF coss.com can read (`invalid_document`) or isn't in the environment (`not_found`), a field doesn't fit or names an unknown role or anchor (`invalid_fields`), or a role is listed twice (`invalid_signers`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -59385,24 +61301,40 @@ } } } - } - }, - "/v1/sign/settings": { - "get": { - "operationId": "getSignSettings", + }, + "delete": { + "operationId": "deleteSignTemplate", "tags": [ - "Sign settings" + "Sign templates" ], - "summary": "Get the Sign settings", - "description": "The API key's environment's settings: which sites may embed the signing page, how it looks, and the consent text signers accept.", + "summary": "Delete a template", + "description": "Requests made from it, and its documents, stay.", "security": [ { "bearerAuth": [] } ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "The template's id.", + "examples": [ + "stpl_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + }, + "required": true, + "description": "The template's id.", + "name": "id", + "in": "path" + }, + { + "$ref": "#/components/parameters/CossVersion" + } + ], "responses": { "200": { - "description": "The settings.", + "description": "The deleted template.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -59426,7 +61358,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignSettings" + "$ref": "#/components/schemas/DeletedSignTemplate" } } } @@ -59494,6 +61426,27 @@ } } }, + "404": { + "description": "No template with this id exists in the API key's environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -59563,38 +61516,68 @@ } } } - }, - "parameters": [ - { - "$ref": "#/components/parameters/CossVersion" - } - ] - }, - "patch": { - "operationId": "updateSignSettings", + } + } + }, + "/v1/sign/templates/{id}/requests": { + "post": { + "operationId": "createSignRequestFromTemplate", "tags": [ - "Sign settings" + "Sign templates" ], - "summary": "Update the Sign settings", - "description": "Changes the fields given; the others keep their values.", + "summary": "Create a request from a template", + "description": "A signature request with the template's documents and fields: name one signer for each role, and give its merge fields' values. Like creating a request, it is sent at once unless `send` is `false`, and an `Idempotency-Key` makes retries safe.", "security": [ { "bearerAuth": [] } ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "The template's id.", + "examples": [ + "stpl_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + }, + "required": true, + "description": "The template's id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Makes retries safe: a repeated request with the same key and body returns the original request (in its current state) instead of creating another, for 24 hours. 1-256 characters.", + "examples": [ + "welcome-user-123" + ] + }, + "required": false, + "description": "Makes retries safe: a repeated request with the same key and body returns the original request (in its current state) instead of creating another, for 24 hours. 1-256 characters.", + "name": "Idempotency-Key", + "in": "header" + }, + { + "$ref": "#/components/parameters/CossVersion" + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateSignSettingsRequest" + "$ref": "#/components/schemas/CreateSignRequestFromTemplateRequest" } } } }, "responses": { - "200": { - "description": "The settings.", + "201": { + "description": "The signature request.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -59605,6 +61588,15 @@ ] } }, + "Idempotent-Replayed": { + "description": "`true` when this response replays an earlier request with the same `Idempotency-Key`.", + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, "Coss-Version": { "description": "The API version this response is rendered in.", "schema": { @@ -59618,13 +61610,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignSettings" + "$ref": "#/components/schemas/SignRequest" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`) or the `Idempotency-Key` header is invalid (`invalid_idempotency_key`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -59686,8 +61678,50 @@ } } }, + "404": { + "description": "No template with this id exists in the API key's environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Requests with this `Idempotency-Key` are racing (`request_in_progress`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "The body failed validation (`validation_error`), or an origin isn't `https://host[:port]` (`invalid_origin`).", + "description": "The template isn't ready (`template_incomplete`), a role has no signer or two (`invalid_signers`), a merge field doesn't exist (`invalid_fields`), `expires_at` is out of range (`invalid_expiry`), or the `Idempotency-Key` was used with a different body (`idempotency_key_reused`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -59756,7 +61790,7 @@ } }, "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "description": "Sign is temporarily unavailable (`sign_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -59776,40 +61810,53 @@ } } } - }, - "parameters": [ - { - "$ref": "#/components/parameters/CossVersion" - } - ] + } } }, - "/v1/sign/templates": { + "/v1/sign/templates/{id}/builder_sessions": { "post": { - "operationId": "createSignTemplate", + "operationId": "createSignBuilderSession", "tags": [ "Sign templates" ], - "summary": "Create a template", - "description": "Documents with fields for roles (`client`, `company`) instead of named signers, made into requests by naming a signer per role. Fields by position or text anchor (resolved now). Can start empty and be built in the embedded builder (create a builder session).", + "summary": "Create a builder session", + "description": "A short-lived link to the embedded builder for this template: upload documents, add roles, and drag fields onto pages. Embed it with `mountBuilder` from `coss/sign` (from an origin in your Sign settings' `allowed_origins`) for whoever edits the template.", "security": [ { "bearerAuth": [] } ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "The template's id.", + "examples": [ + "stpl_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + }, + "required": true, + "description": "The template's id.", + "name": "id", + "in": "path" + }, + { + "$ref": "#/components/parameters/CossVersion" + } + ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateSignTemplateRequest" + "$ref": "#/components/schemas/CreateSignBuilderSessionRequest" } } } }, "responses": { "201": { - "description": "The template.", + "description": "The builder session, with its link.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -59833,13 +61880,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignTemplate" + "$ref": "#/components/schemas/SignBuilderSession" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -59901,29 +61948,8 @@ } } }, - "413": { - "description": "The request body is larger than 40 MB (`payload_too_large`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The body failed validation (`validation_error`), a document isn't a PDF coss.com can read (`invalid_document`) or isn't in the environment (`not_found`), a field doesn't fit or names an unknown role or anchor (`invalid_fields`), or a role is listed twice (`invalid_signers`).", + "404": { + "description": "No template with this id exists in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60012,56 +62038,55 @@ } } } - }, - "parameters": [ - { - "$ref": "#/components/parameters/CossVersion" - } - ] - }, - "get": { - "operationId": "listSignTemplates", + } + } + }, + "/v1/verifications": { + "post": { + "operationId": "startVerification", "tags": [ - "Sign templates" + "Verifications" ], - "summary": "List templates", - "description": "Newest first. Pass the last `id` as `after` while `has_more` is true.", + "summary": "Start a verification", + "description": "Sends a one-time code to a phone number (SMS) or an email address and returns the verification, `pending` until you check the code the end user typed (`POST /v1/verifications/check`). Starting again for the same destination while it is pending sends a new code (the earlier one stops working), up to 5 times. Before a new verification costs anything, the fraud guard checks it (your Risk lists, premium-rate numbers, Tor, and velocity per IP, user, number range and country, with how often their codes are entered): a blocked one is stored with its `reasons` and nothing is sent. Pass the end user's `ip` (and your `user_id`) so it can tell them apart. SMS codes come from coss.com's shared numbers unless the environment's Verify settings name one of your own; email codes from your sender, else coss.com's. Sandbox (`coss_test_…`) keys deliver nothing and the code is always zeros.", "security": [ { "bearerAuth": [] } ], "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "description": "1-100, default 20." - }, - "required": false, - "description": "1-100, default 20.", - "name": "limit", - "in": "query" - }, { "schema": { "type": "string", - "maxLength": 64, - "description": "Cursor: the `id` of the last item of the previous page." + "minLength": 1, + "maxLength": 256, + "description": "Makes retries safe: a repeated request with the same key and body returns the original verification instead of sending another code, for 24 hours. 1-256 characters.", + "examples": [ + "welcome-user-123" + ] }, "required": false, - "description": "Cursor: the `id` of the last item of the previous page.", - "name": "after", - "in": "query" + "description": "Makes retries safe: a repeated request with the same key and body returns the original verification instead of sending another code, for 24 hours. 1-256 characters.", + "name": "Idempotency-Key", + "in": "header" }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartVerificationRequest" + } + } + } + }, "responses": { - "200": { - "description": "A page of templates.", + "201": { + "description": "The verification, `pending`, with the send that just went out.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60072,6 +62097,15 @@ ] } }, + "Idempotent-Replayed": { + "description": "`true` when this response replays an earlier request with the same `Idempotency-Key`.", + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, "Coss-Version": { "description": "The API version this response is rendered in.", "schema": { @@ -60085,13 +62119,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignTemplateList" + "$ref": "#/components/schemas/Verification" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`) or the `Idempotency-Key` header is invalid (`invalid_idempotency_key`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60132,6 +62166,27 @@ } } }, + "402": { + "description": "Live sending is paused for an overdue invoice or the spend limit (`billing_hold`) or a used-up budget (`budget_exceeded`), or the plan doesn't include Verify or the destination country isn't priced (`plan_feature_unavailable`) (`billing_error`). Never retried: it needs a plan change or a payment.", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -60153,8 +62208,35 @@ } } }, + "409": { + "description": "Requests with this `Idempotency-Key` are racing (`request_in_progress`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "A query parameter failed validation (`validation_error`).", + "description": "The body failed validation (`validation_error`, see `issues`); `to` isn't a valid number or address (`invalid_destination`) or its country isn't allowed (`country_not_allowed`); the fraud guard blocked it (`verification_blocked`: the verification is stored with its `reasons`); the code was already sent 5 times (`too_many_sends`); the recipient opted out or is suppressed (`recipient_suppressed`); the code couldn't be sent (`send_failed`, `sender_not_ready`…); or the `Idempotency-Key` was already used with a different body (`idempotency_key_reused`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60175,7 +62257,7 @@ } }, "429": { - "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", + "description": "Too many requests with this API key (`rate_limited`), or too many codes to this destination or from coss.com's shared numbers today (`quota_exceeded`). Retry after `Retry-After` seconds.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60223,7 +62305,7 @@ } }, "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "description": "coss.com's shared numbers aren't available for this destination (`shared_sender_unavailable`): name your own number in Verify's settings.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60244,16 +62326,14 @@ } } } - } - }, - "/v1/sign/templates/{id}": { + }, "get": { - "operationId": "getSignTemplate", + "operationId": "listVerifications", "tags": [ - "Sign templates" + "Verifications" ], - "summary": "Retrieve a template", - "description": "A template with its documents, roles and fields. `ready` says whether requests can be made from it.", + "summary": "List verifications", + "description": "Verifications of the API key's environment, newest first. Pass the last `id` as `after` to get the next page while `has_more` is true.", "security": [ { "bearerAuth": [] @@ -60263,15 +62343,55 @@ { "schema": { "type": "string", - "description": "The template's id.", - "examples": [ - "stpl_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] + "minLength": 1, + "maxLength": 320, + "description": "Only verifications to this destination." }, - "required": true, - "description": "The template's id.", - "name": "id", - "in": "path" + "required": false, + "description": "Only verifications to this destination.", + "name": "to", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "pending", + "approved", + "expired", + "canceled", + "failed", + "blocked" + ], + "description": "Only verifications in this status." + }, + "required": false, + "description": "Only verifications in this status.", + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "1-100, default 20." + }, + "required": false, + "description": "1-100, default 20.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "maxLength": 64, + "description": "Cursor: the `id` of the last item of the previous page." + }, + "required": false, + "description": "Cursor: the `id` of the last item of the previous page.", + "name": "after", + "in": "query" }, { "$ref": "#/components/parameters/CossVersion" @@ -60279,7 +62399,7 @@ ], "responses": { "200": { - "description": "The template.", + "description": "A page of verifications.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60303,7 +62423,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignTemplate" + "$ref": "#/components/schemas/VerificationList" } } } @@ -60371,8 +62491,8 @@ } } }, - "404": { - "description": "No template with this id exists in the API key's environment (`not_found`).", + "422": { + "description": "A query parameter failed validation (`validation_error`, see `issues`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60462,14 +62582,16 @@ } } } - }, - "patch": { - "operationId": "updateSignTemplate", + } + }, + "/v1/verifications/check": { + "post": { + "operationId": "checkVerification", "tags": [ - "Sign templates" + "Verifications" ], - "summary": "Update a template", - "description": "Changes the fields given. `documents`, `roles` and `fields` each replace the whole list. Requests already made from it don't change.", + "summary": "Check a code", + "description": "Checks the code the end user typed, by the verification's id or by the destination (its latest verification). `valid: true` approves it (`verification.approved`, billed once); a wrong code spends an attempt, and the last one fails it. Checking an approved verification again with its code stays valid until it expires, without being billed again.", "security": [ { "bearerAuth": [] @@ -60479,15 +62601,17 @@ { "schema": { "type": "string", - "description": "The template's id.", + "minLength": 1, + "maxLength": 256, + "description": "Makes retries safe: a repeated check with the same key returns the same answer without spending another attempt. 1-256 characters.", "examples": [ - "stpl_01k7c8m4q2f3e9v6t5r1n0p8yz" + "welcome-user-123" ] }, - "required": true, - "description": "The template's id.", - "name": "id", - "in": "path" + "required": false, + "description": "Makes retries safe: a repeated check with the same key returns the same answer without spending another attempt. 1-256 characters.", + "name": "Idempotency-Key", + "in": "header" }, { "$ref": "#/components/parameters/CossVersion" @@ -60498,14 +62622,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateSignTemplateRequest" + "$ref": "#/components/schemas/CheckVerificationRequest" } } } }, "responses": { "200": { - "description": "The template.", + "description": "Whether the code is right, and the verification after the check.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60529,13 +62653,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignTemplate" + "$ref": "#/components/schemas/VerificationCheck" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`) or the `Idempotency-Key` header is invalid (`invalid_idempotency_key`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60598,28 +62722,7 @@ } }, "404": { - "description": "No template with this id exists in the API key's environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "413": { - "description": "The request body is larger than 40 MB (`payload_too_large`).", + "description": "No such verification in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60640,7 +62743,7 @@ } }, "422": { - "description": "The body failed validation (`validation_error`), a document isn't a PDF coss.com can read (`invalid_document`) or isn't in the environment (`not_found`), a field doesn't fit or names an unknown role or anchor (`invalid_fields`), or a role is listed twice (`invalid_signers`).", + "description": "The body failed validation (`validation_error`, see `issues`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60661,7 +62764,7 @@ } }, "429": { - "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", + "description": "Too many requests with this API key (`rate_limited`), or too many checks for this destination (`quota_exceeded`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60709,7 +62812,7 @@ } }, "503": { - "description": "Sign is temporarily unavailable (`sign_unavailable`).", + "description": "The API key could not be verified right now; retry (`service_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60730,14 +62833,16 @@ } } } - }, - "delete": { - "operationId": "deleteSignTemplate", + } + }, + "/v1/verifications/{id}": { + "get": { + "operationId": "getVerification", "tags": [ - "Sign templates" + "Verifications" ], - "summary": "Delete a template", - "description": "Requests made from it, and its documents, stay.", + "summary": "Retrieve a verification", + "description": "A verification's status and destination. Its code is never returned.", "security": [ { "bearerAuth": [] @@ -60747,13 +62852,13 @@ { "schema": { "type": "string", - "description": "The template's id.", + "description": "The verification's id.", "examples": [ - "stpl_01k7c8m4q2f3e9v6t5r1n0p8yz" + "vrf_01k8e2m4q2f3e9v6t5r1n0p8yz" ] }, "required": true, - "description": "The template's id.", + "description": "The verification's id.", "name": "id", "in": "path" }, @@ -60763,7 +62868,7 @@ ], "responses": { "200": { - "description": "The deleted template.", + "description": "The verification.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60787,7 +62892,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeletedSignTemplate" + "$ref": "#/components/schemas/Verification" } } } @@ -60856,7 +62961,7 @@ } }, "404": { - "description": "No template with this id exists in the API key's environment (`not_found`).", + "description": "No such verification in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -60948,14 +63053,14 @@ } } }, - "/v1/sign/templates/{id}/requests": { + "/v1/verifications/{id}/cancel": { "post": { - "operationId": "createSignRequestFromTemplate", + "operationId": "cancelVerification", "tags": [ - "Sign templates" + "Verifications" ], - "summary": "Create a request from a template", - "description": "A signature request with the template's documents and fields: name one signer for each role, and give its merge fields' values. Like creating a request, it is sent at once unless `send` is `false`, and an `Idempotency-Key` makes retries safe.", + "summary": "Cancel a verification", + "description": "Cancels a pending verification: its code stops working. Canceling it again returns it as is.", "security": [ { "bearerAuth": [] @@ -60965,48 +63070,23 @@ { "schema": { "type": "string", - "description": "The template's id.", + "description": "The verification's id.", "examples": [ - "stpl_01k7c8m4q2f3e9v6t5r1n0p8yz" + "vrf_01k8e2m4q2f3e9v6t5r1n0p8yz" ] }, "required": true, - "description": "The template's id.", + "description": "The verification's id.", "name": "id", "in": "path" }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "description": "Makes retries safe: a repeated request with the same key and body returns the original request (in its current state) instead of creating another, for 24 hours. 1-256 characters.", - "examples": [ - "welcome-user-123" - ] - }, - "required": false, - "description": "Makes retries safe: a repeated request with the same key and body returns the original request (in its current state) instead of creating another, for 24 hours. 1-256 characters.", - "name": "Idempotency-Key", - "in": "header" - }, { "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateSignRequestFromTemplateRequest" - } - } - } - }, "responses": { - "201": { - "description": "The signature request.", + "200": { + "description": "The canceled verification.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61017,15 +63097,6 @@ ] } }, - "Idempotent-Replayed": { - "description": "`true` when this response replays an earlier request with the same `Idempotency-Key`.", - "schema": { - "type": "string", - "enum": [ - "true" - ] - } - }, "Coss-Version": { "description": "The API version this response is rendered in.", "schema": { @@ -61039,13 +63110,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignRequest" + "$ref": "#/components/schemas/Verification" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`) or the `Idempotency-Key` header is invalid (`invalid_idempotency_key`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61108,28 +63179,7 @@ } }, "404": { - "description": "No template with this id exists in the API key's environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Requests with this `Idempotency-Key` are racing (`request_in_progress`).", + "description": "No such verification in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61150,7 +63200,7 @@ } }, "422": { - "description": "The template isn't ready (`template_incomplete`), a role has no signer or two (`invalid_signers`), a merge field doesn't exist (`invalid_fields`), `expires_at` is out of range (`invalid_expiry`), or the `Idempotency-Key` was used with a different body (`idempotency_key_reused`).", + "description": "The verification isn't pending (`verification_not_cancelable`): approved, failed, blocked or expired.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61219,7 +63269,7 @@ } }, "503": { - "description": "Sign is temporarily unavailable (`sign_unavailable`).", + "description": "The API key could not be verified right now; retry (`service_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61242,50 +63292,22 @@ } } }, - "/v1/sign/templates/{id}/builder_sessions": { - "post": { - "operationId": "createSignBuilderSession", + "/v1/verify/settings": { + "get": { + "operationId": "getVerifySettings", "tags": [ - "Sign templates" + "Verify settings" ], - "summary": "Create a builder session", - "description": "A short-lived link to the embedded builder for this template: upload documents, add roles, and drag fields onto pages. Embed it with `mountBuilder` from `coss/sign` (from an origin in your Sign settings' `allowed_origins`) for whoever edits the template.", + "summary": "Get the Verify settings", + "description": "The API key's environment's settings, with defaults filled in: the name codes are sent for, code length, lifetime and attempts, the senders, and the countries SMS codes may go to.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The template's id.", - "examples": [ - "stpl_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - }, - "required": true, - "description": "The template's id.", - "name": "id", - "in": "path" - }, - { - "$ref": "#/components/parameters/CossVersion" - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateSignBuilderSessionRequest" - } - } - } - }, "responses": { - "201": { - "description": "The builder session, with its link.", + "200": { + "description": "The settings.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61309,7 +63331,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SignBuilderSession" + "$ref": "#/components/schemas/VerifySettings" } } } @@ -61377,27 +63399,6 @@ } } }, - "404": { - "description": "No template with this id exists in the API key's environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -61447,7 +63448,7 @@ } }, "503": { - "description": "Sign is temporarily unavailable (`sign_unavailable`).", + "description": "The API key could not be verified right now; retry (`service_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61467,55 +63468,38 @@ } } } - } - } - }, - "/v1/verifications": { - "post": { - "operationId": "startVerification", + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] + }, + "patch": { + "operationId": "updateVerifySettings", "tags": [ - "Verifications" + "Verify settings" ], - "summary": "Start a verification", - "description": "Sends a one-time code to a phone number (SMS) or an email address and returns the verification, `pending` until you check the code the end user typed (`POST /v1/verifications/check`). Starting again for the same destination while it is pending sends a new code (the earlier one stops working), up to 5 times. Before a new verification costs anything, the fraud guard checks it (your Risk lists, premium-rate numbers, Tor, and velocity per IP, user, number range and country, with how often their codes are entered): a blocked one is stored with its `reasons` and nothing is sent. Pass the end user's `ip` (and your `user_id`) so it can tell them apart. SMS codes come from coss.com's shared numbers unless the environment's Verify settings name one of your own; email codes from your sender, else coss.com's. Sandbox (`coss_test_…`) keys deliver nothing and the code is always zeros.", + "summary": "Update the Verify settings", + "description": "Changes the fields given; the others keep their values. `sms_from` must be one of the organization's numbers, and a live key's `email_from` must be on one of its verified domains.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "description": "Makes retries safe: a repeated request with the same key and body returns the original verification instead of sending another code, for 24 hours. 1-256 characters.", - "examples": [ - "welcome-user-123" - ] - }, - "required": false, - "description": "Makes retries safe: a repeated request with the same key and body returns the original verification instead of sending another code, for 24 hours. 1-256 characters.", - "name": "Idempotency-Key", - "in": "header" - }, - { - "$ref": "#/components/parameters/CossVersion" - } - ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StartVerificationRequest" + "$ref": "#/components/schemas/UpdateVerifySettingsRequest" } } } }, "responses": { - "201": { - "description": "The verification, `pending`, with the send that just went out.", + "200": { + "description": "The settings.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61526,15 +63510,6 @@ ] } }, - "Idempotent-Replayed": { - "description": "`true` when this response replays an earlier request with the same `Idempotency-Key`.", - "schema": { - "type": "string", - "enum": [ - "true" - ] - } - }, "Coss-Version": { "description": "The API version this response is rendered in.", "schema": { @@ -61548,13 +63523,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Verification" + "$ref": "#/components/schemas/VerifySettings" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`) or the `Idempotency-Key` header is invalid (`invalid_idempotency_key`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61595,27 +63570,6 @@ } } }, - "402": { - "description": "Live sending is paused for an overdue invoice or the spend limit (`billing_hold`) or a used-up budget (`budget_exceeded`), or the plan doesn't include Verify or the destination country isn't priced (`plan_feature_unavailable`) (`billing_error`). Never retried: it needs a plan change or a payment.", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -61637,35 +63591,8 @@ } } }, - "409": { - "description": "Requests with this `Idempotency-Key` are racing (`request_in_progress`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - }, - "Retry-After": { - "description": "Seconds to wait before retrying.", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "422": { - "description": "The body failed validation (`validation_error`, see `issues`); `to` isn't a valid number or address (`invalid_destination`) or its country isn't allowed (`country_not_allowed`); the fraud guard blocked it (`verification_blocked`: the verification is stored with its `reasons`); the code was already sent 5 times (`too_many_sends`); the recipient opted out or is suppressed (`recipient_suppressed`); the code couldn't be sent (`send_failed`, `sender_not_ready`…); or the `Idempotency-Key` was already used with a different body (`idempotency_key_reused`).", + "description": "The body failed validation (`validation_error`), or a sender isn't the organization's (`invalid_settings`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61686,7 +63613,7 @@ } }, "429": { - "description": "Too many requests with this API key (`rate_limited`), or too many codes to this destination or from coss.com's shared numbers today (`quota_exceeded`). Retry after `Retry-After` seconds.", + "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61734,7 +63661,7 @@ } }, "503": { - "description": "coss.com's shared numbers aren't available for this destination (`shared_sender_unavailable`): name your own number in Verify's settings.", + "description": "The API key could not be verified right now; retry (`service_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61754,81 +63681,40 @@ } } } - } - }, - "get": { - "operationId": "listVerifications", + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] + } + }, + "/v1/forms": { + "post": { + "operationId": "createForm", "tags": [ - "Verifications" + "Forms" ], - "summary": "List verifications", - "description": "Verifications of the API key's environment, newest first. Pass the last `id` as `after` to get the next page while `has_more` is true.", + "summary": "Create a form", + "description": "A hosted form endpoint: point an HTML `
` (or `coss/forms`) at its `endpoint`. Every submission is screened by Risk (the honeypot, captcha, email, IP, device, velocity and content) and accepted ones go to webhooks (`form.submission.received`), notification emails and contacts. Without `fields`, any field is accepted.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 320, - "description": "Only verifications to this destination." - }, - "required": false, - "description": "Only verifications to this destination.", - "name": "to", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "pending", - "approved", - "expired", - "canceled", - "failed", - "blocked" - ], - "description": "Only verifications in this status." - }, - "required": false, - "description": "Only verifications in this status.", - "name": "status", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "description": "1-100, default 20." - }, - "required": false, - "description": "1-100, default 20.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "maxLength": 64, - "description": "Cursor: the `id` of the last item of the previous page." - }, - "required": false, - "description": "Cursor: the `id` of the last item of the previous page.", - "name": "after", - "in": "query" - }, - { - "$ref": "#/components/parameters/CossVersion" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFormRequest" + } + } } - ], + }, "responses": { - "200": { - "description": "A page of verifications.", + "201": { + "description": "The form.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61852,13 +63738,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerificationList" + "$ref": "#/components/schemas/Form" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61920,8 +63806,29 @@ } } }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "A query parameter failed validation (`validation_error`, see `issues`).", + "description": "The body failed validation (`validation_error`), or the form can't be saved: duplicate or misplaced fields (`invalid_fields`), an origin or redirect that isn't https (`invalid_origin`, `invalid_redirect`), a sender or recipient that isn't verified (`invalid_sender`, `invalid_recipient`), an unknown audience, topic or property (`invalid_contacts`), no captcha in the risk settings (`captcha_not_configured`), a hosted page without fields or asking for credentials (`hosted_needs_fields`, `hosted_content_refused`), a blocked file type or uploads turned off while a `file` field remains (`invalid_files`), or too many forms (`form_limit_reached`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -61990,7 +63897,7 @@ } }, "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "description": "Forms isn't available in this deployment yet (`forms_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62010,55 +63917,56 @@ } } } - } - } - }, - "/v1/verifications/check": { - "post": { - "operationId": "checkVerification", + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] + }, + "get": { + "operationId": "listForms", "tags": [ - "Verifications" + "Forms" ], - "summary": "Check a code", - "description": "Checks the code the end user typed, by the verification's id or by the destination (its latest verification). `valid: true` approves it (`verification.approved`, billed once); a wrong code spends an attempt, and the last one fails it. Checking an approved verification again with its code stays valid until it expires, without being billed again.", + "summary": "List forms", + "description": "The environment's forms, newest first.", "security": [ { "bearerAuth": [] } ], "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "1-100, default 20." + }, + "required": false, + "description": "1-100, default 20.", + "name": "limit", + "in": "query" + }, { "schema": { "type": "string", - "minLength": 1, - "maxLength": 256, - "description": "Makes retries safe: a repeated check with the same key returns the same answer without spending another attempt. 1-256 characters.", - "examples": [ - "welcome-user-123" - ] + "maxLength": 64, + "description": "Cursor: the `id` of the last item of the previous page." }, "required": false, - "description": "Makes retries safe: a repeated check with the same key returns the same answer without spending another attempt. 1-256 characters.", - "name": "Idempotency-Key", - "in": "header" + "description": "Cursor: the `id` of the last item of the previous page.", + "name": "after", + "in": "query" }, { "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CheckVerificationRequest" - } - } - } - }, "responses": { "200": { - "description": "Whether the code is right, and the verification after the check.", + "description": "A page of forms.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62082,13 +63990,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerificationCheck" + "$ref": "#/components/schemas/FormList" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`) or the `Idempotency-Key` header is invalid (`invalid_idempotency_key`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62150,29 +64058,8 @@ } } }, - "404": { - "description": "No such verification in the API key's environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "422": { - "description": "The body failed validation (`validation_error`, see `issues`).", + "description": "A query parameter failed validation (`validation_error`, see `issues`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62193,7 +64080,7 @@ } }, "429": { - "description": "Too many requests with this API key (`rate_limited`), or too many checks for this destination (`quota_exceeded`).", + "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62241,7 +64128,7 @@ } }, "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "description": "Forms isn't available in this deployment yet (`forms_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62264,14 +64151,14 @@ } } }, - "/v1/verifications/{id}": { + "/v1/forms/{id}": { "get": { - "operationId": "getVerification", + "operationId": "getForm", "tags": [ - "Verifications" + "Forms" ], - "summary": "Retrieve a verification", - "description": "A verification's status and destination. Its code is never returned.", + "summary": "Get a form", + "description": "A form's settings, with the `endpoint` it posts to and its `hosted_url`.", "security": [ { "bearerAuth": [] @@ -62281,13 +64168,13 @@ { "schema": { "type": "string", - "description": "The verification's id.", + "description": "The form's id.", "examples": [ - "vrf_01k8e2m4q2f3e9v6t5r1n0p8yz" + "form_01kd2m8n3p4q5r6s7t8v9w0x1y" ] }, "required": true, - "description": "The verification's id.", + "description": "The form's id.", "name": "id", "in": "path" }, @@ -62297,7 +64184,7 @@ ], "responses": { "200": { - "description": "The verification.", + "description": "The form.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62321,7 +64208,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Verification" + "$ref": "#/components/schemas/Form" } } } @@ -62390,7 +64277,7 @@ } }, "404": { - "description": "No such verification in the API key's environment (`not_found`).", + "description": "No form with this id exists in the API key's environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62459,7 +64346,7 @@ } }, "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "description": "Forms isn't available in this deployment yet (`forms_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62480,16 +64367,14 @@ } } } - } - }, - "/v1/verifications/{id}/cancel": { - "post": { - "operationId": "cancelVerification", + }, + "patch": { + "operationId": "updateForm", "tags": [ - "Verifications" + "Forms" ], - "summary": "Cancel a verification", - "description": "Cancels a pending verification: its code stops working. Canceling it again returns it as is.", + "summary": "Update a form", + "description": "Only the fields given change, within each group (`spam`, `contacts`, …) too.", "security": [ { "bearerAuth": [] @@ -62499,13 +64384,13 @@ { "schema": { "type": "string", - "description": "The verification's id.", + "description": "The form's id.", "examples": [ - "vrf_01k8e2m4q2f3e9v6t5r1n0p8yz" + "form_01kd2m8n3p4q5r6s7t8v9w0x1y" ] }, "required": true, - "description": "The verification's id.", + "description": "The form's id.", "name": "id", "in": "path" }, @@ -62513,9 +64398,19 @@ "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFormRequest" + } + } + } + }, "responses": { "200": { - "description": "The canceled verification.", + "description": "The form.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62539,13 +64434,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Verification" + "$ref": "#/components/schemas/Form" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62608,7 +64503,55 @@ } }, "404": { - "description": "No such verification in the API key's environment (`not_found`).", + "description": "No form with this id exists in the API key's environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Other updates of this form kept racing this one (`form_conflict`). Retry after `Retry-After` seconds.", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + }, + "Retry-After": { + "description": "Seconds to wait.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62629,7 +64572,7 @@ } }, "422": { - "description": "The verification isn't pending (`verification_not_cancelable`): approved, failed, blocked or expired.", + "description": "The body failed validation (`validation_error`), or the form can't be saved: duplicate or misplaced fields (`invalid_fields`), an origin or redirect that isn't https (`invalid_origin`, `invalid_redirect`), a sender or recipient that isn't verified (`invalid_sender`, `invalid_recipient`), an unknown audience, topic or property (`invalid_contacts`), no captcha in the risk settings (`captcha_not_configured`), a hosted page without fields or asking for credentials (`hosted_needs_fields`, `hosted_content_refused`), a blocked file type or uploads turned off while a `file` field remains (`invalid_files`), or too many forms (`form_limit_reached`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62698,7 +64641,7 @@ } }, "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "description": "Forms isn't available in this deployment yet (`forms_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62719,24 +64662,40 @@ } } } - } - }, - "/v1/verify/settings": { - "get": { - "operationId": "getVerifySettings", + }, + "delete": { + "operationId": "deleteForm", "tags": [ - "Verify settings" + "Forms" ], - "summary": "Get the Verify settings", - "description": "The API key's environment's settings, with defaults filled in: the name codes are sent for, code length, lifetime and attempts, the senders, and the countries SMS codes may go to.", + "summary": "Delete a form", + "description": "Deletes the form with every submission and uploaded file. Its endpoint answers 404 from then on.", "security": [ { "bearerAuth": [] } ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "The form's id.", + "examples": [ + "form_01kd2m8n3p4q5r6s7t8v9w0x1y" + ] + }, + "required": true, + "description": "The form's id.", + "name": "id", + "in": "path" + }, + { + "$ref": "#/components/parameters/CossVersion" + } + ], "responses": { "200": { - "description": "The settings.", + "description": "The form was deleted.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62760,7 +64719,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerifySettings" + "$ref": "#/components/schemas/DeletedForm" } } } @@ -62828,6 +64787,27 @@ } } }, + "404": { + "description": "No form with this id exists in the API key's environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -62877,7 +64857,7 @@ } }, "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", + "description": "Forms isn't available in this deployment yet (`forms_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62897,38 +64877,76 @@ } } } - }, - "parameters": [ - { - "$ref": "#/components/parameters/CossVersion" - } - ] - }, - "patch": { - "operationId": "updateVerifySettings", + } + } + }, + "/v1/forms/{id}/submissions": { + "get": { + "operationId": "listFormSubmissions", "tags": [ - "Verify settings" + "Form submissions" ], - "summary": "Update the Verify settings", - "description": "Changes the fields given; the others keep their values. `sms_from` must be one of the organization's numbers, and a live key's `email_from` must be on one of its verified domains.", + "summary": "List a form's submissions", + "description": "Newest first. `status` narrows them: `accepted` (delivered), `held` (Risk asked for a review), `spam` and `pending_confirmation` (waiting for the submitter's email link).", "security": [ { "bearerAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateVerifySettingsRequest" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "description": "The form's id.", + "examples": [ + "form_01kd2m8n3p4q5r6s7t8v9w0x1y" + ] + }, + "required": true, + "description": "The form's id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string", + "description": "Only submissions in these statuses, comma-separated: `held,spam`." + }, + "required": false, + "description": "Only submissions in these statuses, comma-separated: `held,spam`.", + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "1-100, default 20." + }, + "required": false, + "description": "1-100, default 20.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "maxLength": 64, + "description": "Cursor: the `id` of the last item of the previous page." + }, + "required": false, + "description": "Cursor: the `id` of the last item of the previous page.", + "name": "after", + "in": "query" + }, + { + "$ref": "#/components/parameters/CossVersion" } - }, + ], "responses": { "200": { - "description": "The settings.", + "description": "A page of submissions.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -62952,7 +64970,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerifySettings" + "$ref": "#/components/schemas/FormSubmissionList" } } } @@ -63020,8 +65038,29 @@ } } }, + "404": { + "description": "No form with this id exists in the API key's environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "The body failed validation (`validation_error`), or a sender isn't the organization's (`invalid_settings`).", + "description": "A query parameter failed validation (`validation_error`, see `issues`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -63110,40 +65149,56 @@ } } } - }, - "parameters": [ - { - "$ref": "#/components/parameters/CossVersion" - } - ] + } } }, - "/v1/forms": { - "post": { - "operationId": "createForm", + "/v1/forms/{id}/submissions/{submission_id}": { + "get": { + "operationId": "getFormSubmission", "tags": [ - "Forms" + "Form submissions" ], - "summary": "Create a form", - "description": "A hosted form endpoint: point an HTML `` (or `coss/forms`) at its `endpoint`. Every submission is screened by Risk (the honeypot, captcha, email, IP, device, velocity and content) and accepted ones go to webhooks (`form.submission.received`), notification emails and contacts. Without `fields`, any field is accepted.", + "summary": "Get a submission", + "description": "One submission of the form, with its data, files and Risk's assessment.", "security": [ { "bearerAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateFormRequest" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "description": "The form's id.", + "examples": [ + "form_01kd2m8n3p4q5r6s7t8v9w0x1y" + ] + }, + "required": true, + "description": "The form's id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string", + "description": "The submission's id.", + "examples": [ + "fsub_01kd2m9a0b1c2d3e4f5g6h7j8k" + ] + }, + "required": true, + "description": "The submission's id.", + "name": "submission_id", + "in": "path" + }, + { + "$ref": "#/components/parameters/CossVersion" } - }, + ], "responses": { - "201": { - "description": "The form.", + "200": { + "description": "The submission.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -63167,13 +65222,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Form" + "$ref": "#/components/schemas/FormSubmission" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -63235,29 +65290,8 @@ } } }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The body failed validation (`validation_error`), or the form can't be saved: duplicate or misplaced fields (`invalid_fields`), an origin or redirect that isn't https (`invalid_origin`, `invalid_redirect`), a sender or recipient that isn't verified (`invalid_sender`, `invalid_recipient`), an unknown audience, topic or property (`invalid_contacts`), no captcha in the risk settings (`captcha_not_configured`), a hosted page without fields or asking for credentials (`hosted_needs_fields`, `hosted_content_refused`), a blocked file type or uploads turned off while a `file` field remains (`invalid_files`), or too many forms (`form_limit_reached`).", + "404": { + "description": "No such form, or no submission with this id in it (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -63326,7 +65360,7 @@ } }, "503": { - "description": "Forms isn't available in this deployment yet (`forms_unavailable`).", + "description": "The API key could not be verified right now; retry (`service_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -63346,20 +65380,15 @@ } } } - }, - "parameters": [ - { - "$ref": "#/components/parameters/CossVersion" - } - ] + } }, - "get": { - "operationId": "listForms", + "delete": { + "operationId": "deleteFormSubmission", "tags": [ - "Forms" + "Form submissions" ], - "summary": "List forms", - "description": "The environment's forms, newest first.", + "summary": "Delete a submission", + "description": "Deletes the submission and its files.", "security": [ { "bearerAuth": [] @@ -63368,26 +65397,29 @@ "parameters": [ { "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "description": "1-100, default 20." + "type": "string", + "description": "The form's id.", + "examples": [ + "form_01kd2m8n3p4q5r6s7t8v9w0x1y" + ] }, - "required": false, - "description": "1-100, default 20.", - "name": "limit", - "in": "query" + "required": true, + "description": "The form's id.", + "name": "id", + "in": "path" }, { "schema": { "type": "string", - "maxLength": 64, - "description": "Cursor: the `id` of the last item of the previous page." + "description": "The submission's id.", + "examples": [ + "fsub_01kd2m9a0b1c2d3e4f5g6h7j8k" + ] }, - "required": false, - "description": "Cursor: the `id` of the last item of the previous page.", - "name": "after", - "in": "query" + "required": true, + "description": "The submission's id.", + "name": "submission_id", + "in": "path" }, { "$ref": "#/components/parameters/CossVersion" @@ -63395,7 +65427,7 @@ ], "responses": { "200": { - "description": "A page of forms.", + "description": "The submission was deleted.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -63419,7 +65451,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FormList" + "$ref": "#/components/schemas/DeletedFormSubmission" } } } @@ -63487,8 +65519,8 @@ } } }, - "422": { - "description": "A query parameter failed validation (`validation_error`, see `issues`).", + "404": { + "description": "No such form, or no submission with this id in it (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -63557,7 +65589,7 @@ } }, "503": { - "description": "Forms isn't available in this deployment yet (`forms_unavailable`).", + "description": "The API key could not be verified right now; retry (`service_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -63580,14 +65612,14 @@ } } }, - "/v1/forms/{id}": { - "get": { - "operationId": "getForm", + "/v1/forms/{id}/submissions/{submission_id}/release": { + "post": { + "operationId": "releaseFormSubmission", "tags": [ - "Forms" + "Form submissions" ], - "summary": "Get a form", - "description": "A form's settings, with the `endpoint` it posts to and its `hosted_url`.", + "summary": "Release a submission", + "description": "Not spam: a `held` or `spam` submission becomes `accepted` and is delivered (webhooks, notifications, contacts), and Risk records its assessment as `legitimate`. Releasing an accepted submission returns it as is.", "security": [ { "bearerAuth": [] @@ -63607,13 +65639,26 @@ "name": "id", "in": "path" }, + { + "schema": { + "type": "string", + "description": "The submission's id.", + "examples": [ + "fsub_01kd2m9a0b1c2d3e4f5g6h7j8k" + ] + }, + "required": true, + "description": "The submission's id.", + "name": "submission_id", + "in": "path" + }, { "$ref": "#/components/parameters/CossVersion" } ], "responses": { "200": { - "description": "The form.", + "description": "The released submission.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -63637,7 +65682,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Form" + "$ref": "#/components/schemas/FormSubmission" } } } @@ -63706,7 +65751,28 @@ } }, "404": { - "description": "No form with this id exists in the API key's environment (`not_found`).", + "description": "No such form, or no submission with this id in it (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Only held and spam submissions can be released (`submission_not_releasable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -63775,7 +65841,7 @@ } }, "503": { - "description": "Forms isn't available in this deployment yet (`forms_unavailable`).", + "description": "The API key could not be verified right now; retry (`service_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -63796,14 +65862,16 @@ } } } - }, - "patch": { - "operationId": "updateForm", + } + }, + "/v1/forms/{id}/submissions/{submission_id}/files/{file_id}": { + "get": { + "operationId": "getFormSubmissionFile", "tags": [ - "Forms" + "Form submissions" ], - "summary": "Update a form", - "description": "Only the fields given change, within each group (`spam`, `contacts`, …) too.", + "summary": "Download a file", + "description": "A file uploaded with the submission, as `application/octet-stream`: whatever its type, treat it as untrusted (a submitter chose it).", "security": [ { "bearerAuth": [] @@ -63823,23 +65891,39 @@ "name": "id", "in": "path" }, + { + "schema": { + "type": "string", + "description": "The submission's id.", + "examples": [ + "fsub_01kd2m9a0b1c2d3e4f5g6h7j8k" + ] + }, + "required": true, + "description": "The submission's id.", + "name": "submission_id", + "in": "path" + }, + { + "schema": { + "type": "string", + "description": "The file's id (`files[].id` of the submission).", + "examples": [ + "ffile_01kd2m9a0b1c2d3e4f5g6h7j8m" + ] + }, + "required": true, + "description": "The file's id (`files[].id` of the submission).", + "name": "file_id", + "in": "path" + }, { "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateFormRequest" - } - } - } - }, "responses": { "200": { - "description": "The form.", + "description": "The file's bytes.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -63861,15 +65945,16 @@ } }, "content": { - "application/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/Form" + "type": "string", + "format": "binary" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -63932,76 +66017,7 @@ } }, "404": { - "description": "No form with this id exists in the API key's environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Other updates of this form kept racing this one (`form_conflict`). Retry after `Retry-After` seconds.", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - }, - "Retry-After": { - "description": "Seconds to wait.", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The body failed validation (`validation_error`), or the form can't be saved: duplicate or misplaced fields (`invalid_fields`), an origin or redirect that isn't https (`invalid_origin`, `invalid_redirect`), a sender or recipient that isn't verified (`invalid_sender`, `invalid_recipient`), an unknown audience, topic or property (`invalid_contacts`), no captcha in the risk settings (`captcha_not_configured`), a hosted page without fields or asking for credentials (`hosted_needs_fields`, `hosted_content_refused`), a blocked file type or uploads turned off while a `file` field remains (`invalid_files`), or too many forms (`form_limit_reached`).", + "description": "No such form, submission or file (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -64091,40 +66107,34 @@ } } } - }, - "delete": { - "operationId": "deleteForm", + } + }, + "/v1/scheduling/hosts": { + "post": { + "operationId": "createSchedulingHost", "tags": [ - "Forms" + "Scheduling hosts" ], - "summary": "Delete a form", - "description": "Deletes the form with every submission and uploaded file. Its endpoint answers 404 from then on.", + "summary": "Create a host", + "description": "A person who can be booked: one of your users, such as a doctor or a tutor.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The form's id.", - "examples": [ - "form_01kd2m8n3p4q5r6s7t8v9w0x1y" - ] - }, - "required": true, - "description": "The form's id.", - "name": "id", - "in": "path" - }, - { - "$ref": "#/components/parameters/CossVersion" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateHostRequest" + } + } } - ], + }, "responses": { - "200": { - "description": "The form was deleted.", + "201": { + "description": "The host.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -64148,13 +66158,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeletedForm" + "$ref": "#/components/schemas/SchedulingHost" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -64216,8 +66226,29 @@ } } }, - "404": { - "description": "No form with this id exists in the API key's environment (`not_found`).", + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "The request failed validation (`validation_error`, see `issues`), `time_zone` isn't an IANA name (`invalid_time_zone`), or another host has the `uid` (`uid_taken`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -64286,7 +66317,7 @@ } }, "503": { - "description": "Forms isn't available in this deployment yet (`forms_unavailable`).", + "description": "The API key could not be verified right now; retry (`service_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -64306,66 +66337,44 @@ } } } - } - } - }, - "/v1/forms/{id}/submissions": { + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] + }, "get": { - "operationId": "listFormSubmissions", + "operationId": "listSchedulingHosts", "tags": [ - "Form submissions" + "Scheduling hosts" ], - "summary": "List a form's submissions", - "description": "Newest first. `status` narrows them: `accepted` (delivered), `held` (Risk asked for a review), `spam` and `pending_confirmation` (waiting for the submitter's email link).", + "summary": "List hosts", + "description": "The environment's hosts, newest first.", "security": [ { "bearerAuth": [] } ], "parameters": [ - { - "schema": { - "type": "string", - "description": "The form's id.", - "examples": [ - "form_01kd2m8n3p4q5r6s7t8v9w0x1y" - ] - }, - "required": true, - "description": "The form's id.", - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "string", - "description": "Only submissions in these statuses, comma-separated: `held,spam`." - }, - "required": false, - "description": "Only submissions in these statuses, comma-separated: `held,spam`.", - "name": "status", - "in": "query" - }, { "schema": { "type": "integer", "minimum": 1, "maximum": 100, - "description": "1-100, default 20." + "default": 20 }, "required": false, - "description": "1-100, default 20.", "name": "limit", "in": "query" }, { "schema": { "type": "string", - "maxLength": 64, - "description": "Cursor: the `id` of the last item of the previous page." + "description": "The last id of the previous page." }, "required": false, - "description": "Cursor: the `id` of the last item of the previous page.", + "description": "The last id of the previous page.", "name": "after", "in": "query" }, @@ -64375,7 +66384,7 @@ ], "responses": { "200": { - "description": "A page of submissions.", + "description": "A page of hosts.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -64399,7 +66408,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FormSubmissionList" + "$ref": "#/components/schemas/SchedulingHostList" } } } @@ -64467,29 +66476,8 @@ } } }, - "404": { - "description": "No form with this id exists in the API key's environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "422": { - "description": "A query parameter failed validation (`validation_error`, see `issues`).", + "description": "The request failed validation (`validation_error`, see `issues`), or can't be done: see `code`.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -64581,14 +66569,14 @@ } } }, - "/v1/forms/{id}/submissions/{submission_id}": { + "/v1/scheduling/hosts/{host}": { "get": { - "operationId": "getFormSubmission", + "operationId": "getSchedulingHost", "tags": [ - "Form submissions" + "Scheduling hosts" ], - "summary": "Get a submission", - "description": "One submission of the form, with its data, files and Risk's assessment.", + "summary": "Retrieve a host", + "description": "A host by its id or your uid.", "security": [ { "bearerAuth": [] @@ -64598,27 +66586,14 @@ { "schema": { "type": "string", - "description": "The form's id.", - "examples": [ - "form_01kd2m8n3p4q5r6s7t8v9w0x1y" - ] - }, - "required": true, - "description": "The form's id.", - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "string", - "description": "The submission's id.", + "description": "The host's id or uid.", "examples": [ - "fsub_01kd2m9a0b1c2d3e4f5g6h7j8k" + "host_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The submission's id.", - "name": "submission_id", + "description": "The host's id or uid.", + "name": "host", "in": "path" }, { @@ -64627,7 +66602,7 @@ ], "responses": { "200": { - "description": "The submission.", + "description": "The host.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -64651,7 +66626,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FormSubmission" + "$ref": "#/components/schemas/SchedulingHost" } } } @@ -64720,7 +66695,7 @@ } }, "404": { - "description": "No such form, or no submission with this id in it (`not_found`).", + "description": "Nothing with this id or uid exists in the environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -64811,13 +66786,13 @@ } } }, - "delete": { - "operationId": "deleteFormSubmission", + "patch": { + "operationId": "updateSchedulingHost", "tags": [ - "Form submissions" + "Scheduling hosts" ], - "summary": "Delete a submission", - "description": "Deletes the submission and its files.", + "summary": "Update a host", + "description": "Changes what's given. A `disabled` host keeps its bookings but gets no new ones.", "security": [ { "bearerAuth": [] @@ -64827,36 +66802,33 @@ { "schema": { "type": "string", - "description": "The form's id.", - "examples": [ - "form_01kd2m8n3p4q5r6s7t8v9w0x1y" - ] - }, - "required": true, - "description": "The form's id.", - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "string", - "description": "The submission's id.", + "description": "The host's id or uid.", "examples": [ - "fsub_01kd2m9a0b1c2d3e4f5g6h7j8k" + "host_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The submission's id.", - "name": "submission_id", + "description": "The host's id or uid.", + "name": "host", "in": "path" }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateHostRequest" + } + } + } + }, "responses": { "200": { - "description": "The submission was deleted.", + "description": "The host.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -64880,13 +66852,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeletedFormSubmission" + "$ref": "#/components/schemas/SchedulingHost" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -64949,7 +66921,49 @@ } }, "404": { - "description": "No such form, or no submission with this id in it (`not_found`).", + "description": "Nothing with this id or uid exists in the environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "The request failed validation (`validation_error`, see `issues`), `time_zone` isn't an IANA name (`invalid_time_zone`), or another host has the `uid` (`uid_taken`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -65039,16 +67053,14 @@ } } } - } - }, - "/v1/forms/{id}/submissions/{submission_id}/release": { - "post": { - "operationId": "releaseFormSubmission", + }, + "delete": { + "operationId": "deleteSchedulingHost", "tags": [ - "Form submissions" + "Scheduling hosts" ], - "summary": "Release a submission", - "description": "Not spam: a `held` or `spam` submission becomes `accepted` and is delivered (webhooks, notifications, contacts), and Risk records its assessment as `legitimate`. Releasing an accepted submission returns it as is.", + "summary": "Delete a host", + "description": "Removes the host and its schedules and calendars. Refused while bookings are still to come (`host_has_live_bookings`): cancel them, or set the host's status to `disabled`.", "security": [ { "bearerAuth": [] @@ -65058,27 +67070,14 @@ { "schema": { "type": "string", - "description": "The form's id.", - "examples": [ - "form_01kd2m8n3p4q5r6s7t8v9w0x1y" - ] - }, - "required": true, - "description": "The form's id.", - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "string", - "description": "The submission's id.", + "description": "The host's id or uid.", "examples": [ - "fsub_01kd2m9a0b1c2d3e4f5g6h7j8k" + "host_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The submission's id.", - "name": "submission_id", + "description": "The host's id or uid.", + "name": "host", "in": "path" }, { @@ -65087,7 +67086,7 @@ ], "responses": { "200": { - "description": "The released submission.", + "description": "Deleted.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -65111,7 +67110,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FormSubmission" + "$ref": "#/components/schemas/DeletedSchedulingHost" } } } @@ -65180,7 +67179,7 @@ } }, "404": { - "description": "No such form, or no submission with this id in it (`not_found`).", + "description": "Nothing with this id or uid exists in the environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -65201,7 +67200,7 @@ } }, "422": { - "description": "Only held and spam submissions can be released (`submission_not_releasable`).", + "description": "The request failed validation (`validation_error`, see `issues`), or can't be done: see `code`.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -65293,14 +67292,14 @@ } } }, - "/v1/forms/{id}/submissions/{submission_id}/files/{file_id}": { - "get": { - "operationId": "getFormSubmissionFile", + "/v1/scheduling/hosts/{host}/schedules": { + "post": { + "operationId": "createSchedulingSchedule", "tags": [ - "Form submissions" + "Scheduling schedules" ], - "summary": "Download a file", - "description": "A file uploaded with the submission, as `application/octet-stream`: whatever its type, treat it as untrusted (a submitter chose it).", + "summary": "Create a schedule", + "description": "A host's weekly hours and date overrides, in local time. The first one is the host's default.", "security": [ { "bearerAuth": [] @@ -65310,49 +67309,33 @@ { "schema": { "type": "string", - "description": "The form's id.", - "examples": [ - "form_01kd2m8n3p4q5r6s7t8v9w0x1y" - ] - }, - "required": true, - "description": "The form's id.", - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "string", - "description": "The submission's id.", - "examples": [ - "fsub_01kd2m9a0b1c2d3e4f5g6h7j8k" - ] - }, - "required": true, - "description": "The submission's id.", - "name": "submission_id", - "in": "path" - }, - { - "schema": { - "type": "string", - "description": "The file's id (`files[].id` of the submission).", + "description": "The host's id or uid.", "examples": [ - "ffile_01kd2m9a0b1c2d3e4f5g6h7j8m" + "host_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The file's id (`files[].id` of the submission).", - "name": "file_id", + "description": "The host's id or uid.", + "name": "host", "in": "path" }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertScheduleRequest" + } + } + } + }, "responses": { - "200": { - "description": "The file's bytes.", + "201": { + "description": "The schedule.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -65374,16 +67357,15 @@ } }, "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/SchedulingSchedule" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -65446,7 +67428,49 @@ } }, "404": { - "description": "No such form, submission or file (`not_found`).", + "description": "Nothing with this id or uid exists in the environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "The request failed validation (`validation_error`, see `issues`), a range ends before it starts or a date has two overrides (`invalid_schedule`), or `time_zone` isn't an IANA name (`invalid_time_zone`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -65515,7 +67539,7 @@ } }, "503": { - "description": "Forms isn't available in this deployment yet (`forms_unavailable`).", + "description": "The API key could not be verified right now; retry (`service_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -65536,34 +67560,40 @@ } } } - } - }, - "/v1/scheduling/hosts": { - "post": { - "operationId": "createSchedulingHost", + }, + "get": { + "operationId": "listSchedulingSchedules", "tags": [ - "Scheduling hosts" + "Scheduling schedules" ], - "summary": "Create a host", - "description": "A person who can be booked: one of your users, such as a doctor or a tutor.", + "summary": "List a host's schedules", + "description": "All of the host's schedules, oldest first.", "security": [ { "bearerAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateHostRequest" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "description": "The host's id or uid.", + "examples": [ + "host_01kd2m9a0b1c2d3e4f5g6h7j8m" + ] + }, + "required": true, + "description": "The host's id or uid.", + "name": "host", + "in": "path" + }, + { + "$ref": "#/components/parameters/CossVersion" } - }, + ], "responses": { - "201": { - "description": "The host.", + "200": { + "description": "The schedules.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -65587,13 +67617,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingHost" + "$ref": "#/components/schemas/SchedulingScheduleList" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -65655,29 +67685,8 @@ } } }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The request failed validation (`validation_error`, see `issues`), `time_zone` isn't an IANA name (`invalid_time_zone`), or another host has the `uid` (`uid_taken`).", + "404": { + "description": "Nothing with this id or uid exists in the environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -65766,20 +67775,17 @@ } } } - }, - "parameters": [ - { - "$ref": "#/components/parameters/CossVersion" - } - ] - }, + } + } + }, + "/v1/scheduling/hosts/{host}/schedules/{schedule_id}": { "get": { - "operationId": "listSchedulingHosts", + "operationId": "getSchedulingSchedule", "tags": [ - "Scheduling hosts" + "Scheduling schedules" ], - "summary": "List hosts", - "description": "The environment's hosts, newest first.", + "summary": "Retrieve a schedule", + "description": "One of the host's schedules.", "security": [ { "bearerAuth": [] @@ -65788,24 +67794,29 @@ "parameters": [ { "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 + "type": "string", + "description": "The host's id or uid.", + "examples": [ + "host_01kd2m9a0b1c2d3e4f5g6h7j8m" + ] }, - "required": false, - "name": "limit", - "in": "query" + "required": true, + "description": "The host's id or uid.", + "name": "host", + "in": "path" }, { "schema": { "type": "string", - "description": "The last id of the previous page." + "description": "The schedule's id.", + "examples": [ + "sched_01kd2m9a0b1c2d3e4f5g6h7j8m" + ] }, - "required": false, - "description": "The last id of the previous page.", - "name": "after", - "in": "query" + "required": true, + "description": "The schedule's id.", + "name": "schedule_id", + "in": "path" }, { "$ref": "#/components/parameters/CossVersion" @@ -65813,7 +67824,7 @@ ], "responses": { "200": { - "description": "A page of hosts.", + "description": "The schedule.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -65837,7 +67848,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingHostList" + "$ref": "#/components/schemas/SchedulingSchedule" } } } @@ -65905,8 +67916,8 @@ } } }, - "422": { - "description": "The request failed validation (`validation_error`, see `issues`), or can't be done: see `code`.", + "404": { + "description": "Nothing with this id or uid exists in the environment (`not_found`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -65996,16 +68007,14 @@ } } } - } - }, - "/v1/scheduling/hosts/{host}": { - "get": { - "operationId": "getSchedulingHost", + }, + "put": { + "operationId": "updateSchedulingSchedule", "tags": [ - "Scheduling hosts" + "Scheduling schedules" ], - "summary": "Retrieve a host", - "description": "A host by its id or your uid.", + "summary": "Replace a schedule", + "description": "Replaces its weekly rules, and its overrides when given. Existing bookings stay: a schedule only decides what's offered next.", "security": [ { "bearerAuth": [] @@ -66025,13 +68034,36 @@ "name": "host", "in": "path" }, + { + "schema": { + "type": "string", + "description": "The schedule's id.", + "examples": [ + "sched_01kd2m9a0b1c2d3e4f5g6h7j8m" + ] + }, + "required": true, + "description": "The schedule's id.", + "name": "schedule_id", + "in": "path" + }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertScheduleRequest" + } + } + } + }, "responses": { "200": { - "description": "The host.", + "description": "The schedule.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -66055,13 +68087,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingHost" + "$ref": "#/components/schemas/SchedulingSchedule" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -66144,6 +68176,48 @@ } } }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "The request failed validation (`validation_error`, see `issues`), a range ends before it starts or a date has two overrides (`invalid_schedule`), or `time_zone` isn't an IANA name (`invalid_time_zone`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -66215,13 +68289,13 @@ } } }, - "patch": { - "operationId": "updateSchedulingHost", + "delete": { + "operationId": "deleteSchedulingSchedule", "tags": [ - "Scheduling hosts" + "Scheduling schedules" ], - "summary": "Update a host", - "description": "Changes what's given. A `disabled` host keeps its bookings but gets no new ones.", + "summary": "Delete a schedule", + "description": "Refused for the host's default schedule.", "security": [ { "bearerAuth": [] @@ -66241,23 +68315,26 @@ "name": "host", "in": "path" }, + { + "schema": { + "type": "string", + "description": "The schedule's id.", + "examples": [ + "sched_01kd2m9a0b1c2d3e4f5g6h7j8m" + ] + }, + "required": true, + "description": "The schedule's id.", + "name": "schedule_id", + "in": "path" + }, { "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateHostRequest" - } - } - } - }, "responses": { "200": { - "description": "The host.", + "description": "Deleted.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -66281,13 +68358,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingHost" + "$ref": "#/components/schemas/DeletedSchedulingSchedule" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -66370,29 +68447,8 @@ } } }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "422": { - "description": "The request failed validation (`validation_error`, see `issues`), `time_zone` isn't an IANA name (`invalid_time_zone`), or another host has the `uid` (`uid_taken`).", + "description": "This is the host's default schedule (`invalid_schedule`): make another one the default first.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -66482,40 +68538,34 @@ } } } - }, - "delete": { - "operationId": "deleteSchedulingHost", + } + }, + "/v1/scheduling/event_types": { + "post": { + "operationId": "createSchedulingEventType", "tags": [ - "Scheduling hosts" + "Scheduling event types" ], - "summary": "Delete a host", - "description": "Removes the host and its schedules and calendars. Refused while bookings are still to come (`host_has_live_bookings`): cancel them, or set the host's status to `disabled`.", + "summary": "Create an event type", + "description": "What can be booked: `one_on_one` with one host, `pool` for any free host, `collective` when every required host must be free, or `group` for a session with seats.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The host's id or uid.", - "examples": [ - "host_01kd2m9a0b1c2d3e4f5g6h7j8m" - ] - }, - "required": true, - "description": "The host's id or uid.", - "name": "host", - "in": "path" - }, - { - "$ref": "#/components/parameters/CossVersion" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEventTypeRequest" + } + } } - ], + }, "responses": { - "200": { - "description": "Deleted.", + "201": { + "description": "The event type.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -66539,13 +68589,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeletedSchedulingHost" + "$ref": "#/components/schemas/SchedulingEventType" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -66628,8 +68678,29 @@ } } }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "The request failed validation (`validation_error`, see `issues`), or can't be done: see `code`.", + "description": "The request failed validation (`validation_error`, see `issues`), the settings don't fit together (`invalid_event_type`), `grid_time_zone` isn't an IANA name (`invalid_time_zone`), another event type has the `uid` (`uid_taken`), or its text was refused (`content_refused`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -66718,53 +68789,54 @@ } } } - } - } - }, - "/v1/scheduling/hosts/{host}/schedules": { - "post": { - "operationId": "createSchedulingSchedule", + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] + }, + "get": { + "operationId": "listSchedulingEventTypes", "tags": [ - "Scheduling schedules" + "Scheduling event types" ], - "summary": "Create a schedule", - "description": "A host's weekly hours and date overrides, in local time. The first one is the host's default.", + "summary": "List event types", + "description": "The environment's event types, newest first.", "security": [ { "bearerAuth": [] } ], "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, { "schema": { "type": "string", - "description": "The host's id or uid.", - "examples": [ - "host_01kd2m9a0b1c2d3e4f5g6h7j8m" - ] + "description": "The last id of the previous page." }, - "required": true, - "description": "The host's id or uid.", - "name": "host", - "in": "path" + "required": false, + "description": "The last id of the previous page.", + "name": "after", + "in": "query" }, { "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpsertScheduleRequest" - } - } - } - }, "responses": { - "201": { - "description": "The schedule.", + "200": { + "description": "A page of event types.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -66788,13 +68860,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingSchedule" + "$ref": "#/components/schemas/SchedulingEventTypeList" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -66856,50 +68928,8 @@ } } }, - "404": { - "description": "Nothing with this id or uid exists in the environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "422": { - "description": "The request failed validation (`validation_error`, see `issues`), a range ends before it starts or a date has two overrides (`invalid_schedule`), or `time_zone` isn't an IANA name (`invalid_time_zone`).", + "description": "The request failed validation (`validation_error`, see `issues`), or can't be done: see `code`.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -66989,14 +69019,16 @@ } } } - }, + } + }, + "/v1/scheduling/event_types/{event_type}": { "get": { - "operationId": "listSchedulingSchedules", + "operationId": "getSchedulingEventType", "tags": [ - "Scheduling schedules" + "Scheduling event types" ], - "summary": "List a host's schedules", - "description": "All of the host's schedules, oldest first.", + "summary": "Retrieve an event type", + "description": "An event type by its id or your uid.", "security": [ { "bearerAuth": [] @@ -67006,14 +69038,14 @@ { "schema": { "type": "string", - "description": "The host's id or uid.", + "description": "The event type's id or uid.", "examples": [ - "host_01kd2m9a0b1c2d3e4f5g6h7j8m" + "evtype_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The host's id or uid.", - "name": "host", + "description": "The event type's id or uid.", + "name": "event_type", "in": "path" }, { @@ -67022,7 +69054,7 @@ ], "responses": { "200": { - "description": "The schedules.", + "description": "The event type.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -67046,7 +69078,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingScheduleList" + "$ref": "#/components/schemas/SchedulingEventType" } } } @@ -67205,16 +69237,14 @@ } } } - } - }, - "/v1/scheduling/hosts/{host}/schedules/{schedule_id}": { - "get": { - "operationId": "getSchedulingSchedule", + }, + "patch": { + "operationId": "updateSchedulingEventType", "tags": [ - "Scheduling schedules" + "Scheduling event types" ], - "summary": "Retrieve a schedule", - "description": "One of the host's schedules.", + "summary": "Update an event type", + "description": "Changes what's given. Existing bookings keep what they were booked with. Set `status` to `archived` to stop new bookings.", "security": [ { "bearerAuth": [] @@ -67224,36 +69254,33 @@ { "schema": { "type": "string", - "description": "The host's id or uid.", - "examples": [ - "host_01kd2m9a0b1c2d3e4f5g6h7j8m" - ] - }, - "required": true, - "description": "The host's id or uid.", - "name": "host", - "in": "path" - }, - { - "schema": { - "type": "string", - "description": "The schedule's id.", + "description": "The event type's id or uid.", "examples": [ - "sched_01kd2m9a0b1c2d3e4f5g6h7j8m" + "evtype_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The schedule's id.", - "name": "schedule_id", + "description": "The event type's id or uid.", + "name": "event_type", "in": "path" }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEventTypeRequest" + } + } + } + }, "responses": { "200": { - "description": "The schedule.", + "description": "The event type.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -67277,13 +69304,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingSchedule" + "$ref": "#/components/schemas/SchedulingEventType" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -67366,6 +69393,48 @@ } } }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "The request failed validation (`validation_error`, see `issues`), the settings don't fit together (`invalid_event_type`), `grid_time_zone` isn't an IANA name (`invalid_time_zone`), another event type has the `uid` (`uid_taken`), or its text was refused (`content_refused`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -67436,14 +69505,16 @@ } } } - }, - "put": { - "operationId": "updateSchedulingSchedule", + } + }, + "/v1/scheduling/event_types/{event_type}/slots": { + "get": { + "operationId": "listSchedulingSlots", "tags": [ - "Scheduling schedules" + "Scheduling availability" ], - "summary": "Replace a schedule", - "description": "Replaces its weekly rules, and its overrides when given. Existing bookings stay: a schedule only decides what's offered next.", + "summary": "List available slots", + "description": "Starts an attendee can book between `start` and `end` (at most 62 days), as UTC instants to show in their own time zone. A host whose calendar can't be read isn't offered.", "security": [ { "bearerAuth": [] @@ -67453,46 +69524,79 @@ { "schema": { "type": "string", - "description": "The host's id or uid.", + "description": "The event type's id or uid.", "examples": [ - "host_01kd2m9a0b1c2d3e4f5g6h7j8m" + "evtype_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The host's id or uid.", - "name": "host", + "description": "The event type's id or uid.", + "name": "event_type", "in": "path" }, { "schema": { "type": "string", - "description": "The schedule's id.", + "description": "An ISO 8601 instant with an offset.", "examples": [ - "sched_01kd2m9a0b1c2d3e4f5g6h7j8m" + "2027-01-04T15:00:00.000Z" ] }, "required": true, - "description": "The schedule's id.", - "name": "schedule_id", - "in": "path" + "description": "An ISO 8601 instant with an offset.", + "name": "start", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "An ISO 8601 instant with an offset.", + "examples": [ + "2027-01-04T15:00:00.000Z" + ] + }, + "required": true, + "description": "An ISO 8601 instant with an offset.", + "name": "end", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Only this host's slots (id or uid)." + }, + "required": false, + "description": "Only this host's slots (id or uid).", + "name": "host", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 5, + "maximum": 1440 + }, + "required": false, + "name": "duration_minutes", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "A JSON object narrowing a pool, such as `{\"licensed_in\":\"CA\"}`." + }, + "required": false, + "description": "A JSON object narrowing a pool, such as `{\"licensed_in\":\"CA\"}`.", + "name": "attributes", + "in": "query" }, { "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpsertScheduleRequest" - } - } - } - }, "responses": { "200": { - "description": "The schedule.", + "description": "The slots.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -67516,13 +69620,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingSchedule" + "$ref": "#/components/schemas/SchedulingSlotList" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -67605,29 +69709,8 @@ } } }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "422": { - "description": "The request failed validation (`validation_error`, see `issues`), a range ends before it starts or a date has two overrides (`invalid_schedule`), or `time_zone` isn't an IANA name (`invalid_time_zone`).", + "description": "The request failed validation (`validation_error`, see `issues`), or `start`, `end` or `duration_minutes` is invalid: `end` isn't after `start`, the window is longer than 62 days, or the duration isn't one of the event type's (`invalid_time`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -67717,14 +69800,16 @@ } } } - }, - "delete": { - "operationId": "deleteSchedulingSchedule", + } + }, + "/v1/scheduling/event_types/{event_type}/slots/explain": { + "get": { + "operationId": "explainSchedulingSlot", "tags": [ - "Scheduling schedules" + "Scheduling availability" ], - "summary": "Delete a schedule", - "description": "Refused for the host's default schedule.", + "summary": "Explain a slot", + "description": "Why one start can or can't be booked right now, host by host, decided the same way a booking is. For support and debugging: it says when a host is busy, so don't show it to attendees.", "security": [ { "bearerAuth": [] @@ -67734,28 +69819,58 @@ { "schema": { "type": "string", - "description": "The host's id or uid.", + "description": "The event type's id or uid.", "examples": [ - "host_01kd2m9a0b1c2d3e4f5g6h7j8m" + "evtype_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The host's id or uid.", - "name": "host", + "description": "The event type's id or uid.", + "name": "event_type", "in": "path" }, { "schema": { "type": "string", - "description": "The schedule's id.", + "description": "An ISO 8601 instant with an offset.", "examples": [ - "sched_01kd2m9a0b1c2d3e4f5g6h7j8m" + "2027-01-04T15:00:00.000Z" ] }, "required": true, - "description": "The schedule's id.", - "name": "schedule_id", - "in": "path" + "description": "An ISO 8601 instant with an offset.", + "name": "start", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Only this host (id or uid)." + }, + "required": false, + "description": "Only this host (id or uid).", + "name": "host", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 5, + "maximum": 1440 + }, + "required": false, + "name": "duration_minutes", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "A JSON object narrowing a pool, such as `{\"licensed_in\":\"CA\"}`." + }, + "required": false, + "description": "A JSON object narrowing a pool, such as `{\"licensed_in\":\"CA\"}`.", + "name": "attributes", + "in": "query" }, { "$ref": "#/components/parameters/CossVersion" @@ -67763,7 +69878,7 @@ ], "responses": { "200": { - "description": "Deleted.", + "description": "Whether it can be booked, and why.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -67787,7 +69902,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeletedSchedulingSchedule" + "$ref": "#/components/schemas/SchedulingSlotExplanation" } } } @@ -67877,7 +69992,7 @@ } }, "422": { - "description": "This is the host's default schedule (`invalid_schedule`): make another one the default first.", + "description": "The request failed validation (`validation_error`, see `issues`), or `start`, `end` or `duration_minutes` is invalid: `end` isn't after `start`, the window is longer than 62 days, or the duration isn't one of the event type's (`invalid_time`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -67969,14 +70084,14 @@ } } }, - "/v1/scheduling/event_types": { + "/v1/scheduling/availability/search": { "post": { - "operationId": "createSchedulingEventType", + "operationId": "searchSchedulingAvailability", "tags": [ - "Scheduling event types" + "Scheduling availability" ], - "summary": "Create an event type", - "description": "What can be booked: `one_on_one` with one host, `pool` for any free host, `collective` when every required host must be free, or `group` for a session with seats.", + "summary": "Search availability across hosts", + "description": "For \"who's free\" pages: each matching host's soonest slots, soonest first, across up to 500 hosts.", "security": [ { "bearerAuth": [] @@ -67987,14 +70102,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateEventTypeRequest" + "$ref": "#/components/schemas/AvailabilitySearchRequest" } } } }, "responses": { - "201": { - "description": "The event type.", + "200": { + "description": "Hosts with slots.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -68018,7 +70133,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingEventType" + "$ref": "#/components/schemas/SchedulingAvailabilitySearchResult" } } } @@ -68129,7 +70244,7 @@ } }, "422": { - "description": "The request failed validation (`validation_error`, see `issues`), the settings don't fit together (`invalid_event_type`), `grid_time_zone` isn't an IANA name (`invalid_time_zone`), another event type has the `uid` (`uid_taken`), or its text was refused (`content_refused`).", + "description": "The request failed validation (`validation_error`, see `issues`), or `start`, `end` or `duration_minutes` is invalid: `end` isn't after `start`, the window is longer than 62 days, or the duration isn't one of the event type's (`invalid_time`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -68224,48 +70339,54 @@ "$ref": "#/components/parameters/CossVersion" } ] - }, - "get": { - "operationId": "listSchedulingEventTypes", + } + }, + "/v1/scheduling/bookings": { + "post": { + "operationId": "createSchedulingBooking", "tags": [ - "Scheduling event types" + "Scheduling bookings" ], - "summary": "List event types", - "description": "The environment's event types, newest first.", + "summary": "Create a booking", + "description": "Books a slot, or joins a group session. Without a `host`, a pool chooses fairly among those free. An event type that requires approval answers with a `pending_approval` booking.", "security": [ { "bearerAuth": [] } ], "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, { "schema": { "type": "string", - "description": "The last id of the previous page." + "minLength": 1, + "maxLength": 256, + "description": "Makes retries safe: a repeated request with the same key and body returns the original booking instead of creating another, for 24 hours. A refused request keeps no key. 1-256 characters.", + "examples": [ + "welcome-user-123" + ] }, "required": false, - "description": "The last id of the previous page.", - "name": "after", - "in": "query" + "description": "Makes retries safe: a repeated request with the same key and body returns the original booking instead of creating another, for 24 hours. A refused request keeps no key. 1-256 characters.", + "name": "Idempotency-Key", + "in": "header" }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBookingRequest" + } + } + } + }, "responses": { - "200": { - "description": "A page of event types.", + "201": { + "description": "The booking.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -68276,6 +70397,15 @@ ] } }, + "Idempotent-Replayed": { + "description": "`true` when this response replays an earlier request with the same `Idempotency-Key`.", + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, "Coss-Version": { "description": "The API version this response is rendered in.", "schema": { @@ -68289,13 +70419,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingEventTypeList" + "$ref": "#/components/schemas/SchedulingBooking" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -68357,8 +70487,71 @@ } } }, + "404": { + "description": "Nothing with this id or uid exists in the environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "A request with this key is still running (`idempotency_in_progress`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "The request failed validation (`validation_error`, see `issues`), or can't be done: see `code`.", + "description": "The time isn't available (`slot_unavailable`), `start`, `duration_minutes` or the occurrence count is out of bounds (`invalid_time`), the attendee's time zone isn't an IANA name (`invalid_time_zone`), the answers or attendee are invalid (`invalid_answers`, `invalid_attendee`), or the `Idempotency-Key` was used with a different request (`idempotency_key_reused`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -68448,34 +70641,105 @@ } } } - } - }, - "/v1/scheduling/event_types/{event_type}": { + }, "get": { - "operationId": "getSchedulingEventType", + "operationId": "listSchedulingBookings", "tags": [ - "Scheduling event types" + "Scheduling bookings" ], - "summary": "Retrieve an event type", - "description": "An event type by its id or your uid.", + "summary": "List bookings", + "description": "The environment's bookings, newest first. Answers aren't included.", "security": [ { "bearerAuth": [] } ], "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, { "schema": { "type": "string", - "description": "The event type's id or uid.", + "description": "The last id of the previous page." + }, + "required": false, + "description": "The last id of the previous page.", + "name": "after", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "host", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "event_type", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "held", + "pending_approval", + "confirmed", + "cancelled", + "declined" + ] + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "attendee_uid", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "An ISO 8601 instant with an offset.", "examples": [ - "evtype_01kd2m9a0b1c2d3e4f5g6h7j8m" + "2027-01-04T15:00:00.000Z" ] }, - "required": true, - "description": "The event type's id or uid.", - "name": "event_type", - "in": "path" + "required": false, + "description": "An ISO 8601 instant with an offset.", + "name": "start_after", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "An ISO 8601 instant with an offset.", + "examples": [ + "2027-01-04T15:00:00.000Z" + ] + }, + "required": false, + "description": "An ISO 8601 instant with an offset.", + "name": "start_before", + "in": "query" }, { "$ref": "#/components/parameters/CossVersion" @@ -68483,7 +70747,7 @@ ], "responses": { "200": { - "description": "The event type.", + "description": "A page of bookings.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -68507,7 +70771,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingEventType" + "$ref": "#/components/schemas/SchedulingBookingList" } } } @@ -68575,8 +70839,8 @@ } } }, - "404": { - "description": "Nothing with this id or uid exists in the environment (`not_found`).", + "422": { + "description": "The request failed validation (`validation_error`, see `issues`), or can't be done: see `code`.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -68666,14 +70930,16 @@ } } } - }, - "patch": { - "operationId": "updateSchedulingEventType", + } + }, + "/v1/scheduling/bookings/{id}": { + "get": { + "operationId": "getSchedulingBooking", "tags": [ - "Scheduling event types" + "Scheduling bookings" ], - "summary": "Update an event type", - "description": "Changes what's given. Existing bookings keep what they were booked with. Set `status` to `archived` to stop new bookings.", + "summary": "Retrieve a booking", + "description": "A booking with its attendees and their answers.", "security": [ { "bearerAuth": [] @@ -68683,33 +70949,23 @@ { "schema": { "type": "string", - "description": "The event type's id or uid.", + "description": "The booking's id.", "examples": [ - "evtype_01kd2m9a0b1c2d3e4f5g6h7j8m" + "book_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The event type's id or uid.", - "name": "event_type", + "description": "The booking's id.", + "name": "id", "in": "path" }, { "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateEventTypeRequest" - } - } - } - }, "responses": { "200": { - "description": "The event type.", + "description": "The booking.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -68733,13 +70989,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingEventType" + "$ref": "#/components/schemas/SchedulingBooking" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -68822,48 +71078,6 @@ } } }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The request failed validation (`validation_error`, see `issues`), the settings don't fit together (`invalid_event_type`), `grid_time_zone` isn't an IANA name (`invalid_time_zone`), another event type has the `uid` (`uid_taken`), or its text was refused (`content_refused`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -68936,14 +71150,14 @@ } } }, - "/v1/scheduling/event_types/{event_type}/slots": { - "get": { - "operationId": "listSchedulingSlots", + "/v1/scheduling/bookings/{id}/reschedule": { + "post": { + "operationId": "rescheduleSchedulingBooking", "tags": [ - "Scheduling availability" + "Scheduling bookings" ], - "summary": "List available slots", - "description": "Starts an attendee can book between `start` and `end` (at most 62 days), as UTC instants to show in their own time zone. A host whose calendar can't be read isn't offered.", + "summary": "Reschedule a booking", + "description": "Moves it to a new time, keeping its id. The old time stays booked if the new one isn't free.", "security": [ { "bearerAuth": [] @@ -68953,79 +71167,33 @@ { "schema": { "type": "string", - "description": "The event type's id or uid.", + "description": "The booking's id.", "examples": [ - "evtype_01kd2m9a0b1c2d3e4f5g6h7j8m" + "book_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The event type's id or uid.", - "name": "event_type", + "description": "The booking's id.", + "name": "id", "in": "path" }, - { - "schema": { - "type": "string", - "description": "An ISO 8601 instant with an offset.", - "examples": [ - "2027-01-04T15:00:00.000Z" - ] - }, - "required": true, - "description": "An ISO 8601 instant with an offset.", - "name": "start", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "An ISO 8601 instant with an offset.", - "examples": [ - "2027-01-04T15:00:00.000Z" - ] - }, - "required": true, - "description": "An ISO 8601 instant with an offset.", - "name": "end", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Only this host's slots (id or uid)." - }, - "required": false, - "description": "Only this host's slots (id or uid).", - "name": "host", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 5, - "maximum": 1440 - }, - "required": false, - "name": "duration_minutes", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "A JSON object narrowing a pool, such as `{\"licensed_in\":\"CA\"}`." - }, - "required": false, - "description": "A JSON object narrowing a pool, such as `{\"licensed_in\":\"CA\"}`.", - "name": "attributes", - "in": "query" - }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RescheduleBookingRequest" + } + } + } + }, "responses": { "200": { - "description": "The slots.", + "description": "The booking.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -69049,13 +71217,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingSlotList" + "$ref": "#/components/schemas/SchedulingBooking" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -69138,8 +71306,29 @@ } } }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "The request failed validation (`validation_error`, see `issues`), or `start`, `end` or `duration_minutes` is invalid: `end` isn't after `start`, the window is longer than 62 days, or the duration isn't one of the event type's (`invalid_time`).", + "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -69231,14 +71420,14 @@ } } }, - "/v1/scheduling/event_types/{event_type}/slots/explain": { - "get": { - "operationId": "explainSchedulingSlot", + "/v1/scheduling/bookings/{id}/cancel": { + "post": { + "operationId": "cancelSchedulingBooking", "tags": [ - "Scheduling availability" + "Scheduling bookings" ], - "summary": "Explain a slot", - "description": "Why one start can or can't be booked right now, host by host, decided the same way a booking is. For support and debugging: it says when a host is busy, so don't show it to attendees.", + "summary": "Cancel a booking", + "description": "Frees the time and removes the calendar event. A cancelled booking stays charged.", "security": [ { "bearerAuth": [] @@ -69248,66 +71437,33 @@ { "schema": { "type": "string", - "description": "The event type's id or uid.", + "description": "The booking's id.", "examples": [ - "evtype_01kd2m9a0b1c2d3e4f5g6h7j8m" + "book_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The event type's id or uid.", - "name": "event_type", + "description": "The booking's id.", + "name": "id", "in": "path" }, - { - "schema": { - "type": "string", - "description": "An ISO 8601 instant with an offset.", - "examples": [ - "2027-01-04T15:00:00.000Z" - ] - }, - "required": true, - "description": "An ISO 8601 instant with an offset.", - "name": "start", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Only this host (id or uid)." - }, - "required": false, - "description": "Only this host (id or uid).", - "name": "host", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 5, - "maximum": 1440 - }, - "required": false, - "name": "duration_minutes", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "A JSON object narrowing a pool, such as `{\"licensed_in\":\"CA\"}`." - }, - "required": false, - "description": "A JSON object narrowing a pool, such as `{\"licensed_in\":\"CA\"}`.", - "name": "attributes", - "in": "query" - }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelSchedulingBookingRequest" + } + } + } + }, "responses": { "200": { - "description": "Whether it can be booked, and why.", + "description": "The booking.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -69331,7 +71487,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingSlotExplanation" + "$ref": "#/components/schemas/SchedulingBooking" } } } @@ -69421,7 +71577,7 @@ } }, "422": { - "description": "The request failed validation (`validation_error`, see `issues`), or `start`, `end` or `duration_minutes` is invalid: `end` isn't after `start`, the window is longer than 62 days, or the duration isn't one of the event type's (`invalid_time`).", + "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -69513,32 +71669,61 @@ } } }, - "/v1/scheduling/availability/search": { - "post": { - "operationId": "searchSchedulingAvailability", + "/v1/scheduling/bookings/{id}/events": { + "get": { + "operationId": "listSchedulingBookingEvents", "tags": [ - "Scheduling availability" + "Scheduling bookings" ], - "summary": "Search availability across hosts", - "description": "For \"who's free\" pages: each matching host's soonest slots, soonest first, across up to 500 hosts.", + "summary": "List a booking's history", + "description": "What's happened to a booking, oldest first, and who did it. There's no personal data in here.", "security": [ { "bearerAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AvailabilitySearchRequest" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "description": "The booking's id.", + "examples": [ + "book_01kd2m9a0b1c2d3e4f5g6h7j8m" + ] + }, + "required": true, + "description": "The booking's id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "The last id of the previous page." + }, + "required": false, + "description": "The last id of the previous page.", + "name": "after", + "in": "query" + }, + { + "$ref": "#/components/parameters/CossVersion" } - }, + ], "responses": { "200": { - "description": "Hosts with slots.", + "description": "The events.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -69562,13 +71747,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingAvailabilitySearchResult" + "$ref": "#/components/schemas/SchedulingBookingEventList" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -69651,48 +71836,6 @@ } } }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The request failed validation (`validation_error`, see `issues`), or `start`, `end` or `duration_minutes` is invalid: `end` isn't after `start`, the window is longer than 62 days, or the duration isn't one of the event type's (`invalid_time`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -69762,22 +71905,17 @@ } } } - }, - "parameters": [ - { - "$ref": "#/components/parameters/CossVersion" - } - ] + } } }, - "/v1/scheduling/bookings": { + "/v1/scheduling/bookings/{id}/approve": { "post": { - "operationId": "createSchedulingBooking", + "operationId": "approveSchedulingBooking", "tags": [ "Scheduling bookings" ], - "summary": "Create a booking", - "description": "Books a slot, or joins a group session. Without a `host`, a pool chooses fairly among those free. An event type that requires approval answers with a `pending_approval` booking.", + "summary": "Approve a booking", + "description": "Confirms a `pending_approval` booking.", "security": [ { "bearerAuth": [] @@ -69787,34 +71925,22 @@ { "schema": { "type": "string", - "minLength": 1, - "maxLength": 256, - "description": "Makes retries safe: a repeated request with the same key and body returns the original booking instead of creating another, for 24 hours. A refused request keeps no key. 1-256 characters.", + "description": "The booking's id.", "examples": [ - "welcome-user-123" + "book_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, - "required": false, - "description": "Makes retries safe: a repeated request with the same key and body returns the original booking instead of creating another, for 24 hours. A refused request keeps no key. 1-256 characters.", - "name": "Idempotency-Key", - "in": "header" + "required": true, + "description": "The booking's id.", + "name": "id", + "in": "path" }, { "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateBookingRequest" - } - } - } - }, "responses": { - "201": { + "200": { "description": "The booking.", "headers": { "X-Request-Id": { @@ -69826,15 +71952,6 @@ ] } }, - "Idempotent-Replayed": { - "description": "`true` when this response replays an earlier request with the same `Idempotency-Key`.", - "schema": { - "type": "string", - "enum": [ - "true" - ] - } - }, "Coss-Version": { "description": "The API version this response is rendered in.", "schema": { @@ -69854,7 +71971,7 @@ } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -69937,50 +72054,8 @@ } } }, - "409": { - "description": "A request with this key is still running (`idempotency_in_progress`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "422": { - "description": "The time isn't available (`slot_unavailable`), `start`, `duration_minutes` or the occurrence count is out of bounds (`invalid_time`), the attendee's time zone isn't an IANA name (`invalid_time_zone`), the answers or attendee are invalid (`invalid_answers`, `invalid_attendee`), or the `Idempotency-Key` was used with a different request (`idempotency_key_reused`).", + "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -70070,105 +72145,34 @@ } } } - }, - "get": { - "operationId": "listSchedulingBookings", + } + }, + "/v1/scheduling/bookings/{id}/decline": { + "post": { + "operationId": "declineSchedulingBooking", "tags": [ "Scheduling bookings" ], - "summary": "List bookings", - "description": "The environment's bookings, newest first. Answers aren't included.", + "summary": "Decline a booking", + "description": "Refuses a `pending_approval` booking and frees the time.", "security": [ { "bearerAuth": [] } ], "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "The last id of the previous page." - }, - "required": false, - "description": "The last id of the previous page.", - "name": "after", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "host", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "event_type", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "held", - "pending_approval", - "confirmed", - "cancelled", - "declined" - ] - }, - "required": false, - "name": "status", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "attendee_uid", - "in": "query" - }, { "schema": { "type": "string", - "description": "An ISO 8601 instant with an offset.", - "examples": [ - "2027-01-04T15:00:00.000Z" - ] - }, - "required": false, - "description": "An ISO 8601 instant with an offset.", - "name": "start_after", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "An ISO 8601 instant with an offset.", + "description": "The booking's id.", "examples": [ - "2027-01-04T15:00:00.000Z" + "book_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, - "required": false, - "description": "An ISO 8601 instant with an offset.", - "name": "start_before", - "in": "query" + "required": true, + "description": "The booking's id.", + "name": "id", + "in": "path" }, { "$ref": "#/components/parameters/CossVersion" @@ -70176,7 +72180,7 @@ ], "responses": { "200": { - "description": "A page of bookings.", + "description": "The booking.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -70200,7 +72204,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingBookingList" + "$ref": "#/components/schemas/SchedulingBooking" } } } @@ -70268,8 +72272,29 @@ } } }, + "404": { + "description": "Nothing with this id or uid exists in the environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "The request failed validation (`validation_error`, see `issues`), or can't be done: see `code`.", + "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -70361,14 +72386,14 @@ } } }, - "/v1/scheduling/bookings/{id}": { - "get": { - "operationId": "getSchedulingBooking", + "/v1/scheduling/bookings/{id}/no_show": { + "post": { + "operationId": "markSchedulingBookingNoShow", "tags": [ "Scheduling bookings" ], - "summary": "Retrieve a booking", - "description": "A booking with its attendees and their answers.", + "summary": "Mark a no-show", + "description": "Records that the attendee didn't come, once the booking has started.", "security": [ { "bearerAuth": [] @@ -70507,6 +72532,27 @@ } } }, + "422": { + "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -70579,14 +72625,14 @@ } } }, - "/v1/scheduling/bookings/{id}/reschedule": { - "post": { - "operationId": "rescheduleSchedulingBooking", + "/v1/scheduling/bookings/{id}/location": { + "patch": { + "operationId": "updateSchedulingBookingLocation", "tags": [ "Scheduling bookings" ], - "summary": "Reschedule a booking", - "description": "Moves it to a new time, keeping its id. The old time stays booked if the new one isn't free.", + "summary": "Set a booking's location", + "description": "Where it happens, such as the meeting link you made after the `scheduling.booking.confirmed` webhook.", "security": [ { "bearerAuth": [] @@ -70615,7 +72661,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RescheduleBookingRequest" + "$ref": "#/components/schemas/UpdateBookingLocationRequest" } } } @@ -70849,14 +72895,14 @@ } } }, - "/v1/scheduling/bookings/{id}/cancel": { + "/v1/scheduling/holds": { "post": { - "operationId": "cancelSchedulingBooking", + "operationId": "createSchedulingHold", "tags": [ "Scheduling bookings" ], - "summary": "Cancel a booking", - "description": "Frees the time and removes the calendar event. A cancelled booking stays charged.", + "summary": "Hold a slot", + "description": "Keeps a slot for a while (the event type's `hold_ttl_seconds`) so you can take payment, then confirm it. Answers with a `held` booking, which isn't charged and sends nothing.", "security": [ { "bearerAuth": [] @@ -70866,33 +72912,35 @@ { "schema": { "type": "string", - "description": "The booking's id.", + "minLength": 1, + "maxLength": 256, + "description": "Makes retries safe: a repeated request with the same key and body returns the original booking instead of creating another, for 24 hours. A refused request keeps no key. 1-256 characters.", "examples": [ - "book_01kd2m9a0b1c2d3e4f5g6h7j8m" + "welcome-user-123" ] }, - "required": true, - "description": "The booking's id.", - "name": "id", - "in": "path" + "required": false, + "description": "Makes retries safe: a repeated request with the same key and body returns the original booking instead of creating another, for 24 hours. A refused request keeps no key. 1-256 characters.", + "name": "Idempotency-Key", + "in": "header" }, { "$ref": "#/components/parameters/CossVersion" } ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CancelSchedulingBookingRequest" + "$ref": "#/components/schemas/CreateHoldRequest" } } } }, "responses": { - "200": { - "description": "The booking.", + "201": { + "description": "The held booking.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -70903,6 +72951,15 @@ ] } }, + "Idempotent-Replayed": { + "description": "`true` when this response replays an earlier request with the same `Idempotency-Key`.", + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, "Coss-Version": { "description": "The API version this response is rendered in.", "schema": { @@ -70922,7 +72979,7 @@ } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -71005,8 +73062,50 @@ } } }, + "409": { + "description": "A request with this key is still running (`idempotency_in_progress`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", + "description": "The time isn't available (`slot_unavailable`), `start`, `duration_minutes` or the occurrence count is out of bounds (`invalid_time`), the attendee's time zone isn't an IANA name (`invalid_time_zone`), the answers or attendee are invalid (`invalid_answers`, `invalid_attendee`), or the `Idempotency-Key` was used with a different request (`idempotency_key_reused`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -71098,14 +73197,14 @@ } } }, - "/v1/scheduling/bookings/{id}/events": { - "get": { - "operationId": "listSchedulingBookingEvents", + "/v1/scheduling/holds/{id}/confirm": { + "post": { + "operationId": "confirmSchedulingHold", "tags": [ "Scheduling bookings" ], - "summary": "List a booking's history", - "description": "What's happened to a booking, oldest first, and who did it. There's no personal data in here.", + "summary": "Confirm a hold", + "description": "Turns a hold into a booking. Always succeeds before the hold expires. Repeating it is safe.", "security": [ { "bearerAuth": [] @@ -71125,34 +73224,23 @@ "name": "id", "in": "path" }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "The last id of the previous page." - }, - "required": false, - "description": "The last id of the previous page.", - "name": "after", - "in": "query" - }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmHoldRequest" + } + } + } + }, "responses": { "200": { - "description": "The events.", + "description": "The booking.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -71176,13 +73264,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingBookingEventList" + "$ref": "#/components/schemas/SchedulingBooking" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -71265,6 +73353,48 @@ } } }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -71337,14 +73467,14 @@ } } }, - "/v1/scheduling/bookings/{id}/approve": { - "post": { - "operationId": "approveSchedulingBooking", + "/v1/scheduling/holds/{id}": { + "delete": { + "operationId": "releaseSchedulingHold", "tags": [ "Scheduling bookings" ], - "summary": "Approve a booking", - "description": "Confirms a `pending_approval` booking.", + "summary": "Release a hold", + "description": "Gives the slot back early.", "security": [ { "bearerAuth": [] @@ -71370,7 +73500,7 @@ ], "responses": { "200": { - "description": "The booking.", + "description": "The cancelled booking.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -71576,14 +73706,14 @@ } } }, - "/v1/scheduling/bookings/{id}/decline": { + "/v1/scheduling/series": { "post": { - "operationId": "declineSchedulingBooking", + "operationId": "createSchedulingSeries", "tags": [ - "Scheduling bookings" + "Scheduling series" ], - "summary": "Decline a booking", - "description": "Refuses a `pending_approval` booking and frees the time.", + "summary": "Create a recurring series", + "description": "Books up to 52 occurrences, keeping the attendee's local time across DST changes. Each one is an ordinary booking that can move on its own, and a pool keeps one host throughout.", "security": [ { "bearerAuth": [] @@ -71593,23 +73723,35 @@ { "schema": { "type": "string", - "description": "The booking's id.", + "minLength": 1, + "maxLength": 256, + "description": "Makes retries safe: a repeated request with the same key and body returns the original booking instead of creating another, for 24 hours. A refused request keeps no key. 1-256 characters.", "examples": [ - "book_01kd2m9a0b1c2d3e4f5g6h7j8m" + "welcome-user-123" ] }, - "required": true, - "description": "The booking's id.", - "name": "id", - "in": "path" + "required": false, + "description": "Makes retries safe: a repeated request with the same key and body returns the original booking instead of creating another, for 24 hours. A refused request keeps no key. 1-256 characters.", + "name": "Idempotency-Key", + "in": "header" }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSeriesRequest" + } + } + } + }, "responses": { - "200": { - "description": "The booking.", + "201": { + "description": "The series and its bookings.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -71620,6 +73762,15 @@ ] } }, + "Idempotent-Replayed": { + "description": "`true` when this response replays an earlier request with the same `Idempotency-Key`.", + "schema": { + "type": "string", + "enum": [ + "true" + ] + } + }, "Coss-Version": { "description": "The API version this response is rendered in.", "schema": { @@ -71633,13 +73784,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingBooking" + "$ref": "#/components/schemas/SchedulingSeries" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -71722,8 +73873,50 @@ } } }, + "409": { + "description": "A request with this key is still running (`idempotency_in_progress`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", + "description": "The time isn't available (`slot_unavailable`), `start`, `duration_minutes` or the occurrence count is out of bounds (`invalid_time`), the attendee's time zone isn't an IANA name (`invalid_time_zone`), the answers or attendee are invalid (`invalid_answers`, `invalid_attendee`), or the `Idempotency-Key` was used with a different request (`idempotency_key_reused`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -71815,40 +74008,32 @@ } } }, - "/v1/scheduling/bookings/{id}/no_show": { + "/v1/scheduling/series/preview": { "post": { - "operationId": "markSchedulingBookingNoShow", + "operationId": "previewSchedulingSeries", "tags": [ - "Scheduling bookings" + "Scheduling series" ], - "summary": "Mark a no-show", - "description": "Records that the attendee didn't come, once the booking has started.", + "summary": "Preview a recurring series", + "description": "Which occurrences would be free, without booking anything.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The booking's id.", - "examples": [ - "book_01kd2m9a0b1c2d3e4f5g6h7j8m" - ] - }, - "required": true, - "description": "The booking's id.", - "name": "id", - "in": "path" - }, - { - "$ref": "#/components/parameters/CossVersion" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSeriesRequest" + } + } } - ], + }, "responses": { "200": { - "description": "The booking.", + "description": "Each occurrence.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -71872,13 +74057,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingBooking" + "$ref": "#/components/schemas/SchedulingSeriesPreview" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -71961,8 +74146,29 @@ } } }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", + "description": "The request failed validation (`validation_error`, see `issues`), or `start`, `end` or `duration_minutes` is invalid: `end` isn't after `start`, the window is longer than 62 days, or the duration isn't one of the event type's (`invalid_time`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -72051,17 +74257,22 @@ } } } - } + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] } }, - "/v1/scheduling/bookings/{id}/location": { - "patch": { - "operationId": "updateSchedulingBookingLocation", + "/v1/scheduling/series/{id}/cancel": { + "post": { + "operationId": "cancelSchedulingSeries", "tags": [ - "Scheduling bookings" + "Scheduling series" ], - "summary": "Set a booking's location", - "description": "Where it happens, such as the meeting link you made after the `scheduling.booking.confirmed` webhook.", + "summary": "Cancel a recurring series", + "description": "Cancels the occurrences from `from_index` on, or all of them.", "security": [ { "bearerAuth": [] @@ -72071,13 +74282,13 @@ { "schema": { "type": "string", - "description": "The booking's id.", + "description": "The series' id.", "examples": [ - "book_01kd2m9a0b1c2d3e4f5g6h7j8m" + "series_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The booking's id.", + "description": "The series' id.", "name": "id", "in": "path" }, @@ -72090,14 +74301,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateBookingLocationRequest" + "$ref": "#/components/schemas/CancelSeriesRequest" } } } }, "responses": { "200": { - "description": "The booking.", + "description": "The cancelled bookings.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -72121,7 +74332,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingBooking" + "$ref": "#/components/schemas/SchedulingCancelledSeries" } } } @@ -72231,27 +74442,6 @@ } } }, - "422": { - "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -72324,14 +74514,14 @@ } } }, - "/v1/scheduling/holds": { - "post": { - "operationId": "createSchedulingHold", + "/v1/scheduling/attendees/{uid}": { + "delete": { + "operationId": "eraseSchedulingAttendee", "tags": [ "Scheduling bookings" ], - "summary": "Hold a slot", - "description": "Keeps a slot for a while (the event type's `hold_ttl_seconds`) so you can take payment, then confirm it. Answers with a `held` booking, which isn't charged and sends nothing.", + "summary": "Erase an attendee", + "description": "Removes this person's details and answers from every booking now, and ends any booker session fixed to them. The bookings themselves stay.", "security": [ { "bearerAuth": [] @@ -72341,35 +74531,23 @@ { "schema": { "type": "string", - "minLength": 1, - "maxLength": 256, - "description": "Makes retries safe: a repeated request with the same key and body returns the original booking instead of creating another, for 24 hours. A refused request keeps no key. 1-256 characters.", + "description": "Your id for the attendee.", "examples": [ - "welcome-user-123" + "patient_77" ] }, - "required": false, - "description": "Makes retries safe: a repeated request with the same key and body returns the original booking instead of creating another, for 24 hours. A refused request keeps no key. 1-256 characters.", - "name": "Idempotency-Key", - "in": "header" + "required": true, + "description": "Your id for the attendee.", + "name": "uid", + "in": "path" }, { "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateHoldRequest" - } - } - } - }, "responses": { - "201": { - "description": "The held booking.", + "200": { + "description": "How many were erased.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -72380,15 +74558,6 @@ ] } }, - "Idempotent-Replayed": { - "description": "`true` when this response replays an earlier request with the same `Idempotency-Key`.", - "schema": { - "type": "string", - "enum": [ - "true" - ] - } - }, "Coss-Version": { "description": "The API version this response is rendered in.", "schema": { @@ -72402,13 +74571,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingBooking" + "$ref": "#/components/schemas/SchedulingAttendeeErasure" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -72470,90 +74639,6 @@ } } }, - "404": { - "description": "Nothing with this id or uid exists in the environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "A request with this key is still running (`idempotency_in_progress`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The time isn't available (`slot_unavailable`), `start`, `duration_minutes` or the occurrence count is out of bounds (`invalid_time`), the attendee's time zone isn't an IANA name (`invalid_time_zone`), the answers or attendee are invalid (`invalid_answers`, `invalid_attendee`), or the `Idempotency-Key` was used with a different request (`idempotency_key_reused`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -72626,50 +74711,32 @@ } } }, - "/v1/scheduling/holds/{id}/confirm": { + "/v1/scheduling/attendees/erase": { "post": { - "operationId": "confirmSchedulingHold", + "operationId": "eraseSchedulingAttendeeByEmail", "tags": [ "Scheduling bookings" ], - "summary": "Confirm a hold", - "description": "Turns a hold into a booking. Always succeeds before the hold expires. Repeating it is safe.", + "summary": "Erase an attendee or guest by email", + "description": "Removes this address's details and answers from every booking now, for someone you have no `uid` for, such as a guest. The bookings themselves stay.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The booking's id.", - "examples": [ - "book_01kd2m9a0b1c2d3e4f5g6h7j8m" - ] - }, - "required": true, - "description": "The booking's id.", - "name": "id", - "in": "path" - }, - { - "$ref": "#/components/parameters/CossVersion" - } - ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConfirmHoldRequest" + "$ref": "#/components/schemas/EraseSchedulingAttendeeByEmailRequest" } } } }, "responses": { "200": { - "description": "The booking.", + "description": "How many were erased.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -72693,7 +74760,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingBooking" + "$ref": "#/components/schemas/SchedulingAttendeeErasureByEmail" } } } @@ -72761,27 +74828,6 @@ } } }, - "404": { - "description": "Nothing with this id or uid exists in the environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "415": { "description": "The body is not sent as `application/json` (`unsupported_media_type`).", "headers": { @@ -72803,27 +74849,6 @@ } } }, - "422": { - "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -72893,17 +74918,22 @@ } } } - } + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] } }, - "/v1/scheduling/holds/{id}": { - "delete": { - "operationId": "releaseSchedulingHold", + "/v1/scheduling/hosts/{host}/calendar_connections/authorize": { + "post": { + "operationId": "authorizeSchedulingCalendar", "tags": [ - "Scheduling bookings" + "Scheduling calendars" ], - "summary": "Release a hold", - "description": "Gives the slot back early.", + "summary": "Start connecting a calendar", + "description": "A URL for the host to give consent at Google or Microsoft. Open it in a new window: providers refuse to be framed. Coss.com reads only busy times, and writes events with a neutral title unless the event type's `host_calendar_details` is `full`.", "security": [ { "bearerAuth": [] @@ -72913,23 +74943,33 @@ { "schema": { "type": "string", - "description": "The booking's id.", + "description": "The host's id or uid.", "examples": [ - "book_01kd2m9a0b1c2d3e4f5g6h7j8m" + "host_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The booking's id.", - "name": "id", + "description": "The host's id or uid.", + "name": "host", "in": "path" }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthorizeCalendarRequest" + } + } + } + }, "responses": { "200": { - "description": "The cancelled booking.", + "description": "Where to send the host.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -72953,13 +74993,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingBooking" + "$ref": "#/components/schemas/SchedulingCalendarAuthorization" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -73042,8 +75082,29 @@ } } }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "The booking can't change that way (`booking_not_changeable`), the new time isn't available (`slot_unavailable`), or a hold expired (`hold_expired`).", + "description": "The request failed validation (`validation_error`, see `issues`), or the calendar can't be connected: the provider isn't set up, the server's URL is refused, or a simulated calendar was asked for outside a sandbox environment (`calendar_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -73135,14 +75196,14 @@ } } }, - "/v1/scheduling/series": { + "/v1/scheduling/hosts/{host}/calendar_connections": { "post": { - "operationId": "createSchedulingSeries", + "operationId": "createSchedulingCalendarConnection", "tags": [ - "Scheduling series" + "Scheduling calendars" ], - "summary": "Create a recurring series", - "description": "Books up to 52 occurrences, keeping the attendee's local time across DST changes. Each one is an ordinary booking that can move on its own, and a pool keeps one host throughout.", + "summary": "Connect a calendar with a password", + "description": "CalDAV (Apple and others) with an app password, or, in sandbox environments, a `simulated` calendar whose busy times you set yourself. Google and Microsoft use `authorize`.", "security": [ { "bearerAuth": [] @@ -73152,17 +75213,15 @@ { "schema": { "type": "string", - "minLength": 1, - "maxLength": 256, - "description": "Makes retries safe: a repeated request with the same key and body returns the original booking instead of creating another, for 24 hours. A refused request keeps no key. 1-256 characters.", + "description": "The host's id or uid.", "examples": [ - "welcome-user-123" + "host_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, - "required": false, - "description": "Makes retries safe: a repeated request with the same key and body returns the original booking instead of creating another, for 24 hours. A refused request keeps no key. 1-256 characters.", - "name": "Idempotency-Key", - "in": "header" + "required": true, + "description": "The host's id or uid.", + "name": "host", + "in": "path" }, { "$ref": "#/components/parameters/CossVersion" @@ -73173,14 +75232,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateSeriesRequest" + "$ref": "#/components/schemas/CreateCalendarConnectionRequest" } } } }, "responses": { "201": { - "description": "The series and its bookings.", + "description": "The connection.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -73191,15 +75250,6 @@ ] } }, - "Idempotent-Replayed": { - "description": "`true` when this response replays an earlier request with the same `Idempotency-Key`.", - "schema": { - "type": "string", - "enum": [ - "true" - ] - } - }, "Coss-Version": { "description": "The API version this response is rendered in.", "schema": { @@ -73213,7 +75263,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingSeries" + "$ref": "#/components/schemas/SchedulingCalendarConnection" } } } @@ -73302,27 +75352,6 @@ } } }, - "409": { - "description": "A request with this key is still running (`idempotency_in_progress`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "415": { "description": "The body is not sent as `application/json` (`unsupported_media_type`).", "headers": { @@ -73345,7 +75374,7 @@ } }, "422": { - "description": "The time isn't available (`slot_unavailable`), `start`, `duration_minutes` or the occurrence count is out of bounds (`invalid_time`), the attendee's time zone isn't an IANA name (`invalid_time_zone`), the answers or attendee are invalid (`invalid_answers`, `invalid_attendee`), or the `Idempotency-Key` was used with a different request (`idempotency_key_reused`).", + "description": "The request failed validation (`validation_error`, see `issues`), or the calendar can't be connected: the provider isn't set up, the server's URL is refused, or a simulated calendar was asked for outside a sandbox environment (`calendar_unavailable`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -73435,34 +75464,40 @@ } } } - } - }, - "/v1/scheduling/series/preview": { - "post": { - "operationId": "previewSchedulingSeries", + }, + "get": { + "operationId": "listSchedulingCalendarConnections", "tags": [ - "Scheduling series" + "Scheduling calendars" ], - "summary": "Preview a recurring series", - "description": "Which occurrences would be free, without booking anything.", + "summary": "List a host's calendar connections", + "description": "With each connection's calendars and which of them count.", "security": [ { "bearerAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateSeriesRequest" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "description": "The host's id or uid.", + "examples": [ + "host_01kd2m9a0b1c2d3e4f5g6h7j8m" + ] + }, + "required": true, + "description": "The host's id or uid.", + "name": "host", + "in": "path" + }, + { + "$ref": "#/components/parameters/CossVersion" } - }, + ], "responses": { "200": { - "description": "Each occurrence.", + "description": "The connections.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -73486,13 +75521,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingSeriesPreview" + "$ref": "#/components/schemas/SchedulingCalendarConnectionList" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -73575,48 +75610,6 @@ } } }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The request failed validation (`validation_error`, see `issues`), or `start`, `end` or `duration_minutes` is invalid: `end` isn't after `start`, the window is longer than 62 days, or the duration isn't one of the event type's (`invalid_time`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -73686,22 +75679,17 @@ } } } - }, - "parameters": [ - { - "$ref": "#/components/parameters/CossVersion" - } - ] + } } }, - "/v1/scheduling/series/{id}/cancel": { - "post": { - "operationId": "cancelSchedulingSeries", + "/v1/scheduling/hosts/{host}/calendar_connections/{id}": { + "delete": { + "operationId": "deleteSchedulingCalendarConnection", "tags": [ - "Scheduling series" + "Scheduling calendars" ], - "summary": "Cancel a recurring series", - "description": "Cancels the occurrences from `from_index` on, or all of them.", + "summary": "Disconnect a calendar", + "description": "Revokes coss.com's access and deletes every busy time read through it. One a calendar delegation manages is refused (`calendar_managed`).", "security": [ { "bearerAuth": [] @@ -73711,13 +75699,26 @@ { "schema": { "type": "string", - "description": "The series' id.", + "description": "The host's id or uid.", "examples": [ - "series_01kd2m9a0b1c2d3e4f5g6h7j8m" + "host_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The series' id.", + "description": "The host's id or uid.", + "name": "host", + "in": "path" + }, + { + "schema": { + "type": "string", + "description": "The connection's id.", + "examples": [ + "calconn_01kd2m9a0b1c2d3e4f5g6h7j8m" + ] + }, + "required": true, + "description": "The connection's id.", "name": "id", "in": "path" }, @@ -73725,19 +75726,9 @@ "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CancelSeriesRequest" - } - } - } - }, "responses": { "200": { - "description": "The cancelled bookings.", + "description": "Disconnected.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -73761,13 +75752,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingCancelledSeries" + "$ref": "#/components/schemas/DeletedSchedulingCalendarConnection" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -73850,8 +75841,8 @@ } } }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "422": { + "description": "The request failed validation (`validation_error`, see `issues`), or can't be done: see `code`.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -73943,14 +75934,14 @@ } } }, - "/v1/scheduling/attendees/{uid}": { - "delete": { - "operationId": "eraseSchedulingAttendee", + "/v1/scheduling/hosts/{host}/calendars/{calendar_id}": { + "patch": { + "operationId": "updateSchedulingCalendar", "tags": [ - "Scheduling bookings" + "Scheduling calendars" ], - "summary": "Erase an attendee", - "description": "Removes this person's details and answers from every booking now, and ends any booker session fixed to them. The bookings themselves stay.", + "summary": "Choose how a calendar counts", + "description": "Whether its busy times block slots, and whether bookings are written to it.", "security": [ { "bearerAuth": [] @@ -73960,23 +75951,46 @@ { "schema": { "type": "string", - "description": "Your id for the attendee.", + "description": "The host's id or uid.", "examples": [ - "patient_77" + "host_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "Your id for the attendee.", - "name": "uid", + "description": "The host's id or uid.", + "name": "host", + "in": "path" + }, + { + "schema": { + "type": "string", + "description": "The calendar's id.", + "examples": [ + "cal_01kd2m9a0b1c2d3e4f5g6h7j8m" + ] + }, + "required": true, + "description": "The calendar's id.", + "name": "calendar_id", "in": "path" }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCalendarRequest" + } + } + } + }, "responses": { "200": { - "description": "How many were erased.", + "description": "The host's connections.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -74000,13 +76014,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingAttendeeErasure" + "$ref": "#/components/schemas/SchedulingCalendarConnectionList" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -74068,6 +76082,48 @@ } } }, + "404": { + "description": "Nothing with this id or uid exists in the environment (`not_found`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -74140,14 +76196,14 @@ } } }, - "/v1/scheduling/attendees/erase": { + "/v1/scheduling/calendar_delegations": { "post": { - "operationId": "eraseSchedulingAttendeeByEmail", + "operationId": "createSchedulingCalendarDelegation", "tags": [ - "Scheduling bookings" + "Scheduling calendars" ], - "summary": "Erase an attendee or guest by email", - "description": "Removes this address's details and answers from every booking now, for someone you have no `uid` for, such as a guest. The bookings themselves stay.", + "summary": "Add a calendar delegation", + "description": "An admin authorizes once, and every host on the domain gets their calendar with no consent screen. It stays `pending`: authorize what it returns, then verify it. Credentials are never returned.", "security": [ { "bearerAuth": [] @@ -74158,14 +76214,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EraseSchedulingAttendeeByEmailRequest" + "$ref": "#/components/schemas/CreateCalendarDelegationRequest" } } } }, "responses": { - "200": { - "description": "How many were erased.", + "201": { + "description": "The delegation.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -74189,7 +76245,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingAttendeeErasureByEmail" + "$ref": "#/components/schemas/SchedulingCalendarDelegation" } } } @@ -74278,6 +76334,27 @@ } } }, + "422": { + "description": "The credentials or mailbox aren't valid (`invalid_calendar_delegation`), or the provider refused them (`calendar_delegation_refused`, and `message` says what to fix).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -74353,52 +76430,22 @@ "$ref": "#/components/parameters/CossVersion" } ] - } - }, - "/v1/scheduling/hosts/{host}/calendar_connections/authorize": { - "post": { - "operationId": "authorizeSchedulingCalendar", + }, + "get": { + "operationId": "listSchedulingCalendarDelegations", "tags": [ "Scheduling calendars" ], - "summary": "Start connecting a calendar", - "description": "A URL for the host to give consent at Google or Microsoft. Open it in a new window: providers refuse to be framed. Coss.com reads only busy times, and writes events with a neutral title unless the event type's `host_calendar_details` is `full`.", + "summary": "List calendar delegations", + "description": "With how many hosts each has connected.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The host's id or uid.", - "examples": [ - "host_01kd2m9a0b1c2d3e4f5g6h7j8m" - ] - }, - "required": true, - "description": "The host's id or uid.", - "name": "host", - "in": "path" - }, - { - "$ref": "#/components/parameters/CossVersion" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuthorizeCalendarRequest" - } - } - } - }, "responses": { "200": { - "description": "Where to send the host.", + "description": "The delegations.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -74422,13 +76469,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingCalendarAuthorization" + "$ref": "#/components/schemas/SchedulingCalendarDelegationList" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -74490,69 +76537,6 @@ } } }, - "404": { - "description": "Nothing with this id or uid exists in the environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The request failed validation (`validation_error`, see `issues`), or the calendar can't be connected: the provider isn't set up, the server's URL is refused, or a simulated calendar was asked for outside a sandbox environment (`calendar_unavailable`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -74622,17 +76606,22 @@ } } } - } + }, + "parameters": [ + { + "$ref": "#/components/parameters/CossVersion" + } + ] } }, - "/v1/scheduling/hosts/{host}/calendar_connections": { - "post": { - "operationId": "createSchedulingCalendarConnection", + "/v1/scheduling/calendar_delegations/{id}": { + "get": { + "operationId": "getSchedulingCalendarDelegation", "tags": [ "Scheduling calendars" ], - "summary": "Connect a calendar with a password", - "description": "CalDAV (Apple and others) with an app password, or, in sandbox environments, a `simulated` calendar whose busy times you set yourself. Google and Microsoft use `authorize`.", + "summary": "Get a calendar delegation", + "description": "Its status, what to authorize, and how many hosts it has connected.", "security": [ { "bearerAuth": [] @@ -74642,33 +76631,23 @@ { "schema": { "type": "string", - "description": "The host's id or uid.", + "description": "The delegation's id.", "examples": [ - "host_01kd2m9a0b1c2d3e4f5g6h7j8m" + "caldlg_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The host's id or uid.", - "name": "host", + "description": "The delegation's id.", + "name": "id", "in": "path" }, { "$ref": "#/components/parameters/CossVersion" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCalendarConnectionRequest" - } - } - } - }, "responses": { - "201": { - "description": "The connection.", + "200": { + "description": "The delegation.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -74692,13 +76671,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingCalendarConnection" + "$ref": "#/components/schemas/SchedulingCalendarDelegation" } } } }, "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -74781,48 +76760,6 @@ } } }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "The request failed validation (`validation_error`, see `issues`), or the calendar can't be connected: the provider isn't set up, the server's URL is refused, or a simulated calendar was asked for outside a sandbox environment (`calendar_unavailable`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -74894,13 +76831,13 @@ } } }, - "get": { - "operationId": "listSchedulingCalendarConnections", + "patch": { + "operationId": "updateSchedulingCalendarDelegation", "tags": [ "Scheduling calendars" ], - "summary": "List a host's calendar connections", - "description": "With each connection's calendars and which of them count.", + "summary": "Update a calendar delegation", + "description": "New credentials are tried on the verified mailbox before they replace the old ones, and end an outage if they work.", "security": [ { "bearerAuth": [] @@ -74910,23 +76847,33 @@ { "schema": { "type": "string", - "description": "The host's id or uid.", + "description": "The delegation's id.", "examples": [ - "host_01kd2m9a0b1c2d3e4f5g6h7j8m" + "caldlg_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The host's id or uid.", - "name": "host", + "description": "The delegation's id.", + "name": "id", "in": "path" }, { "$ref": "#/components/parameters/CossVersion" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCalendarDelegationRequest" + } + } + } + }, "responses": { "200": { - "description": "The connections.", + "description": "The delegation.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -74950,13 +76897,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingCalendarConnectionList" + "$ref": "#/components/schemas/SchedulingCalendarDelegation" } } } }, "400": { - "description": "The `Coss-Version` header names an unknown API version (`invalid_api_version`).", + "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -75039,6 +76986,48 @@ } } }, + "415": { + "description": "The body is not sent as `application/json` (`unsupported_media_type`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "The credentials or mailbox aren't valid (`invalid_calendar_delegation`), or the provider refused them (`calendar_delegation_refused`, and `message` says what to fix).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -75109,16 +77098,14 @@ } } } - } - }, - "/v1/scheduling/hosts/{host}/calendar_connections/{id}": { + }, "delete": { - "operationId": "deleteSchedulingCalendarConnection", + "operationId": "deleteSchedulingCalendarDelegation", "tags": [ "Scheduling calendars" ], - "summary": "Disconnect a calendar", - "description": "Revokes coss.com's access and deletes every busy time read through it. One a calendar delegation manages is refused (`calendar_managed`).", + "summary": "Delete a calendar delegation", + "description": "Its hosts lose these calendars within minutes. They stay bookable with no conflict check, and events already written stay on their calendars, out of reach of later moves or cancellations.", "security": [ { "bearerAuth": [] @@ -75128,26 +77115,13 @@ { "schema": { "type": "string", - "description": "The host's id or uid.", - "examples": [ - "host_01kd2m9a0b1c2d3e4f5g6h7j8m" - ] - }, - "required": true, - "description": "The host's id or uid.", - "name": "host", - "in": "path" - }, - { - "schema": { - "type": "string", - "description": "The connection's id.", + "description": "The delegation's id.", "examples": [ - "calconn_01kd2m9a0b1c2d3e4f5g6h7j8m" + "caldlg_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The connection's id.", + "description": "The delegation's id.", "name": "id", "in": "path" }, @@ -75157,7 +77131,7 @@ ], "responses": { "200": { - "description": "Disconnected.", + "description": "Being removed.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -75181,7 +77155,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeletedSchedulingCalendarConnection" + "$ref": "#/components/schemas/DeletedSchedulingCalendarDelegation" } } } @@ -75270,27 +77244,6 @@ } } }, - "422": { - "description": "The request failed validation (`validation_error`, see `issues`), or can't be done: see `code`.", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -75363,14 +77316,14 @@ } } }, - "/v1/scheduling/hosts/{host}/calendars/{calendar_id}": { - "patch": { - "operationId": "updateSchedulingCalendar", + "/v1/scheduling/calendar_delegations/{id}/verify": { + "post": { + "operationId": "verifySchedulingCalendarDelegation", "tags": [ "Scheduling calendars" ], - "summary": "Choose how a calendar counts", - "description": "Whether its busy times block slots, and whether bookings are written to it.", + "summary": "Verify a calendar delegation", + "description": "Tries one real mailbox. Passing makes it `active`, and its hosts start connecting. Google can take up to 24 hours to apply a new authorization, so a refusal may just need time.", "security": [ { "bearerAuth": [] @@ -75380,27 +77333,14 @@ { "schema": { "type": "string", - "description": "The host's id or uid.", - "examples": [ - "host_01kd2m9a0b1c2d3e4f5g6h7j8m" - ] - }, - "required": true, - "description": "The host's id or uid.", - "name": "host", - "in": "path" - }, - { - "schema": { - "type": "string", - "description": "The calendar's id.", + "description": "The delegation's id.", "examples": [ - "cal_01kd2m9a0b1c2d3e4f5g6h7j8m" + "caldlg_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The calendar's id.", - "name": "calendar_id", + "description": "The delegation's id.", + "name": "id", "in": "path" }, { @@ -75412,14 +77352,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateCalendarRequest" + "$ref": "#/components/schemas/VerifyCalendarDelegationRequest" } } } }, "responses": { "200": { - "description": "The host's connections.", + "description": "The delegation.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -75443,7 +77383,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingCalendarConnectionList" + "$ref": "#/components/schemas/SchedulingCalendarDelegation" } } } @@ -75553,6 +77493,27 @@ } } }, + "422": { + "description": "The credentials or mailbox aren't valid (`invalid_calendar_delegation`), or the provider refused them (`calendar_delegation_refused`, and `message` says what to fix).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "429": { "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", "headers": { @@ -75625,14 +77586,14 @@ } } }, - "/v1/scheduling/calendar_delegations": { + "/v1/scheduling/oauth_apps": { "post": { - "operationId": "createSchedulingCalendarDelegation", + "operationId": "createSchedulingOauthApp", "tags": [ "Scheduling calendars" ], - "summary": "Add a calendar delegation", - "description": "An admin authorizes once, and every host on the domain gets their calendar with no consent screen. It stays `pending`: authorize what it returns, then verify it. Credentials are never returned.", + "summary": "Add your own OAuth app", + "description": "New consents in this environment use it, so the consent screen shows your brand. Connections made before it stay on coss.com's app until their host consents again. On the Scale plan and above. Secrets are never returned.", "security": [ { "bearerAuth": [] @@ -75643,14 +77604,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateCalendarDelegationRequest" + "$ref": "#/components/schemas/CreateOauthAppRequest" } } } }, "responses": { "201": { - "description": "The delegation.", + "description": "The app.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -75674,7 +77635,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingCalendarDelegation" + "$ref": "#/components/schemas/SchedulingOauthApp" } } } @@ -75721,6 +77682,27 @@ } } }, + "402": { + "description": "The plan doesn't include this (`plan_feature_unavailable`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -75764,7 +77746,7 @@ } }, "422": { - "description": "The credentials or mailbox aren't valid (`invalid_calendar_delegation`), or the provider refused them (`calendar_delegation_refused`, and `message` says what to fix).", + "description": "The app isn't valid (`invalid_oauth_app`), or the provider refused its client ID and secret (`oauth_app_refused`), and nothing was stored.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -75861,12 +77843,12 @@ ] }, "get": { - "operationId": "listSchedulingCalendarDelegations", + "operationId": "listSchedulingOauthApps", "tags": [ "Scheduling calendars" ], - "summary": "List calendar delegations", - "description": "With how many hosts each has connected.", + "summary": "List your OAuth apps", + "description": "At most one per provider, with how many connections use each.", "security": [ { "bearerAuth": [] @@ -75874,7 +77856,7 @@ ], "responses": { "200": { - "description": "The delegations.", + "description": "The apps.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -75898,7 +77880,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingCalendarDelegationList" + "$ref": "#/components/schemas/SchedulingOauthAppList" } } } @@ -76043,14 +78025,14 @@ ] } }, - "/v1/scheduling/calendar_delegations/{id}": { + "/v1/scheduling/oauth_apps/{id}": { "get": { - "operationId": "getSchedulingCalendarDelegation", + "operationId": "getSchedulingOauthApp", "tags": [ "Scheduling calendars" ], - "summary": "Get a calendar delegation", - "description": "Its status, what to authorize, and how many hosts it has connected.", + "summary": "Get an OAuth app", + "description": "Its status, what to register at the provider, and how many connections use it.", "security": [ { "bearerAuth": [] @@ -76060,13 +78042,13 @@ { "schema": { "type": "string", - "description": "The delegation's id.", + "description": "The OAuth app's id.", "examples": [ - "caldlg_01kd2m9a0b1c2d3e4f5g6h7j8m" + "calapp_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The delegation's id.", + "description": "The OAuth app's id.", "name": "id", "in": "path" }, @@ -76076,7 +78058,7 @@ ], "responses": { "200": { - "description": "The delegation.", + "description": "The app.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -76100,7 +78082,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingCalendarDelegation" + "$ref": "#/components/schemas/SchedulingOauthApp" } } } @@ -76261,12 +78243,12 @@ } }, "patch": { - "operationId": "updateSchedulingCalendarDelegation", + "operationId": "updateSchedulingOauthApp", "tags": [ "Scheduling calendars" ], - "summary": "Update a calendar delegation", - "description": "New credentials are tried on the verified mailbox before they replace the old ones, and end an outage if they work.", + "summary": "Update an OAuth app", + "description": "A new client secret is tried on a connection before it replaces the old one, and ends an outage if it works. Its client ID can't change: a different app needs its hosts to consent again.", "security": [ { "bearerAuth": [] @@ -76276,13 +78258,13 @@ { "schema": { "type": "string", - "description": "The delegation's id.", + "description": "The OAuth app's id.", "examples": [ - "caldlg_01kd2m9a0b1c2d3e4f5g6h7j8m" + "calapp_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The delegation's id.", + "description": "The OAuth app's id.", "name": "id", "in": "path" }, @@ -76295,14 +78277,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateCalendarDelegationRequest" + "$ref": "#/components/schemas/UpdateOauthAppRequest" } } } }, "responses": { "200": { - "description": "The delegation.", + "description": "The app.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -76326,7 +78308,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SchedulingCalendarDelegation" + "$ref": "#/components/schemas/SchedulingOauthApp" } } } @@ -76437,7 +78419,7 @@ } }, "422": { - "description": "The credentials or mailbox aren't valid (`invalid_calendar_delegation`), or the provider refused them (`calendar_delegation_refused`, and `message` says what to fix).", + "description": "The app isn't valid (`invalid_oauth_app`), or the provider refused its client ID and secret (`oauth_app_refused`), and nothing was stored.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -76529,12 +78511,12 @@ } }, "delete": { - "operationId": "deleteSchedulingCalendarDelegation", + "operationId": "deleteSchedulingOauthApp", "tags": [ "Scheduling calendars" ], - "summary": "Delete a calendar delegation", - "description": "Its hosts lose these calendars within minutes. They stay bookable with no conflict check, and events already written stay on their calendars, out of reach of later moves or cancellations.", + "summary": "Delete an OAuth app", + "description": "Refused while connections use it (`oauth_app_in_use`), since only it can refresh their access: disconnect them first. New consents go back to coss.com's app.", "security": [ { "bearerAuth": [] @@ -76544,13 +78526,13 @@ { "schema": { "type": "string", - "description": "The delegation's id.", + "description": "The OAuth app's id.", "examples": [ - "caldlg_01kd2m9a0b1c2d3e4f5g6h7j8m" + "calapp_01kd2m9a0b1c2d3e4f5g6h7j8m" ] }, "required": true, - "description": "The delegation's id.", + "description": "The OAuth app's id.", "name": "id", "in": "path" }, @@ -76560,7 +78542,7 @@ ], "responses": { "200": { - "description": "Being removed.", + "description": "Deleted.", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", @@ -76584,7 +78566,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeletedSchedulingCalendarDelegation" + "$ref": "#/components/schemas/DeletedSchedulingOauthApp" } } } @@ -76673,257 +78655,8 @@ } } }, - "429": { - "description": "Too many requests with this API key (`rate_limited`), or too many requests with invalid keys from this address (`too_many_failed_authentications`). Retry after `Retry-After` seconds. Successful responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the limit is fully restored).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - }, - "Retry-After": { - "description": "Seconds to wait before retrying.", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Unexpected server error (`api_error` / `internal_error`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "503": { - "description": "The API key could not be verified right now; retry (`service_unavailable`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, - "/v1/scheduling/calendar_delegations/{id}/verify": { - "post": { - "operationId": "verifySchedulingCalendarDelegation", - "tags": [ - "Scheduling calendars" - ], - "summary": "Verify a calendar delegation", - "description": "Tries one real mailbox. Passing makes it `active`, and its hosts start connecting. Google can take up to 24 hours to apply a new authorization, so a refusal may just need time.", - "security": [ - { - "bearerAuth": [] - } - ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The delegation's id.", - "examples": [ - "caldlg_01kd2m9a0b1c2d3e4f5g6h7j8m" - ] - }, - "required": true, - "description": "The delegation's id.", - "name": "id", - "in": "path" - }, - { - "$ref": "#/components/parameters/CossVersion" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VerifyCalendarDelegationRequest" - } - } - } - }, - "responses": { - "200": { - "description": "The delegation.", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - }, - "Coss-Version": { - "description": "The API version this response is rendered in.", - "schema": { - "type": "string", - "examples": [ - "2026-10-10" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SchedulingCalendarDelegation" - } - } - } - }, - "400": { - "description": "The body is not valid JSON (`invalid_json`). Also when the `Coss-Version` header names an unknown API version (`invalid_api_version`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "The API key is missing, invalid, revoked or expired (`authentication_error`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Nothing with this id or uid exists in the environment (`not_found`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "The body is not sent as `application/json` (`unsupported_media_type`).", - "headers": { - "X-Request-Id": { - "description": "Unique id of this request. Include it when contacting support.", - "schema": { - "type": "string", - "examples": [ - "req_01k7c8m4q2f3e9v6t5r1n0p8yz" - ] - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "422": { - "description": "The credentials or mailbox aren't valid (`invalid_calendar_delegation`), or the provider refused them (`calendar_delegation_refused`, and `message` says what to fix).", + "description": "Connections still use it (`oauth_app_in_use`).", "headers": { "X-Request-Id": { "description": "Unique id of this request. Include it when contacting support.", diff --git a/apps/api/src/middleware/request-log.ts b/apps/api/src/middleware/request-log.ts index f663dce2e..b6ba08c1c 100644 --- a/apps/api/src/middleware/request-log.ts +++ b/apps/api/src/middleware/request-log.ts @@ -50,10 +50,11 @@ const CONTENT_RESPONSES = ["/v1/received_emails", "/v1/inboxes", "/v1/sms/receiv const FORM_SUBMISSIONS = /^\/v1\/forms\/[^/]+\/submissions(\/|$)/; /** * Scheduling's attendee details and answers: these requests carry them both ways, always. A calendar - * delegation's requests carry a service account's private key or an app's client secret. + * delegation's requests carry a service account's private key or an app's client secret, and so do + * a customer's own OAuth apps. */ const SCHEDULING_PERSONAL = - /^\/v1\/scheduling\/((bookings|holds|series|attendees|booker_sessions|calendar_delegations)(\/|$)|hosts\/[^/]+\/calendar_connections)/; + /^\/v1\/scheduling\/((bookings|holds|series|attendees|booker_sessions|calendar_delegations|oauth_apps)(\/|$)|hosts\/[^/]+\/calendar_connections)/; /** * Responses that are capabilities: preference links change a contact's consent and need * `contact:update`, and Sign's session links (the token is in the URL's fragment too) sign as a diff --git a/apps/api/src/routes/scheduling-callbacks.ts b/apps/api/src/routes/scheduling-callbacks.ts index 87c824936..91c34a0cc 100644 --- a/apps/api/src/routes/scheduling-callbacks.ts +++ b/apps/api/src/routes/scheduling-callbacks.ts @@ -9,10 +9,12 @@ import { completeCalendarAuthorization, handleCalendarPush, handleZoomDeauthorization, + oauthAppCredentials, verifyZoomEvent, + zoomOauthAppForEvents, zoomUrlValidation, } from "@coss/scheduling"; -import { Hono } from "hono"; +import { type Context, Hono } from "hono"; import type { AppEnv } from "../types.ts"; export const SCHEDULING_CALLBACK_PATH = "/scheduling"; @@ -67,9 +69,8 @@ export function createSchedulingCallbackRoutes(opts: { calendars: CalendarDeps } return c.text(PAGE_TEXT[outcome], outcome === "connected" ? 200 : 400); }); - router.post("/zoom/events", async (c) => { - const secret = opts.calendars.apps.zoom?.webhookSecret; - if (!secret) return c.body(null, 404); + // Whichever app sent it, it only ever reaches that app's connections. + const zoomEvent = async (c: Context, secret: string, oauthAppId: string | null) => { const body = await c.req.text(); const verified = verifyZoomEvent({ secret, @@ -87,9 +88,23 @@ export function createSchedulingCallbackRoutes(opts: { calendars: CalendarDeps } return c.json(zoomUrlValidation(event.payload.plainToken, secret)); } if (event.event === "app_deauthorized" && event.payload?.user_id) { - await handleZoomDeauthorization(event.payload.user_id, opts.calendars); + await handleZoomDeauthorization(event.payload.user_id, opts.calendars, oauthAppId); } return c.body(null, 200); + }; + + router.post("/zoom/events", async (c) => { + const secret = opts.calendars.apps.zoom?.webhookSecret; + if (!secret) return c.body(null, 404); + return zoomEvent(c, secret, null); + }); + + // A customer's own Zoom app, with its own Secret Token: never coss.com's, or anyone else's. + router.post("/zoom/events/:appId", async (c) => { + const app = await zoomOauthAppForEvents(c.req.param("appId")); + const secret = app && oauthAppCredentials(app, opts.calendars.secretsKeys).webhookSecret; + if (!app || !secret) return c.body(null, 404); + return zoomEvent(c, secret, app.id); }); router.post("/push/google", async (c) => { diff --git a/apps/api/src/routes/scheduling-client.ts b/apps/api/src/routes/scheduling-client.ts index 944774e53..c1c3cf52e 100644 --- a/apps/api/src/routes/scheduling-client.ts +++ b/apps/api/src/routes/scheduling-client.ts @@ -23,6 +23,7 @@ import { getHost, getLogoRow, getSettingsRow, + hasOauthApp, listConnections, listEventTypeSlots, listRecentHostBookings, @@ -41,6 +42,7 @@ import { updateCalendar, updateHost, updateSchedule, + zoomConnectable, } from "@coss/scheduling"; import { AuthorizeCalendarRequest, @@ -280,11 +282,18 @@ export function createSchedulingClientRoutes(opts: { }); } if (session.kind === "host") { - const host = await getHost(env, session.hostId as string); + const [host, zoomAvailable, ownOauthApp, settings] = await Promise.all([ + getHost(env, session.hostId as string), + deps.calendars ? zoomConnectable(env, deps.calendars) : false, + hasOauthApp(env), + getSettingsRow(env), + ]); return c.json({ ...base, host: { id: host.id, name: host.name, time_zone: host.time_zone }, - zoom_available: Boolean(deps.calendars?.apps.zoom), + zoom_available: zoomAvailable, + own_oauth_app: ownOauthApp, + privacy_url: settings?.privacyUrl ?? null, }); } return c.json(base); diff --git a/apps/api/src/routes/scheduling.ts b/apps/api/src/routes/scheduling.ts index f7c50847b..014c34c5b 100644 --- a/apps/api/src/routes/scheduling.ts +++ b/apps/api/src/routes/scheduling.ts @@ -20,12 +20,14 @@ import { createHost, createHostSession, createManageSession, + createOauthApp, createSchedule, createSeries, declineBooking, deleteCalendarDelegation, deleteHold, deleteHost, + deleteOauthApp, deleteSchedule, disconnectCalendar, eraseAttendee, @@ -35,6 +37,7 @@ import { getCalendarDelegation, getEventType, getHost, + getOauthApp, getSchedule, getSettings, getSettingsRow, @@ -45,6 +48,7 @@ import { listEventTypeSlots, listEventTypes, listHosts, + listOauthApps, listSchedules, markNoShow, previewSeries, @@ -60,6 +64,7 @@ import { updateCalendarDelegation, updateEventType, updateHost, + updateOauthApp, updateSchedule, updateSettings, verifyCalendarDelegation, @@ -86,10 +91,12 @@ import { CreateEventTypeRequest, CreateHoldRequest, CreateHostRequest, + CreateOauthAppRequest, CreateSeriesRequest, DeletedCalendarConnection, DeletedCalendarDelegation, DeletedHost, + DeletedOauthApp, DeletedSchedule, EraseAttendeeByEmailRequest, ErasedAttendee, @@ -101,6 +108,8 @@ import { HostObject, ListBookingEventsQuery, ListBookingsQuery, + OauthAppList, + OauthAppObject, PageQuery, RescheduleBookingRequest, ScheduleList, @@ -117,6 +126,7 @@ import { UpdateCalendarRequest, UpdateEventTypeRequest, UpdateHostRequest, + UpdateOauthAppRequest, UpdateSettingsRequest, UpsertScheduleRequest, VerifyCalendarDelegationRequest, @@ -183,6 +193,9 @@ const CalendarParams = HostParams.extend({ const DelegationParams = z.object({ id: param("The delegation's id.", "caldlg_01kd2m9a0b1c2d3e4f5g6h7j8m"), }); +const OauthAppParams = z.object({ + id: param("The OAuth app's id.", "calapp_01kd2m9a0b1c2d3e4f5g6h7j8m"), +}); const AttendeeParams = z.object({ uid: param("Your id for the attendee.", "patient_77") }); const bodyErrors = { @@ -1197,6 +1210,117 @@ export const deleteCalendarDelegationRoute = createRoute({ }, }); +const refusedOauthApp = errorDoc( + "The app isn't valid (`invalid_oauth_app`), or the provider refused its client ID and secret " + + "(`oauth_app_refused`), and nothing was stored.", +); + +export const createOauthAppRoute = createRoute({ + method: "post", + path: "/scheduling/oauth_apps", + operationId: "createSchedulingOauthApp", + tags: [TAG_CALENDARS], + summary: "Add your own OAuth app", + description: + "New consents in this environment use it, so the consent screen shows your brand. Connections " + + "made before it stay on coss.com's app until their host consents again. On the Scale plan and " + + "above. Secrets are never returned.", + security: [{ bearerAuth: [] }], + middleware: writing("update", "POST /v1/scheduling/oauth_apps"), + request: { + body: { + required: true, + content: { "application/json": { schema: CreateOauthAppRequest } }, + }, + }, + responses: { + 201: jsonDoc("The app.", OauthAppObject), + ...commonErrors, + ...bodyErrors, + 402: errorDoc("The plan doesn't include this (`plan_feature_unavailable`)."), + 422: refusedOauthApp, + }, +}); + +export const listOauthAppsRoute = createRoute({ + method: "get", + path: "/scheduling/oauth_apps", + operationId: "listSchedulingOauthApps", + tags: [TAG_CALENDARS], + summary: "List your OAuth apps", + description: "At most one per provider, with how many connections use each.", + security: [{ bearerAuth: [] }], + middleware: read(), + responses: { + 200: jsonDoc("The apps.", OauthAppList), + ...commonErrors, + }, +}); + +export const getOauthAppRoute = createRoute({ + method: "get", + path: "/scheduling/oauth_apps/{id}", + operationId: "getSchedulingOauthApp", + tags: [TAG_CALENDARS], + summary: "Get an OAuth app", + description: "Its status, what to register at the provider, and how many connections use it.", + security: [{ bearerAuth: [] }], + middleware: read(), + request: { params: OauthAppParams }, + responses: { + 200: jsonDoc("The app.", OauthAppObject), + ...commonErrors, + 404: notFound, + }, +}); + +export const updateOauthAppRoute = createRoute({ + method: "patch", + path: "/scheduling/oauth_apps/{id}", + operationId: "updateSchedulingOauthApp", + tags: [TAG_CALENDARS], + summary: "Update an OAuth app", + description: + "A new client secret is tried on a connection before it replaces the old one, and ends an outage " + + "if it works. Its client ID can't change: a different app needs its hosts to consent again.", + security: [{ bearerAuth: [] }], + middleware: writing("update", "PATCH /v1/scheduling/oauth_apps/{id}"), + request: { + params: OauthAppParams, + body: { + required: true, + content: { "application/json": { schema: UpdateOauthAppRequest } }, + }, + }, + responses: { + 200: jsonDoc("The app.", OauthAppObject), + ...commonErrors, + ...bodyErrors, + 404: notFound, + 422: refusedOauthApp, + }, +}); + +export const deleteOauthAppRoute = createRoute({ + method: "delete", + path: "/scheduling/oauth_apps/{id}", + operationId: "deleteSchedulingOauthApp", + tags: [TAG_CALENDARS], + summary: "Delete an OAuth app", + description: + "Refused while connections use it (`oauth_app_in_use`), since only it can refresh their access: " + + "disconnect them first. New consents go back to coss.com's app.", + security: [{ bearerAuth: [] }], + middleware: acting("update"), + request: { params: OauthAppParams }, + responses: { + 200: jsonDoc("Deleted.", DeletedOauthApp), + ...commonErrors, + 404: notFound, + 422: errorDoc("Connections still use it (`oauth_app_in_use`)."), + }, +}); + export const setSimulatedBusyRoute = createRoute({ method: "put", path: "/scheduling/hosts/{host}/simulated_busy", @@ -2017,6 +2141,50 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA ); }); + router.openapi(createOauthAppRoute, async (c) => { + const app = await createOauthApp(c.var.auth, c.req.valid("json"), { + ...calendars(), + plans: c.var.plans ?? undefined, + }); + await recordApiAudit(c, { + action: "scheduling_oauth_app.created", + target: { type: "scheduling_oauth_app", id: app.id, label: app.provider }, + }); + return c.json(app, 201); + }); + + router.openapi(listOauthAppsRoute, async (c) => { + const data = await listOauthApps(c.var.auth, calendars()); + return c.json({ object: "list" as const, data, has_more: false }, 200); + }); + + router.openapi(getOauthAppRoute, async (c) => + c.json(await getOauthApp(c.var.auth, c.req.valid("param").id, calendars()), 200), + ); + + router.openapi(updateOauthAppRoute, async (c) => { + const app = await updateOauthApp( + c.var.auth, + c.req.valid("param").id, + c.req.valid("json"), + calendars(), + ); + await recordApiAudit(c, { + action: "scheduling_oauth_app.updated", + target: { type: "scheduling_oauth_app", id: app.id, label: app.provider }, + }); + return c.json(app, 200); + }); + + router.openapi(deleteOauthAppRoute, async (c) => { + const { id } = await deleteOauthApp(c.var.auth, c.req.valid("param").id); + await recordApiAudit(c, { + action: "scheduling_oauth_app.deleted", + target: { type: "scheduling_oauth_app", id }, + }); + return c.json({ object: "scheduling.oauth_app" as const, id, deleted: true as const }, 200); + }); + router.openapi(setSimulatedBusyRoute, async (c) => { await setSimulatedBusy( c.var.auth, diff --git a/apps/api/src/webhook-events.ts b/apps/api/src/webhook-events.ts index e12592bcd..2e0f3b5a3 100644 --- a/apps/api/src/webhook-events.ts +++ b/apps/api/src/webhook-events.ts @@ -14,6 +14,7 @@ import { SchedulingBookingWebhookEvent, SchedulingCalendarDelegationWebhookEvent, SchedulingCalendarWebhookEvent, + SchedulingOauthAppWebhookEvent, } from "@coss/scheduling/schemas"; import { SignRequestWebhookEvent } from "@coss/sign/schemas"; import { ReceivedSmsWebhookEvent, SmsWebhookEvent } from "@coss/sms/schemas"; @@ -43,6 +44,7 @@ const WEBHOOK_EVENTS = { SchedulingBookingWebhookEvent, SchedulingCalendarDelegationWebhookEvent, SchedulingCalendarWebhookEvent, + SchedulingOauthAppWebhookEvent, }; export function registerWebhookEvents(app: OpenAPIHono): void { diff --git a/apps/api/test/agent-surface-exemptions.ts b/apps/api/test/agent-surface-exemptions.ts index 5d6a5604e..60b7b8518 100644 --- a/apps/api/test/agent-surface-exemptions.ts +++ b/apps/api/test/agent-surface-exemptions.ts @@ -20,6 +20,9 @@ const SECRET = "A signing secret is shown once, when it's created: never handed const DELEGATION = "Takes a Workspace or tenant's credentials, reaching every mailbox on a domain: set up by its admin, not an agent."; +const OAUTH_APP = + "Takes an OAuth app's client secret, set up by whoever owns that app at the provider, not an agent."; + export const AGENT_SURFACE_EXEMPTIONS: Readonly> = { "POST /v1/emails/batch": SENDS, "GET /v1/emails/batch/{id}": READ, @@ -73,6 +76,11 @@ export const AGENT_SURFACE_EXEMPTIONS: Readonly> = { "PATCH /v1/scheduling/calendar_delegations/{id}": DELEGATION, "POST /v1/scheduling/calendar_delegations/{id}/verify": DELEGATION, "DELETE /v1/scheduling/calendar_delegations/{id}": REMOVES, + "POST /v1/scheduling/oauth_apps": OAUTH_APP, + "GET /v1/scheduling/oauth_apps": READ, + "GET /v1/scheduling/oauth_apps/{id}": READ, + "PATCH /v1/scheduling/oauth_apps/{id}": OAUTH_APP, + "DELETE /v1/scheduling/oauth_apps/{id}": REMOVES, "POST /v1/scheduling/hosts/{host}/calendar_connections": CHANGE, "DELETE /v1/scheduling/hosts/{host}/calendar_connections/{id}": REMOVES, "PATCH /v1/scheduling/hosts/{host}/calendars/{calendar_id}": CHANGE, diff --git a/apps/api/test/app.test.ts b/apps/api/test/app.test.ts index c123b991c..3d6c26504 100644 --- a/apps/api/test/app.test.ts +++ b/apps/api/test/app.test.ts @@ -312,6 +312,8 @@ describe("GET /openapi.json", () => { "/v1/scheduling/hosts/{host}/schedules/{schedule_id}", "/v1/scheduling/hosts/{host}/sessions", "/v1/scheduling/hosts/{host}/simulated_busy", + "/v1/scheduling/oauth_apps", + "/v1/scheduling/oauth_apps/{id}", "/v1/scheduling/series", "/v1/scheduling/series/preview", "/v1/scheduling/series/{id}/cancel", diff --git a/apps/api/test/contract-types.ts b/apps/api/test/contract-types.ts index 9022499a1..713d51004 100644 --- a/apps/api/test/contract-types.ts +++ b/apps/api/test/contract-types.ts @@ -230,6 +230,7 @@ import type { CreateEventTypeRequest, CreateHoldRequest, CreateHostRequest, + CreateOauthAppRequest, CreateSeriesRequest, EraseAttendeeByEmailRequest, ErasedAttendee, @@ -237,11 +238,13 @@ import type { EventTypeObject, ExplainSlotQuery, HostObject, + OauthAppObject, RescheduleBookingRequest, ScheduleObject, SchedulingBookingWebhookEvent, SchedulingCalendarDelegationWebhookEvent, SchedulingCalendarWebhookEvent, + SchedulingOauthAppWebhookEvent, SeriesPreview, SeriesResult, SetSimulatedBusyRequest, @@ -254,6 +257,7 @@ import type { UpdateCalendarRequest, UpdateEventTypeRequest, UpdateHostRequest, + UpdateOauthAppRequest, UpdateSettingsRequest, UpsertScheduleRequest, VerifyCalendarDelegationRequest, @@ -319,6 +323,7 @@ import type { CreateSchedulingEventTypeParams, CreateSchedulingHoldParams, CreateSchedulingHostParams, + CreateSchedulingOauthAppParams, CreateSchedulingSeriesParams, CreateSegmentParams, CreateSubaccountParams, @@ -382,6 +387,7 @@ import type { SchedulingCancelledSeries, SchedulingEventType, SchedulingHost, + SchedulingOauthApp, SchedulingSchedule, SchedulingSeries, SchedulingSeriesPreview, @@ -517,6 +523,7 @@ import type { UpdateSchedulingCalendarParams, UpdateSchedulingEventTypeParams, UpdateSchedulingHostParams, + UpdateSchedulingOauthAppParams, UpdateSchedulingSettingsParams, UpdateSegmentParams, UpdateStorageSettingsParams, @@ -778,7 +785,8 @@ export type Contract = [ | z.output | z.output | z.output - | z.output, + | z.output + | z.output, WebhookEvent > >, @@ -1179,6 +1187,9 @@ export type SubaccountContract = [ Assert< MutuallyAssignable, SchedulingCalendarDelegation> >, + Assert, CreateSchedulingOauthAppParams>>, + Assert, UpdateSchedulingOauthAppParams>>, + Assert, SchedulingOauthApp>>, Assert< MutuallyAssignable, SetSchedulingSimulatedBusyParams> >, diff --git a/apps/api/test/scheduling-oauth-apps.test.ts b/apps/api/test/scheduling-oauth-apps.test.ts new file mode 100644 index 000000000..1945aba7c --- /dev/null +++ b/apps/api/test/scheduling-oauth-apps.test.ts @@ -0,0 +1,415 @@ +import { describe, expect, test } from "bun:test"; +import { createHash, createHmac, randomUUID } from "node:crypto"; +import { createPlanGate } from "@coss/billing"; +import { createTestContract } from "@coss/billing/testing"; +import { + type CalendarDeps, + completeCalendarAuthorization, + createHost, + listConnections, + type SchedulingEnvironment, + startCalendarAuthorization, +} from "@coss/scheduling"; +import { FakeGoogleCalendarApi, FakeZoomApi, testSchedulingDeps } from "@coss/scheduling/testing"; +import { createTestTenant } from "@coss/tenancy/testing"; +import { secretsKeyRing } from "@coss/webhooks"; +import { createTestApp, errorOf } from "./helpers.ts"; + +const keys = secretsKeyRing([createHash("sha256").update("api oauth apps").digest()]); + +function calendars(fetcher: NonNullable): CalendarDeps { + return { + secretsKeys: keys, + apps: { + google: { clientId: "coss-google", clientSecret: "coss-secret" }, + microsoft: null, + zoom: { clientId: "coss-zoom", clientSecret: "coss-zs", webhookSecret: "coss-token" }, + }, + publicUrl: "https://api.example.test", + fetcher, + }; +} + +async function appWith( + fetcher: NonNullable, + options: { plans?: ReturnType; deps?: CalendarDeps } = {}, +) { + const tenant = await createTestTenant(); + const deps = options.deps ?? calendars(fetcher); + const { app } = createTestApp({ + scheduling: testSchedulingDeps({ calendars: deps }), + ...(options.plans ? { plans: options.plans } : {}), + }); + const headers = { + authorization: `Bearer ${tenant.liveKey}`, + "content-type": "application/json", + }; + const env: SchedulingEnvironment = { + organizationId: tenant.organization.id, + projectId: tenant.project.id, + environmentId: tenant.production.id, + mode: "live", + }; + return { app, tenant, deps, headers, env }; +} + +const googleApp = JSON.stringify({ + provider: "google", + client_id: "acme-google", + client_secret: "acme-secret", + redirect_uri: "https://acme.example/oauth/google", +}); + +const signed = (secret: string, body: string) => { + const timestamp = String(Math.floor(Date.now() / 1000)); + const hash = createHmac("sha256", secret).update(`v0:${timestamp}:${body}`).digest("hex"); + return { + "x-zm-request-timestamp": timestamp, + "x-zm-signature": `v0=${hash}`, + "content-type": "application/json", + }; +}; + +describe("/v1/scheduling/oauth_apps", () => { + test("adding, reading, updating and deleting, and a secret never comes back", async () => { + const api = new FakeGoogleCalendarApi(); + const { app, headers } = await appWith(api.fetch); + const created = await app.request("/v1/scheduling/oauth_apps", { + method: "POST", + headers, + body: googleApp, + }); + expect(created.status).toBe(201); + const body = (await created.json()) as Record; + expect(JSON.stringify(body)).not.toContain("acme-secret"); + expect(body).toMatchObject({ + object: "scheduling.oauth_app", + provider: "google", + redirect_uri: "https://acme.example/oauth/google", + coss_callback_url: "https://api.example.test/scheduling/oauth/google/callback", + status: "active", + zoom_event_url: null, + }); + + const listed = (await (await app.request("/v1/scheduling/oauth_apps", { headers })).json()) as { + data: { id: string }[]; + }; + expect(listed.data.map((row) => row.id)).toEqual([body.id as string]); + + const patched = await app.request(`/v1/scheduling/oauth_apps/${body.id}`, { + method: "PATCH", + headers, + body: JSON.stringify({ completion_url: "https://acme.example/done" }), + }); + expect(((await patched.json()) as { completion_url: string }).completion_url).toBe( + "https://acme.example/done", + ); + + const removed = await app.request(`/v1/scheduling/oauth_apps/${body.id}`, { + method: "DELETE", + headers, + }); + expect(await removed.json()).toEqual({ + object: "scheduling.oauth_app", + id: body.id, + deleted: true, + }); + }); + + test("a wrong secret is a 422, and an address that isn't https too", async () => { + const api = new FakeGoogleCalendarApi(); + api.refusedSecret = "acme-secret"; + const { app, headers } = await appWith(api.fetch); + const refused = await app.request("/v1/scheduling/oauth_apps", { + method: "POST", + headers, + body: googleApp, + }); + expect(refused.status).toBe(422); + expect((await errorOf(refused)).code).toBe("oauth_app_refused"); + + api.refusedSecret = null; + const insecure = await app.request("/v1/scheduling/oauth_apps", { + method: "POST", + headers, + body: JSON.stringify({ + provider: "google", + client_id: "c", + client_secret: "s", + redirect_uri: "http://acme.example/oauth", + }), + }); + expect(insecure.status).toBe(422); + expect((await errorOf(insecure)).code).toBe("invalid_oauth_app"); + }); + + test("a tenant that isn't one can't reach the token URL", async () => { + const urls: string[] = []; + const { app, headers } = await appWith(async (url) => { + urls.push(url); + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + }); + const res = await app.request("/v1/scheduling/oauth_apps", { + method: "POST", + headers, + body: JSON.stringify({ + provider: "microsoft", + client_id: "c", + client_secret: "s", + tenant_id: "evil.example/../../other?x=", + }), + }); + expect(res.status).toBe(422); + expect(urls).toHaveLength(0); + }); + + test("adding one needs the Scale plan, in a sandbox too", async () => { + const api = new FakeGoogleCalendarApi(); + const { app, headers, tenant } = await appWith(api.fetch, { plans: createPlanGate() }); + const create = (key: string) => + app.request("/v1/scheduling/oauth_apps", { + method: "POST", + headers: { ...headers, authorization: `Bearer ${key}` }, + body: googleApp, + }); + for (const key of [tenant.liveKey, tenant.sandboxKey]) { + const refused = await create(key); + expect(refused.status).toBe(402); + const error = await errorOf(refused); + expect(error.code).toBe("plan_feature_unavailable"); + expect(error.message).toContain("Scale"); + } + expect((await app.request("/v1/scheduling/oauth_apps", { headers })).status).toBe(200); + + await createTestContract( + tenant.organization.id, + { version: 1, plan: "scale" }, + { effectiveFrom: new Date("2026-01-01T00:00:00Z") }, + ); + expect((await create(tenant.liveKey)).status).toBe(201); + }); + + test("a sandbox key can't see a live environment's app", async () => { + const api = new FakeGoogleCalendarApi(); + const { app, headers, tenant } = await appWith(api.fetch); + const created = (await ( + await app.request("/v1/scheduling/oauth_apps", { method: "POST", headers, body: googleApp }) + ).json()) as { id: string }; + const res = await app.request(`/v1/scheduling/oauth_apps/${created.id}`, { + headers: { authorization: `Bearer ${tenant.sandboxKey}` }, + }); + expect(res.status).toBe(404); + }); +}); + +describe("the forwarded callback", () => { + test("a consent comes back through coss.com's callback and lands on the completion URL", async () => { + const api = new FakeGoogleCalendarApi(); + const { app, headers, deps, env } = await appWith(api.fetch); + await app.request("/v1/scheduling/oauth_apps", { + method: "POST", + headers, + body: JSON.stringify({ + provider: "google", + client_id: "acme-google", + client_secret: "acme-secret", + redirect_uri: "https://acme.example/oauth/google", + completion_url: "https://acme.example/connected", + }), + }); + const host = await createHost(env, { name: "Dr. Reyes", time_zone: "UTC" }); + const { url } = await startCalendarAuthorization( + env, + host.id, + { provider: "google", returnUrl: null }, + deps, + ); + const state = new URL(url).searchParams.get("state") as string; + // What the customer's forwarding page does: the same query string, on to coss.com's callback. + const res = await app.request( + `/scheduling/oauth/google/callback?code=code&state=${encodeURIComponent(state)}`, + ); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe( + "https://acme.example/connected?coss_calendar=connected", + ); + expect(api.tokenRequests.at(-1)?.redirectUri).toBe("https://acme.example/oauth/google"); + }); +}); + +describe("a customer's Zoom events", () => { + async function connected() { + const api = new FakeZoomApi(); + // The test database is shared, and coss.com's own events reach any organization's connections. + api.userId = `zoom_${randomUUID()}`; + const fixture = await appWith(api.fetch); + const created = (await ( + await fixture.app.request("/v1/scheduling/oauth_apps", { + method: "POST", + headers: fixture.headers, + body: JSON.stringify({ + provider: "zoom", + client_id: "acme-zoom", + client_secret: "acme-zs", + webhook_secret: "acme-token", + }), + }) + ).json()) as { id: string; zoom_event_url: string }; + const host = await createHost(fixture.env, { name: "Dr. Reyes", time_zone: "UTC" }); + const { url } = await startCalendarAuthorization( + fixture.env, + host.id, + { provider: "zoom", returnUrl: null }, + fixture.deps, + ); + await completeCalendarAuthorization( + { + provider: "zoom", + state: new URL(url).searchParams.get("state") as string, + code: "code", + error: null, + }, + fixture.deps, + ); + return { ...fixture, api, host, created }; + } + + const deauthorized = (userId: string) => + JSON.stringify({ event: "app_deauthorized", payload: { user_id: userId } }); + + test("its own URL is on the wire, and its own token signs events there", async () => { + const { app, api, host, env, created } = await connected(); + expect(created.zoom_event_url).toBe( + `https://api.example.test/scheduling/zoom/events/${created.id}`, + ); + const body = deauthorized(api.userId); + const res = await app.request(`/scheduling/zoom/events/${created.id}`, { + method: "POST", + headers: signed("acme-token", body), + body, + }); + expect(res.status).toBe(200); + expect(await listConnections(env, host.id)).toHaveLength(0); + }); + + test("Zoom's URL validation answers with the app's own token", async () => { + const { app, created } = await connected(); + const body = JSON.stringify({ + event: "endpoint.url_validation", + payload: { plainToken: "plain" }, + }); + const res = await app.request(`/scheduling/zoom/events/${created.id}`, { + method: "POST", + headers: signed("acme-token", body), + body, + }); + expect(await res.json()).toEqual({ + plainToken: "plain", + encryptedToken: createHmac("sha256", "acme-token").update("plain").digest("hex"), + }); + }); + + test("another token's signature changes nothing, coss.com's included", async () => { + const { app, api, host, env, created } = await connected(); + const body = deauthorized(api.userId); + for (const secret of ["coss-token", "someone-elses"]) { + const res = await app.request(`/scheduling/zoom/events/${created.id}`, { + method: "POST", + headers: signed(secret, body), + body, + }); + expect(res.status).toBe(401); + } + expect(await listConnections(env, host.id)).toHaveLength(1); + }); + + test("coss.com's own events can't reach a connection through a customer's app", async () => { + const { app, api, host, env } = await connected(); + const body = deauthorized(api.userId); + const res = await app.request("/scheduling/zoom/events", { + method: "POST", + headers: signed("coss-token", body), + body, + }); + expect(res.status).toBe(200); + expect(await listConnections(env, host.id)).toHaveLength(1); + }); + + test("an app that doesn't exist, or has no token, is a 404", async () => { + const api = new FakeZoomApi(); + const { app, headers } = await appWith(api.fetch); + const noToken = (await ( + await app.request("/v1/scheduling/oauth_apps", { + method: "POST", + headers, + body: JSON.stringify({ provider: "zoom", client_id: "c", client_secret: "s" }), + }) + ).json()) as { id: string }; + const body = deauthorized(api.userId); + for (const id of [noToken.id, "calapp_nope", "not-an-id"]) { + const res = await app.request(`/scheduling/zoom/events/${id}`, { + method: "POST", + headers: signed("anything", body), + body, + }); + expect(res.status).toBe(404); + } + }); +}); + +describe("the hosted page's session", () => { + test("says when the customer speaks for themselves, and offers Zoom through their own app", async () => { + const api = new FakeZoomApi(); + const { app, headers, env } = await appWith(api.fetch, { + deps: { ...calendars(api.fetch), apps: { google: null, microsoft: null } }, + }); + const host = await createHost(env, { name: "Dr. Reyes", time_zone: "UTC" }); + const read = async () => { + const session = (await ( + await app.request(`/v1/scheduling/hosts/${host.id}/sessions`, { + method: "POST", + headers, + body: "{}", + }) + ).json()) as { token: string }; + return (await ( + await app.request("/scheduling/client/session", { + headers: { authorization: `Bearer ${session.token}` }, + }) + ).json()) as { zoom_available: boolean; own_oauth_app: boolean; privacy_url: string | null }; + }; + expect(await read()).toMatchObject({ + zoom_available: false, + own_oauth_app: false, + privacy_url: null, + }); + await app.request("/v1/scheduling/oauth_apps", { + method: "POST", + headers, + body: JSON.stringify({ provider: "zoom", client_id: "c", client_secret: "s" }), + }); + await app.request("/v1/scheduling/settings", { + method: "PATCH", + headers, + body: JSON.stringify({ privacy_url: "https://acme.example/privacy" }), + }); + expect(await read()).toMatchObject({ + zoom_available: true, + own_oauth_app: true, + privacy_url: "https://acme.example/privacy", + }); + }); + + test("a privacy URL has to be https, since it becomes a link", async () => { + const api = new FakeGoogleCalendarApi(); + const { app, headers } = await appWith(api.fetch); + for (const privacy_url of ["javascript:alert(1)", "http://acme.example/privacy"]) { + const res = await app.request("/v1/scheduling/settings", { + method: "PATCH", + headers, + body: JSON.stringify({ privacy_url }), + }); + expect(res.status).toBe(422); + } + }); +}); diff --git a/apps/api/test/sdk-contract.test.ts b/apps/api/test/sdk-contract.test.ts index 911d125b2..7547663fc 100644 --- a/apps/api/test/sdk-contract.test.ts +++ b/apps/api/test/sdk-contract.test.ts @@ -4,6 +4,7 @@ * contract-types.ts (compile time). */ import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { createCreditGrant, runDueBillingPeriods } from "@coss/billing"; import { createTestContract, recordTestUsage } from "@coss/billing/testing"; import { db } from "@coss/db"; @@ -25,12 +26,14 @@ import { createLogWriter } from "@coss/logs"; import { cachedOverrides, setRateLimitOverride } from "@coss/ratelimit"; import { memoryLimiter } from "@coss/ratelimit/testing"; import { suspendOrganization } from "@coss/reputation"; +import { FakeGoogleCalendarApi, testSchedulingDeps } from "@coss/scheduling/testing"; import { createTestTenant } from "@coss/tenancy/testing"; import { API_VERSIONS, LATEST_API_VERSION } from "@coss/versioning"; import { enqueueWebhookMessage, runDueLogDrainDeliveries, runDueWebhookDeliveries, + secretsKeyRing, } from "@coss/webhooks"; import { TEST_SECRETS_KEYS, testSender, webhookReceiver } from "@coss/webhooks/testing"; import { @@ -1967,6 +1970,41 @@ describe("coss.risk checkouts, devices and calibration against the API", () => { }); describe("coss.scheduling against the API", () => { + test("your own OAuth apps", async () => { + const tenant = await createTestTenant(); + const { app } = createTestApp({ + scheduling: testSchedulingDeps({ + calendars: { + secretsKeys: secretsKeyRing([createHash("sha256").update("sdk oauth apps").digest()]), + apps: { google: null, microsoft: null }, + publicUrl: "https://api.example.test", + fetcher: new FakeGoogleCalendarApi().fetch, + }, + }), + }); + const { scheduling } = clientFor(app, tenant.sandboxKey); + + const oauthApp = await scheduling.oauthApps.create({ + provider: "google", + client_id: "acme-google", + client_secret: "acme-secret", + redirect_uri: "https://example.com/oauth/google", + }); + expect(oauthApp.error).toBeNull(); + expect(oauthApp.data?.redirect_uri).toBe("https://example.com/oauth/google"); + const oauthApps = await scheduling.oauthApps.list(); + expect(oauthApps.data?.data.map((app) => app.id)).toEqual([oauthApp.data?.id as string]); + const updatedApp = await scheduling.oauthApps.update(oauthApp.data?.id as string, { + completion_url: "https://example.com/connected", + }); + expect(updatedApp.data?.completion_url).toBe("https://example.com/connected"); + expect((await scheduling.oauthApps.get(oauthApp.data?.id as string)).data?.status).toBe( + "active", + ); + const removedApp = await scheduling.oauthApps.delete(oauthApp.data?.id as string); + expect(removedApp.data?.deleted).toBe(true); + }); + test("host, schedule, event type, slots, a booking's life, a hold, a session", async () => { const tenant = await createTestTenant(); const { app } = createTestApp(); diff --git a/apps/scheduling/src/app/h/[sessionId]/calendars.tsx b/apps/scheduling/src/app/h/[sessionId]/calendars.tsx index 51472b9aa..838220c50 100644 --- a/apps/scheduling/src/app/h/[sessionId]/calendars.tsx +++ b/apps/scheduling/src/app/h/[sessionId]/calendars.tsx @@ -145,6 +145,10 @@ function ConnectionCard(props: { export function Calendars(props: { call: ClientCall; session: HostSession }) { const { call, session } = props; + // A customer with their own app and name speaks for themselves here, as the consent screen does. + const customerSpeaks = session.own_oauth_app && Boolean(session.brand_name); + const consentName = customerSpeaks ? session.brand_name : "coss.com"; + const consentPrivacy = customerSpeaks ? session.privacy_url : "https://coss.com/legal/privacy"; const [connections, setConnections] = useState(null); const [problem, setProblem] = useState(null); const [caldavOpen, setCaldavOpen] = useState(false); @@ -381,18 +385,23 @@ export function Calendars(props: { call: ClientCall; session: HostSession }) { ) : null}

- Connecting a calendar lets coss.com see when you're busy and which calendars you have, - and add, change and remove the events for your bookings. Your other events only count as - busy times: coss.com never keeps their titles or details, and you can disconnect at any - time.{" "} - - Privacy Policy - + Connecting a calendar lets {consentName} see when you're busy and which calendars you + have, and add, change and remove the events for your bookings. Your other events only + count as busy times: {consentName} never keeps their titles or details, and you can + disconnect at any time. + {consentPrivacy ? ( + <> + {" "} + + Privacy Policy + + + ) : null}

)} diff --git a/apps/scheduling/src/app/h/[sessionId]/types.ts b/apps/scheduling/src/app/h/[sessionId]/types.ts index 18b743a29..95b6e1865 100644 --- a/apps/scheduling/src/app/h/[sessionId]/types.ts +++ b/apps/scheduling/src/app/h/[sessionId]/types.ts @@ -5,4 +5,7 @@ export type HostSession = Branding & { sandbox: boolean; host: { id: string; name: string; time_zone: string }; zoom_available: boolean; + /** This environment has its own OAuth app, so what's said before a consent is the customer's. */ + own_oauth_app: boolean; + privacy_url: string | null; }; diff --git a/apps/site/content/docs/scheduling/calendars.mdx b/apps/site/content/docs/scheduling/calendars.mdx index d2da6fa15..b56854722 100644 --- a/apps/site/content/docs/scheduling/calendars.mdx +++ b/apps/site/content/docs/scheduling/calendars.mdx @@ -4,7 +4,8 @@ description: Keep hosts from being booked over something else, and put bookings --- With hundreds of hosts on Google Workspace or Microsoft 365, an admin can authorize a whole domain once -instead: see [calendar delegation](/docs/scheduling/delegation). +instead: see [calendar delegation](/docs/scheduling/delegation). To show your own brand on the consent screen, see +[your own OAuth apps](/docs/scheduling/oauth-apps). Otherwise, a host connects their calendars on [their own page](/docs/scheduling/embedding), or through your UI. For Google and Microsoft, ask for a consent URL and open it in a new window, because providers refuse diff --git a/apps/site/content/docs/scheduling/meta.json b/apps/site/content/docs/scheduling/meta.json index f987eab2f..d9edc02d6 100644 --- a/apps/site/content/docs/scheduling/meta.json +++ b/apps/site/content/docs/scheduling/meta.json @@ -8,6 +8,7 @@ "hosts", "calendars", "delegation", + "oauth-apps", "zoom", "bookings", "pools-and-groups", diff --git a/apps/site/content/docs/scheduling/oauth-apps.mdx b/apps/site/content/docs/scheduling/oauth-apps.mdx new file mode 100644 index 000000000..6ce3cc274 --- /dev/null +++ b/apps/site/content/docs/scheduling/oauth-apps.mdx @@ -0,0 +1,182 @@ +--- +title: Your own OAuth apps +description: Connect hosts' calendars through your own Google, Microsoft or Zoom app, so the consent screen shows your brand. +--- + + + +When a host [connects a calendar](/docs/scheduling/calendars), Google, Microsoft or Zoom shows a consent screen +naming the app that's asking. By default that's coss.com's. With your own app, it's your name and logo. + +You register the app with the provider, and give coss.com its client ID and secret. Each environment has its own +apps, one per provider. You own the provider relationship: their review of your app, and what your privacy policy +tells people. Coss.com requests the same access as it does through its own app, and +[no more](/docs/scheduling/calendars#what-cosscom-reads-and-writes). + +With hundreds of hosts on one Google Workspace or Microsoft 365 domain, you may not need a consent screen at all: +see [calendar delegation](/docs/scheduling/delegation). + +## How connections move over + +- **New consents** in the environment use your app as soon as you add it. +- **Connected hosts stay on coss.com's app** until they connect again, because tokens only work with the app that + issued them. Nothing moves by itself, and nobody is disconnected. A calendar connection's `oauth_app` says which it + uses. +- If your app stops working, coss.com never falls back to its own. + +## Google + +1. In a Google Cloud project, enable the Google Calendar API, and create an OAuth client of type **Web application**. +2. Add these scopes to your consent screen, exactly as given: + - `https://www.googleapis.com/auth/calendar.events.freebusy` + - `https://www.googleapis.com/auth/calendar.events.owned` + - `https://www.googleapis.com/auth/calendar.calendarlist.readonly` +3. Add a page on your own domain as the client's authorized redirect URI, and [have it forward](#forwarding-page). +4. Add the app: + + ```ts twoslash + import { Coss } from "coss"; + const coss = new Coss(); + const clientSecret = ""; + // ---cut--- + const { data: app } = await coss.scheduling.oauthApps.create({ + provider: "google", + client_id: "1234567890-abc.apps.googleusercontent.com", + client_secret: clientSecret, + redirect_uri: "https://example.com/oauth/google", + completion_url: "https://example.com/calendar-connected", + }); + // app?.coss_callback_url is where your page forwards to. + ``` + + + An external Google app needs its own [brand verification](https://support.google.com/cloud/answer/13464321). Google + requires you to own every domain on the app, redirect URIs included, which is why it needs a page on your domain: you + can't verify `api.coss.com`. Until it's verified, Google shows a warning and limits it to 100 users. An **Internal** + app, only for your own Workspace, skips this. + + +Your privacy policy has to say how you use Google user data, and that coss.com handles it for you. Check it meets +Google's [API Services User Data Policy](https://developers.google.com/terms/api-services-user-data-policy). + +## Microsoft + +1. In [Microsoft Entra](https://entra.microsoft.com), register an application. For hosts outside your own tenant, make + it multi-tenant. +2. Add the delegated Microsoft Graph permissions `Calendars.ReadWrite`, `offline_access` and `User.Read`. +3. Under **Authentication**, add a **Web** redirect URI. Use `coss_callback_url` from step 5, or + [your own page](#forwarding-page). +4. Under **Certificates & secrets**, create a client secret and note when it expires. +5. Add the app: + + ```ts twoslash + import { Coss } from "coss"; + const coss = new Coss(); + const clientSecret = ""; + // ---cut--- + await coss.scheduling.oauthApps.create({ + provider: "microsoft", + client_id: "3f1c7a52-8e0b-4d6e-9a27-5c4b1e7d2f60", + client_secret: clientSecret, + secret_expires_at: "2028-01-01T00:00:00Z", + }); + ``` + +For an app that's only for your own tenant, also pass `tenant_id`: Microsoft refuses a single-tenant app otherwise. + +## Zoom + +1. In the [Zoom App Marketplace](https://marketplace.zoom.us/develop), create a general app that users manage. +2. Set the OAuth redirect URL to `coss_callback_url` from step 4, or [your own page](#forwarding-page). +3. Add these scopes to the app, since Zoom doesn't take them in the request: `meeting:read:list_meetings`, + `meeting:read:meeting`, `meeting:write:meeting`, `meeting:update:meeting`, `meeting:delete:meeting` and + `user:read:user`. +4. Add the app, with its **Secret Token**: + + ```ts twoslash + import { Coss } from "coss"; + const coss = new Coss(); + const clientSecret = ""; + const secretToken = ""; + // ---cut--- + const { data: app } = await coss.scheduling.oauthApps.create({ + provider: "zoom", + client_id: "abcDEF123", + client_secret: clientSecret, + webhook_secret: secretToken, + }); + // app?.zoom_event_url + ``` + +5. Under **Event Subscriptions**, subscribe to **User has uninstalled or deauthorized the app**, at `zoom_event_url`. + That address is your app's own, and its events only ever reach connections made through it. + + + Zoom only sends deauthorization events for apps published on its Marketplace. On an unpublished app, a host removing it + in Zoom shows up later as a connection that [needs reconnecting](/docs/scheduling/calendars), instead of straight away. + + +## Forwarding page + +A page on your domain takes the provider's redirect, and sends the browser on to `coss_callback_url` with the same query +string. It needs nothing else: `code` and `state` already say everything. In most frameworks it's a single redirect: + +```ts +export function GET(request: Request) { + const { search } = new URL(request.url); + return Response.redirect(`https://api.coss.com/scheduling/oauth/google/callback${search}`, 302); +} +``` + +Register the page with the provider, and pass it as `redirect_uri`. The browser passes through `api.coss.com` for a +moment, then lands on the consent's `return_url` or the app's `completion_url`, with `coss_calendar` set to `connected`, +`denied` or `invalid`. + +## On your host page + +Set a name and a privacy policy in [settings](/docs/scheduling/embedding), and what a host reads before connecting +names you and links your policy instead of coss.com's: + +```ts twoslash +import { Coss } from "coss"; +const coss = new Coss(); +// ---cut--- +await coss.scheduling.settings.update({ + brand_name: "Acme Health", + privacy_url: "https://example.com/privacy", +}); +``` + +## When access is lost + +- **The provider refuses your client ID or secret**, because it was deleted or has expired: the app becomes `failing`, + you get `scheduling.oauth_app.failing`, and your owners and admins get an email. Hosts aren't told, since they can't + fix it. New consents are refused. Calendars can't be read, so hosts stop being offered once what's known of them is + out of date, according to your `calendar_failure_mode`. +- **To fix it**, update the secret. It's tried on a host's connection before it replaces the old one, so a bad paste + changes nothing. Calendars recover on their own, you get `scheduling.oauth_app.restored`, and nobody has to reconnect. +- **During a long outage**, a booking's calendar event is retried for a while, then given up on with + `scheduling.booking.calendar_write_failed`. The booking stands, and that event isn't written once the secret is fixed. +- **One host revokes access**: that connection needs reconnecting, as with coss.com's app, and the app is fine. + +```ts twoslash +import { Coss } from "coss"; +const coss = new Coss(); +const newSecret = ""; +// ---cut--- +await coss.scheduling.oauthApps.update("calapp_01kd2m9a0b1c2d3e4f5g6h7j8m", { + client_secret: newSecret, + secret_expires_at: "2029-01-01T00:00:00Z", +}); +``` + +Owners and admins are warned 14 days before `secret_expires_at`. + +## Removing an app + +An app can't be deleted while connections use it (`oauth_app_in_use`), since only it can refresh their access. +[Disconnect them](/docs/scheduling/calendars) first, and new consents go back to coss.com's app. To move to a different +client ID, hosts have to consent again. + +If your plan changes, a self-serve downgrade is refused until your apps are deleted. If a contract ends, your apps keep +working, so no host loses their calendar, and you can still rotate a secret or delete an app, but not add one. diff --git a/apps/site/content/docs/scheduling/zoom.mdx b/apps/site/content/docs/scheduling/zoom.mdx index 3887abfbf..47f92713c 100644 --- a/apps/site/content/docs/scheduling/zoom.mdx +++ b/apps/site/content/docs/scheduling/zoom.mdx @@ -8,7 +8,7 @@ meeting when a booking is confirmed, moves it when the booking is rescheduled, a is cancelled. The join link goes to the attendee and the host, and onto the host's calendar event. Zoom has to approve coss.com's app before hosts outside Cal.com's own Zoom account can connect. Until then, a host -elsewhere who tries to connect sees an error from Zoom. +elsewhere who tries to connect sees an error from Zoom. You can also use [your own Zoom app](/docs/scheduling/oauth-apps). ## Adding the app @@ -16,7 +16,7 @@ Hosts add the app from the page you give them, not from the Zoom App Marketplace 1. Open your hosted page from your product. If you've embedded it, it's in your own settings. 2. Under **Calendars**, choose **Connect Zoom**. -3. Sign in to Zoom if you're asked, read what coss.com is asking for, and choose **Allow**. +3. Sign in to Zoom if you're asked, read what's being asked for, and choose **Allow**. 4. You're back on the page, with Zoom listed as connected. If you build your own page, ask for a consent URL and open it in a new window: @@ -70,7 +70,7 @@ A host can remove it in either place: - **On your hosted page:** under **Calendars**, choose **Disconnect** next to Zoom. This also revokes coss.com's access at Zoom. -- **In Zoom:** in the Zoom App Marketplace, go to **Manage**, then **Added Apps**, and remove coss.com. +- **In Zoom:** in the Zoom App Marketplace, go to **Manage**, then **Added Apps**, and remove the app. Either way, coss.com deletes the tokens it holds, and new bookings no longer get a Zoom link. Meetings already created stay on the host's Zoom account until the host deletes them, and their links keep working. diff --git a/apps/site/src/lib/changelog.ts b/apps/site/src/lib/changelog.ts index b90f5862f..90d60514a 100644 --- a/apps/site/src/lib/changelog.ts +++ b/apps/site/src/lib/changelog.ts @@ -14,6 +14,12 @@ export interface ChangelogEntry { } export const CHANGELOG: readonly ChangelogEntry[] = [ + { + date: "2026-11-04", + title: "Your own OAuth apps for Scheduling", + body: "Connect hosts' calendars through your own Google, Microsoft or Zoom app, so the consent screen shows your brand instead of ours.", + href: "/docs/scheduling/oauth-apps", + }, { date: "2026-11-03", title: "Scheduling", diff --git a/apps/site/src/lib/sdk-samples.ts b/apps/site/src/lib/sdk-samples.ts index 68b6477e8..54cc25a63 100644 --- a/apps/site/src/lib/sdk-samples.ts +++ b/apps/site/src/lib/sdk-samples.ts @@ -493,6 +493,26 @@ export const SDK_SAMPLES: Record = { method: "scheduling.calendarDelegations.delete", args: (r) => [pathId(r)], }, + "POST /v1/scheduling/oauth_apps": { + method: "scheduling.oauthApps.create", + args: (r) => [tsValue(r.body ?? {})], + }, + "GET /v1/scheduling/oauth_apps": { + method: "scheduling.oauthApps.list", + args: () => [], + }, + "GET /v1/scheduling/oauth_apps/{id}": { + method: "scheduling.oauthApps.get", + args: (r) => [pathId(r)], + }, + "PATCH /v1/scheduling/oauth_apps/{id}": { + method: "scheduling.oauthApps.update", + args: (r) => [pathId(r), tsValue(r.body ?? {})], + }, + "DELETE /v1/scheduling/oauth_apps/{id}": { + method: "scheduling.oauthApps.delete", + args: (r) => [pathId(r)], + }, "POST /v1/scheduling/hosts/{host}/sessions": { method: "scheduling.sessions.createForHost", args: (r) => [tsValue(r.path.host), tsValue(r.body ?? {})], diff --git a/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/actions.ts b/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/actions.ts index f1259152f..4cf9c3212 100644 --- a/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/actions.ts +++ b/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/actions.ts @@ -10,25 +10,30 @@ import { createHost, createHostSession, createManageSession, + createOauthApp, createSchedule, declineBooking, deleteCalendarDelegation, deleteHost, + deleteOauthApp, getEventType, getHost, SchedulingError, updateCalendarDelegation, updateEventType, updateHost, + updateOauthApp, updateSettings, verifyCalendarDelegation, } from "@coss/scheduling"; import { LOGO_MAX_BYTES } from "@coss/scheduling/branding"; import { + CreateOauthAppRequest, EVENT_TYPE_KINDS, UpdateEventTypeRequest, UpdateHostLocationsRequest, UpdateHostRequest, + UpdateOauthAppRequest, } from "@coss/scheduling/schemas"; import { getEnvironment } from "@coss/tenancy"; import { refresh } from "next/cache"; @@ -36,6 +41,7 @@ import { redirect } from "next/navigation"; import type { ActionResult } from "@/lib/action-result"; import { ActionError, formString, runAction } from "@/lib/actions"; import { recordAudit } from "@/lib/audit"; +import { planGate } from "@/lib/billing"; import { schedulingCalendarDeps, schedulingDeps, schedulingUrl } from "@/lib/scheduling"; import { eventTypeChanges, issueLabel, parseJsonObject } from "@/lib/scheduling-edit"; import { type OrgContext, requireOrgPermission } from "@/lib/session"; @@ -135,6 +141,91 @@ function secretExpiry(formData: FormData): string | undefined { return date.toISOString(); } +/** Environment-scoped like the API's, so the event reaches this environment's log drains. */ +function auditOauthApp( + context: OrgContext, + env: { projectId: string; environmentId: string }, + action: string, + id: string, + label?: string, +) { + return recordAudit(context.session, { + organizationId: context.organization.id, + projectId: env.projectId, + environmentId: env.environmentId, + action, + target: { type: "scheduling_oauth_app", id, ...(label ? { label } : {}) }, + }); +} + +export async function createOauthAppAction(_prev: ActionResult | null, formData: FormData) { + return runAction(async () => { + const { context, env } = await contextFor(formData, "update"); + const provider = formString(formData, "provider"); + const optional = (name: string, field: string) => { + const value = formString(formData, name).trim(); + return value ? { [field]: value } : {}; + }; + const secretExpiresAt = secretExpiry(formData); + const params = checked( + CreateOauthAppRequest.safeParse({ + provider, + client_id: formString(formData, "clientId").trim(), + client_secret: formString(formData, "clientSecret"), + ...optional("redirectUri", "redirect_uri"), + ...optional("completionUrl", "completion_url"), + ...(provider === "microsoft" ? optional("tenantId", "tenant_id") : {}), + ...(provider === "zoom" ? optional("webhookSecret", "webhook_secret") : {}), + ...(secretExpiresAt ? { secret_expires_at: secretExpiresAt } : {}), + }), + ); + const app = await friendly(() => + createOauthApp(env, params, { ...schedulingCalendarDeps(), plans: planGate() }), + ); + await auditOauthApp(context, env, "scheduling_oauth_app.created", app.id, app.provider); + refresh(); + return null; + }); +} + +export async function updateOauthAppAction(_prev: ActionResult | null, formData: FormData) { + return runAction(async () => { + const { context, env } = await contextFor(formData, "update"); + const provider = formString(formData, "provider"); + const clientSecret = formString(formData, "clientSecret"); + const webhookSecret = formString(formData, "webhookSecret"); + const params = checked( + UpdateOauthAppRequest.safeParse({ + ...(clientSecret ? { client_secret: clientSecret } : {}), + ...(webhookSecret ? { webhook_secret: webhookSecret } : {}), + // An empty field clears what it holds: these forms always send all of them. + ...(provider === "microsoft" + ? { tenant_id: formString(formData, "tenantId").trim() || null } + : {}), + redirect_uri: formString(formData, "redirectUri").trim() || null, + completion_url: formString(formData, "completionUrl").trim() || null, + secret_expires_at: secretExpiry(formData) ?? null, + }), + ); + const app = await friendly(() => + updateOauthApp(env, formString(formData, "oauthAppId"), params, schedulingCalendarDeps()), + ); + await auditOauthApp(context, env, "scheduling_oauth_app.updated", app.id, app.provider); + refresh(); + return null; + }); +} + +export async function deleteOauthAppAction(_prev: ActionResult | null, formData: FormData) { + return runAction(async () => { + const { context, env } = await contextFor(formData, "update"); + const { id } = await friendly(() => deleteOauthApp(env, formString(formData, "oauthAppId"))); + await auditOauthApp(context, env, "scheduling_oauth_app.deleted", id); + refresh(); + return null; + }); +} + export async function createDelegationAction(_prev: ActionResult | null, formData: FormData) { return runAction(async () => { const { context, env } = await contextFor(formData, "update"); @@ -601,6 +692,7 @@ export async function saveSettingsAction(_prev: ActionResult | null, formData: F .split(/[\s,]+/) .filter(Boolean); const brandName = formString(formData, "brandName"); + const privacyUrl = formString(formData, "privacyUrl").trim(); const emailFrom = formString(formData, "emailFrom"); const smsFrom = formString(formData, "smsFrom"); const accentColor = formString(formData, "accentColor"); @@ -619,6 +711,7 @@ export async function saveSettingsAction(_prev: ActionResult | null, formData: F { allowed_origins: origins, brand_name: brandName || null, + privacy_url: privacyUrl || null, accent_color: useAccent ? accentColor : null, ...(newLogo ? { logo: { content: Buffer.from(await newLogo.arrayBuffer()).toString("base64") } } diff --git a/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/hosts/page.tsx b/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/hosts/page.tsx index dd7dbd822..ff09ba08d 100644 --- a/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/hosts/page.tsx +++ b/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/hosts/page.tsx @@ -1,4 +1,4 @@ -import { failingCalendarDelegations, listHosts } from "@coss/scheduling"; +import { failingCalendarDelegations, failingOauthApps, listHosts } from "@coss/scheduling"; import { Alert, AlertDescription, AlertTitle } from "@coss/ui/components/alert"; import { Table, @@ -37,7 +37,10 @@ export default async function HostsPage({ const search = await searchParams; const after = typeof search.after === "string" ? search.after : undefined; const page = await listHosts(scope, { limit: PAGE_SIZE, after }); - const failing = await failingCalendarDelegations(scope); + const [failing, failingApps] = await Promise.all([ + failingCalendarDelegations(scope), + failingOauthApps(scope), + ]); const last = page.data.at(-1); const hostsPath = `${schedulingPath}/hosts`; const canUpdate = can({ scheduling: ["update"] }); @@ -59,6 +62,18 @@ export default async function HostsPage({ ) : null} + {failingApps.length > 0 ? ( + + Your OAuth app's credentials were refused + + Calendars connected through it can't be read, so hosts stop being offered once what's + known of them is out of date.{" "} + + Update the secret + + + + ) : null} ) { + return ( + <> + {Object.entries(props).map(([name, value]) => + value ? : null, + )} + + ); +} + +/** The fields an app and an edit share. React resets uncontrolled ones after an action. */ +function AddressFields(props: { + provider: Provider; + bind: (name: string) => { + name: string; + value: string; + onChange: (event: { target: { value: string } }) => void; + }; +}) { + const { provider, bind } = props; + return ( + <> + {provider === "microsoft" ? ( + + Directory (tenant) ID + + + Only for an app limited to your own tenant. Leave it empty for a multi-tenant app. + + + ) : null} + + Your forwarding page + + + {provider === "google" + ? "Required for an external Google app, since Google verifies the redirect's domain as yours. It sends the browser on to coss.com's redirect URI, with the same query string." + : "Optional. A page on your domain, registered as the redirect URI, that sends the browser on to coss.com's with the same query string."} + + + + After consent + + + Where the browser lands afterwards, unless a consent names its own. Empty: a plain page + saying what happened. + + + + Secret expires + + Owners and admins are warned 14 days before. + + + ); +} + +function useFormValues(initial: Record = {}) { + const [values, setValues] = useState>(initial); + const bind = (name: string) => ({ + name, + value: values[name] ?? "", + onChange: (event: { target: { value: string } }) => + setValues((current) => ({ ...current, [name]: event.target.value })), + }); + return { bind, setValues }; +} + +export function NewOauthAppDialog(props: EnvTarget & { provider: Provider; name: string }) { + const { provider, name, ...target } = props; + const [state, formAction, pending] = useActionToast(createOauthAppAction, { + success: "OAuth app added", + }); + const [open, setOpen] = useState(false); + const { bind, setValues } = useFormValues(); + useEffect(() => { + if (state?.ok) { + setOpen(false); + setValues({}); + } + }, [state, setValues]); + return ( + + }>Use your own + +
+ + + + Your own {name} app + + New consents in this environment use it. Hosts already connected stay on coss.com's + app until they connect again. + + + + + Client ID + + + + Client secret + + + It's tried with {name} first, encrypted, and never shown again. + + + {provider === "zoom" ? ( + + Secret Token + + + From your Zoom app's Features page, to verify its deauthorization events. + + + ) : null} + + {state && !state.ok ? : null} + + + }>Cancel + + + +
+
+ ); +} + +export function EditOauthAppDialog( + props: EnvTarget & { + oauthAppId: string; + provider: Provider; + name: string; + tenantId: string | null; + redirectUri: string | null; + completionUrl: string | null; + secretExpiresAt: string | null; + }, +) { + const { provider, name, tenantId, redirectUri, completionUrl, secretExpiresAt, ...target } = + props; + const [state, formAction, pending] = useActionToast(updateOauthAppAction, { + success: "OAuth app updated", + }); + const [open, setOpen] = useState(false); + const { bind, setValues } = useFormValues({ + tenantId: tenantId ?? "", + redirectUri: redirectUri ?? "", + completionUrl: completionUrl ?? "", + secretExpiresAt: secretExpiresAt?.slice(0, 10) ?? "", + }); + useEffect(() => { + if (state?.ok) { + setOpen(false); + setValues((current) => ({ ...current, clientSecret: "", webhookSecret: "" })); + } + }, [state, setValues]); + return ( + + }>Edit + +
+ + + + Your {name} app + + A different client ID needs its hosts to consent again, so that means a new app. + + + + + New client secret + + + It's tried with {name} on a host's connection before it replaces the old one. Leave + it empty to keep the current one. + + + {provider === "zoom" ? ( + + New Secret Token + + Leave it empty to keep the current one. + + ) : null} + + {state && !state.ok ? : null} + + + }>Cancel + + + +
+
+ ); +} + +export function DeleteOauthAppButton( + props: EnvTarget & { oauthAppId: string; name: string; connections: number }, +) { + const { name, connections, ...target } = props; + const [state, formAction, pending] = useActionToast(deleteOauthAppAction, { + success: "OAuth app deleted", + }); + return ( + + }>Delete + +
+ + + Delete your {name} app? + + {connections > 0 + ? `${connections === 1 ? "1 calendar connection uses" : `${connections} calendar connections use`} it, and only this app can refresh their access, so it can't be deleted until they're disconnected.` + : "New consents go back to coss.com's app."} + + +
+ {state && !state.ok ? : null} +
+ + }>Keep + + + +
+
+ ); +} diff --git a/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/oauth-apps/page.tsx b/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/oauth-apps/page.tsx new file mode 100644 index 000000000..07197888d --- /dev/null +++ b/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/oauth-apps/page.tsx @@ -0,0 +1,189 @@ +import { + GOOGLE_SCOPES, + listOauthApps, + MICROSOFT_SCOPES, + oauthRedirectUri, + ZOOM_APP_SCOPES, +} from "@coss/scheduling"; +import type { OauthAppObjectOutput } from "@coss/scheduling/schemas"; +import { Alert, AlertDescription, AlertTitle } from "@coss/ui/components/alert"; +import { Button } from "@coss/ui/components/button"; +import { KeyRoundIcon } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { CopyValue } from "@/components/copy-value"; +import { EmptyState } from "@/components/empty-state"; +import { PageHeader } from "@/components/page-header"; +import { Panel, PanelBody } from "@/components/panel"; +import { SpecItem, SpecList } from "@/components/spec-list"; +import { StatusDot, type StatusTone } from "@/components/status-dot"; +import { planHas } from "@/lib/billing"; +import { schedulingCalendarDeps } from "@/lib/scheduling"; +import { schedulingPageContext } from "../page-context"; +import { DeleteOauthAppButton, EditOauthAppDialog, NewOauthAppDialog } from "./oauth-app-actions"; + +export const metadata: Metadata = { title: "OAuth apps" }; + +const PROVIDERS = [ + { key: "google", name: "Google", scopes: GOOGLE_SCOPES }, + { key: "microsoft", name: "Microsoft", scopes: MICROSOFT_SCOPES }, + { key: "zoom", name: "Zoom", scopes: ZOOM_APP_SCOPES }, +] as const; + +function statusOf(app: OauthAppObjectOutput): { tone: StatusTone; label: string } { + if (app.status === "failing") return { tone: "error", label: "Refused by the provider" }; + return app.verified_at + ? { tone: "success", label: "Active" } + : { tone: "warning", label: "Waiting for a first consent" }; +} + +export default async function OauthAppsPage({ + params, +}: PageProps<"/[orgSlug]/[projectSlug]/[envSlug]/scheduling/oauth-apps">) { + const { organization, scope, can, target } = await schedulingPageContext(await params); + const deps = schedulingCalendarDeps(); + const [apps, planAllows] = await Promise.all([ + listOauthApps(scope, deps), + planHas(organization.id, "schedulingOauthApps"), + ]); + const canUpdate = can({ scheduling: ["update"] }); + return ( +
+ + {!planAllows && apps.length === 0 ? ( + + + } + size="sm" + variant="outline" + > + Compare plans + + } + compact + description="Bring-your-own OAuth apps are part of the Scale plan and above." + icon={KeyRoundIcon} + title="Upgrade to use your own OAuth apps" + /> + + + ) : ( + PROVIDERS.map(({ key, name, scopes }) => { + const app = apps.find((candidate) => candidate.provider === key); + const status = app ? statusOf(app) : null; + return ( + + +
+
+ {name} + + {status ? ( + {status.label} + ) : ( + "Using coss.com's app" + )} + +
+ {canUpdate && app ? ( +
+ + +
+ ) : null} + {canUpdate && !app && planAllows ? ( + + ) : null} +
+
+ {app?.status === "failing" ? ( + + + Calendars connected through this app can't be read + + {name} refused its client ID and secret + {app.last_error_code ? ` (${app.last_error_code})` : ""}. Hosts stop being + offered once what's known of their calendars is out of date, and new + connections are refused. Update the client secret, and nobody has to + reconnect. + + + + ) : null} + + + {app ? ( + <> + + + + {app.connections} + {app.tenant_id ? ( + + + + ) : null} + {app.secret_expires_at ? ( + + {app.secret_expires_at.slice(0, 10)} + + ) : null} + {app.redirect_uri ? ( + + + + ) : null} + {app.completion_url ? ( + + + + ) : null} + {app.zoom_event_url ? ( + + + + ) : null} + + ) : null} + + + + + + + + +
+ ); + }) + )} +
+ ); +} diff --git a/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/scheduling-actions.tsx b/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/scheduling-actions.tsx index cd17fe0d9..eae4962df 100644 --- a/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/scheduling-actions.tsx +++ b/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/scheduling/scheduling-actions.tsx @@ -815,6 +815,7 @@ export function SettingsForm( settings: { allowed_origins: string[]; brand_name: string | null; + privacy_url: string | null; accent_color: string | null; logo: { data_url: string; width: number; height: number } | null; email_from: string | null; @@ -863,6 +864,19 @@ export function SettingsForm( Sites that can embed the hosted pages and call the client API, one per line. + + Privacy policy + + + With your own OAuth app and a name, hosts see this beside what connecting a calendar + does. + +
When a host's calendar can't be read diff --git a/apps/web/src/lib/billing-preflight.ts b/apps/web/src/lib/billing-preflight.ts index e14dae753..b6e81779b 100644 --- a/apps/web/src/lib/billing-preflight.ts +++ b/apps/web/src/lib/billing-preflight.ts @@ -6,7 +6,7 @@ import { getOrganizationSsoProvider, hasScimConnection } from "@coss/tenancy"; /** How much of each feature the organization uses now (what a downgrade would leave behind). */ async function featureUsage(organizationId: string): Promise> { - const [sso, scim, drain, tracking, subaccount] = await Promise.all([ + const [sso, scim, drain, tracking, subaccount, oauthApp] = await Promise.all([ getOrganizationSsoProvider({ organizationId }), hasScimConnection({ organizationId }), db.query.logDrain.findFirst({ @@ -19,6 +19,11 @@ async function featureUsage(organizationId: string): Promise eq(row.organizationId, organizationId), + }), ]); return { sso: sso ? 1 : 0, @@ -30,6 +35,7 @@ async function featureUsage(organizationId: string): Promise = { auditLog: "", dedicatedIps: "", subaccounts: "Delete your live email sub-accounts.", + schedulingOauthApps: "Delete your Scheduling OAuth apps.", }; /** diff --git a/apps/web/src/lib/navigation.ts b/apps/web/src/lib/navigation.ts index 6ffe93f40..1b60925c6 100644 --- a/apps/web/src/lib/navigation.ts +++ b/apps/web/src/lib/navigation.ts @@ -184,6 +184,7 @@ const SEGMENT_LABELS: Record = { hooks: "Hooks", "event-types": "Event types", "calendar-delegation": "Calendar delegation", + "oauth-apps": "OAuth apps", endpoints: "Endpoints", received: "Received", inboxes: "Inboxes", diff --git a/apps/worker/src/jobs.ts b/apps/worker/src/jobs.ts index c8aa2fd8e..644ea0da8 100644 --- a/apps/worker/src/jobs.ts +++ b/apps/worker/src/jobs.ts @@ -77,6 +77,7 @@ import { runDueCalendarWrites, runDueSchedulingNotifications, type SchedulingWorkerDeps, + warnOfExpiringOauthAppSecrets, } from "@coss/scheduling"; import { contentScreeningCheck } from "@coss/screening"; import { @@ -256,6 +257,7 @@ export const WORKER_LOOP_NAMES = [ "scheduling-expiry", "scheduling-notifications", "scheduling-calendar-sync", + "scheduling-oauth-app-secrets", "scheduling-calendar-writes", "scheduling-calendar-channels", "scheduling-calendar-delegations", @@ -808,6 +810,14 @@ export function startJobs(deps: WorkerDeps): Loop[] { intervalMs: 15_000, run: async () => (await runDueCalendarDelegations(calendars, { limit: 5 })) === 5, }), + start({ + name: "scheduling-oauth-app-secrets", + intervalMs: 60 * 60_000, + run: async () => { + await warnOfExpiringOauthAppSecrets(calendars); + return false; + }, + }), start({ name: "scheduling-calendar-host-edits", intervalMs: 5 * 60_000, diff --git a/packages/billing/src/catalog/entitlements.ts b/packages/billing/src/catalog/entitlements.ts index bdc59bb3c..5cced95f3 100644 --- a/packages/billing/src/catalog/entitlements.ts +++ b/packages/billing/src/catalog/entitlements.ts @@ -8,6 +8,7 @@ export const FEATURE_KEYS = [ "scim", "dedicatedIps", "subaccounts", + "schedulingOauthApps", ] as const; export type FeatureKey = (typeof FEATURE_KEYS)[number]; @@ -58,6 +59,7 @@ export const FEATURE_LABELS: Record = { scim: "Directory sync (SCIM)", dedicatedIps: "Dedicated IPs", subaccounts: "Email for platforms (sub-accounts)", + schedulingOauthApps: "Bring-your-own OAuth apps for Scheduling", }; export const LIMIT_LABELS: Record = { diff --git a/packages/billing/src/catalog/plans.ts b/packages/billing/src/catalog/plans.ts index 66d9cc73a..f5cfc3c19 100644 --- a/packages/billing/src/catalog/plans.ts +++ b/packages/billing/src/catalog/plans.ts @@ -94,6 +94,7 @@ export const PLANS = { scim: false, dedicatedIps: false, subaccounts: false, + schedulingOauthApps: false, }, limits: { domains: 1, @@ -141,6 +142,7 @@ export const PLANS = { scim: false, dedicatedIps: false, subaccounts: false, + schedulingOauthApps: false, }, limits: { domains: 10, @@ -195,6 +197,7 @@ export const PLANS = { scim: false, dedicatedIps: false, subaccounts: true, + schedulingOauthApps: true, }, limits: { domains: 100, @@ -248,6 +251,7 @@ export const PLANS = { scim: true, dedicatedIps: false, subaccounts: true, + schedulingOauthApps: true, }, limits: { domains: null, diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index ad40a8eb4..0d6ca2485 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -43,6 +43,7 @@ const ENVIRONMENT_PAGES: Record = { "scheduling-hosts": "/scheduling/hosts", "scheduling-event-types": "/scheduling/event-types", "scheduling-calendar-delegation": "/scheduling/calendar-delegation", + "scheduling-oauth-apps": "/scheduling/oauth-apps", "scheduling-settings": "/scheduling/settings", hooks: "/hooks", "hooks-event-types": "/hooks/event-types", diff --git a/packages/dashboard-routes/src/index.ts b/packages/dashboard-routes/src/index.ts index 7059fc599..75a58b5ad 100644 --- a/packages/dashboard-routes/src/index.ts +++ b/packages/dashboard-routes/src/index.ts @@ -159,6 +159,7 @@ export const PRODUCTS: readonly Product[] = [ { label: "Hosts", path: "/scheduling/hosts" }, { label: "Event types", path: "/scheduling/event-types" }, { label: "Calendar delegation", path: "/scheduling/calendar-delegation" }, + { label: "OAuth apps", path: "/scheduling/oauth-apps" }, { label: "Settings", path: "/scheduling/settings" }, ], }, diff --git a/packages/db/src/id.ts b/packages/db/src/id.ts index 25536733f..ce73eea0f 100644 --- a/packages/db/src/id.ts +++ b/packages/db/src/id.ts @@ -157,6 +157,7 @@ export const ID_PREFIXES = { schedulingEventType: "evtype", schedulingCalendarConnection: "calconn", schedulingCalendarDelegation: "caldlg", + schedulingOauthApp: "calapp", schedulingCalendar: "cal", schedulingClaim: "claim", schedulingBooking: "book", diff --git a/packages/db/src/schema/scheduling.ts b/packages/db/src/schema/scheduling.ts index cdaeeb56d..dd81cc17f 100644 --- a/packages/db/src/schema/scheduling.ts +++ b/packages/db/src/schema/scheduling.ts @@ -118,6 +118,16 @@ export const schedulingDelegationProvider = pgEnum("scheduling_delegation_provid "google", "microsoft", ]); +export const schedulingOauthAppProvider = pgEnum("scheduling_oauth_app_provider", [ + "google", + "microsoft", + "zoom", +]); +export const schedulingOauthAppStatus = pgEnum("scheduling_oauth_app_status", [ + "active", + // The provider refused the app's own credentials. Hosts aren't touched: their calendars go stale. + "failing", +]); export const schedulingDelegationStatus = pgEnum("scheduling_delegation_status", [ "pending", "active", @@ -406,6 +416,49 @@ export const schedulingCalendarDelegation = pgTable( ], ); +/** + * A customer's own OAuth app for one provider, so consent screens show their brand. New consents in + * the environment use it; a connection stays on the app that issued its tokens (`oauth_app_id`). + */ +export const schedulingOauthApp = pgTable( + "scheduling_oauth_app", + { + id: text("id") + .primaryKey() + .$defaultFn(() => newId(ID_PREFIXES.schedulingOauthApp)), + organizationId: text("organization_id").notNull(), + projectId: text("project_id").notNull(), + environmentId: text("environment_id").notNull(), + provider: schedulingOauthAppProvider("provider").notNull(), + clientId: text("client_id").notNull(), + // Microsoft only: a single-tenant app can't use `common`. It goes into a URL, so it's validated. + tenantId: text("tenant_id"), + // `encryptSecret`, and listed in `reencryptWebhookSecrets`. Never returned. + encryptedCredentials: text("encrypted_credentials").notNull(), + // Null: coss.com's callback. Set: the customer's own page, which forwards there. + redirectUri: text("redirect_uri"), + // Where the browser lands afterwards when the consent didn't name a `return_url`. + completionUrl: text("completion_url"), + secretExpiresAt: instant("secret_expires_at"), + status: schedulingOauthAppStatus("status").notNull().default("active"), + lastErrorCode: text("last_error_code"), + lastErrorAt: instant("last_error_at"), + // A consent has completed through it, which is the first proof its secret works. + verifiedAt: instant("verified_at"), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [ + foreignKey({ + name: "scheduling_oauth_app_environment_fk", + columns: [t.environmentId, t.organizationId], + foreignColumns: [environment.id, environment.organizationId], + }).onDelete("cascade"), + unique("scheduling_oauth_app_id_organization_unique").on(t.id, t.organizationId), + unique("scheduling_oauth_app_provider_unique").on(t.environmentId, t.provider), + ], +); + export const schedulingCalendarConnection = pgTable( "scheduling_calendar_connection", { @@ -423,6 +476,8 @@ export const schedulingCalendarConnection = pgTable( encryptedCredentials: text("encrypted_credentials"), // Set: the delegation's credentials reach this mailbox, and it isn't the host's to end. delegationId: text("delegation_id"), + // The customer's app that issued its tokens, which only that app can refresh. Null: coss.com's. + oauthAppId: text("oauth_app_id"), // When a delegation first reached this mailbox. Null with one: it never has, so a host's calendars // here are only a stand-in, and there are no events. reachedAt: instant("reached_at"), @@ -446,7 +501,16 @@ export const schedulingCalendarConnection = pgTable( schedulingCalendarDelegation.organizationId, ], }).onDelete("cascade"), + // No cascade: deleting an app with connections is refused, as their tokens would be useless. + foreignKey({ + name: "scheduling_calendar_connection_oauth_app_fk", + columns: [t.oauthAppId, t.organizationId], + foreignColumns: [schedulingOauthApp.id, schedulingOauthApp.organizationId], + }), unique("scheduling_calendar_connection_id_organization_unique").on(t.id, t.organizationId), + index("scheduling_calendar_connection_oauth_app_idx") + .on(t.oauthAppId) + .where(sql`${t.oauthAppId} IS NOT NULL`), index("scheduling_calendar_connection_delegation_idx") .on(t.delegationId) .where(sql`${t.delegationId} IS NOT NULL`), @@ -1046,6 +1110,9 @@ export const schedulingOauthState = pgTable( stateHash: text("state_hash").notNull(), encryptedCodeVerifier: text("encrypted_code_verifier").notNull(), returnUrl: text("return_url"), + // Fixed when the consent starts: a code can only be exchanged with what it was issued for. + oauthAppId: text("oauth_app_id"), + redirectUri: text("redirect_uri"), expiresAt: instant("expires_at").notNull(), createdAt: createdAt(), }, @@ -1055,6 +1122,11 @@ export const schedulingOauthState = pgTable( columns: [t.hostId, t.organizationId], foreignColumns: [schedulingHost.id, schedulingHost.organizationId], }).onDelete("cascade"), + foreignKey({ + name: "scheduling_oauth_state_oauth_app_fk", + columns: [t.oauthAppId, t.organizationId], + foreignColumns: [schedulingOauthApp.id, schedulingOauthApp.organizationId], + }).onDelete("cascade"), unique("scheduling_oauth_state_hash_unique").on(t.stateHash), index("scheduling_oauth_state_expires_at_idx").on(t.expiresAt), ], @@ -1072,6 +1144,8 @@ export const schedulingSettings = pgTable( allowedOrigins: jsonb("allowed_origins").$type().notNull().default([]), brandName: text("brand_name"), accentColor: text("accent_color"), + // What the host's page links to before a consent, with `brand_name` and the customer's own app. + privacyUrl: text("privacy_url"), emailFrom: text("email_from"), smsFrom: text("sms_from"), notifications: jsonb("notifications").$type(), diff --git a/packages/email/src/test-event-schemas.ts b/packages/email/src/test-event-schemas.ts index 467b879e6..9d66920f5 100644 --- a/packages/email/src/test-event-schemas.ts +++ b/packages/email/src/test-event-schemas.ts @@ -12,6 +12,7 @@ import { SchedulingBookingWebhookEvent, SchedulingCalendarDelegationWebhookEvent, SchedulingCalendarWebhookEvent, + SchedulingOauthAppWebhookEvent, } from "@coss/scheduling/schemas"; import { SIGN_WEBHOOK_EVENT_TYPES, SignRequestWebhookEvent } from "@coss/sign/schemas"; import { @@ -78,6 +79,7 @@ export function webhookEventSchema(type: TestWebhookEventType): z.ZodType { if (type.startsWith("scheduling.calendar_delegation.")) { return SchedulingCalendarDelegationWebhookEvent; } + if (type.startsWith("scheduling.oauth_app.")) return SchedulingOauthAppWebhookEvent; if (type.startsWith("scheduling.")) return SchedulingCalendarWebhookEvent; if (type.startsWith("email.")) return EmailWebhookEvent; if (type.startsWith("hook_endpoint.")) return HookEndpointWebhookEvent; @@ -163,6 +165,7 @@ export const WebhookTestEventObject = z SchedulingCalendarWebhookEvent, SchedulingCalendarDelegationWebhookEvent, SchedulingCalendarDelegationWebhookEvent, + SchedulingOauthAppWebhookEvent, ]) .meta({ description: diff --git a/packages/email/src/test-events.ts b/packages/email/src/test-events.ts index 6f92ffcf6..ce82a06a9 100644 --- a/packages/email/src/test-events.ts +++ b/packages/email/src/test-events.ts @@ -299,6 +299,14 @@ export function sampleWebhookEvent(type: TestWebhookEventType, now: Date): Store code: type.endsWith(".failing") ? "unauthorized_client" : null, occurred_at: at, }; + } else if (type.startsWith("scheduling.oauth_app.")) { + data = { + ...base, + oauth_app_id: newId(ID_PREFIXES.schedulingOauthApp), + provider: "google", + code: type.endsWith(".failing") ? "app_refused" : null, + occurred_at: at, + }; } else if (type.startsWith("scheduling.calendar.")) { data = { ...base, diff --git a/packages/scheduling/AGENTS.md b/packages/scheduling/AGENTS.md index 7f2d5d69b..f64c96f4e 100644 --- a/packages/scheduling/AGENTS.md +++ b/packages/scheduling/AGENTS.md @@ -181,6 +181,45 @@ once, and every host on it gets their calendar with no consent screen. domain-wide delegation, Graph ids matching between `/me` and `/users/{id}`, app-only subscriptions and Teams links) needs checking against a real Workspace and tenant when any of this changes. +### Customers' own OAuth apps + +`calendar/oauth-apps.ts` (managing them, plan-gated on `schedulingOauthApps` when created), `oauth-app-state.ts` (reading +credentials, and refusals; imported by `connections.ts`, so it can't import that). One app per environment and provider, so +consent screens show the customer's brand. + +- **A connection is pinned to the app that issued its tokens** (`oauth_app_id`, null for coss.com's), because only that + app can refresh or revoke them. Never look an app up by environment for an existing connection: adding an app would + knock out everyone connected before it. A re-consent moves the pin, and `client_id` never changes on a row, so a + different app means new consents. +- **A consent is pinned when it starts** (`scheduling_oauth_state.oauth_app_id`, `redirect_uri`): a code is exchanged with + what it was issued for, whatever's been edited since. +- **No fallback**: a failing app refuses new consents rather than using coss.com's, which would show the wrong brand and + pin the connection to it. With no app of its own, an environment uses `deps.apps` as before. +- **A bad secret isn't a host's problem.** `invalid_client` and `deleted_client` become the `transient` `app_refused`, never + `auth`, so no host is marked or emailed, and `calendarVerdict` hides them when their data goes stale. One outage is one + compare-and-set on the app (`markOauthAppFailing`, only if it still holds the secret that was refused), so one webhook, + log line and owner email. The same refusal of coss.com's own app goes to Sentry. `unauthorized_client` stays `auth`: + Microsoft answers that for a single tenant that disallows the app, which says nothing about the secret. +- **A new secret is tried first** (`checkOnConnections`): a real token through the refresh path, keeping what comes back, + since Zoom's work once. With no connections, `checkWithoutConnections` tries a token that can't exist: it only refuses on + `app_refused`, so passing doesn't prove the secret where a provider checks the token first. `verified_at`, set by the + first consent, does. Any successful refresh ends an outage too (`oauthAppAccepted`). +- **Zoom's deauthorization events are per app** (`POST /scheduling/zoom/events/:appId`, its own Secret Token), and + `handleZoomDeauthorization` only reaches that app's connections, or with no id, coss.com's. A customer holds their token, + so an unscoped handler would let them delete other organizations' connections. Keep the filter. Zoom doesn't send these + events for unpublished apps. +- **A forwarding page** (`redirect_uri`) is the customer's own, because Google verifies redirect domains as the app owner's, + and nobody else can verify `api.coss.com`. It sends `code` and `state` to the unchanged callback. Only the customer's own + app is ever sent there, so it isn't an open redirect. `completion_url` fills in a consent's `return_url`, and is stored on + the state like one. +- **Deleting** is refused while connections reference the app (a foreign key with no cascade, and `FOR UPDATE` first). It's + NO ACTION rather than RESTRICT, so deleting an environment or organization still takes both in one statement. +- **Plans** gate creating only, like every feature here: a lapsed contract keeps working, and rotating or deleting always + works, since refusing would take hosts' calendars out. `downgradeBlockers` refuses a self-serve downgrade while apps exist. +- Provider behaviour the fakes can't prove needs a real app when this changes: which error each provider gives a bad secret, + whether Microsoft checks the app before the token, Google's verification with a forwarding page, and channels under a + customer's project. + A Meet or Teams link becomes a `link` location on the booking, stored with `conference` (stripped from the wire), so a new host gets a meeting of their own (`locationForNewHost`). A browser only chooses among an event type's `locations` (`locationFromBrowser`): it can't supply its own link or address. diff --git a/packages/scheduling/src/calendar/connections.ts b/packages/scheduling/src/calendar/connections.ts index 5dd48696e..342942873 100644 --- a/packages/scheduling/src/calendar/connections.ts +++ b/packages/scheduling/src/calendar/connections.ts @@ -25,6 +25,7 @@ import { recordCalendarEvent } from "./events.ts"; import { googleCalendarClient } from "./google.ts"; import { microsoftCalendarClient } from "./microsoft.ts"; import { + APP_REFUSED, authorizationUrl, exchangeCode, type OAuthApp, @@ -34,6 +35,14 @@ import { refreshTokens, revokeToken, } from "./oauth.ts"; +import { + environmentOauthApp, + markOauthAppFailing, + type OauthAppRow, + oauthAppOf, + pinnedOauthApp, + restoreOauthApp, +} from "./oauth-app-state.ts"; import { simulatedCalendarClient } from "./simulated.ts"; import { ZOOM_CALENDAR_ID, zoomCalendarClient, zoomUserId } from "./zoom.ts"; @@ -59,7 +68,7 @@ type Env = Scope & { projectId: string; mode: "live" | "sandbox" }; export type CalendarDeps = { secretsKeys: SecretsKeyRing; - /** Coss.com's OAuth apps. A provider without one can't be connected. */ + /** Coss.com's OAuth apps. Without one, only an environment's own app can connect a provider. */ apps: { google: OAuthApp | null; microsoft: OAuthApp | null; @@ -86,7 +95,7 @@ const REQUEST_TIMEOUT_MS = 30_000; // Past a year: a weekly series of 52 has to fit, or its last occurrences couldn't be verified. export const SYNC_HORIZON_DAYS = 400; -export const fetcherOf = (deps: CalendarDeps): HttpFetch => { +export const fetcherOf = (deps: Pick): HttpFetch => { const fetcher = deps.fetcher ?? fetch; const timeoutMs = deps.requestTimeoutMs ?? REQUEST_TIMEOUT_MS; return async (url, init) => { @@ -101,7 +110,8 @@ export const fetcherOf = (deps: CalendarDeps): HttpFetch => { } }; }; -export const nowMs = (deps: CalendarDeps): number => (deps.now?.() ?? new Date()).getTime(); +export const nowMs = (deps: Pick): number => + (deps.now?.() ?? new Date()).getTime(); export const oauthRedirectUri = (deps: Pick, provider: OAuthProvider) => `${withoutTrailingSlashes(deps.publicUrl)}/scheduling/oauth/${provider}/callback`; @@ -120,6 +130,19 @@ const appOf = (deps: CalendarDeps, provider: OAuthProvider): OAuthApp => { return app; }; +/** The app a connection's tokens came from: only that one can refresh or revoke them. */ +async function appOfConnection( + connection: Pick, + provider: OAuthProvider, + deps: CalendarDeps, +): Promise<{ app: OAuthApp; own: OauthAppRow | null }> { + if (!connection.oauthAppId) return { app: appOf(deps, provider), own: null }; + const own = await pinnedOauthApp(connection.oauthAppId, connection.organizationId); + // A foreign key keeps it while the connection exists, so this is a read of a row just deleted. + if (!own) throw new CalendarError("transient", "oauth_app_gone", "This OAuth app is gone."); + return { app: oauthAppOf(own, deps.secretsKeys), own }; +} + const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); /** Starts a consent: the state is random, and only its hash is kept. It's never a credential. */ @@ -129,7 +152,16 @@ export async function startCalendarAuthorization( input: { provider: OAuthProvider; returnUrl: string | null }, deps: CalendarDeps, ): Promise<{ url: string }> { - const app = appOf(deps, input.provider); + const own = await environmentOauthApp(scope, input.provider); + // Never coss.com's app instead: it would show the wrong brand, and stay pinned to it. + if (own?.status === "failing") { + throw new SchedulingError( + "calendar_unavailable", + `This environment's ${input.provider} OAuth app was refused by the provider. Update its client secret.`, + ); + } + const app = own ? oauthAppOf(own, deps.secretsKeys) : appOf(deps, input.provider); + const redirectUri = own?.redirectUri ?? oauthRedirectUri(deps, input.provider); const host = await resolveHost(db, scope, hostRef); const state = randomBytes(32).toString("base64url"); const { verifier, challenge } = pkcePair(); @@ -140,14 +172,16 @@ export async function startCalendarAuthorization( provider: input.provider, stateHash: sha256(state), encryptedCodeVerifier: encryptSecret(verifier, deps.secretsKeys), - returnUrl: input.returnUrl, + returnUrl: input.returnUrl ?? own?.completionUrl ?? null, + oauthAppId: own?.id ?? null, + redirectUri, expiresAt: new Date(nowMs(deps) + OAUTH_STATE_TTL_MS), }); return { url: authorizationUrl({ provider: input.provider, app, - redirectUri: oauthRedirectUri(deps, input.provider), + redirectUri, state, codeChallenge: challenge, }), @@ -206,6 +240,7 @@ async function saveConnection( accountId: string; encryptedCredentials: string | null; client: CalendarClient; + oauthAppId?: string | null; }, deps: Pick, ): Promise { @@ -246,6 +281,7 @@ async function saveConnection( provider: input.provider, accountId: input.accountId, encryptedCredentials: input.encryptedCredentials, + oauthAppId: input.oauthAppId ?? null, }) .onConflictDoUpdate({ target: [ @@ -255,6 +291,8 @@ async function saveConnection( ], set: { encryptedCredentials: input.encryptedCredentials, + // A new consent can come through a different app, and only that one can refresh these. + oauthAppId: input.oauthAppId ?? null, status: "active", lastErrorCode: null, lastErrorAt: null, @@ -308,16 +346,25 @@ export async function completeCalendarAuthorization( return { returnUrl: state?.returnUrl ?? null, outcome: "invalid" }; } if (input.error || !input.code) return { returnUrl: state.returnUrl, outcome: "denied" }; - const app = appOf(deps, input.provider); - const tokens = await exchangeCode({ - provider: input.provider, - app, - code: input.code, - codeVerifier: decryptSecret(state.encryptedCodeVerifier, deps.secretsKeys), - redirectUri: oauthRedirectUri(deps, input.provider), - fetcher: fetcherOf(deps), - nowMs: nowMs(deps), - }); + // What the consent started with, never what the environment has now. + const { app, own } = await appOfConnection(state, input.provider, deps); + let tokens: OAuthTokens; + try { + tokens = await exchangeCode({ + provider: input.provider, + app, + code: input.code, + codeVerifier: decryptSecret(state.encryptedCodeVerifier, deps.secretsKeys), + redirectUri: state.redirectUri ?? oauthRedirectUri(deps, input.provider), + fetcher: fetcherOf(deps), + nowMs: nowMs(deps), + }); + } catch (error) { + if (own && error instanceof CalendarError && error.code === APP_REFUSED) { + await markOauthAppFailing(own, error.code, new Date(nowMs(deps))); + } + throw error; + } const client = oauthClient(input.provider, tokens.accessToken, deps); const accountId = await oauthAccountId(input.provider, tokens.accessToken, client, deps); await saveConnection( @@ -331,9 +378,11 @@ export async function completeCalendarAuthorization( accountId, encryptedCredentials: encryptCredentials(tokens, deps.secretsKeys), client, + oauthAppId: own?.id ?? null, }, deps, ); + if (own) await oauthAppAccepted(own, deps); return { returnUrl: state.returnUrl, outcome: "connected", hostId: state.hostId }; } @@ -547,12 +596,29 @@ export async function setSimulatedBusy( }); } +/** The provider took this app's secret: the first proof it works, and the end of any outage. */ +async function oauthAppAccepted(own: OauthAppRow, deps: Pick): Promise { + const now = deps.now?.() ?? new Date(); + if (!own.verifiedAt) { + await db + .update(schema.schedulingOauthApp) + .set({ verifiedAt: now }) + .where( + and( + eq(schema.schedulingOauthApp.id, own.id), + eq(schema.schedulingOauthApp.organizationId, own.organizationId), + ), + ); + } + if (own.status === "failing") await restoreOauthApp(own, now); +} + /** A re-encrypted row still holds the token used; any other value is newer, and stays. */ -async function saveRefreshedTokens( +export async function saveRefreshedTokens( connection: ConnectionRow, usedRefreshToken: string, tokens: OAuthTokens, - deps: CalendarDeps, + deps: Pick, ): Promise { await db.transaction(async (tx) => { const where = and( @@ -601,15 +667,21 @@ export async function clientFor( if (!usedRefreshToken) { throw new CalendarError("auth", "no_refresh_token", "Calendar access has expired."); } + const { app, own } = await appOfConnection(connection, provider, deps); try { tokens = await refreshTokens({ provider, - app: appOf(deps, provider), + app, refreshToken: usedRefreshToken, fetcher: fetcherOf(deps), nowMs: nowMs(deps), }); } catch (error) { + // One outage of the app, not one per host. Coss.com's own is ours to hear about. + if (error instanceof CalendarError && error.code === APP_REFUSED) { + if (own) await markOauthAppFailing(own, error.code, new Date(nowMs(deps))); + else console.error(`[scheduling] ${provider} refused coss.com's OAuth app`, error); + } // Zoom's refresh tokens work once, so another worker may just have used this one. if (error instanceof CalendarError && error.kind === "auth") { const [current] = await db @@ -626,6 +698,7 @@ export async function clientFor( throw error; } await saveRefreshedTokens(connection, usedRefreshToken, tokens, deps); + if (own) await oauthAppAccepted(own, deps); } return oauthClient(provider, tokens.accessToken, deps); } @@ -713,6 +786,8 @@ export type ConnectionObject = { status: "active" | "needs_reauth"; /** The delegation that manages it: the host can't disconnect or reconnect it. */ delegation: string | null; + /** Your own OAuth app its access came through. Null: coss.com's, or there's no consent. */ + oauth_app: string | null; calendars: { id: string; external_id: string; @@ -747,6 +822,7 @@ async function toConnectionObjects( // A customer's code only needs to know the host's out; `delegation` says whose problem it is. status: row.status === "active" ? "active" : "needs_reauth", delegation: row.delegationId, + oauth_app: row.oauthAppId, calendars: calendars .filter( (calendar) => @@ -920,10 +996,15 @@ export async function disconnectCalendar( return { id: connection.id }; } -/** Someone removed the app in Zoom: every connection of that Zoom user goes, in any organization. */ +/** + * Someone removed an app in Zoom: that Zoom user's connections through that app go, and nobody else's. + * Coss.com's app (`oauthAppId` null) covers any organization that used it. A customer's holds its own + * event token, so it must never reach a connection that came through another app. + */ export async function handleZoomDeauthorization( zoomUser: string, deps: CalendarDeps, + oauthAppId: string | null = null, ): Promise { const connections = await db .select() @@ -932,6 +1013,9 @@ export async function handleZoomDeauthorization( and( eq(schedulingCalendarConnection.provider, "zoom"), eq(schedulingCalendarConnection.accountId, zoomUser), + oauthAppId + ? eq(schedulingCalendarConnection.oauthAppId, oauthAppId) + : isNull(schedulingCalendarConnection.oauthAppId), ), ); for (const connection of connections) { @@ -1082,16 +1166,21 @@ export async function releaseConnection( } // A delegation's tokens aren't this host's to revoke. if (connection.encryptedCredentials && !connection.delegationId) { - const tokens = JSON.parse( - decryptSecret(connection.encryptedCredentials, deps.secretsKeys), - ) as OAuthTokens; - if (tokens.refreshToken) { - await revokeToken( - connection.provider, - tokens.refreshToken, - fetcherOf(deps), - deps.apps[connection.provider], + // As it is now: `clientFor` may just have spent the refresh token this row was read with. + const [current] = await db + .select({ stored: schedulingCalendarConnection.encryptedCredentials }) + .from(schedulingCalendarConnection) + .where( + and( + eq(schedulingCalendarConnection.id, connection.id), + eq(schedulingCalendarConnection.organizationId, connection.organizationId), + ), ); + const stored = current?.stored ?? connection.encryptedCredentials; + const tokens = JSON.parse(decryptSecret(stored, deps.secretsKeys)) as OAuthTokens; + if (tokens.refreshToken) { + const { app } = await appOfConnection(connection, connection.provider, deps); + await revokeToken(connection.provider, tokens.refreshToken, fetcherOf(deps), app); } } } catch (error) { diff --git a/packages/scheduling/src/calendar/delegation-notifications.ts b/packages/scheduling/src/calendar/delegation-notifications.ts index 97776ad46..6879cad9d 100644 --- a/packages/scheduling/src/calendar/delegation-notifications.ts +++ b/packages/scheduling/src/calendar/delegation-notifications.ts @@ -8,7 +8,7 @@ import { db, schema } from "@coss/db"; import type { NotificationContext, NotificationKinds } from "@coss/notifications"; import { and, eq } from "drizzle-orm"; -const { environment, project, schedulingCalendarDelegation } = schema; +const { environment, project, schedulingCalendarDelegation, schedulingOauthApp } = schema; /** How long before a client secret expires its owners are warned. */ export const SECRET_WARNING_MS = 14 * 86_400_000; @@ -34,6 +34,44 @@ async function delegationOf(context: NotificationContext) { return row ?? null; } +async function oauthAppOf(context: NotificationContext) { + const id = context.payload.oauthAppId; + if (typeof id !== "string") return null; + const [row] = await db + .select({ + app: schedulingOauthApp, + projectSlug: project.slug, + environmentSlug: environment.slug, + }) + .from(schedulingOauthApp) + .innerJoin(environment, eq(environment.id, schedulingOauthApp.environmentId)) + .innerJoin(project, eq(project.id, schedulingOauthApp.projectId)) + .where( + and( + eq(schedulingOauthApp.id, id), + eq(schedulingOauthApp.organizationId, context.organizationId), + ), + ); + return row ?? null; +} + +const PROVIDER_NAMES = { google: "Google", microsoft: "Microsoft", zoom: "Zoom" }; + +const oauthAppPage = ( + context: NotificationContext, + row: { projectSlug: string; environmentSlug: string }, +) => + context.url( + environmentHref( + { + organization: context.organization.slug, + project: row.projectSlug, + environment: row.environmentSlug, + }, + "/scheduling/oauth-apps", + ), + ); + const pageOf = ( context: NotificationContext, row: { projectSlug: string; environmentSlug: string }, @@ -98,4 +136,50 @@ export const SCHEDULING_NOTIFICATION_KINDS: NotificationKinds = { }; }, }, + "scheduling.oauth_app_failing": { + async render(context) { + const row = await oauthAppOf(context); + if (!row) return { skip: "the app is gone" }; + if (row.app.status !== "failing") return { skip: "it works again" }; + const provider = PROVIDER_NAMES[row.app.provider]; + return { + subject: `${provider} refused your OAuth app's credentials`, + blocks: [ + { + paragraph: `${provider} refused the client ID and secret of your own OAuth app, so calendars connected through it can't be read. Hosts stop being offered once what's known of their calendars is out of date, and new connections are refused. Existing bookings stay as they are.`, + }, + { + paragraph: + "Update the client secret, and they come back by themselves. Nobody has to reconnect anything.", + }, + { button: { label: "Update the secret", url: oauthAppPage(context, row) } }, + ], + }; + }, + }, + "scheduling.oauth_app_secret_expiring": { + async render(context) { + const row = await oauthAppOf(context); + if (!row) return { skip: "the app is gone" }; + const expires = row.app.secretExpiresAt; + if ( + !expires || + expires < context.now || + expires.getTime() - context.now.getTime() > SECRET_WARNING_MS + ) { + return { skip: "no secret about to expire" }; + } + const provider = PROVIDER_NAMES[row.app.provider]; + return { + subject: `The client secret of your ${provider} OAuth app expires soon`, + blocks: [ + { + paragraph: `The client secret of your own ${provider} OAuth app expires on ${expires.toISOString().slice(0, 10)}. When it does, calendars connected through it can't be read, and hosts stop being offered for new bookings.`, + }, + { paragraph: `Create a new secret at ${provider}, then paste it into coss.com.` }, + { button: { label: "Update the secret", url: oauthAppPage(context, row) } }, + ], + }; + }, + }, }; diff --git a/packages/scheduling/src/calendar/delegation.ts b/packages/scheduling/src/calendar/delegation.ts index 760a32abb..089104a6b 100644 --- a/packages/scheduling/src/calendar/delegation.ts +++ b/packages/scheduling/src/calendar/delegation.ts @@ -649,6 +649,8 @@ async function connectHost( const upgraded = host.existing ? null : await connectionToUpgrade(tx, delegation, host); const fields = { delegationId: delegation.id, + // Taken over from a host's own consent, the delegation's credentials are all that matter. + oauthAppId: null, reachedAt: now, encryptedCredentials: encryptCredentials(tokens, deps.secretsKeys), status: "active" as const, diff --git a/packages/scheduling/src/calendar/oauth-app-state.ts b/packages/scheduling/src/calendar/oauth-app-state.ts new file mode 100644 index 000000000..c3b45e2af --- /dev/null +++ b/packages/scheduling/src/calendar/oauth-app-state.ts @@ -0,0 +1,190 @@ +/** + * A customer's OAuth app as `connections.ts` needs it: reading its credentials, and the provider + * refusing them. Apart from `oauth-apps.ts` (managing them), which needs `connections.ts`. + * + * A refusal is one outage of one app, so it's one webhook, one log line and one email to the owners. + * Hosts aren't told, since they can't fix it, and they aren't marked: their calendars go stale, and + * `calendarVerdict` decides when that stops them being offered, as for any calendar that can't be read. + */ +import { db, type Executor, schema } from "@coss/db"; +import { ID_PREFIXES, isId, newId } from "@coss/db/id"; +import { appendLogEntries } from "@coss/logs"; +import { enqueueNotification } from "@coss/notifications"; +import { + decryptSecret, + encryptSecret, + enqueueWebhookMessage, + type SecretsKeyRing, +} from "@coss/webhooks"; +import { and, eq } from "drizzle-orm"; +import type { SchedulingOauthAppWebhookEventType } from "../webhook-types.ts"; +import type { OAuthApp, OAuthProvider } from "./oauth.ts"; + +const { schedulingOauthApp } = schema; + +export type OauthAppRow = typeof schedulingOauthApp.$inferSelect; + +export type OauthAppCredentials = { + clientSecret: string; + /** Zoom's Secret Token, which signs the app's deauthorization events. */ + webhookSecret?: string | null; +}; + +export const encryptOauthAppCredentials = ( + credentials: OauthAppCredentials, + keys: SecretsKeyRing, +): string => encryptSecret(JSON.stringify(credentials), keys); + +export const oauthAppCredentials = ( + row: Pick, + keys: SecretsKeyRing, +): OauthAppCredentials => + JSON.parse(decryptSecret(row.encryptedCredentials, keys)) as OauthAppCredentials; + +export const oauthAppOf = (row: OauthAppRow, keys: SecretsKeyRing): OAuthApp => ({ + clientId: row.clientId, + clientSecret: oauthAppCredentials(row, keys).clientSecret, + tenantId: row.tenantId, +}); + +/** The app new consents of this environment use. Null: coss.com's. */ +export async function environmentOauthApp( + scope: { organizationId: string; environmentId: string }, + provider: OAuthProvider, + executor: Executor = db, +): Promise { + const [row] = await executor + .select() + .from(schedulingOauthApp) + .where( + and( + eq(schedulingOauthApp.organizationId, scope.organizationId), + eq(schedulingOauthApp.environmentId, scope.environmentId), + eq(schedulingOauthApp.provider, provider), + ), + ); + return row ?? null; +} + +/** The app a connection or a consent is pinned to. */ +export async function pinnedOauthApp( + id: string, + organizationId: string, +): Promise { + const [row] = await db + .select() + .from(schedulingOauthApp) + .where( + and(eq(schedulingOauthApp.id, id), eq(schedulingOauthApp.organizationId, organizationId)), + ); + return row ?? null; +} + +/** + * The app Zoom's event names in its URL. The id comes from an unauthenticated request, so nothing + * here is trusted until the event's signature has been checked with this app's token. + */ +export async function zoomOauthAppForEvents(id: string): Promise { + if (!isId(ID_PREFIXES.schedulingOauthApp, id)) return null; + const [row] = await db + .select() + .from(schedulingOauthApp) + .where(and(eq(schedulingOauthApp.id, id), eq(schedulingOauthApp.provider, "zoom"))); + return row ?? null; +} + +async function announce( + tx: Parameters[0], + app: OauthAppRow, + type: SchedulingOauthAppWebhookEventType, + code: string | null, + now: Date, +): Promise { + const id = newId(ID_PREFIXES.webhookEvent); + await enqueueWebhookMessage(tx, { + id, + organizationId: app.organizationId, + environmentId: app.environmentId, + type, + payload: { + id, + object: "event", + type, + created_at: now.toISOString(), + data: { oauth_app_id: app.id, provider: app.provider, code, occurred_at: now.toISOString() }, + }, + }); + const failing = type === "scheduling.oauth_app.failing"; + await appendLogEntries(tx, [ + { + organizationId: app.organizationId, + projectId: app.projectId, + environmentId: app.environmentId, + occurredAt: now, + source: "scheduling", + level: failing ? "error" : "info", + type, + message: failing + ? `Your ${app.provider} OAuth app's credentials were refused: calendars connected through it can't be read` + : `Your ${app.provider} OAuth app's credentials work again`, + resourceType: "scheduling_oauth_app", + resourceId: app.id, + attributes: { provider: app.provider, ...(code ? { code } : {}) }, + }, + ]); +} + +/** + * Once per outage (compare-and-set). Only if the row still holds the secret that was refused: a + * request on its way while someone rotates it says nothing about the new one. + */ +export async function markOauthAppFailing( + app: Pick, + code: string, + now: Date, +): Promise { + await db.transaction(async (tx) => { + const [changed] = await tx + .update(schedulingOauthApp) + .set({ status: "failing", lastErrorCode: code, lastErrorAt: now }) + .where( + and( + eq(schedulingOauthApp.id, app.id), + eq(schedulingOauthApp.organizationId, app.organizationId), + eq(schedulingOauthApp.status, "active"), + eq(schedulingOauthApp.encryptedCredentials, app.encryptedCredentials), + ), + ) + .returning(); + if (!changed) return; + await announce(tx, changed, "scheduling.oauth_app.failing", code, now); + await enqueueNotification(tx, { + organizationId: changed.organizationId, + kind: "scheduling.oauth_app_failing", + dedupeKey: `${changed.id}:${now.getTime()}`, + payload: { oauthAppId: changed.id }, + now, + }); + }); +} + +/** The provider accepted it again. Calendars recover on their own next read. */ +export async function restoreOauthApp( + app: Pick, + now: Date, +): Promise { + await db.transaction(async (tx) => { + const [changed] = await tx + .update(schedulingOauthApp) + .set({ status: "active", lastErrorCode: null, lastErrorAt: null }) + .where( + and( + eq(schedulingOauthApp.id, app.id), + eq(schedulingOauthApp.organizationId, app.organizationId), + eq(schedulingOauthApp.status, "failing"), + ), + ) + .returning(); + if (changed) await announce(tx, changed, "scheduling.oauth_app.restored", null, now); + }); +} diff --git a/packages/scheduling/src/calendar/oauth-apps.ts b/packages/scheduling/src/calendar/oauth-apps.ts new file mode 100644 index 000000000..c560ca40f --- /dev/null +++ b/packages/scheduling/src/calendar/oauth-apps.ts @@ -0,0 +1,520 @@ +/** + * A customer's own OAuth apps, so calendar consent screens show their brand. An environment has at + * most one per provider, and new consents use it. A connection keeps the app that issued its tokens, + * so connections made before an app existed stay on coss.com's until their host consents again. + * + * The secret is tried before it's stored, on a real connection if there is one, since a bad paste + * would otherwise take every host's calendar out at their next refresh. + */ +import type { PlanGate } from "@coss/billing/gate"; +import { db, schema } from "@coss/db"; +import { ID_PREFIXES, isId } from "@coss/db/id"; +import { withoutTrailingSlashes } from "@coss/db/url"; +import { enqueueNotification } from "@coss/notifications"; +import { decryptSecret } from "@coss/webhooks"; +import { and, asc, eq, gt, inArray, isNull, lte, sql } from "drizzle-orm"; +import { SchedulingError } from "../errors.ts"; +import type { + CreateOauthAppRequestOutput, + OauthAppObjectOutput, + UpdateOauthAppRequestOutput, +} from "../schemas.ts"; +import type { SchedulingEnvironment } from "../types.ts"; +import { CalendarError } from "./client.ts"; +import { + type CalendarDeps, + fetcherOf, + nowMs, + oauthRedirectUri, + saveRefreshedTokens, +} from "./connections.ts"; +import { SECRET_WARNING_MS } from "./delegation-notifications.ts"; +import { + APP_REFUSED, + GOOGLE_SCOPES, + MICROSOFT_SCOPES, + type OAuthApp, + type OAuthTokens, + refreshTokens, +} from "./oauth.ts"; +import { + encryptOauthAppCredentials, + environmentOauthApp, + type OauthAppRow, + oauthAppCredentials, + restoreOauthApp, +} from "./oauth-app-state.ts"; + +export { oauthAppCredentials, zoomOauthAppForEvents } from "./oauth-app-state.ts"; + +const { schedulingCalendarConnection, schedulingOauthApp } = schema; + +type Scope = Pick; +type Scan = { limit?: number; scope?: { organizationIds: readonly string[] } }; + +export type OauthAppDeps = Pick< + CalendarDeps, + "secretsKeys" | "publicUrl" | "fetcher" | "requestTimeoutMs" | "now" +> & { + /** Only creating an app is gated: rotating or removing one always works. */ + plans?: Pick | undefined; +}; + +// What a Zoom app has to be given in its own settings: Zoom doesn't take scopes in the request. +export const ZOOM_APP_SCOPES = [ + "meeting:read:list_meetings", + "meeting:read:meeting", + "meeting:write:meeting", + "meeting:update:meeting", + "meeting:delete:meeting", + "user:read:user", +]; + +const SCOPES = { google: GOOGLE_SCOPES, microsoft: MICROSOFT_SCOPES, zoom: ZOOM_APP_SCOPES }; +const SECRET_CHECK_CONNECTIONS = 3; +// Never a real token, so all this can learn is whether the provider accepts the app. +const SECRET_CHECK_TOKEN = "coss-secret-check"; + +const invalid = (message: string, param: string) => + new SchedulingError("invalid_oauth_app", message, { param }); + +const refused = () => + new SchedulingError( + "oauth_app_refused", + "The provider refused this client ID and secret. Check them, and that the secret hasn't expired.", + ); + +const unanswered = () => + new SchedulingError("calendar_unavailable", "The provider didn't answer. Try again."); + +const isUniqueViolation = (error: unknown): boolean => { + const code = (error as { code?: string; cause?: { code?: string } }).cause?.code; + return code === "23505" || (error as { code?: string }).code === "23505"; +}; + +/** + * A live environment's addresses are https. A sandbox may use localhost, to try a forwarder from a + * laptop. Only the customer's own app can ever be sent to these, so they're no open redirect. + */ +function checkedUrl( + value: string | null | undefined, + param: string, + mode: SchedulingEnvironment["mode"], +): string | null | undefined { + if (value === null || value === undefined) return value; + let url: URL; + try { + url = new URL(value); + } catch { + throw invalid("Use a full URL.", param); + } + const local = url.hostname === "localhost" || url.hostname === "127.0.0.1"; + if (url.protocol !== "https:" && !(url.protocol === "http:" && local && mode === "sandbox")) { + throw invalid("Use an https URL.", param); + } + if (url.username || url.password || url.hash) { + throw invalid("This URL can't have credentials or a fragment.", param); + } + return value; +} + +async function connectedCounts(rows: readonly OauthAppRow[]): Promise> { + const counts = new Map(); + const first = rows[0]; + if (!first) return counts; + const found = await db + .select({ + oauthAppId: schedulingCalendarConnection.oauthAppId, + connected: sql`count(*)::int`, + }) + .from(schedulingCalendarConnection) + .where( + and( + eq(schedulingCalendarConnection.organizationId, first.organizationId), + inArray( + schedulingCalendarConnection.oauthAppId, + rows.map((row) => row.id), + ), + ), + ) + .groupBy(schedulingCalendarConnection.oauthAppId); + for (const row of found) counts.set(row.oauthAppId as string, row.connected); + return counts; +} + +function toObject( + row: OauthAppRow, + counts: Map, + deps: Pick, +): OauthAppObjectOutput { + return { + object: "scheduling.oauth_app", + id: row.id, + provider: row.provider, + client_id: row.clientId, + tenant_id: row.tenantId, + redirect_uri: row.redirectUri, + coss_callback_url: oauthRedirectUri(deps, row.provider), + completion_url: row.completionUrl, + zoom_event_url: + row.provider === "zoom" + ? `${withoutTrailingSlashes(deps.publicUrl)}/scheduling/zoom/events/${row.id}` + : null, + scopes: [...SCOPES[row.provider]], + status: row.status, + last_error_code: row.lastErrorCode, + last_error_at: row.lastErrorAt?.toISOString() ?? null, + secret_expires_at: row.secretExpiresAt?.toISOString() ?? null, + verified_at: row.verifiedAt?.toISOString() ?? null, + connections: counts.get(row.id) ?? 0, + created_at: row.createdAt.toISOString(), + updated_at: row.updatedAt.toISOString(), + }; +} + +const objectOf = async (row: OauthAppRow, deps: Pick) => + toObject(row, await connectedCounts([row]), deps); + +async function findOauthApp(scope: Scope, id: string): Promise { + const [row] = isId(ID_PREFIXES.schedulingOauthApp, id) + ? await db + .select() + .from(schedulingOauthApp) + .where( + and( + eq(schedulingOauthApp.id, id), + eq(schedulingOauthApp.organizationId, scope.organizationId), + eq(schedulingOauthApp.environmentId, scope.environmentId), + ), + ) + : []; + if (!row) throw new SchedulingError("not_found", `No OAuth app "${id}" here.`); + return row; +} + +export async function listOauthApps( + scope: Scope, + deps: Pick, +): Promise { + const rows = await db + .select() + .from(schedulingOauthApp) + .where( + and( + eq(schedulingOauthApp.organizationId, scope.organizationId), + eq(schedulingOauthApp.environmentId, scope.environmentId), + ), + ) + .orderBy(asc(schedulingOauthApp.provider)); + const counts = await connectedCounts(rows); + return rows.map((row) => toObject(row, counts, deps)); +} + +export async function getOauthApp( + scope: Scope, + id: string, + deps: Pick, +): Promise { + return objectOf(await findOauthApp(scope, id), deps); +} + +/** Whether a host's page can offer Zoom: coss.com's app, or this environment's own. */ +export async function zoomConnectable( + scope: Scope, + deps: Pick, +): Promise { + return Boolean(deps.apps.zoom) || Boolean(await environmentOauthApp(scope, "zoom")); +} + +/** Whether a host's page should speak for the customer: it has an app of its own. */ +export async function hasOauthApp(scope: Scope): Promise { + const [row] = await db + .select({ id: schedulingOauthApp.id }) + .from(schedulingOauthApp) + .where( + and( + eq(schedulingOauthApp.organizationId, scope.organizationId), + eq(schedulingOauthApp.environmentId, scope.environmentId), + ), + ) + .limit(1); + return Boolean(row); +} + +/** + * With no host's token to try, a token that can't exist: a provider that knows the app says that + * token is no good, and one that doesn't says so about the app. Not every provider checks the app + * first, so passing here doesn't prove the secret. The first consent does (`verified_at`). + */ +async function checkWithoutConnections( + provider: OauthAppRow["provider"], + app: OAuthApp, + deps: OauthAppDeps, +): Promise { + try { + await refreshTokens({ + provider, + app, + refreshToken: SECRET_CHECK_TOKEN, + fetcher: fetcherOf(deps), + nowMs: nowMs(deps), + }); + } catch (error) { + if (!(error instanceof CalendarError)) throw error; + if (error.code === APP_REFUSED) throw refused(); + if (error.kind !== "auth") throw unanswered(); + } +} + +/** On a host's real token, keeping what comes back: Zoom's work once. */ +async function checkOnConnections( + row: OauthAppRow, + app: OAuthApp, + deps: OauthAppDeps, +): Promise { + const connections = await db + .select() + .from(schedulingCalendarConnection) + .where( + and( + eq(schedulingCalendarConnection.oauthAppId, row.id), + eq(schedulingCalendarConnection.organizationId, row.organizationId), + eq(schedulingCalendarConnection.status, "active"), + isNull(schedulingCalendarConnection.delegationId), + ), + ) + .limit(SECRET_CHECK_CONNECTIONS); + for (const connection of connections) { + if (!connection.encryptedCredentials) continue; + const stored = JSON.parse( + decryptSecret(connection.encryptedCredentials, deps.secretsKeys), + ) as OAuthTokens; + if (!stored.refreshToken) continue; + try { + const tokens = await refreshTokens({ + provider: row.provider, + app, + refreshToken: stored.refreshToken, + fetcher: fetcherOf(deps), + nowMs: nowMs(deps), + }); + await saveRefreshedTokens(connection, stored.refreshToken, tokens, deps); + return true; + } catch (error) { + if (!(error instanceof CalendarError)) throw error; + if (error.code === APP_REFUSED) throw refused(); + // That host's own access is gone, or another worker just spent their token: ask about another. + if (error.kind !== "auth") throw unanswered(); + } + } + return false; +} + +export async function createOauthApp( + env: SchedulingEnvironment, + params: CreateOauthAppRequestOutput, + deps: OauthAppDeps, +): Promise { + await deps.plans?.requireFeature(env.organizationId, "schedulingOauthApps"); + const redirectUri = checkedUrl(params.redirect_uri, "redirect_uri", env.mode); + const completionUrl = checkedUrl(params.completion_url, "completion_url", env.mode); + const tenantId = params.provider === "microsoft" ? (params.tenant_id ?? null) : null; + await checkWithoutConnections( + params.provider, + { clientId: params.client_id, clientSecret: params.client_secret, tenantId }, + deps, + ); + try { + const [row] = await db + .insert(schedulingOauthApp) + .values({ + organizationId: env.organizationId, + projectId: env.projectId, + environmentId: env.environmentId, + provider: params.provider, + clientId: params.client_id, + tenantId, + redirectUri: redirectUri ?? null, + completionUrl: completionUrl ?? null, + secretExpiresAt: params.secret_expires_at ? new Date(params.secret_expires_at) : null, + encryptedCredentials: encryptOauthAppCredentials( + { + clientSecret: params.client_secret, + webhookSecret: params.provider === "zoom" ? (params.webhook_secret ?? null) : null, + }, + deps.secretsKeys, + ), + }) + .returning(); + return objectOf(row as OauthAppRow, deps); + } catch (error) { + if (isUniqueViolation(error)) { + throw invalid( + `This environment already has a ${params.provider} OAuth app. Update it, or delete it first.`, + "provider", + ); + } + throw error; + } +} + +/** Not plan-gated: a secret that can't be rotated would take hosts' calendars out when it expires. */ +export async function updateOauthApp( + env: SchedulingEnvironment, + id: string, + params: UpdateOauthAppRequestOutput, + deps: OauthAppDeps, +): Promise { + const row = await findOauthApp(env, id); + if (params.tenant_id !== undefined && row.provider !== "microsoft") { + throw invalid("Only a Microsoft app has a tenant.", "tenant_id"); + } + if (params.webhook_secret !== undefined && row.provider !== "zoom") { + throw invalid("Only a Zoom app has a Secret Token.", "webhook_secret"); + } + const redirectUri = checkedUrl(params.redirect_uri, "redirect_uri", env.mode); + const completionUrl = checkedUrl(params.completion_url, "completion_url", env.mode); + const tenantId = params.tenant_id !== undefined ? params.tenant_id : row.tenantId; + const current = oauthAppCredentials(row, deps.secretsKeys); + const rotating = params.client_secret !== undefined; + if (params.client_secret !== undefined) { + const app = { clientId: row.clientId, clientSecret: params.client_secret, tenantId }; + if (!(await checkOnConnections(row, app, deps))) { + await checkWithoutConnections(row.provider, app, deps); + } + } + const credentialsChanged = rotating || params.webhook_secret !== undefined; + const [updated] = await db + .update(schedulingOauthApp) + .set({ + ...(params.tenant_id !== undefined ? { tenantId } : {}), + ...(redirectUri !== undefined ? { redirectUri } : {}), + ...(completionUrl !== undefined ? { completionUrl } : {}), + // A new secret's expiry is what's asked for now: the old one's says nothing about it. + ...(params.secret_expires_at !== undefined || rotating + ? { secretExpiresAt: params.secret_expires_at ? new Date(params.secret_expires_at) : null } + : {}), + ...(credentialsChanged + ? { + encryptedCredentials: encryptOauthAppCredentials( + { + clientSecret: params.client_secret ?? current.clientSecret, + webhookSecret: + params.webhook_secret !== undefined + ? params.webhook_secret + : (current.webhookSecret ?? null), + }, + deps.secretsKeys, + ), + } + : {}), + }) + .where( + and( + eq(schedulingOauthApp.id, row.id), + eq(schedulingOauthApp.organizationId, row.organizationId), + ), + ) + .returning(); + if (!updated) throw new SchedulingError("not_found", `No OAuth app "${id}" here.`); + if (rotating && updated.status === "failing") { + await restoreOauthApp(updated, new Date(nowMs(deps))); + return getOauthApp(env, id, deps); + } + return objectOf(updated, deps); +} + +/** + * Refused while connections use it: nothing else can refresh their tokens, and hosts' calendars aren't + * coss.com's to drop. Taking the row first means a consent finishing now fails on its foreign key. + */ +export async function deleteOauthApp(scope: Scope, id: string): Promise<{ id: string }> { + const row = await findOauthApp(scope, id); + await db.transaction(async (tx) => { + await tx + .select({ id: schedulingOauthApp.id }) + .from(schedulingOauthApp) + .where( + and( + eq(schedulingOauthApp.id, row.id), + eq(schedulingOauthApp.organizationId, row.organizationId), + ), + ) + .for("update"); + const [used] = await tx + .select({ connections: sql`count(*)::int` }) + .from(schedulingCalendarConnection) + .where( + and( + eq(schedulingCalendarConnection.oauthAppId, row.id), + eq(schedulingCalendarConnection.organizationId, row.organizationId), + ), + ); + const connections = used?.connections ?? 0; + if (connections > 0) { + throw new SchedulingError( + "oauth_app_in_use", + `${connections} calendar ${connections === 1 ? "connection uses" : "connections use"} this app, and only it can refresh their access. Disconnect them first.`, + ); + } + await tx + .delete(schedulingOauthApp) + .where( + and( + eq(schedulingOauthApp.id, row.id), + eq(schedulingOauthApp.organizationId, row.organizationId), + ), + ); + }); + return { id }; +} + +/** For the dashboard's banner: which of the environment's apps are refused right now. */ +export async function failingOauthApps(scope: Scope): Promise { + const rows = await db + .select({ provider: schedulingOauthApp.provider }) + .from(schedulingOauthApp) + .where( + and( + eq(schedulingOauthApp.organizationId, scope.organizationId), + eq(schedulingOauthApp.environmentId, scope.environmentId), + eq(schedulingOauthApp.status, "failing"), + ), + ); + return rows.map((row) => row.provider); +} + +/** Owners and admins hear before a client secret expires. Once per secret (the dedupe key). */ +export async function warnOfExpiringOauthAppSecrets( + deps: Pick, + opts: Scan = {}, +): Promise { + const now = deps.now?.() ?? new Date(); + const rows = await db + .select({ + id: schedulingOauthApp.id, + organizationId: schedulingOauthApp.organizationId, + expires: schedulingOauthApp.secretExpiresAt, + }) + .from(schedulingOauthApp) + .where( + and( + gt(schedulingOauthApp.secretExpiresAt, now), + lte(schedulingOauthApp.secretExpiresAt, new Date(now.getTime() + SECRET_WARNING_MS)), + opts.scope + ? inArray(schedulingOauthApp.organizationId, [...opts.scope.organizationIds]) + : undefined, + ), + ) + .limit(opts.limit ?? 200); + for (const row of rows) { + await enqueueNotification(db, { + organizationId: row.organizationId, + kind: "scheduling.oauth_app_secret_expiring", + dedupeKey: `${row.id}:${(row.expires as Date).toISOString()}`, + payload: { oauthAppId: row.id }, + now, + }); + } + return rows.length; +} diff --git a/packages/scheduling/src/calendar/oauth.ts b/packages/scheduling/src/calendar/oauth.ts index 4130318f8..f955e22cc 100644 --- a/packages/scheduling/src/calendar/oauth.ts +++ b/packages/scheduling/src/calendar/oauth.ts @@ -1,10 +1,19 @@ -/** OAuth for calendar access: coss.com's own apps, PKCE, and refresh. */ +/** OAuth for calendar access: coss.com's apps or a customer's own, PKCE, and refresh. */ import { createHash, randomBytes } from "node:crypto"; import { CalendarError, type HttpFetch } from "./client.ts"; export type OAuthProvider = "google" | "microsoft" | "zoom"; -export type OAuthApp = { clientId: string; clientSecret: string }; +export type OAuthApp = { + clientId: string; + clientSecret: string; + /** Microsoft only. Unset: `common`, which a single-tenant app can't use. */ + tenantId?: string | null | undefined; +}; + +/** The provider refused the app itself, rather than one host's access. */ +export const APP_REFUSED = "app_refused"; +const APP_ERRORS = new Set(["invalid_client", "deleted_client"]); export type OAuthTokens = { accessToken: string; @@ -30,8 +39,8 @@ const ENDPOINTS = { scopes: GOOGLE_SCOPES, }, microsoft: { - authorize: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", - token: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + authorize: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize", + token: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", revoke: null, scopes: MICROSOFT_SCOPES, }, @@ -44,6 +53,9 @@ const ENDPOINTS = { }, } as const; +const inTenant = (url: string, app: OAuthApp) => + url.replace("{tenant}", encodeURIComponent(app.tenantId || "common")); + export function pkcePair(): { verifier: string; challenge: string } { const verifier = randomBytes(32).toString("base64url"); return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") }; @@ -75,7 +87,7 @@ export function authorizationUrl(input: { params.set("response_mode", "query"); params.set("prompt", "select_account"); } - return `${endpoints.authorize}?${params}`; + return `${inTenant(endpoints.authorize, input.app)}?${params}`; } const basicAuth = (app: OAuthApp) => @@ -88,7 +100,7 @@ async function tokenRequest( fetcher: HttpFetch, nowMs: number, ): Promise { - const res = await fetcher(ENDPOINTS[provider].token, { + const res = await fetcher(inTenant(ENDPOINTS[provider].token, app), { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", @@ -107,6 +119,11 @@ async function tokenRequest( error?: string; }; if (!res.ok || !json.access_token) { + // A wrong or deleted secret says nothing about any host, and every one of them would get an email. + // Not `unauthorized_client`: Microsoft answers that for one tenant that doesn't allow the app. + if (json.error && APP_ERRORS.has(json.error)) { + throw new CalendarError("transient", APP_REFUSED, "The provider refused this OAuth app."); + } // A refresh token that's revoked or expired needs the host back; anything else may pass. if (json.error === "invalid_grant" || res.status === 400 || res.status === 401) { throw new CalendarError( diff --git a/packages/scheduling/src/calendar/zoom.ts b/packages/scheduling/src/calendar/zoom.ts index 1d76191ef..2dd08284e 100644 --- a/packages/scheduling/src/calendar/zoom.ts +++ b/packages/scheduling/src/calendar/zoom.ts @@ -38,9 +38,9 @@ const errorOf = (res: Response) => const minutes = (event: CalendarEventWrite) => Math.round((event.endMs - event.startMs) / 60_000); const zoomTime = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z"); -/** Zoom has no idempotency key, so a meeting carries this: a hash, as the host can read it. */ -const meetingMarker = (key: string) => - `coss.com appointment ${createHash("sha256").update(key).digest("hex")}`; +/** Zoom has no idempotency key, so a meeting carries a hash of one, as the host can read it. */ +const keyHash = (key: string) => createHash("sha256").update(key).digest("hex"); +const meetingMarker = (key: string) => `Appointment ${keyHash(key)}`; export function zoomCalendarClient(input: { accessToken: string; @@ -67,7 +67,8 @@ export function zoomCalendarClient(input: { }); // `upcoming` is reported to leave out the agenda; `scheduled` has it, cut to 250 characters. const meetingsWithKey = async (key: string): Promise => { - const marker = meetingMarker(key); + // The hash alone: meetings from before this said "coss.com appointment". + const marker = keyHash(key); const found: ZoomMeeting[] = []; let token = ""; do { diff --git a/packages/scheduling/src/errors.ts b/packages/scheduling/src/errors.ts index 8acc65dfe..356327ea8 100644 --- a/packages/scheduling/src/errors.ts +++ b/packages/scheduling/src/errors.ts @@ -20,6 +20,9 @@ export type SchedulingErrorCode = | "calendar_managed" | "invalid_calendar_delegation" | "calendar_delegation_refused" + | "invalid_oauth_app" + | "oauth_app_refused" + | "oauth_app_in_use" | "scheduling_unavailable" | "account_suspended" | "idempotency_in_progress" @@ -48,6 +51,9 @@ const STATUS_BY_CODE = { calendar_managed: 422, invalid_calendar_delegation: 422, calendar_delegation_refused: 422, + invalid_oauth_app: 422, + oauth_app_refused: 422, + oauth_app_in_use: 422, scheduling_unavailable: 503, account_suspended: 403, idempotency_in_progress: 409, diff --git a/packages/scheduling/src/index.ts b/packages/scheduling/src/index.ts index 48fc80907..69a23a7ae 100644 --- a/packages/scheduling/src/index.ts +++ b/packages/scheduling/src/index.ts @@ -8,6 +8,7 @@ export * from "./calendar/delegation.ts"; export { SCHEDULING_NOTIFICATION_KINDS } from "./calendar/delegation-notifications.ts"; export { buildIcs } from "./calendar/ical.ts"; export * from "./calendar/oauth.ts"; +export * from "./calendar/oauth-apps.ts"; export * from "./calendar/sync.ts"; export * from "./calendar/writes.ts"; export * from "./calendar/zoom.ts"; diff --git a/packages/scheduling/src/schemas.ts b/packages/scheduling/src/schemas.ts index cce81f9d8..fbf45d1ac 100644 --- a/packages/scheduling/src/schemas.ts +++ b/packages/scheduling/src/schemas.ts @@ -28,10 +28,15 @@ export const SCHEDULING_DELEGATION_EVENT_TYPES = [ "scheduling.calendar_delegation.failing", "scheduling.calendar_delegation.restored", ] as const; +export const SCHEDULING_OAUTH_APP_EVENT_TYPES = [ + "scheduling.oauth_app.failing", + "scheduling.oauth_app.restored", +] as const; export const SCHEDULING_WEBHOOK_EVENT_TYPES = [ ...SCHEDULING_BOOKING_EVENT_TYPES, ...SCHEDULING_CALENDAR_EVENT_TYPES, ...SCHEDULING_DELEGATION_EVENT_TYPES, + ...SCHEDULING_OAUTH_APP_EVENT_TYPES, ] as const; export type SchedulingWebhookEventType = (typeof SCHEDULING_WEBHOOK_EVENT_TYPES)[number]; @@ -1239,6 +1244,10 @@ export const SettingsObject = z logo: SettingsLogo.nullable().meta({ description: "At the top of the hosted pages, and of emails sent from `email_from`.", }), + privacy_url: z.string().nullable().meta({ + description: + "Your privacy policy, linked on a host's page beside what connecting a calendar does, once this environment has its own OAuth app and a `brand_name`.", + }), email_from: z.string().nullable().meta({ description: "Sender of booking emails, on a verified domain.", }), @@ -1279,6 +1288,7 @@ export const SettingsObject = z brand_name: "Acme Health", accent_color: null, logo: null, + privacy_url: null, email_from: "appointments@example.com", sms_from: null, calendar_failure_mode: "hide", @@ -1314,6 +1324,15 @@ export const UpdateSettingsRequest = z .nullable() .optional() .meta({ description: "Your logo, a PNG or JPEG. `null` removes it." }), + privacy_url: z + .url({ protocol: /^https$/ }) + .max(2000) + .nullable() + .optional() + .meta({ + description: + "Your privacy policy, as an https URL, for hosts connecting a calendar through your own OAuth app. `null` removes it.", + }), email_from: z.email().max(320).nullable().optional().meta({ description: "Sender of booking emails, on a verified domain. `null`: coss.com's own address.", @@ -1706,6 +1725,35 @@ export const SchedulingCalendarWebhookEvent = z ], }); +export const SchedulingOauthAppWebhookEvent = z + .object({ + ...webhookEnvelope, + type: z.enum(SCHEDULING_OAUTH_APP_EVENT_TYPES), + data: z.object({ + oauth_app_id: z.string(), + provider: z.string(), + code: z.string().nullable().meta({ description: "What the provider said, when it refused." }), + occurred_at: z.string(), + }), + }) + .meta({ + id: "SchedulingOauthAppWebhookEvent", + examples: [ + { + id: "whev_01kd2m9a0b1c2d3e4f5g6h7j8m", + object: "event", + type: "scheduling.oauth_app.failing", + created_at: "2027-01-01T10:00:00.000Z", + data: { + oauth_app_id: "calapp_01kd2m9a0b1c2d3e4f5g6h7j8m", + provider: "google", + code: "app_refused", + occurred_at: "2027-01-01T10:00:00.000Z", + }, + }, + ], + }); + export const SchedulingCalendarDelegationWebhookEvent = z .object({ ...webhookEnvelope, @@ -1884,6 +1932,7 @@ const CONNECTION_EXAMPLE = { account: "ana@example.com", status: "active" as const, delegation: null, + oauth_app: null, calendars: [ { id: "cal_01kd2m9a0b1c2d3e4f5g6h7j8m", @@ -1911,6 +1960,10 @@ export const CalendarConnectionObject = z status: z.enum(["active", "needs_reauth"]).meta({ description: "`needs_reauth`: access was lost, and this host isn't offered until it's back.", }), + oauth_app: z.string().nullable().meta({ + description: + "Your own OAuth app this connection's access came through. `null`: coss.com's, or no consent was involved.", + }), delegation: z.string().nullable().meta({ description: "The calendar delegation that manages this connection. It can't be disconnected or reconnected by itself.", @@ -1933,6 +1986,164 @@ export const CalendarConnectionObject = z }) .meta({ id: "SchedulingCalendarConnection", examples: [CONNECTION_EXAMPLE] }); +const ClientId = z.string().min(1).max(500).regex(/^\S+$/, "A client ID has no spaces.").meta({ + description: "The app's client ID at the provider.", +}); + +const ClientSecret = z.string().min(1).max(1000).meta({ + description: "The app's client secret. Never returned.", +}); + +const OauthAppTenant = z + .string() + .regex(/^[a-zA-Z0-9](?:[a-zA-Z0-9.-]{0,253})$/, "A tenant ID or domain, like contoso.com.") + .meta({ + description: + "The Directory (tenant) ID, for an app that's only for your own tenant. Leave it out for a multi-tenant app.", + }); + +const RedirectUri = z.string().max(2000).meta({ + description: + "A page on your own domain, registered with the provider, that forwards `code` and `state` to `coss_callback_url`. Google requires this for an external app, since it has to verify the redirect's domain as yours. Leave it out to use `coss_callback_url` directly.", +}); + +const CompletionUrl = z.string().max(2000).meta({ + description: + "Where the browser lands after a consent that didn't name a `return_url`, with `coss_calendar` set to `connected`, `denied` or `invalid`. Leave it out for a plain page saying what happened.", +}); + +const OauthAppSecretExpiresAt = z.iso.datetime({ offset: true }).meta({ + description: "When the client secret expires. Owners and admins are warned 14 days before.", +}); + +const ZoomWebhookSecret = z.string().min(1).max(1000).meta({ + description: + "The Secret Token of your Zoom app, which signs its events at `zoom_event_url`. Never returned.", +}); + +export const CreateOauthAppRequest = z + .discriminatedUnion("provider", [ + z.strictObject({ + provider: z.literal("google"), + client_id: ClientId, + client_secret: ClientSecret, + redirect_uri: RedirectUri.optional(), + completion_url: CompletionUrl.optional(), + secret_expires_at: OauthAppSecretExpiresAt.optional(), + }), + z.strictObject({ + provider: z.literal("microsoft"), + client_id: ClientId, + client_secret: ClientSecret, + tenant_id: OauthAppTenant.optional(), + redirect_uri: RedirectUri.optional(), + completion_url: CompletionUrl.optional(), + secret_expires_at: OauthAppSecretExpiresAt.optional(), + }), + z.strictObject({ + provider: z.literal("zoom"), + client_id: ClientId, + client_secret: ClientSecret, + webhook_secret: ZoomWebhookSecret.optional(), + redirect_uri: RedirectUri.optional(), + completion_url: CompletionUrl.optional(), + secret_expires_at: OauthAppSecretExpiresAt.optional(), + }), + ]) + .meta({ + id: "CreateOauthAppRequest", + examples: [ + { + provider: "google", + client_id: "1234567890-abc.apps.googleusercontent.com", + client_secret: "GOCSPX-abcdefghijklmnopqrstuvwxyz", + redirect_uri: "https://example.com/oauth/google", + completion_url: "https://example.com/calendar-connected", + }, + ], + }); +export type CreateOauthAppRequestOutput = z.output; + +export const UpdateOauthAppRequest = z + .strictObject({ + client_secret: ClientSecret.optional().meta({ + description: + "A new client secret. It's tried on a connection before it replaces the old one.", + }), + tenant_id: OauthAppTenant.nullable().optional(), + webhook_secret: ZoomWebhookSecret.nullable().optional(), + redirect_uri: RedirectUri.nullable().optional(), + completion_url: CompletionUrl.nullable().optional(), + secret_expires_at: OauthAppSecretExpiresAt.nullable().optional(), + }) + .meta({ + id: "UpdateOauthAppRequest", + examples: [{ client_secret: "GOCSPX-abcdefghijklmnopqrstuvwxyz" }], + }); +export type UpdateOauthAppRequestOutput = z.output; + +const OAUTH_APP_EXAMPLE = { + object: "scheduling.oauth_app" as const, + id: "calapp_01kd2m9a0b1c2d3e4f5g6h7j8m", + provider: "google" as const, + client_id: "1234567890-abc.apps.googleusercontent.com", + tenant_id: null, + redirect_uri: "https://example.com/oauth/google", + coss_callback_url: "https://api.coss.com/scheduling/oauth/google/callback", + completion_url: "https://example.com/calendar-connected", + zoom_event_url: null, + scopes: ["https://www.googleapis.com/auth/calendar.events.freebusy"], + status: "active" as const, + last_error_code: null, + last_error_at: null, + secret_expires_at: null, + verified_at: "2027-01-01T10:00:00.000Z", + connections: 12, + created_at: "2027-01-01T10:00:00.000Z", + updated_at: "2027-01-01T10:00:00.000Z", +}; + +export const OauthAppObject = z + .object({ + object: z.literal("scheduling.oauth_app"), + id: z.string(), + provider: z.enum(["google", "microsoft", "zoom"]), + client_id: z.string(), + tenant_id: z.string().nullable(), + redirect_uri: z.string().nullable(), + coss_callback_url: z.string().meta({ + description: + "Register this with the provider if `redirect_uri` isn't set. If it is, that page forwards `code` and `state` here.", + }), + completion_url: z.string().nullable(), + zoom_event_url: z.string().nullable().meta({ + description: + "Zoom only: the Event notification endpoint URL for your app's deauthorization events.", + }), + scopes: z.array(z.string()).meta({ + description: "What to enable on your app at the provider.", + }), + status: z.enum(["active", "failing"]).meta({ + description: + "`failing`: the provider refused the app's credentials. New consents are refused, and calendars connected through it can't be read until they work again.", + }), + last_error_code: z.string().nullable(), + last_error_at: z.string().nullable(), + secret_expires_at: z.string().nullable(), + verified_at: z.string().nullable().meta({ + description: "When a consent first completed through this app, which proves its secret.", + }), + connections: z.number().int().meta({ + description: "Calendar connections using this app. It can't be deleted while there are any.", + }), + created_at: z.string(), + updated_at: z.string(), + }) + .meta({ id: "SchedulingOauthApp", examples: [OAUTH_APP_EXAMPLE] }); +export type OauthAppObjectOutput = z.output; + +export const OauthAppList = listOf(OauthAppObject, "SchedulingOauthAppList", OAUTH_APP_EXAMPLE); + const DelegationDomain = z .string() .max(253) @@ -2077,6 +2288,12 @@ export const CalendarDelegationList = listOf( DELEGATION_EXAMPLE, ); +export const DeletedOauthApp = deleted( + "scheduling.oauth_app", + "DeletedSchedulingOauthApp", + "calapp_01kd2m9a0b1c2d3e4f5g6h7j8m", +); + export const DeletedCalendarDelegation = deleted( "scheduling.calendar_delegation", "DeletedSchedulingCalendarDelegation", diff --git a/packages/scheduling/src/settings.ts b/packages/scheduling/src/settings.ts index 22c944f84..6be9bf4ad 100644 --- a/packages/scheduling/src/settings.ts +++ b/packages/scheduling/src/settings.ts @@ -35,6 +35,7 @@ function toSettingsObject( allowed_origins: row?.allowedOrigins ?? [], brand_name: row?.brandName ?? null, accent_color: row?.accentColor ?? null, + privacy_url: row?.privacyUrl ?? null, logo: logo ? { content_type: logo.contentType as "image/png" | "image/jpeg", @@ -50,6 +51,21 @@ function toSettingsObject( }; } +/** It becomes a link on a host's page, so nothing but https: the dashboard skips the wire schema. */ +function checkedPrivacyUrl(value: string | null | undefined): string | null | undefined { + if (value === null || value === undefined) return value; + let protocol: string | null = null; + try { + protocol = new URL(value).protocol; + } catch {} + if (protocol !== "https:") { + throw new SchedulingError("invalid_event_type", "Use an https URL for your privacy policy.", { + param: "privacy_url", + }); + } + return value; +} + /** `https://host[:port]`, or also localhost in a sandbox. */ export function checkedOrigin(value: string, mode: "live" | "sandbox"): string { const refused = new SchedulingError( @@ -93,6 +109,7 @@ export async function updateSettings( opts: { screen?: SetupScreening | undefined } = {}, ): Promise { refuseLinks(params.brand_name, "brand_name"); + checkedPrivacyUrl(params.privacy_url); if (params.brand_name) await screenSetup(env, params.brand_name, opts.screen, "brand_name"); if (params.logo) await saveLogo(env, params.logo.content); else if (params.logo === null) await removeLogo(env); @@ -102,6 +119,7 @@ export async function updateSettings( : {}), ...(params.brand_name !== undefined ? { brandName: params.brand_name } : {}), ...(params.accent_color !== undefined ? { accentColor: params.accent_color } : {}), + ...(params.privacy_url !== undefined ? { privacyUrl: params.privacy_url } : {}), ...(params.email_from !== undefined ? { emailFrom: params.email_from } : {}), ...(params.sms_from !== undefined ? { smsFrom: params.sms_from } : {}), ...(params.calendar_failure_mode !== undefined diff --git a/packages/scheduling/src/testing.ts b/packages/scheduling/src/testing.ts index a680961a6..0159c6855 100644 --- a/packages/scheduling/src/testing.ts +++ b/packages/scheduling/src/testing.ts @@ -81,6 +81,16 @@ export class FakeGoogleCalendarApi { failNext: { path: RegExp; status: number; body?: unknown } | null = null; loseNextAnswer = false; refreshFails = false; + /** A client secret Google says no to, as when it's been deleted in the Cloud console. */ + refusedSecret: string | null = null; + /** Every OAuth exchange and refresh, with the app it was made with. */ + readonly tokenRequests: { + grantType: string | null; + clientId: string | null; + clientSecret: string | null; + redirectUri: string | null; + refreshToken: string | null; + }[] = []; emptyFreeBusy = false; /** Set to accept a service account's assertions, checked against this key. */ serviceAccountPublicKey: string | null = null; @@ -178,6 +188,16 @@ export class FakeGoogleCalendarApi { this.minted.push(sub); return json(200, { access_token: `dwd:${sub}`, expires_in: 3600 }); } + this.tokenRequests.push({ + grantType: form.get("grant_type"), + clientId: form.get("client_id"), + clientSecret: form.get("client_secret"), + redirectUri: form.get("redirect_uri"), + refreshToken: form.get("refresh_token"), + }); + if (form.get("client_secret") === this.refusedSecret) { + return json(401, { error: "invalid_client" }); + } if (this.refreshFails) return json(400, { error: "invalid_grant" }); return json(200, { access_token: "access", refresh_token: "refresh", expires_in: 3600 }); } @@ -411,6 +431,8 @@ export class FakeZoomApi { readonly bodies: Record[] = []; readonly requests: string[] = []; readonly revoked: string[] = []; + /** The Basic credentials of every token request: which app asked. */ + readonly tokenAuthorizations: string[] = []; userId = "zoom_user_1"; loseNextAnswer = false; failNext: { path: RegExp; status: number } | null = null; @@ -439,6 +461,7 @@ export class FakeZoomApi { return json(200, { status: "success" }); } const form = new URLSearchParams(String(init?.body)); + this.tokenAuthorizations.push(new Headers(init?.headers).get("authorization") ?? ""); if (form.get("grant_type") === "refresh_token") { if (form.get("refresh_token") !== this.refreshToken) { return json(400, { error: "invalid_grant" }); diff --git a/packages/scheduling/src/webhook-types.ts b/packages/scheduling/src/webhook-types.ts index c197e0584..b471afbb3 100644 --- a/packages/scheduling/src/webhook-types.ts +++ b/packages/scheduling/src/webhook-types.ts @@ -3,9 +3,12 @@ export type { SchedulingWebhookEventType } from "./schemas.ts"; import type { SCHEDULING_CALENDAR_EVENT_TYPES, SCHEDULING_DELEGATION_EVENT_TYPES, + SCHEDULING_OAUTH_APP_EVENT_TYPES, } from "./schemas.ts"; export type SchedulingCalendarWebhookEventType = (typeof SCHEDULING_CALENDAR_EVENT_TYPES)[number]; export type SchedulingDelegationWebhookEventType = (typeof SCHEDULING_DELEGATION_EVENT_TYPES)[number]; + +export type SchedulingOauthAppWebhookEventType = (typeof SCHEDULING_OAUTH_APP_EVENT_TYPES)[number]; diff --git a/packages/scheduling/test/oauth-apps.test.ts b/packages/scheduling/test/oauth-apps.test.ts new file mode 100644 index 000000000..519abc78d --- /dev/null +++ b/packages/scheduling/test/oauth-apps.test.ts @@ -0,0 +1,762 @@ +import { describe, expect, test } from "bun:test"; +import { createHash, randomUUID } from "node:crypto"; +import { db, schema } from "@coss/db"; +import { createTestTenant } from "@coss/tenancy/testing"; +import { decryptSecret, reencryptWebhookSecrets, secretsKeyRing } from "@coss/webhooks"; +import { and, eq } from "drizzle-orm"; +import { + APP_REFUSED, + type CalendarDeps, + CalendarError, + clientFor, + completeCalendarAuthorization, + createBooking, + createEventType, + createHost, + createOauthApp, + createSchedule, + deleteOauthApp, + disconnectCalendar, + getOauthApp, + handleZoomDeauthorization, + listConnections, + listOauthApps, + runDueCalendarSyncs, + runDueCalendarWrites, + type SchedulingEnvironment, + SchedulingError, + startCalendarAuthorization, + updateOauthApp, + warnOfExpiringOauthAppSecrets, +} from "../src/index.ts"; +import { FakeGoogleCalendarApi, FakeZoomApi, testSchedulingDeps } from "../src/testing.ts"; + +const NOW = new Date("2031-01-06T00:00:00Z"); +// Past the hour an access token lasts, so `clientFor` has to refresh. +const LATER = new Date(NOW.getTime() + 2 * 3_600_000); +const keys = secretsKeyRing([createHash("sha256").update("oauth apps test").digest()]); +const scopeOf = (env: SchedulingEnvironment) => ({ organizationIds: [env.organizationId] }); + +function depsOf( + fetcher: CalendarDeps["fetcher"], + now: () => Date = () => NOW, + zoom = false, +): CalendarDeps { + return { + secretsKeys: keys, + apps: { + google: { clientId: "coss-google", clientSecret: "coss-secret" }, + microsoft: { clientId: "coss-ms", clientSecret: "coss-ms-secret" }, + ...(zoom + ? { zoom: { clientId: "coss-zoom", clientSecret: "coss-zoom-secret", webhookSecret: "t" } } + : {}), + }, + publicUrl: "https://api.example.test", + fetcher: fetcher as NonNullable, + now, + }; +} + +async function setUp() { + const tenant = await createTestTenant(); + const env: SchedulingEnvironment = { + organizationId: tenant.organization.id, + projectId: tenant.project.id, + environmentId: tenant.production.id, + mode: "live", + }; + const host = await createHost(env, { name: "Dr. Reyes", time_zone: "UTC" }); + return { tenant, env, host }; +} + +const googleApp = { + provider: "google" as const, + client_id: "acme-google", + client_secret: "acme-secret", +}; + +async function consent( + env: SchedulingEnvironment, + hostId: string, + deps: CalendarDeps, + provider: "google" | "zoom" = "google", +) { + const { url } = await startCalendarAuthorization( + env, + hostId, + { provider, returnUrl: null }, + deps, + ); + const parsed = new URL(url); + const state = parsed.searchParams.get("state") as string; + const result = await completeCalendarAuthorization( + { provider, state, code: "code", error: null }, + deps, + ); + return { parsed, result }; +} + +const connectionsOf = (hostId: string) => + db + .select() + .from(schema.schedulingCalendarConnection) + .where(eq(schema.schedulingCalendarConnection.hostId, hostId)); + +const errorCode = async (run: Promise) => { + try { + await run; + return "ok"; + } catch (error) { + if (error instanceof SchedulingError) return error.code; + throw error; + } +}; + +describe("which app a consent uses", () => { + test("an environment's own app replaces coss.com's on the consent screen and the exchange", async () => { + const { env, host } = await setUp(); + const api = new FakeGoogleCalendarApi(); + const deps = depsOf(api.fetch); + const app = await createOauthApp(env, googleApp, deps); + const { parsed, result } = await consent(env, host.id, deps); + expect(result.outcome).toBe("connected"); + expect(parsed.searchParams.get("client_id")).toBe("acme-google"); + expect(api.tokenRequests.at(-1)).toMatchObject({ + grantType: "authorization_code", + clientId: "acme-google", + clientSecret: "acme-secret", + }); + const [connection] = await connectionsOf(host.id); + expect(connection?.oauthAppId).toBe(app.id); + expect((await listConnections(env, host.id))[0]?.oauth_app).toBe(app.id); + expect((await getOauthApp(env, app.id, deps)).verified_at).not.toBeNull(); + expect((await getOauthApp(env, app.id, deps)).connections).toBe(1); + }); + + test("a forwarder on the customer's domain is the redirect URI, at both ends", async () => { + const { env, host } = await setUp(); + const api = new FakeGoogleCalendarApi(); + const deps = depsOf(api.fetch); + await createOauthApp( + env, + { + ...googleApp, + redirect_uri: "https://acme.example/oauth/google", + completion_url: "https://acme.example/connected", + }, + deps, + ); + const { parsed, result } = await consent(env, host.id, deps); + expect(parsed.searchParams.get("redirect_uri")).toBe("https://acme.example/oauth/google"); + expect(api.tokenRequests.at(-1)?.redirectUri).toBe("https://acme.example/oauth/google"); + expect(result.returnUrl).toBe("https://acme.example/connected"); + }); + + test("the consent's own return_url wins over the app's completion URL", async () => { + const { env, host } = await setUp(); + const api = new FakeGoogleCalendarApi(); + const deps = depsOf(api.fetch); + await createOauthApp( + env, + { ...googleApp, completion_url: "https://acme.example/connected" }, + deps, + ); + const { url } = await startCalendarAuthorization( + env, + host.id, + { provider: "google", returnUrl: "https://acme.example/this-one" }, + deps, + ); + const state = new URL(url).searchParams.get("state") as string; + const result = await completeCalendarAuthorization( + { provider: "google", state, code: "code", error: null }, + deps, + ); + expect(result.returnUrl).toBe("https://acme.example/this-one"); + }); + + test("editing the app during a consent can't change what its code is exchanged with", async () => { + const { env, host } = await setUp(); + const api = new FakeGoogleCalendarApi(); + const deps = depsOf(api.fetch); + const app = await createOauthApp( + env, + { ...googleApp, redirect_uri: "https://acme.example/before" }, + deps, + ); + const { url } = await startCalendarAuthorization( + env, + host.id, + { provider: "google", returnUrl: null }, + deps, + ); + await updateOauthApp(env, app.id, { redirect_uri: "https://acme.example/after" }, deps); + const state = new URL(url).searchParams.get("state") as string; + await completeCalendarAuthorization( + { provider: "google", state, code: "code", error: null }, + deps, + ); + expect(api.tokenRequests.at(-1)?.redirectUri).toBe("https://acme.example/before"); + }); + + test("an environment with no coss.com app can still connect through its own", async () => { + const { env, host } = await setUp(); + const api = new FakeGoogleCalendarApi(); + const deps: CalendarDeps = { + ...depsOf(api.fetch), + apps: { google: null, microsoft: null }, + }; + expect( + await errorCode( + startCalendarAuthorization(env, host.id, { provider: "google", returnUrl: null }, deps), + ), + ).toBe("calendar_unavailable"); + await createOauthApp(env, googleApp, deps); + expect((await consent(env, host.id, deps)).result.outcome).toBe("connected"); + }); + + test("a single-tenant Microsoft app uses its tenant, never common", async () => { + const { env, host } = await setUp(); + const urls: string[] = []; + const fetcher = async (url: string) => { + urls.push(url); + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + }; + const deps = depsOf(fetcher); + await createOauthApp( + env, + { + provider: "microsoft", + client_id: "acme-ms", + client_secret: "s", + tenant_id: "contoso.example", + }, + deps, + ); + const { url } = await startCalendarAuthorization( + env, + host.id, + { provider: "microsoft", returnUrl: null }, + deps, + ); + expect(new URL(url).pathname).toBe("/contoso.example/oauth2/v2.0/authorize"); + expect(new URL(urls[0] as string).pathname).toBe("/contoso.example/oauth2/v2.0/token"); + }); +}); + +describe("which app a connection refreshes with", () => { + test("a connection from before the app existed stays on coss.com's, until the host consents again", async () => { + const { env, host } = await setUp(); + const api = new FakeGoogleCalendarApi(); + await consent(env, host.id, depsOf(api.fetch)); + const before = (await connectionsOf(host.id))[0]; + expect(before?.oauthAppId).toBeNull(); + + const app = await createOauthApp(env, googleApp, depsOf(api.fetch)); + await clientFor( + before as NonNullable, + depsOf(api.fetch, () => LATER), + ); + expect(api.tokenRequests.at(-1)).toMatchObject({ + grantType: "refresh_token", + clientId: "coss-google", + }); + + await consent(env, host.id, depsOf(api.fetch)); + const after = (await connectionsOf(host.id))[0]; + expect(after?.id).toBe(before?.id); + expect(after?.oauthAppId).toBe(app.id); + await clientFor( + after as NonNullable, + depsOf(api.fetch, () => LATER), + ); + expect(api.tokenRequests.at(-1)).toMatchObject({ + grantType: "refresh_token", + clientId: "acme-google", + }); + }); + + test("two environments' apps never use each other's credentials", async () => { + const { tenant, env, host } = await setUp(); + const sandbox: SchedulingEnvironment = { + ...env, + environmentId: tenant.development.id, + mode: "sandbox", + }; + const sandboxHost = await createHost(sandbox, { name: "Sam", time_zone: "UTC" }); + const api = new FakeGoogleCalendarApi(); + const deps = depsOf(api.fetch); + await createOauthApp(env, googleApp, deps); + await createOauthApp( + sandbox, + { ...googleApp, client_id: "acme-dev", client_secret: "dev-secret" }, + deps, + ); + await consent(env, host.id, deps); + expect(api.tokenRequests.at(-1)?.clientId).toBe("acme-google"); + await consent(sandbox, sandboxHost.id, deps); + expect(api.tokenRequests.at(-1)?.clientId).toBe("acme-dev"); + const [live] = await connectionsOf(host.id); + await clientFor( + live as NonNullable, + depsOf(api.fetch, () => LATER), + ); + expect(api.tokenRequests.at(-1)).toMatchObject({ + clientId: "acme-google", + clientSecret: "acme-secret", + }); + }); +}); + +describe("when the provider refuses the app", () => { + async function connected() { + const fixture = await setUp(); + const api = new FakeGoogleCalendarApi(); + const deps = depsOf(api.fetch); + const app = await createOauthApp(fixture.env, googleApp, deps); + await consent(fixture.env, fixture.host.id, deps); + const [connection] = await connectionsOf(fixture.host.id); + return { ...fixture, api, deps, app, connection: connection as NonNullable }; + } + + const notificationsOf = (organizationId: string, kind: string) => + db + .select() + .from(schema.notification) + .where( + and( + eq(schema.notification.organizationId, organizationId), + eq(schema.notification.kind, kind), + ), + ); + + test("it's one outage of the app: no host is marked or emailed", async () => { + const { env, host, api, app, connection } = await connected(); + api.refusedSecret = "acme-secret"; + const later = depsOf(api.fetch, () => LATER); + for (let i = 0; i < 2; i++) { + const refused = await clientFor(connection, later).catch((error: unknown) => error); + expect(refused).toBeInstanceOf(CalendarError); + expect((refused as CalendarError).code).toBe(APP_REFUSED); + expect((refused as CalendarError).kind).toBe("transient"); + } + expect((await getOauthApp(env, app.id, later)).status).toBe("failing"); + expect((await getOauthApp(env, app.id, later)).last_error_code).toBe(APP_REFUSED); + expect((await connectionsOf(host.id))[0]?.status).toBe("active"); + expect(await notificationsOf(env.organizationId, "scheduling.oauth_app_failing")).toHaveLength( + 1, + ); + const hostEmails = await db + .select() + .from(schema.schedulingNotification) + .where(eq(schema.schedulingNotification.hostId, host.id)); + expect(hostEmails).toHaveLength(0); + }); + + test("a sync through a refused app leaves the host's connection alone", async () => { + const { env, host, api, app } = await connected(); + api.refusedSecret = "acme-secret"; + await runDueCalendarSyncs( + depsOf(api.fetch, () => LATER), + { scope: scopeOf(env) }, + ); + expect((await connectionsOf(host.id))[0]?.status).toBe("active"); + expect((await getOauthApp(env, app.id, depsOf(api.fetch))).status).toBe("failing"); + }); + + test("while it's failing, consents are refused rather than falling back to coss.com's", async () => { + const { env, host, api, connection } = await connected(); + api.refusedSecret = "acme-secret"; + const later = depsOf(api.fetch, () => LATER); + await clientFor(connection, later).catch(() => {}); + expect( + await errorCode( + startCalendarAuthorization(env, host.id, { provider: "google", returnUrl: null }, later), + ), + ).toBe("calendar_unavailable"); + }); + + test("a good new secret ends the outage, and a bad one keeps the old", async () => { + const { env, api, app, connection } = await connected(); + api.refusedSecret = "acme-secret"; + const later = depsOf(api.fetch, () => LATER); + await clientFor(connection, later).catch(() => {}); + + expect( + await errorCode(updateOauthApp(env, app.id, { client_secret: "acme-secret" }, later)), + ).toBe("oauth_app_refused"); + expect((await getOauthApp(env, app.id, later)).status).toBe("failing"); + + const updated = await updateOauthApp(env, app.id, { client_secret: "new-secret" }, later); + expect(updated.status).toBe("active"); + expect(updated.last_error_code).toBeNull(); + // It was tried on a real connection's token before being stored. + expect(api.tokenRequests.at(-1)).toMatchObject({ + grantType: "refresh_token", + clientSecret: "new-secret", + }); + const [fresh] = await connectionsOf(connection.hostId); + await clientFor( + fresh as NonNullable, + depsOf(api.fetch, () => new Date(LATER.getTime() + 7_200_000)), + ); + expect(api.tokenRequests.at(-1)?.clientSecret).toBe("new-secret"); + }); + + test("a refusal of a secret that's since been replaced doesn't mark the new one", async () => { + const { env, api, app, connection } = await connected(); + const [row] = await db + .select() + .from(schema.schedulingOauthApp) + .where(eq(schema.schedulingOauthApp.id, app.id)); + const later = depsOf(api.fetch, () => LATER); + await updateOauthApp(env, app.id, { client_secret: "new-secret" }, later); + const { markOauthAppFailing } = await import("../src/calendar/oauth-app-state.ts"); + await markOauthAppFailing(row as NonNullable, APP_REFUSED, LATER); + expect((await getOauthApp(env, app.id, later)).status).toBe("active"); + expect(connection.status).toBe("active"); + }); + + async function bookable() { + const fixture = await connected(); + await createSchedule(fixture.env, fixture.host.id, { + rules: [{ days: [0, 1, 2, 3, 4, 5, 6], start: "09:00", end: "17:00" }], + }); + const eventType = await createEventType(fixture.env, { + title: "Consultation", + kind: "one_on_one", + duration_minutes: 30, + hosts: [{ host: fixture.host.id, role: "member", weight: 100, schedule: null }], + } as never); + await runDueCalendarSyncs(fixture.deps, { scope: scopeOf(fixture.env) }); + return { ...fixture, eventType }; + } + + const bookAt = Date.parse("2031-01-06T09:00:00Z"); + const attendee = { uid: "p", name: "Sam Lee", email: "sam@example.com", time_zone: "UTC" }; + const actor = { kind: "system" } as const; + + test("a booking still goes through on what's fresh, and a stale calendar hides the host", async () => { + const { env, host, api, app, eventType } = await bookable(); + // Read just before the token comes due, so the booking's live check is what has to refresh it. + const justRead = new Date(NOW.getTime() + 57 * 60_000); + await runDueCalendarSyncs( + depsOf(api.fetch, () => justRead), + { scope: scopeOf(env) }, + ); + api.refusedSecret = "acme-secret"; + const soon = new Date(NOW.getTime() + 59 * 60_000); + const { booking } = await createBooking( + env, + { eventType: eventType.id, startMs: bookAt, attendee }, + testSchedulingDeps({ calendars: depsOf(api.fetch, () => soon), now: () => soon }), + actor, + ); + expect(booking.status).toBe("confirmed"); + expect((await getOauthApp(env, app.id, depsOf(api.fetch))).status).toBe("failing"); + + // Hours on, nothing's been read: an unreadable calendar never reads as free. + const stale = new Date(NOW.getTime() + 6 * 3_600_000); + expect( + await errorCode( + createBooking( + env, + { + eventType: eventType.id, + startMs: bookAt + 3_600_000, + attendee: { ...attendee, uid: "q" }, + }, + testSchedulingDeps({ calendars: depsOf(api.fetch, () => stale), now: () => stale }), + actor, + ), + ), + ).toBe("slot_unavailable"); + expect((await connectionsOf(host.id))[0]?.status).toBe("active"); + }); + + test("a calendar write waits out the outage, and lands once the secret's fixed", async () => { + const { env, host, api, app, eventType } = await bookable(); + const { booking } = await createBooking( + env, + { eventType: eventType.id, startMs: bookAt, attendee }, + testSchedulingDeps({ now: () => NOW }), + actor, + ); + api.refusedSecret = "acme-secret"; + const failingDeps = { + ...depsOf(api.fetch, () => LATER), + dataKeys: testSchedulingDeps().dataKeys, + }; + await runDueCalendarWrites(failingDeps, { scope: scopeOf(env) }); + const eventsOf = () => + db + .select() + .from(schema.schedulingCalendarEvent) + .where(eq(schema.schedulingCalendarEvent.bookingId, booking.id)); + const [waiting] = await eventsOf(); + expect(waiting?.lastErrorCode).toBe(APP_REFUSED); + expect(waiting?.providerEventId).toBeNull(); + expect(waiting?.appliedVersion).not.toBe(waiting?.desiredVersion); + expect((await connectionsOf(host.id))[0]?.status).toBe("active"); + + await updateOauthApp(env, app.id, { client_secret: "new-secret" }, failingDeps); + const muchLater = new Date(LATER.getTime() + 3_600_000); + await runDueCalendarWrites({ ...failingDeps, now: () => muchLater }, { scope: scopeOf(env) }); + const [written] = await eventsOf(); + expect(written?.providerEventId).not.toBeNull(); + expect(written?.lastErrorCode).toBeNull(); + }); + + test("refusing one host's token doesn't touch the app", async () => { + const { env, host, api, app, connection } = await connected(); + api.refreshFails = true; + const later = depsOf(api.fetch, () => LATER); + const refused = (await clientFor(connection, later).catch((e: unknown) => e)) as CalendarError; + expect(refused.kind).toBe("auth"); + expect((await getOauthApp(env, app.id, later)).status).toBe("active"); + expect((await connectionsOf(host.id))[0]?.oauthAppId).toBe(app.id); + }); + + test("coss.com's own app being refused doesn't mark any host either", async () => { + const { env, host } = await setUp(); + const api = new FakeGoogleCalendarApi(); + await consent(env, host.id, depsOf(api.fetch)); + const [connection] = await connectionsOf(host.id); + api.refusedSecret = "coss-secret"; + const original = console.error; + console.error = () => {}; + try { + await runDueCalendarSyncs( + depsOf(api.fetch, () => LATER), + { scope: scopeOf(env) }, + ); + await clientFor( + connection as NonNullable, + depsOf(api.fetch, () => LATER), + ).catch(() => {}); + } finally { + console.error = original; + } + expect((await connectionsOf(host.id))[0]?.status).toBe("active"); + }); +}); + +describe("managing an app", () => { + test("a wrong secret is refused when it's added", async () => { + const { env } = await setUp(); + const api = new FakeGoogleCalendarApi(); + api.refusedSecret = "wrong"; + const deps = depsOf(api.fetch); + expect( + await errorCode(createOauthApp(env, { ...googleApp, client_secret: "wrong" }, deps)), + ).toBe("oauth_app_refused"); + expect(await listOauthApps(env, deps)).toHaveLength(0); + }); + + test("one per provider per environment", async () => { + const { env } = await setUp(); + const deps = depsOf(new FakeGoogleCalendarApi().fetch); + await createOauthApp(env, googleApp, deps); + expect(await errorCode(createOauthApp(env, googleApp, deps))).toBe("invalid_oauth_app"); + }); + + test("a secret never appears on the wire, and is stored encrypted", async () => { + const { env } = await setUp(); + const deps = depsOf(new FakeGoogleCalendarApi().fetch); + const app = await createOauthApp(env, googleApp, deps); + expect(JSON.stringify(app)).not.toContain("acme-secret"); + const [row] = await db + .select() + .from(schema.schedulingOauthApp) + .where(eq(schema.schedulingOauthApp.id, app.id)); + expect(row?.encryptedCredentials).not.toContain("acme-secret"); + expect(JSON.parse(decryptSecret(row?.encryptedCredentials as string, keys))).toMatchObject({ + clientSecret: "acme-secret", + }); + }); + + test("another organization or environment can't see or change an app", async () => { + const { env } = await setUp(); + const other = await setUp(); + const deps = depsOf(new FakeGoogleCalendarApi().fetch); + const app = await createOauthApp(env, googleApp, deps); + expect(await errorCode(getOauthApp(other.env, app.id, deps))).toBe("not_found"); + expect(await errorCode(updateOauthApp(other.env, app.id, { client_secret: "x" }, deps))).toBe( + "not_found", + ); + expect(await errorCode(deleteOauthApp(other.env, app.id))).toBe("not_found"); + }); + + test("addresses have to be https, except localhost in a sandbox", async () => { + const { tenant, env } = await setUp(); + const deps = depsOf(new FakeGoogleCalendarApi().fetch); + for (const redirect_uri of [ + "http://acme.example/oauth", + "http://localhost:3000/oauth", + "https://user:pass@acme.example/oauth", + "https://acme.example/oauth#x", + "javascript:alert(1)", + "not a url", + ]) { + expect(await errorCode(createOauthApp(env, { ...googleApp, redirect_uri }, deps))).toBe( + "invalid_oauth_app", + ); + } + const sandbox: SchedulingEnvironment = { + ...env, + environmentId: tenant.development.id, + mode: "sandbox", + }; + expect( + await errorCode( + createOauthApp( + sandbox, + { ...googleApp, redirect_uri: "http://localhost:3000/oauth" }, + deps, + ), + ), + ).toBe("ok"); + }); + + test("creating needs the plan, and rotating or deleting never does", async () => { + const { env } = await setUp(); + const deps = depsOf(new FakeGoogleCalendarApi().fetch); + const app = await createOauthApp(env, googleApp, deps); + class PlanRefused extends Error {} + const lapsed = { + ...deps, + plans: { + requireFeature: async () => { + throw new PlanRefused(); + }, + }, + }; + await expect( + createOauthApp(env, { ...googleApp, provider: "zoom" }, lapsed), + ).rejects.toBeInstanceOf(PlanRefused); + expect((await updateOauthApp(env, app.id, { client_secret: "new" }, lapsed)).id).toBe(app.id); + expect(await deleteOauthApp(env, app.id)).toEqual({ id: app.id }); + }); + + test("it can't be deleted while connections use it, and can once they've gone", async () => { + const { env, host } = await setUp(); + const api = new FakeGoogleCalendarApi(); + const deps = depsOf(api.fetch); + const app = await createOauthApp(env, googleApp, deps); + await consent(env, host.id, deps); + expect(await errorCode(deleteOauthApp(env, app.id))).toBe("oauth_app_in_use"); + const [connection] = await connectionsOf(host.id); + await disconnectCalendar(env, host.id, (connection as NonNullable).id, deps); + expect(await deleteOauthApp(env, app.id)).toEqual({ id: app.id }); + }); + + test("deleting an environment takes its apps and connections together", async () => { + const { tenant, env, host } = await setUp(); + const api = new FakeGoogleCalendarApi(); + const deps = depsOf(api.fetch); + const app = await createOauthApp(env, googleApp, deps); + await consent(env, host.id, deps); + await db.delete(schema.environment).where(eq(schema.environment.id, tenant.production.id)); + expect( + await db + .select() + .from(schema.schedulingOauthApp) + .where(eq(schema.schedulingOauthApp.id, app.id)), + ).toHaveLength(0); + }); + + test("a secret about to expire warns once, and a rotation forgets the old date", async () => { + const { env } = await setUp(); + const deps = depsOf(new FakeGoogleCalendarApi().fetch); + const soon = new Date(NOW.getTime() + 3 * 86_400_000).toISOString(); + const app = await createOauthApp(env, { ...googleApp, secret_expires_at: soon }, deps); + await warnOfExpiringOauthAppSecrets(deps, { scope: scopeOf(env) }); + await warnOfExpiringOauthAppSecrets(deps, { scope: scopeOf(env) }); + const sent = await db + .select() + .from(schema.notification) + .where( + and( + eq(schema.notification.organizationId, env.organizationId), + eq(schema.notification.kind, "scheduling.oauth_app_secret_expiring"), + ), + ); + expect(sent).toHaveLength(1); + const rotated = await updateOauthApp(env, app.id, { client_secret: "new" }, deps); + expect(rotated.secret_expires_at).toBeNull(); + }); + + test("re-encryption moves an app's credentials to the new key", async () => { + const { env } = await setUp(); + const deps = depsOf(new FakeGoogleCalendarApi().fetch); + const app = await createOauthApp(env, googleApp, deps); + const rotated = secretsKeyRing([ + createHash("sha256").update("the next key").digest(), + createHash("sha256").update("oauth apps test").digest(), + ]); + await reencryptWebhookSecrets({ + keys: rotated, + scope: scopeOf(env) as { organizationIds: string[] }, + }); + const [row] = await db + .select() + .from(schema.schedulingOauthApp) + .where(eq(schema.schedulingOauthApp.id, app.id)); + const onlyNew = secretsKeyRing([createHash("sha256").update("the next key").digest()]); + expect(JSON.parse(decryptSecret(row?.encryptedCredentials as string, onlyNew))).toMatchObject({ + clientSecret: "acme-secret", + }); + }); +}); + +describe("Zoom's deauthorization events", () => { + const zoomApp = { provider: "zoom" as const, client_id: "acme-zoom", client_secret: "acme-zs" }; + + test("a customer's event removes only that app's connections, in no other organization", async () => { + const mine = await setUp(); + const theirs = await setUp(); + const onCoss = await setUp(); + const api = new FakeZoomApi(); + // The test database is shared, and coss.com's own events reach any organization's connections. + api.userId = `zoom_${randomUUID()}`; + const deps = depsOf(api.fetch, () => NOW, true); + const app = await createOauthApp(mine.env, zoomApp, deps); + const other = await createOauthApp(theirs.env, zoomApp, deps); + // The same Zoom user, reached through three different apps. + await consent(mine.env, mine.host.id, deps, "zoom"); + await consent(theirs.env, theirs.host.id, deps, "zoom"); + await consent(onCoss.env, onCoss.host.id, deps, "zoom"); + expect((await connectionsOf(onCoss.host.id))[0]?.oauthAppId).toBeNull(); + + await handleZoomDeauthorization(api.userId, deps, app.id); + expect(await connectionsOf(mine.host.id)).toHaveLength(0); + expect(await connectionsOf(theirs.host.id)).toHaveLength(1); + expect(await connectionsOf(onCoss.host.id)).toHaveLength(1); + + // Coss.com's app reaches its own connections, and nobody on a customer's. + await handleZoomDeauthorization(api.userId, deps); + expect(await connectionsOf(onCoss.host.id)).toHaveLength(0); + expect(await connectionsOf(theirs.host.id)).toHaveLength(1); + expect((await connectionsOf(theirs.host.id))[0]?.oauthAppId).toBe(other.id); + }); + + test("revoking on disconnect uses the app that issued the token, and the token as it is now", async () => { + const { env, host } = await setUp(); + const api = new FakeZoomApi(); + const deps = depsOf(api.fetch, () => NOW, true); + await createOauthApp(env, zoomApp, deps); + await consent(env, host.id, deps, "zoom"); + const [connection] = await connectionsOf(host.id); + const later = depsOf(api.fetch, () => LATER, true); + await disconnectCalendar( + env, + host.id, + (connection as NonNullable).id, + later, + ); + const basic = `Basic ${Buffer.from("acme-zoom:acme-zs").toString("base64")}`; + expect(api.tokenAuthorizations.at(-1)).toBe(basic); + // The refresh during the disconnect spent the old token, so that's not the one revoked. + expect(api.revoked).toEqual([api.refreshToken]); + }); +}); diff --git a/packages/scheduling/test/reencrypt.test.ts b/packages/scheduling/test/reencrypt.test.ts index 45298ab8e..c237794c4 100644 --- a/packages/scheduling/test/reencrypt.test.ts +++ b/packages/scheduling/test/reencrypt.test.ts @@ -153,10 +153,11 @@ describe("reencryptSchedulingData", () => { and column_name like 'encrypted\\_%' order by 1 `); - // These two hold the webhook key ring's values (`CalendarDeps.secretsKeys`). + // These hold the webhook key ring's values (`CalendarDeps.secretsKeys`). const onWebhookKeys = [ "scheduling_calendar_connection.encrypted_credentials", "scheduling_calendar_delegation.encrypted_credentials", + "scheduling_oauth_app.encrypted_credentials", "scheduling_oauth_state.encrypted_code_verifier", ]; expect(rows.map((row) => row.name)).toEqual( diff --git a/packages/sdk/src/core/routes.generated.ts b/packages/sdk/src/core/routes.generated.ts index a0a7478d7..d4e973f75 100644 --- a/packages/sdk/src/core/routes.generated.ts +++ b/packages/sdk/src/core/routes.generated.ts @@ -154,6 +154,8 @@ export const ROUTES: readonly string[] = [ "/v1/scheduling/hosts/{host}/schedules/{schedule_id}", "/v1/scheduling/hosts/{host}/sessions", "/v1/scheduling/hosts/{host}/simulated_busy", + "/v1/scheduling/oauth_apps", + "/v1/scheduling/oauth_apps/{id}", "/v1/scheduling/series", "/v1/scheduling/series/preview", "/v1/scheduling/series/{id}/cancel", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 062831dd4..f6c0ff859 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -70,6 +70,7 @@ export type { SchedulingEventTypesResource, SchedulingHoldsResource, SchedulingHostsResource, + SchedulingOauthAppsResource, SchedulingResource, SchedulingSchedulesResource, SchedulingSeriesResource, diff --git a/packages/sdk/src/resources/scheduling.ts b/packages/sdk/src/resources/scheduling.ts index 976ad8df2..a6c36695f 100644 --- a/packages/sdk/src/resources/scheduling.ts +++ b/packages/sdk/src/resources/scheduling.ts @@ -14,10 +14,12 @@ import type { CreateSchedulingHoldParams, CreateSchedulingHostParams, CreateSchedulingHostSessionParams, + CreateSchedulingOauthAppParams, CreateSchedulingSeriesParams, DeletedSchedulingCalendarConnection, DeletedSchedulingCalendarDelegation, DeletedSchedulingHost, + DeletedSchedulingOauthApp, DeletedSchedulingSchedule, ExplainSchedulingSlotParams, ListSchedulingBookingEventsParams, @@ -35,6 +37,7 @@ import type { SchedulingCancelledSeries, SchedulingEventType, SchedulingHost, + SchedulingOauthApp, SchedulingPageParams, SchedulingSchedule, SchedulingSeries, @@ -50,6 +53,7 @@ import type { UpdateSchedulingCalendarParams, UpdateSchedulingEventTypeParams, UpdateSchedulingHostParams, + UpdateSchedulingOauthAppParams, UpdateSchedulingSettingsParams, UpsertSchedulingScheduleParams, VerifySchedulingCalendarDelegationParams, @@ -725,6 +729,73 @@ export class SchedulingCalendarDelegationsResource { } } +/** + * `coss.scheduling.oauthApps`: your own Google, Microsoft or Zoom app, so calendar consent screens show + * your brand. Connections stay on the app that issued their tokens. + */ +export class SchedulingOauthAppsResource { + readonly #http: HttpClient; + + /** @internal Use `new Coss().scheduling.oauthApps`. */ + constructor(http: HttpClient) { + this.#http = http; + } + + /** Adds one, trying the secret first. New consents in this environment use it. Not retried. */ + create( + params: CreateSchedulingOauthAppParams, + options?: ReadOptions, + ): Promise> { + return this.#http.request({ + method: "POST", + path: "/v1/scheduling/oauth_apps", + body: params, + retry: false, + signal: options?.signal, + }); + } + + /** The environment's apps, at most one per provider. */ + list(options?: ReadOptions): Promise>> { + return this.#http.request>({ + method: "GET", + path: "/v1/scheduling/oauth_apps", + signal: options?.signal, + }); + } + + get(id: string, options?: ReadOptions): Promise> { + return this.#http.request({ + method: "GET", + path: `/v1/scheduling/oauth_apps/${part(id)}`, + signal: options?.signal, + }); + } + + /** A new client secret is tried before it replaces the old one, and ends an outage if it works. */ + update( + id: string, + params: UpdateSchedulingOauthAppParams, + options?: ReadOptions, + ): Promise> { + return this.#http.request({ + method: "PATCH", + path: `/v1/scheduling/oauth_apps/${part(id)}`, + body: params, + signal: options?.signal, + }); + } + + /** Refused while connections use it, since only it can refresh their access. */ + delete(id: string, options?: ReadOptions): Promise> { + return this.#http.request({ + method: "DELETE", + path: `/v1/scheduling/oauth_apps/${part(id)}`, + signal: options?.signal, + }); + } +} + /** `coss.scheduling.sessions`: a browser's authority, without your API key. */ export class SchedulingSessionsResource { readonly #http: HttpClient; @@ -850,6 +921,7 @@ export class SchedulingResource { readonly series: SchedulingSeriesResource; readonly calendars: SchedulingCalendarsResource; readonly calendarDelegations: SchedulingCalendarDelegationsResource; + readonly oauthApps: SchedulingOauthAppsResource; readonly sessions: SchedulingSessionsResource; readonly attendees: SchedulingAttendeesResource; readonly settings: SchedulingSettingsResource; @@ -866,6 +938,7 @@ export class SchedulingResource { this.series = new SchedulingSeriesResource(http); this.calendars = new SchedulingCalendarsResource(http); this.calendarDelegations = new SchedulingCalendarDelegationsResource(http); + this.oauthApps = new SchedulingOauthAppsResource(http); this.sessions = new SchedulingSessionsResource(http); this.attendees = new SchedulingAttendeesResource(http); this.settings = new SchedulingSettingsResource(http); diff --git a/packages/sdk/src/types/scheduling.ts b/packages/sdk/src/types/scheduling.ts index 6c6feac57..2f356a53d 100644 --- a/packages/sdk/src/types/scheduling.ts +++ b/packages/sdk/src/types/scheduling.ts @@ -567,6 +567,8 @@ export interface SchedulingSettings { allowed_origins: string[]; brand_name: string | null; accent_color: string | null; + /** Linked on a host's page once this environment has its own OAuth app and a `brand_name`. */ + privacy_url: string | null; logo: SchedulingLogo | null; email_from: string | null; sms_from: string | null; @@ -578,6 +580,8 @@ export interface UpdateSchedulingSettingsParams { allowed_origins?: string[]; brand_name?: string | null; accent_color?: string | null; + /** An https URL. */ + privacy_url?: string | null; /** `content` is a PNG or JPEG's bytes, base64-encoded, 200 KB at most. */ logo?: { content: string } | null; email_from?: string | null; @@ -612,6 +616,8 @@ export interface SchedulingCalendarConnection { status: "active" | "needs_reauth"; /** The calendar delegation that manages it, which can't be disconnected or reconnected by itself. */ delegation: string | null; + /** Your own OAuth app its access came through. `null`: coss.com's, or no consent was involved. */ + oauth_app: string | null; calendars: { id: string; external_id: string; @@ -700,6 +706,80 @@ export interface DeletedSchedulingCalendarDelegation { deleted: true; } +interface CreateSchedulingOauthAppBase { + client_id: string; + /** Never returned. */ + client_secret: string; + /** + * A page on your own domain, registered with the provider, that forwards `code` and `state` to + * `coss_callback_url`. Google requires this for an external app. + */ + redirect_uri?: string; + /** Where the browser lands after a consent that didn't name a `return_url`. */ + completion_url?: string; + /** Owners and admins are warned 14 days before. */ + secret_expires_at?: string; +} + +export type CreateSchedulingOauthAppParams = + | (CreateSchedulingOauthAppBase & { provider: "google" }) + | (CreateSchedulingOauthAppBase & { + provider: "microsoft"; + /** For an app that's only for your own tenant. */ + tenant_id?: string; + }) + | (CreateSchedulingOauthAppBase & { + provider: "zoom"; + /** The app's Secret Token, which signs its events at `zoom_event_url`. Never returned. */ + webhook_secret?: string; + }); + +export interface UpdateSchedulingOauthAppParams { + /** Tried on a connection before it replaces the old one. */ + client_secret?: string; + tenant_id?: string | null; + webhook_secret?: string | null; + redirect_uri?: string | null; + completion_url?: string | null; + secret_expires_at?: string | null; +} + +export interface SchedulingOauthApp { + object: "scheduling.oauth_app"; + id: string; + provider: "google" | "microsoft" | "zoom"; + client_id: string; + tenant_id: string | null; + redirect_uri: string | null; + /** Register this with the provider if `redirect_uri` isn't set. If it is, that page forwards here. */ + coss_callback_url: string; + completion_url: string | null; + /** Zoom only: the Event notification endpoint URL for your app's deauthorization events. */ + zoom_event_url: string | null; + /** What to enable on your app at the provider. */ + scopes: string[]; + /** + * `failing`: the provider refused the app's credentials. New consents are refused, and calendars + * connected through it can't be read until they work again. + */ + status: "active" | "failing"; + last_error_code: string | null; + last_error_at: string | null; + secret_expires_at: string | null; + /** When a consent first completed through this app, which proves its secret. */ + verified_at: string | null; + /** Calendar connections using this app. It can't be deleted while there are any. */ + connections: number; + created_at: string; + updated_at: string; +} + +export interface DeletedSchedulingOauthApp { + object: "scheduling.oauth_app"; + id: string; + deleted: true; +} + export interface SetSchedulingSimulatedBusyParams { intervals: { start: string; end: string }[]; } @@ -752,10 +832,15 @@ export type SchedulingCalendarDelegationWebhookEventType = | "scheduling.calendar_delegation.failing" | "scheduling.calendar_delegation.restored"; +export type SchedulingOauthAppWebhookEventType = + | "scheduling.oauth_app.failing" + | "scheduling.oauth_app.restored"; + export type SchedulingWebhookEventType = | SchedulingBookingWebhookEventType | SchedulingCalendarWebhookEventType - | SchedulingCalendarDelegationWebhookEventType; + | SchedulingCalendarDelegationWebhookEventType + | SchedulingOauthAppWebhookEventType; /** Ids, times and statuses only: a person's details and answers come from retrieving the booking. */ export interface SchedulingBookingWebhookEventData { @@ -789,6 +874,21 @@ export interface SchedulingCalendarWebhookEvent { data: { host_id: string; connection_id: string; provider: string; occurred_at: string }; } +export interface SchedulingOauthAppWebhookEvent { + id: string; + object: "event"; + type: SchedulingOauthAppWebhookEventType; + api_version: string; + created_at: string; + data: { + oauth_app_id: string; + provider: string; + /** What the provider said, when it refused. */ + code: string | null; + occurred_at: string; + }; +} + export interface SchedulingCalendarDelegationWebhookEvent { id: string; object: "event"; diff --git a/packages/sdk/src/types/webhook.ts b/packages/sdk/src/types/webhook.ts index 7de3ffeb6..3a61e890a 100644 --- a/packages/sdk/src/types/webhook.ts +++ b/packages/sdk/src/types/webhook.ts @@ -17,6 +17,7 @@ import type { SchedulingBookingWebhookEvent, SchedulingCalendarDelegationWebhookEvent, SchedulingCalendarWebhookEvent, + SchedulingOauthAppWebhookEvent, } from "./scheduling.ts"; import type { SignRequestWebhookEvent } from "./sign.ts"; import type { @@ -490,4 +491,5 @@ export type WebhookEvent = | FormSubmissionWebhookEvent | SchedulingBookingWebhookEvent | SchedulingCalendarWebhookEvent - | SchedulingCalendarDelegationWebhookEvent; + | SchedulingCalendarDelegationWebhookEvent + | SchedulingOauthAppWebhookEvent; diff --git a/packages/sdk/src/webhook-event-types.ts b/packages/sdk/src/webhook-event-types.ts index 95324d1b2..ff0951fd9 100644 --- a/packages/sdk/src/webhook-event-types.ts +++ b/packages/sdk/src/webhook-event-types.ts @@ -90,6 +90,8 @@ export const WEBHOOK_EVENT_TYPES: readonly WebhookEventType[] = [ "scheduling.calendar.disconnected", "scheduling.calendar_delegation.failing", "scheduling.calendar_delegation.restored", + "scheduling.oauth_app.failing", + "scheduling.oauth_app.restored", ]; /** Types only live environments receive: `domain.*`, `account.*`, `subaccount.*` and `billing.*`. */ diff --git a/packages/sdk/test/diagnose-products.test.ts b/packages/sdk/test/diagnose-products.test.ts index 0f390742b..8292d09f3 100644 --- a/packages/sdk/test/diagnose-products.test.ts +++ b/packages/sdk/test/diagnose-products.test.ts @@ -804,6 +804,7 @@ describe("diagnoseSchedulingSlots", () => { { object: "scheduling.calendar_connection" as const, delegation: null, + oauth_app: null, id: "scc_1", host: "shost_1", provider: "caldav" as const, @@ -850,6 +851,7 @@ describe("diagnoseSchedulingSlots", () => { { object: "scheduling.calendar_connection" as const, delegation: null, + oauth_app: null, id: "scc_1", host: "shost_1", provider: "google" as const, @@ -906,6 +908,7 @@ describe("diagnoseSchedulingSlots", () => { const connection = (provider: "zoom" | "simulated") => ({ object: "scheduling.calendar_connection" as const, delegation: null, + oauth_app: null, id: "scc_1", host: "shost_1", provider, @@ -945,6 +948,7 @@ describe("diagnoseSchedulingSlots", () => { { object: "scheduling.calendar_connection", delegation: null, + oauth_app: null, id: "scc_1", host: "shost_1", provider: "google", diff --git a/packages/webhooks/AGENTS.md b/packages/webhooks/AGENTS.md index dcffd43b6..a4e64f11c 100644 --- a/packages/webhooks/AGENTS.md +++ b/packages/webhooks/AGENTS.md @@ -11,7 +11,7 @@ repository root. `v2:` prefix is the AAD; ids are derived from the keys), encrypted with the first key, decrypted with the one named. Keyless `v1:` rows from before are still read by trying every key until `webhooks:reencrypt` rewrites them. Every write of a secret column goes through `encryptSecret`, and `reencryptWebhookSecrets` must cover every - encrypted column (today the webhook and Hooks endpoints' `secret_ciphertext` and `previous_secret_ciphertext`, `log_drain.secret_ciphertext` and `auth_header_ciphertext`, `risk_settings.captcha_secret_ciphertext`, `scheduling_calendar_connection.encrypted_credentials`, `scheduling_calendar_delegation.encrypted_credentials` and `migration.credentials_ciphertext`, the provider key an import from another provider holds until it finishes). Rotation runbook: README.md, + encrypted column (today the webhook and Hooks endpoints' `secret_ciphertext` and `previous_secret_ciphertext`, `log_drain.secret_ciphertext` and `auth_header_ciphertext`, `risk_settings.captcha_secret_ciphertext`, `scheduling_calendar_connection.encrypted_credentials`, `scheduling_calendar_delegation.encrypted_credentials`, `scheduling_oauth_app.encrypted_credentials` and `migration.credentials_ciphertext`, the provider key an import from another provider holds until it finishes). Rotation runbook: README.md, "Rotating `WEBHOOK_SECRETS_KEY`". The initial upgrade from v1-only readers must use README.md's "Upgrading from v1 webhook secret storage" coordinated cutover, not a rolling deployment. - **Delivery engine** (`@coss/webhooks/engine`): webhook deliveries, log drain batches (and future products) share diff --git a/packages/webhooks/src/reencrypt.ts b/packages/webhooks/src/reencrypt.ts index a4ccec8a3..f3cfe7de3 100644 --- a/packages/webhooks/src/reencrypt.ts +++ b/packages/webhooks/src/reencrypt.ts @@ -19,6 +19,7 @@ const { riskSettings, schedulingCalendarConnection, schedulingCalendarDelegation, + schedulingOauthApp, smsAccount, webhookEndpoint, } = schema; @@ -75,6 +76,7 @@ export async function reencryptWebhookSecrets(opts: { await reencryptMigrationCredentials({ ...opts, batchSize }, result); await reencryptCalendarCredentials({ ...opts, batchSize }, result); await reencryptCalendarDelegations({ ...opts, batchSize }, result); + await reencryptSchedulingOauthApps({ ...opts, batchSize }, result); return result; } @@ -447,6 +449,77 @@ async function reencryptCalendarDelegations( } } +async function reencryptSchedulingOauthApps( + opts: { + keys: SecretsKeyRing; + dryRun?: boolean; + batchSize: number; + scope?: { organizationIds: string[] }; + }, + result: ReencryptResult, +): Promise { + const { keys } = opts; + let after = ""; + for (;;) { + const rows = await db + .select({ + id: schedulingOauthApp.id, + organizationId: schedulingOauthApp.organizationId, + stored: schedulingOauthApp.encryptedCredentials, + }) + .from(schedulingOauthApp) + .where( + and( + gt(schedulingOauthApp.id, after), + opts.scope + ? inArray(schedulingOauthApp.organizationId, opts.scope.organizationIds) + : undefined, + ), + ) + .orderBy(asc(schedulingOauthApp.id)) + .limit(opts.batchSize); + for (const row of rows) { + const { stored } = row; + result.checked++; + if (isEncryptedWithPrimary(stored, keys)) continue; + let encryptedCredentials: string; + try { + encryptedCredentials = encryptSecret(decryptSecret(stored, keys), keys); + } catch (error) { + result.unreadable.push({ + id: row.id, + organizationId: row.organizationId, + error: error instanceof Error ? error.message : String(error), + }); + continue; + } + if (opts.dryRun) { + result.reencrypted++; + continue; + } + const [updated] = await db + .update(schedulingOauthApp) + .set({ + encryptedCredentials, + updatedAt: sql`${schedulingOauthApp.updatedAt}`, + }) + .where( + and( + eq(schedulingOauthApp.id, row.id), + eq(schedulingOauthApp.organizationId, row.organizationId), + eq(schedulingOauthApp.encryptedCredentials, stored), + ), + ) + .returning({ id: schedulingOauthApp.id }); + if (updated) result.reencrypted++; + else result.conflicts++; + } + const last = rows.at(-1); + if (!last || rows.length < opts.batchSize) break; + after = last.id; + } +} + /** Provider API keys of imports from other providers (`@coss/migrate`), held until they finish. */ async function reencryptMigrationCredentials( opts: { diff --git a/tooling/sdkgen/src/methods.ts b/tooling/sdkgen/src/methods.ts index c7627b947..8b1db0193 100644 --- a/tooling/sdkgen/src/methods.ts +++ b/tooling/sdkgen/src/methods.ts @@ -57,6 +57,11 @@ export const SDK_METHODS: Readonly> = { "PATCH /v1/scheduling/calendar_delegations/{id}": "scheduling.calendarDelegations.update", "POST /v1/scheduling/calendar_delegations/{id}/verify": "scheduling.calendarDelegations.verify", "DELETE /v1/scheduling/calendar_delegations/{id}": "scheduling.calendarDelegations.delete", + "POST /v1/scheduling/oauth_apps": "scheduling.oauthApps.create", + "GET /v1/scheduling/oauth_apps": "scheduling.oauthApps.list", + "GET /v1/scheduling/oauth_apps/{id}": "scheduling.oauthApps.get", + "PATCH /v1/scheduling/oauth_apps/{id}": "scheduling.oauthApps.update", + "DELETE /v1/scheduling/oauth_apps/{id}": "scheduling.oauthApps.delete", "POST /v1/scheduling/hosts/{host}/sessions": "scheduling.sessions.createForHost", "POST /v1/scheduling/booker_sessions": "scheduling.sessions.createForBooker", "POST /v1/scheduling/bookings/{id}/manage_sessions": "scheduling.sessions.createForBooking", @@ -347,6 +352,7 @@ export const NO_RETRY: ReadonlySet = new Set([ "POST /v1/forms", "POST /v1/scheduling/hosts", "POST /v1/scheduling/calendar_delegations", + "POST /v1/scheduling/oauth_apps", "POST /v1/scheduling/hosts/{host}/schedules", "POST /v1/scheduling/event_types", "POST /v1/scheduling/hosts/{host}/sessions", From b4ea5d231fe2e92f55a344873be3269a1947ceec Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Thu, 5 Nov 2026 08:01:46 -0600 Subject: [PATCH 2/3] Address Codex review (round 1) - Updating an app re-reads its credentials under a row lock, so a Secret Token saved while a new client secret is being tried isn't overwritten. - A successful refresh or consent always tries to end an outage, fenced to the credentials that worked, rather than trusting the status it read beforehand. - The expiry warning walks every app in the window with a cursor: those already warned stay in it for two weeks, and could starve the ones behind them. --- .../scheduling/src/calendar/connections.ts | 4 +- .../src/calendar/oauth-app-state.ts | 19 ++- .../scheduling/src/calendar/oauth-apps.ts | 149 ++++++++++-------- packages/scheduling/test/oauth-apps.test.ts | 84 +++++++++- 4 files changed, 182 insertions(+), 74 deletions(-) diff --git a/packages/scheduling/src/calendar/connections.ts b/packages/scheduling/src/calendar/connections.ts index 342942873..d9b1d3fa1 100644 --- a/packages/scheduling/src/calendar/connections.ts +++ b/packages/scheduling/src/calendar/connections.ts @@ -610,7 +610,9 @@ async function oauthAppAccepted(own: OauthAppRow, deps: Pick, + app: Pick, now: Date, + tx?: Parameters[0], ): Promise { - await db.transaction(async (tx) => { - const [changed] = await tx + const restore = async (executor: Parameters[0]) => { + const [changed] = await executor .update(schedulingOauthApp) .set({ status: "active", lastErrorCode: null, lastErrorAt: null }) .where( @@ -182,9 +186,12 @@ export async function restoreOauthApp( eq(schedulingOauthApp.id, app.id), eq(schedulingOauthApp.organizationId, app.organizationId), eq(schedulingOauthApp.status, "failing"), + eq(schedulingOauthApp.encryptedCredentials, app.encryptedCredentials), ), ) .returning(); - if (changed) await announce(tx, changed, "scheduling.oauth_app.restored", null, now); - }); + if (changed) await announce(executor, changed, "scheduling.oauth_app.restored", null, now); + }; + if (tx) await restore(tx); + else await db.transaction(restore); } diff --git a/packages/scheduling/src/calendar/oauth-apps.ts b/packages/scheduling/src/calendar/oauth-apps.ts index c560ca40f..138551a35 100644 --- a/packages/scheduling/src/calendar/oauth-apps.ts +++ b/packages/scheduling/src/calendar/oauth-apps.ts @@ -375,7 +375,6 @@ export async function updateOauthApp( const redirectUri = checkedUrl(params.redirect_uri, "redirect_uri", env.mode); const completionUrl = checkedUrl(params.completion_url, "completion_url", env.mode); const tenantId = params.tenant_id !== undefined ? params.tenant_id : row.tenantId; - const current = oauthAppCredentials(row, deps.secretsKeys); const rotating = params.client_secret !== undefined; if (params.client_secret !== undefined) { const app = { clientId: row.clientId, clientSecret: params.client_secret, tenantId }; @@ -384,44 +383,48 @@ export async function updateOauthApp( } } const credentialsChanged = rotating || params.webhook_secret !== undefined; - const [updated] = await db - .update(schedulingOauthApp) - .set({ - ...(params.tenant_id !== undefined ? { tenantId } : {}), - ...(redirectUri !== undefined ? { redirectUri } : {}), - ...(completionUrl !== undefined ? { completionUrl } : {}), - // A new secret's expiry is what's asked for now: the old one's says nothing about it. - ...(params.secret_expires_at !== undefined || rotating - ? { secretExpiresAt: params.secret_expires_at ? new Date(params.secret_expires_at) : null } - : {}), - ...(credentialsChanged - ? { - encryptedCredentials: encryptOauthAppCredentials( - { - clientSecret: params.client_secret ?? current.clientSecret, - webhookSecret: - params.webhook_secret !== undefined - ? params.webhook_secret - : (current.webhookSecret ?? null), - }, - deps.secretsKeys, - ), - } - : {}), - }) - .where( - and( - eq(schedulingOauthApp.id, row.id), - eq(schedulingOauthApp.organizationId, row.organizationId), - ), - ) - .returning(); - if (!updated) throw new SchedulingError("not_found", `No OAuth app "${id}" here.`); - if (rotating && updated.status === "failing") { - await restoreOauthApp(updated, new Date(nowMs(deps))); - return getOauthApp(env, id, deps); - } - return objectOf(updated, deps); + const thisRow = and( + eq(schedulingOauthApp.id, row.id), + eq(schedulingOauthApp.organizationId, row.organizationId), + ); + await db.transaction(async (tx) => { + // Read again under the lock: trying that secret took a while, and whatever was saved meanwhile + // has to survive, like a Secret Token this request isn't changing. + const [locked] = await tx.select().from(schedulingOauthApp).where(thisRow).for("update"); + if (!locked) throw new SchedulingError("not_found", `No OAuth app "${id}" here.`); + const current = oauthAppCredentials(locked, deps.secretsKeys); + const [updated] = await tx + .update(schedulingOauthApp) + .set({ + ...(params.tenant_id !== undefined ? { tenantId } : {}), + ...(redirectUri !== undefined ? { redirectUri } : {}), + ...(completionUrl !== undefined ? { completionUrl } : {}), + // A new secret's expiry is what's asked for now: the old one's says nothing about it. + ...(params.secret_expires_at !== undefined || rotating + ? { + secretExpiresAt: params.secret_expires_at ? new Date(params.secret_expires_at) : null, + } + : {}), + ...(credentialsChanged + ? { + encryptedCredentials: encryptOauthAppCredentials( + { + clientSecret: params.client_secret ?? current.clientSecret, + webhookSecret: + params.webhook_secret !== undefined + ? params.webhook_secret + : (current.webhookSecret ?? null), + }, + deps.secretsKeys, + ), + } + : {}), + }) + .where(thisRow) + .returning(); + if (rotating && updated) await restoreOauthApp(updated, new Date(nowMs(deps)), tx); + }); + return getOauthApp(env, id, deps); } /** @@ -484,37 +487,51 @@ export async function failingOauthApps(scope: Scope): Promise { return rows.map((row) => row.provider); } -/** Owners and admins hear before a client secret expires. Once per secret (the dedupe key). */ +/** + * Owners and admins hear before a client secret expires. Once per secret (the dedupe key), and it + * walks every app in the window: those already told stay in it for two weeks, so a batch that always + * started at the beginning would never reach the ones behind them. `limit` is the batch's size. + */ export async function warnOfExpiringOauthAppSecrets( deps: Pick, opts: Scan = {}, ): Promise { const now = deps.now?.() ?? new Date(); - const rows = await db - .select({ - id: schedulingOauthApp.id, - organizationId: schedulingOauthApp.organizationId, - expires: schedulingOauthApp.secretExpiresAt, - }) - .from(schedulingOauthApp) - .where( - and( - gt(schedulingOauthApp.secretExpiresAt, now), - lte(schedulingOauthApp.secretExpiresAt, new Date(now.getTime() + SECRET_WARNING_MS)), - opts.scope - ? inArray(schedulingOauthApp.organizationId, [...opts.scope.organizationIds]) - : undefined, - ), - ) - .limit(opts.limit ?? 200); - for (const row of rows) { - await enqueueNotification(db, { - organizationId: row.organizationId, - kind: "scheduling.oauth_app_secret_expiring", - dedupeKey: `${row.id}:${(row.expires as Date).toISOString()}`, - payload: { oauthAppId: row.id }, - now, - }); + const batchSize = opts.limit ?? 200; + let checked = 0; + let after: string | null = null; + for (;;) { + const rows: { id: string; organizationId: string; expires: Date | null }[] = await db + .select({ + id: schedulingOauthApp.id, + organizationId: schedulingOauthApp.organizationId, + expires: schedulingOauthApp.secretExpiresAt, + }) + .from(schedulingOauthApp) + .where( + and( + gt(schedulingOauthApp.secretExpiresAt, now), + lte(schedulingOauthApp.secretExpiresAt, new Date(now.getTime() + SECRET_WARNING_MS)), + opts.scope + ? inArray(schedulingOauthApp.organizationId, [...opts.scope.organizationIds]) + : undefined, + after ? gt(schedulingOauthApp.id, after) : undefined, + ), + ) + .orderBy(asc(schedulingOauthApp.id)) + .limit(batchSize); + for (const row of rows) { + await enqueueNotification(db, { + organizationId: row.organizationId, + kind: "scheduling.oauth_app_secret_expiring", + dedupeKey: `${row.id}:${(row.expires as Date).toISOString()}`, + payload: { oauthAppId: row.id }, + now, + }); + } + checked += rows.length; + const last = rows.at(-1); + if (!last || rows.length < batchSize) return checked; + after = last.id; } - return rows.length; } diff --git a/packages/scheduling/test/oauth-apps.test.ts b/packages/scheduling/test/oauth-apps.test.ts index 519abc78d..6fd3868a3 100644 --- a/packages/scheduling/test/oauth-apps.test.ts +++ b/packages/scheduling/test/oauth-apps.test.ts @@ -4,6 +4,7 @@ import { db, schema } from "@coss/db"; import { createTestTenant } from "@coss/tenancy/testing"; import { decryptSecret, reencryptWebhookSecrets, secretsKeyRing } from "@coss/webhooks"; import { and, eq } from "drizzle-orm"; +import { markOauthAppFailing } from "../src/calendar/oauth-app-state.ts"; import { APP_REFUSED, type CalendarDeps, @@ -411,7 +412,6 @@ describe("when the provider refuses the app", () => { .where(eq(schema.schedulingOauthApp.id, app.id)); const later = depsOf(api.fetch, () => LATER); await updateOauthApp(env, app.id, { client_secret: "new-secret" }, later); - const { markOauthAppFailing } = await import("../src/calendar/oauth-app-state.ts"); await markOauthAppFailing(row as NonNullable, APP_REFUSED, LATER); expect((await getOauthApp(env, app.id, later)).status).toBe("active"); expect(connection.status).toBe("active"); @@ -507,6 +507,23 @@ describe("when the provider refuses the app", () => { expect(written?.lastErrorCode).toBeNull(); }); + test("a refresh that works ends an outage that began while it was on its way", async () => { + const { env, api, app, connection } = await connected(); + const later = depsOf(api.fetch, () => LATER); + const [row] = await db + .select() + .from(schema.schedulingOauthApp) + .where(eq(schema.schedulingOauthApp.id, app.id)); + const held = api.hold("POST", /\/token$/); + const refreshing = clientFor(connection, later); + await held.arrived; + // Read as active by the refresh above, then refused for someone else before it gets its answer. + await markOauthAppFailing(row as NonNullable, APP_REFUSED, LATER); + held.release(); + await refreshing; + expect((await getOauthApp(env, app.id, later)).status).toBe("active"); + }); + test("refusing one host's token doesn't touch the app", async () => { const { env, host, api, app, connection } = await connected(); api.refreshFails = true; @@ -686,6 +703,71 @@ describe("managing an app", () => { expect(rotated.secret_expires_at).toBeNull(); }); + test("every app about to expire hears, however many there are", async () => { + const { env } = await setUp(); + const other = await setUp(); + const deps = depsOf(new FakeGoogleCalendarApi().fetch); + const soon = new Date(NOW.getTime() + 3 * 86_400_000).toISOString(); + await createOauthApp(env, { ...googleApp, secret_expires_at: soon }, deps); + await createOauthApp(other.env, { ...googleApp, secret_expires_at: soon }, deps); + const scope = { organizationIds: [env.organizationId, other.env.organizationId] }; + // A batch of one: the second app is only reached by going past the first. + expect(await warnOfExpiringOauthAppSecrets(deps, { scope, limit: 1 })).toBe(2); + for (const { organizationId } of [env, other.env]) { + const sent = await db + .select() + .from(schema.notification) + .where( + and( + eq(schema.notification.organizationId, organizationId), + eq(schema.notification.kind, "scheduling.oauth_app_secret_expiring"), + ), + ); + expect(sent).toHaveLength(1); + } + }); + + test("a new secret doesn't lose a Secret Token saved while it was being tried", async () => { + const { env } = await setUp(); + const api = new FakeZoomApi(); + let arrive = () => {}; + let release = () => {}; + const arrived = new Promise((resolve) => { + arrive = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + let holding = false; + const deps = depsOf(async (url, init) => { + if (holding && String(url).includes("/oauth/token")) { + holding = false; + arrive(); + await gate; + } + return api.fetch(url, init); + }); + const app = await createOauthApp( + env, + { provider: "zoom", client_id: "c", client_secret: "old", webhook_secret: "old-token" }, + deps, + ); + holding = true; + const rotating = updateOauthApp(env, app.id, { client_secret: "new" }, deps); + await arrived; + await updateOauthApp(env, app.id, { webhook_secret: "new-token" }, deps); + release(); + await rotating; + const [row] = await db + .select() + .from(schema.schedulingOauthApp) + .where(eq(schema.schedulingOauthApp.id, app.id)); + expect(JSON.parse(decryptSecret(row?.encryptedCredentials as string, keys))).toEqual({ + clientSecret: "new", + webhookSecret: "new-token", + }); + }); + test("re-encryption moves an app's credentials to the new key", async () => { const { env } = await setUp(); const deps = depsOf(new FakeGoogleCalendarApi().fetch); From 695a634ea01cadc5465313beec0098e7ce6ee7bb Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Thu, 5 Nov 2026 08:16:18 -0600 Subject: [PATCH 3/3] Address Codex review (round 3) A Microsoft consent is exchanged at the tenant it started with: the state row now keeps tenant_id alongside the app and redirect URI, so editing the app while a consent is in progress can't send its code to a different authority. The branch's one migration is regenerated with the new column. --- ...clint_barton.sql => 0140_living_forge.sql} | 1 + .../db/migrations/meta/0140_snapshot.json | 8 ++++- packages/db/migrations/meta/_journal.json | 4 +-- packages/db/src/schema/scheduling.ts | 1 + packages/scheduling/AGENTS.md | 2 +- .../scheduling/src/calendar/connections.ts | 4 ++- packages/scheduling/test/oauth-apps.test.ts | 32 +++++++++++++++++++ 7 files changed, 47 insertions(+), 5 deletions(-) rename packages/db/migrations/{0140_zippy_clint_barton.sql => 0140_living_forge.sql} (96%) diff --git a/packages/db/migrations/0140_zippy_clint_barton.sql b/packages/db/migrations/0140_living_forge.sql similarity index 96% rename from packages/db/migrations/0140_zippy_clint_barton.sql rename to packages/db/migrations/0140_living_forge.sql index 44eaed5c0..fd4695fbd 100644 --- a/packages/db/migrations/0140_zippy_clint_barton.sql +++ b/packages/db/migrations/0140_living_forge.sql @@ -25,6 +25,7 @@ CREATE TABLE "scheduling_oauth_app" ( ALTER TABLE "scheduling_calendar_connection" ADD COLUMN "oauth_app_id" text;--> statement-breakpoint ALTER TABLE "scheduling_oauth_state" ADD COLUMN "oauth_app_id" text;--> statement-breakpoint ALTER TABLE "scheduling_oauth_state" ADD COLUMN "redirect_uri" text;--> statement-breakpoint +ALTER TABLE "scheduling_oauth_state" ADD COLUMN "tenant_id" text;--> statement-breakpoint ALTER TABLE "scheduling_settings" ADD COLUMN "privacy_url" text;--> statement-breakpoint ALTER TABLE "scheduling_oauth_app" ADD CONSTRAINT "scheduling_oauth_app_environment_fk" FOREIGN KEY ("environment_id","organization_id") REFERENCES "public"."environment"("id","organization_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "scheduling_calendar_connection" ADD CONSTRAINT "scheduling_calendar_connection_oauth_app_fk" FOREIGN KEY ("oauth_app_id","organization_id") REFERENCES "public"."scheduling_oauth_app"("id","organization_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint diff --git a/packages/db/migrations/meta/0140_snapshot.json b/packages/db/migrations/meta/0140_snapshot.json index 7f7a16a5c..43895745b 100644 --- a/packages/db/migrations/meta/0140_snapshot.json +++ b/packages/db/migrations/meta/0140_snapshot.json @@ -1,5 +1,5 @@ { - "id": "fc331de0-13ad-4baf-8195-d542a5496d23", + "id": "e51100c8-f180-430b-9ea7-30cd2b2521fb", "prevId": "3f39bc05-a004-4202-b808-76f92b10d3e5", "version": "7", "dialect": "postgresql", @@ -24689,6 +24689,12 @@ "primaryKey": false, "notNull": false }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, "expires_at": { "name": "expires_at", "type": "timestamp with time zone", diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index bc3c37c3b..12c9bd246 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -985,8 +985,8 @@ { "idx": 140, "version": "7", - "when": 1793887662889, - "tag": "0140_zippy_clint_barton", + "when": 1793888126066, + "tag": "0140_living_forge", "breakpoints": true } ] diff --git a/packages/db/src/schema/scheduling.ts b/packages/db/src/schema/scheduling.ts index 337b992b8..9fbf9ed27 100644 --- a/packages/db/src/schema/scheduling.ts +++ b/packages/db/src/schema/scheduling.ts @@ -1525,6 +1525,7 @@ export const schedulingOauthState = pgTable( // Fixed when the consent starts: a code can only be exchanged with what it was issued for. oauthAppId: text("oauth_app_id"), redirectUri: text("redirect_uri"), + tenantId: text("tenant_id"), expiresAt: instant("expires_at").notNull(), createdAt: createdAt(), }, diff --git a/packages/scheduling/AGENTS.md b/packages/scheduling/AGENTS.md index d98978a7f..fcf73cfee 100644 --- a/packages/scheduling/AGENTS.md +++ b/packages/scheduling/AGENTS.md @@ -316,7 +316,7 @@ consent screens show the customer's brand. app can refresh or revoke them. Never look an app up by environment for an existing connection: adding an app would knock out everyone connected before it. A re-consent moves the pin, and `client_id` never changes on a row, so a different app means new consents. -- **A consent is pinned when it starts** (`scheduling_oauth_state.oauth_app_id`, `redirect_uri`): a code is exchanged with +- **A consent is pinned when it starts** (`scheduling_oauth_state.oauth_app_id`, `redirect_uri`, `tenant_id`): a code is exchanged with what it was issued for, whatever's been edited since. - **No fallback**: a failing app refuses new consents rather than using coss.com's, which would show the wrong brand and pin the connection to it. With no app of its own, an environment uses `deps.apps` as before. diff --git a/packages/scheduling/src/calendar/connections.ts b/packages/scheduling/src/calendar/connections.ts index d9b1d3fa1..cfaaadbe8 100644 --- a/packages/scheduling/src/calendar/connections.ts +++ b/packages/scheduling/src/calendar/connections.ts @@ -175,6 +175,7 @@ export async function startCalendarAuthorization( returnUrl: input.returnUrl ?? own?.completionUrl ?? null, oauthAppId: own?.id ?? null, redirectUri, + tenantId: own?.tenantId ?? null, expiresAt: new Date(nowMs(deps) + OAUTH_STATE_TTL_MS), }); return { @@ -352,7 +353,8 @@ export async function completeCalendarAuthorization( try { tokens = await exchangeCode({ provider: input.provider, - app, + // The authority that issued this code, whatever the app says now. + app: own ? { ...app, tenantId: state.tenantId } : app, code: input.code, codeVerifier: decryptSecret(state.encryptedCodeVerifier, deps.secretsKeys), redirectUri: state.redirectUri ?? oauthRedirectUri(deps, input.provider), diff --git a/packages/scheduling/test/oauth-apps.test.ts b/packages/scheduling/test/oauth-apps.test.ts index 6fd3868a3..8b53e4d0d 100644 --- a/packages/scheduling/test/oauth-apps.test.ts +++ b/packages/scheduling/test/oauth-apps.test.ts @@ -200,6 +200,38 @@ describe("which app a consent uses", () => { expect(api.tokenRequests.at(-1)?.redirectUri).toBe("https://acme.example/before"); }); + test("a Microsoft consent is exchanged at the tenant it started with", async () => { + const { env, host } = await setUp(); + const urls: string[] = []; + const deps = depsOf(async (url) => { + urls.push(url); + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + }); + const app = await createOauthApp( + env, + { provider: "microsoft", client_id: "c", client_secret: "s", tenant_id: "before.example" }, + deps, + ); + const { url } = await startCalendarAuthorization( + env, + host.id, + { provider: "microsoft", returnUrl: null }, + deps, + ); + await updateOauthApp(env, app.id, { tenant_id: "after.example" }, deps); + urls.length = 0; + await completeCalendarAuthorization( + { + provider: "microsoft", + state: new URL(url).searchParams.get("state") as string, + code: "code", + error: null, + }, + deps, + ).catch(() => {}); + expect(new URL(urls[0] as string).pathname).toBe("/before.example/oauth2/v2.0/token"); + }); + test("an environment with no coss.com app can still connect through its own", async () => { const { env, host } = await setUp(); const api = new FakeGoogleCalendarApi();