Tecto docs
tectoapp.io

Connected accounts

A person’s own authorisation at a foreign service — Google, Microsoft, a CalDAV server or a plain ICS subscription — and the calendar mirror it feeds. Personal, not workspace-shared: these routes answer with the calling account’s connections and nobody else’s, so a workspace admin sees their own and no colleague’s. Two routes change a meeting at the provider, and only on a calendar switched on for it; everything else here reads. No route returns a credential: a refresh token, an app-specific password and a subscription URL all stay on the server, and a subscription URL is treated as the bearer secret it is.

16 operations. Every path is relative to the instance origin; every response body is JSON unless stated. The endpoint descriptions below come straight from the server’s own route table. See API for authentication, errors and pagination.

GET /api/v1/workspaces/{workspaceId}/connected-accounts

The CALLING USER’s connected accounts in this workspace (ADR-0032) — Google, Microsoft, CalDAV/iCloud or an ICS subscription. Personal, not workspace-shared: authorisation is by user, so a workspace admin sees their own accounts here and nobody else’s. Credential-free by construction — no token, no password, and never the subscription URL, which for an ICS feed IS the credential; secretHint carries at most a host or a username. Not to be confused with listImportConnections, which is the workspace’s saved import sources.

listConnections · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required

Response 200application/json

  • items · object[] — required
    • id · string (uuid) — required
    • provider · "google" | "microsoft" | "caldav" | "ics" — required
    • authModel · "oauth2" | "password" | "url" — required
    • accountLabel · string — required
    • secretHint · string | null — required
    • status · "active" | "reauth_required" | "disabled" — required
    • lastError · string | null — required
    • capabilities · "calendar" | "files"[] — required
    • createdAt · string (date-time) — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

POST /api/v1/workspaces/{workspaceId}/connected-accounts

Connect an account whose credential you supply yourself: an ICS/webcal subscription URL, or a CalDAV server with a username and an app-specific password. The credential is sealed on arrival and is never returned by any route. Google and Microsoft are NOT created here — they have nothing to send and start at startOAuth. The connection is written first and its calendars are then discovered inside this call, so listSources normally answers them immediately afterwards; discovery is bounded by a short deadline and cannot fail the connect, so a provider that is slow or down leaves the connection stored with no calendars yet and the sync worker retries the enumeration in the background a minute or so later. A credential that turns out to be wrong surfaces as status: reauth_required with lastError on the returned connection rather than as a failure of this call.

createConnection · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required

Request bodyapplication/json, required

  • object

Response 200application/json

  • id · string (uuid) — required
  • provider · "google" | "microsoft" | "caldav" | "ics" — required
  • authModel · "oauth2" | "password" | "url" — required
  • accountLabel · string — required
  • secretHint · string | null — required
  • status · "active" | "reauth_required" | "disabled" — required
  • lastError · string | null — required
  • capabilities · "calendar" | "files"[] — required
  • createdAt · string (date-time) — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

DELETE /api/v1/workspaces/{workspaceId}/connected-accounts/{connectionId}

Disconnect an account. The calendars under it and every mirrored event go with it by ON DELETE cascade — in the database, not in a worker that might not run (ADR-0032 D5). Nothing is sent to the provider: this removes our copy and our credential, it does not revoke the grant at Google or delete an app-specific password, which the person does at their provider.

deleteConnection · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required
  • connectionId · string (uuid) — required

Response 200application/json

  • ok · true — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

GET /api/v1/workspaces/{workspaceId}/connected-accounts/{connectionId}/calendars

The calendars found on a connected account, selected or not. Discovery fills this in: once when the account is connected, and again on a schedule, so a calendar added at the provider turns up by itself and one removed there stops being listed. A re-discovery is a reconciliation and never a reset — a calendar that is still there keeps its row and its sync state, selected is the person’s and survives it, and only the name, zone and colour are refreshed from the far side. A calendar that disappeared and later came back is listed once rather than twice, but it is a new calendar as far as this route is concerned: its id is new, its mirrored events went with the old one, and it starts unselected. Not everything found is mirrored: an account’s primary calendar is selected on first discovery (and a lone calendar, where the provider names no primary), because selecting thirty subscribed holiday feeds by default is somebody’s planner full of noise and thirty syncs. Anything found later starts unselected. The sync state is per calendar, because calendars fail independently and one calendar losing its cursor must not be reported as the account being broken.

listSources · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required
  • connectionId · string (uuid) — required

Response 200application/json

  • items · object[] — required
    • id · string (uuid) — required
    • connectionId · string (uuid) — required
    • summary · string — required
    • timeZone · string | null — required
    • color · string | null — required
    • selected · boolean — required
    • publishEnabled · boolean — required
    • remoteWritable · boolean | null — required
    • eventsEditable · boolean — required
    • syncStatus · "pending" | "ok" | "backoff" | "failed" — required
    • lastSyncedAt · string (date-time) | null — required
    • lastError · string | null — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

POST /api/v1/workspaces/{workspaceId}/connected-accounts/{connectionId}/resync

Bring every selected calendar on a connection forward so the sync worker looks at it on its next tick, rather than at the poll floor. This does NOT fetch: it changes a schedule, and the answer is how many calendars were queued — the events arrive whenever the worker and the provider are done. Rate limited per connection, because a button that reached the provider on every press would spend somebody’s quota on impatience; a refusal answers retryAfterSeconds so the button can say when instead of only that it will not. Unselected calendars are untouched, and a connection that needs reconnecting is not woken — there is nothing a fresh look can do about a dead grant.

resyncConnection · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required
  • connectionId · string (uuid) — required

Response 200application/json

  • queued · integer — required, 0–9007199254740991
  • retryAfterSeconds · integer | null — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

PATCH /api/v1/workspaces/{workspaceId}/connected-accounts/{connectionId}/calendars/{sourceId}

Select or unselect a calendar, and switch writing back to it on or off. Those two flags are the only writable things on a mirrored calendar — the name, colour and zone belong to the far side and are overwritten by the next discovery of the account, not by a sync, which only mirrors events. Send either or both; what is not sent is not touched. Unselecting stops the mirroring and removes that calendar’s mirrored events. publishEnabled is the third of ADR-0035’s three gates and is false until somebody sets it: without it nothing this API does can change anything at the provider, whatever the account was granted. It cannot be set on an ICS subscription, which is a document somebody else publishes, and a calendar whose remoteWritable is false is one the provider itself refuses to have written. Both flags are for all three products (ADR-0032 D1), since a connection carries no product.

updateSource · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required
  • connectionId · string (uuid) — required
  • sourceId · string (uuid) — required

Request bodyapplication/json, required

  • selected · boolean
  • publishEnabled · boolean

Response 200application/json

  • id · string (uuid) — required
  • connectionId · string (uuid) — required
  • summary · string — required
  • timeZone · string | null — required
  • color · string | null — required
  • selected · boolean — required
  • publishEnabled · boolean — required
  • remoteWritable · boolean | null — required
  • eventsEditable · boolean — required
  • syncStatus · "pending" | "ok" | "backoff" | "failed" — required
  • lastSyncedAt · string (date-time) | null — required
  • lastError · string | null — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

GET /api/v1/workspaces/{workspaceId}/calendar/events

The mirrored events of the calling user’s selected calendars, for a window of days. from and to are inclusive calendar days and timeZone is the IANA zone they are read in — days rather than instants, because an all-day event has no zone and deriving one from a timestamp is how a whole-day event lands on the wrong day. At most 92 days per call; truncated says the answer hit the item cap, which is the difference between "no meetings" and "more than can be drawn". The response also carries every selected calendar with its sync state, so events can be coloured and a failing calendar shown as failing without a second round of calls. A read, and a mirror: the rows come from the last sync, and the only way to change one at the provider is updateCalendarEvent on a calendar switched on for it.

listCalendarEvents · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required

Query parameters

  • from · string (date) — required
  • to · string (date) — required
  • timeZone · string — required, length 1–64

Response 200application/json

  • items · object[] — required
    • id · string (uuid) — required
    • sourceId · string (uuid) — required
    • remoteId · string — required
    • summary · string | null — required
    • location · string | null — required
    • htmlLink · string | null — required
    • allDay · boolean — required
    • startsAt · string (date-time) | null — required
    • endsAt · string (date-time) | null — required
    • startDate · string (date) | null — required
    • endDate · string (date) | null — required
    • status · string — required
    • transparency · string | null — required
    • eventType · string | null — required
    • selfResponse · string | null — required
    • recurring · boolean — required
  • sources · object[] — required
    • id · string (uuid) — required
    • connectionId · string (uuid) — required
    • summary · string — required
    • timeZone · string | null — required
    • color · string | null — required
    • selected · boolean — required
    • publishEnabled · boolean — required
    • remoteWritable · boolean | null — required
    • eventsEditable · boolean — required
    • syncStatus · "pending" | "ok" | "backoff" | "failed" — required
    • lastSyncedAt · string (date-time) | null — required
    • lastError · string | null — required
  • truncated · boolean — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

PATCH /api/v1/workspaces/{workspaceId}/calendar/events

Move or rename a mirrored meeting at the provider (ADR-0035). Names the meeting by sourceId and remoteId and carries only what changes: a new wall-clock span, a new title, or both. What is not sent is filled in from the mirror rather than restated by the caller, so a rename cannot move a meeting back to where it was last drawn. Times are a local YYYY-MM-DDTHH:MM:SS plus an IANA zone and never an instant — resolving the two into one is how a meeting ends up an hour off. Refused unless all three of ADR-0035’s gates say yes, and the refusal says which one: the provider can write at all (an ICS subscription never can), the connection was granted a write scope, and the calendar has publishEnabled. Answers an acknowledgement, not an event — the mirror learns what the meeting now says at the next sync. A meeting that is no longer there answers outcome: detached rather than an error, because nothing is wrong: somebody deleted it, or it moved out from under its id. All-day entries are refused: there is no clock to move. So is an occurrence of a recurring series — “this one or the series?” is a question with consequences at twenty events and ADR-0035 does not answer it, so the refusal says that rather than picking one. recurring on a mirrored event is how a caller knows which meetings those are before it offers the gesture.

updateCalendarEvent · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required

Request bodyapplication/json, required

  • sourceId · string (uuid) — required
  • remoteId · string — required, length 1–1024
  • span · object
    • start · string — required
    • end · string — required
    • timeZone · string — required, length 1–64
  • summary · string — length 0–1024

Response 200application/json

  • remoteId · string — required
  • outcome · "updated" | "detached" — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

DELETE /api/v1/workspaces/{workspaceId}/calendar/events

Delete a mirrored meeting at the provider (ADR-0035), named by sourceId and remoteId. Behind the same three gates as updateCalendarEvent, with the same refusal naming which one said no. A meeting that is already gone is a success and not a 404 — that is the state the caller asked for. An occurrence of a recurring series is refused, for the same reason the update refuses one: deleting one instance of a recurrence is exactly the “this one or the series?” question ADR-0035 leaves open. The mirrored row goes at the next sync, not here: this route writes to a provider, never to the mirror.

deleteCalendarEvent · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required

Query parameters

  • sourceId · string (uuid) — required
  • remoteId · string — required, length 1–1024

Response 200application/json

  • ok · true — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

GET /api/v1/workspaces/{workspaceId}/calendar/ticks

The meetings the CALLING USER has ticked off in this workspace (ADR-0018 amendment 19 D4). A tick is ours and never the calendar’s: no provider has the field, nothing here is written back, and a tick says only “I have dealt with this”. Personal, like the connected accounts themselves — a colleague’s ticks are not workspace data and no parameter widens this. Keyed on the provider’s occurrence, (sourceId, remoteId), and NOT on the mirror’s own event id, which is destroyed whenever a calendar is re-fetched from scratch. No window: an occurrence may be ticked and no longer mirrored — the mirror is a rolling window — so this answers the whole set, which is one row per ticked meeting. Ticks are kept for as long as the mirror could still draw the meeting they name, and swept afterwards.

listCalendarTicks · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required

Response 200application/json

  • items · object[] — required
    • sourceId · string (uuid) — required
    • remoteId · string — required
    • tickedAt · string (date-time) — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

PUT /api/v1/workspaces/{workspaceId}/calendar/ticks

Tick a meeting off, or take the tick back — done states which, so retrying the same call cannot flip it. Writes nothing to the provider and nothing to the mirror: this is a personal overlay on a mirrored occurrence, identified by sourceId and remoteId. The calendar must be one of the caller’s own, connected in this workspace; a tick answers with when it was made, and taking one back answers null. Ticking a meeting that spans several days ticks it on all of them, because the tick names the occurrence and not a day.

setCalendarTick · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required

Request bodyapplication/json, required

  • sourceId · string (uuid) — required
  • remoteId · string — required, length 1–1024
  • done · boolean — required

Response 200application/json

  • sourceId · string (uuid) — required
  • remoteId · string — required
  • tickedAt · string (date-time) — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

GET /api/v1/workspaces/{workspaceId}/calendar/publications

The CALLING USER’s published timeboxes for one document (ADR-0035), and the calendar they go to. A timebox — a day plus a start time — becomes a real event so the hour is visibly taken; nothing else does, not “some time today”, not a deadline, not a row that merely carries an estimate. Personal, like the connected accounts themselves: a colleague publishing the same outline to their own calendar is a different row and neither of you sees the other’s. Per document and complete — no window and no cursor, because a window would hide exactly the rows that are stuck. Each row says what WE wrote (a title and a span, and nothing else), what the calendar says now, and followable: whether the provider’s version may be adopted and the task moved to follow it. That last one is the server’s verdict rather than the caller’s, and it is false while anything is still owed — if the timebox has been dragged since, the document wins and the follow-back waits for our write to land.

listPublications · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required

Query parameters

  • docId · string (uuid) — required

Response 200application/json

  • items · object[] — required
    • nodeId · string (uuid) — required
    • sourceId · string (uuid) — required
    • remoteId · string | null — required
    • state · "dirty" | "deleting" | "published" | "detached" | "failed" — required
    • publishedAt · string (date-time) | null — required
    • divergentSince · string (date-time) | null — required
    • lastError · string | null — required
    • published · object | null — required
      • summary · string — required
      • startLocal · string — required
      • endLocal · string — required
      • timeZone · string — required
    • mirror · object | null — required
      • summary · string | null — required
      • startLocal · string | null — required
      • endLocal · string | null — required
      • timeZone · string — required
    • followable · boolean — required
  • scope · object | null — required
    • docId · string (uuid) — required
    • sourceId · string (uuid) — required
    • timeZone · string — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

PUT /api/v1/workspaces/{workspaceId}/calendar/publication-scopes

Publish a document’s timeboxes to one of your calendars, move them to another, or stop publishing them — sourceId: null is the off position of the same switch, so retrying this call cannot flip it. Without a scope nothing is written at all: the absence of a decision is never read as consent, and a workspace-wide setting would put a colleague’s plan into your private calendar with no gesture behind it. timeZone is required with a calendar and refused without one: wall times are sent with an IANA zone chosen once, here, rather than taken from whichever device wrote last. Switching off — and moving to another calendar — takes back what was published: every affected event is queued for deletion at the provider, and the answer counts them. Refused unless all three of ADR-0035’s gates say yes, naming the one that refused: the provider can write at all (an ICS subscription never can), the connection was granted a write scope, and the calendar has publishEnabled. Switching off passes no gate — taking back your own events is not a new permission. This route carries no event content: the title and span of everything it publishes are derived by the server from the document.

setPublicationScope · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required

Request bodyapplication/json, required

  • docId · string (uuid) — required
  • sourceId · string (uuid) | null — required
  • timeZone · string — length 1–64

Response 200application/json

  • scope · object | null — required
    • docId · string (uuid) — required
    • sourceId · string (uuid) — required
    • timeZone · string — required
  • unpublishing · integer — required, 0–9007199254740991

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

POST /api/v1/workspaces/{workspaceId}/calendar/publications/retry

Try one publication again — the only way out of failed and detached. Both are terminal on purpose: a failed publication has already run out of attempts, and a detached one is an event that answered 404 to an update, where somebody deleted it or it moved out from under its id and re-creating it by itself would resurrect something a person deliberately deleted. The retry bumps a generation, so the create that follows carries a new client-chosen key — a deleted event id is not always reusable. What gets published is derived from the document as it stands NOW, so a retry of a timebox that has since moved publishes where it is today; a retry is not a replay. Rate limited per publication, and a refusal answers retryAfterSeconds so a button can say when instead of only that it will not.

retryPublication · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required

Request bodyapplication/json, required

  • nodeId · string (uuid) — required

Response 200application/json

  • publication · object — required
    • nodeId · string (uuid) — required
    • sourceId · string (uuid) — required
    • remoteId · string | null — required
    • state · "dirty" | "deleting" | "published" | "detached" | "failed" — required
    • publishedAt · string (date-time) | null — required
    • divergentSince · string (date-time) | null — required
    • lastError · string | null — required
    • published · object | null — required
      • summary · string — required
      • startLocal · string — required
      • endLocal · string — required
      • timeZone · string — required
    • mirror · object | null — required
      • summary · string | null — required
      • startLocal · string | null — required
      • endLocal · string | null — required
      • timeZone · string — required
    • followable · boolean — required
  • retryAfterSeconds · integer | null — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

POST /api/v1/workspaces/{workspaceId}/connected-accounts/oauth/start

Begin connecting a Google or Microsoft account: answers a provider URL to send the browser to. Nothing is stored yet — a connection exists only once the person has consented. The state and the PKCE verifier are minted and kept server-side against the caller, the workspace and returnPath; they are never handed to the client, so a flow cannot be started on somebody else’s behalf. The URL expires.

startOAuth · token scope: session only

Path parameters

  • workspaceId · string (uuid) — required

Request bodyapplication/json, required

  • provider · "google" | "microsoft" — required
  • returnPath · string — length 0–512, default "/"

Response 200application/json

  • authorizationUrl · string (uri) — required
  • expiresAt · string (date-time) — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64

POST /api/v1/connectors/oauth/callback

Finish a redirect flow: exchange the authorization code the provider handed back for a refresh token, create the connection, and discover its calendars — which is what lets the screen show whose account was actually consented to. Called by the app page the provider redirected to, with the caller’s session — not by the provider itself — so the connection is bound both to the signed-in person and to the state that was minted for them. state is single-use; a replayed or expired one is refused. A person who pressed cancel is answered 200 with status: denied rather than an error, because the request was well formed and the app still needs returnPath to know where to put them.

oauthCallback · token scope: session only

Request bodyapplication/json, required

  • state · string — required, length 1–512
  • code · string — length 1–2048
  • error · string — length 1–200
  • errorDescription · string — length 0–1000

Response 200application/json

  • status · "connected" | "denied" | "failed" — required
  • connection · object | null — required
    • id · string (uuid) — required
    • provider · "google" | "microsoft" | "caldav" | "ics" — required
    • authModel · "oauth2" | "password" | "url" — required
    • accountLabel · string — required
    • secretHint · string | null — required
    • status · "active" | "reauth_required" | "disabled" — required
    • lastError · string | null — required
    • capabilities · "calendar" | "files"[] — required
    • createdAt · string (date-time) — required
  • returnPath · string — required
  • message · string | null — required

Response defaultapplication/json

  • error · object — required
    • code · "bad_request" | "validation_failed" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "internal" — required
    • message · string — required
    • reason · string — length 1–64