Developer docs
Send events into a room
Every integration installed in a room gets its own webhook URL and signing secret. Post JSON to it from a test runner, a CI job, a monitoring tool or your own service, and the room sees it immediately.
How it works
- 1
Install
A room owner or manager installs an app from the marketplace. The room gets a connection key (conn_…) and a signing secret (whsec_…). Only owners and managers can see them.
- 2
Send
Your system POSTs JSON events to the room's URL with the secret or an HMAC signature.
- 3
Apply
ThinkNCollab verifies the request, stores the delivery, updates the room feed and, for failures and incidents, opens tasks on the room board.
Endpoint
POST https://www.thinkncollab.com/api/v1/integrations/webhook/:connectionKeyContent-Type: application/jsonThe body must be a JSON object up to 10 MB. Name the event in event(or eventType, or the X-Event-Type header).
Authentication
The connection key in the URL identifies the room and must be kept private. Add at least one of the checks below so a leaked URL alone is not enough.
| Header | Purpose |
|---|---|
| X-TNC-Signature | Recommended: v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>"> with the signing secret. Binds the timestamp, so a captured request can't be replayed later. sha256=<hex HMAC of the raw body> (GitHub style) is also accepted. |
| X-TNC-Timestamp | Unix time in seconds (milliseconds also accepted). Required with v1; requests more than five minutes off are rejected. |
| X-TNC-Delivery-Id | Optional, recommended: your unique id for this event. A retry with the same id is acknowledged without running twice. |
| X-TNC-Secret | Deprecated: the secret itself, compared in constant time. Works, but the secret travels with every request; prefer a signature. |
Every request must carry a signature or the secret. Unsigned requests get 401, bodies over 256 KB get 413, and each room accepts up to 180 requests a minute per integration (429 after that).
To replace a leaked URL or secret, a room manager removes the integration and installs it again; that issues a new URL and secret. Partners can rotate the secret for every room using their app from the partner console.
Signing payloads
The signature covers the exact bytes you send. Serialise the body once, sign <timestamp>.<body>, and send that same body.
import crypto from "node:crypto";const body = JSON.stringify({ event: "deploy.finished", service: "web", status: "success" });const timestamp = String(Math.floor(Date.now() / 1000));const signature = "v1=" + crypto.createHmac("sha256", process.env.TNC_SECRET).update(`${timestamp}.${body}`).digest("hex");await fetch(process.env.TNC_WEBHOOK_URL, { method: "POST", headers: { "Content-Type": "application/json", "X-TNC-Timestamp": timestamp, "X-TNC-Signature": signature, "X-TNC-Delivery-Id": crypto.randomUUID(), }, body,});Events by category
What happens to an event depends on the category of the installed app.
Project Management
Two-way sync between issues in your tool and tasks on the room board. A new issue creates a task (in the column whose title matches its status, else the first column); updates change its title, description, status, priority and assignee. Changes made in the room come back to you as task.* events with origin set, so you can skip your own echoes.
- issue.created
- Create the linked task (an update for an issue we haven't seen also creates it).
- issue.updated
- Apply the change if its updatedAt is newer than the last change we applied and than any edit made in the room since. Older updates are ignored and counted.
- issue.deleted
- The task is never deleted: it is completed, tagged removed-upstream and keeps its history.
- Jira webhooks (webhookEvent jira:issue_created / updated / deleted) and Linear webhooks (type Issue, action create / update / remove) are understood as-is.
- Status names map to To do, In progress, Review and Done (todo/backlog, in progress, review/qa, done/closed). If your app's setup form has a field with id statusMap, room managers can add their own names there, one per line, e.g. Ready for QA = review.
- Assignee: only an email that belongs to a member of this room is used. Users are never created and the email is not stored or sent back.
- Send updatedAt on every change. Without it a change applies only when nothing newer is known.
Fields: id (≤ 200 chars: letters, digits, . _ - : # / @), title (≤ 200), description (≤ 8000), status, priority, updatedAt, assigneeEmail
{ "event": "issue.updated", "id": "ENG-4092", "title": "Rotate ingress secrets", "status": "In progress", "priority": "high", "updatedAt": "2026-10-11T09:30:00Z", "assigneeEmail": "dev@example.com"}Testing & QA
Stores each run for the room's QA health card. A failing suite opens one high-priority task per suite (or notes the repeat on the open one); the next passing run of that suite completes it. Tests that failed and then passed within the suite's last 10 runs are reported as flaky.
- test.run.completed
- One finished run of a suite. test:run:completed and older payloads with only totalTests / failedTests also work.
- Up to 50 failures are kept per run (name ≤ 200, message ≤ 500 characters). Runs are kept for 180 days.
- commit must be a 7–40 character hex SHA; other values are dropped.
Fields: suite, total, passed, failed, skipped, durationMs, branch, commit, failures[{ name, message }], finishedAt
{ "event": "test.run.completed", "suite": "Checkout E2E", "total": 42, "passed": 41, "failed": 1, "skipped": 0, "durationMs": 12400, "branch": "main", "commit": "3f2c9e1", "failures": [ { "name": "Applies coupon code", "message": "Expected 80 but received 100" } ]}DevOps & CI/CD
Deployments feed the room's DORA card: deploys per week, change failure rate, time to restore and lead time. Incidents open one task per service and are completed when resolved.
- deployment.started | succeeded | failed
- A deployment of a service to an environment. Production deploys are used for DORA when any are labelled production.
- pipeline.*
- Pipeline runs (pipeline.started, pipeline.succeeded, pipeline.failed, …). Stored, not counted as deployments.
- incident.triggered
- Opens a task for the service, or notes the repeat on its open one.
- incident.resolved
- Completes that task; the time between them counts toward time to restore.
- Send commitAt (when the deployed commit was made) to get lead time for changes.
- Events without a recognised name still work as before: a status of critical, crashed, failed, down or error opens an incident task.
Fields: service, environment, status, commit, commitAt, startedAt, finishedAt, message
{ "event": "deployment.succeeded", "service": "auth-gateway", "environment": "production", "commit": "9b1d4c07", "commitAt": "2026-10-11T07:55:00Z", "startedAt": "2026-10-11T09:02:00Z", "finishedAt": "2026-10-11T09:06:30Z"}Custom webhooks
Stored as a delivery with its event name; visible on the integration page.
Fields: event, plus any JSON fields
{ "event": "custom.batch_completed", "service": "billing-worker", "processed": 1400}HR & People Ops
People are matched only by an email that belongs to a member of the room. Nobody is created, invited or removed automatically, and only who, which days and a coarse kind of leave are kept.
employee.onboarded
Tells the room's owner and managers that someone is expected. No invitation is sent.
Fields: email, name?, startDate? (YYYY-MM-DD)
{ "event": "employee.onboarded", "email": "new.hire@example.com", "name": "Priya Sharma", "startDate": "2026-11-02"}employee.offboarded
If the email belongs to a room member, managers are asked to review their access. The member is never removed automatically.
Fields: email
{ "event": "employee.offboarded", "email": "leaver@example.com"}leave.approved
Records time off for the member (at most a year). Everyone sees “Away” on the dashboard; only managers see the kind.
Fields: email, from, to?, kind? (leave | sick | other), id?
{ "event": "leave.approved", "id": "lv_8812", "email": "dev@example.com", "from": "2026-11-12", "to": "2026-11-14", "kind": "leave"}leave.cancelled
Cancels the time off with that id (or that email and dates).
Fields: id, or email + from + to
{ "event": "leave.cancelled", "id": "lv_8812"}payroll.processed
Tells that member alone that their payslip is ready, with your https link. Amounts are never read or stored: send no salary fields. Hours for payroll come from GET /timesheets (timesheets:read).
Fields: email, period? (≤ 40 chars), payslipUrl? (https)
{ "event": "payroll.processed", "email": "dev@example.com", "period": "October 2026", "payslipUrl": "https://payroll.example.com/payslips/8812"}Communication
Room chat is end-to-end encrypted, so nothing is posted into it. Message text is never stored beyond a 140-character summary line.
command.task.create
Creates a task in the first column of the room's first board.
Fields: title (up to 200), priority? (low | medium | high)
{ "event": "command.task.create", "title": "Fix flaky login test", "priority": "high"}message.*
Logged as a one-line summary on the integration page.
Fields: text?, channel?
{ "event": "message.posted", "channel": "releases", "text": "v2.4.0 is live in production"}Security & Compliance
Findings appear on the dashboard for engineering and team leads. High and critical findings also open one task each, completed when you report them resolved.
vulnerability.found
Stores or updates the finding. High and critical open a task.
Fields: id?, severity (low | medium | high | critical), title?, package?, cve? (CVE-YYYY-NNNN), url? (https)
{ "event": "vulnerability.found", "id": "SNYK-JS-LODASH-1040724", "severity": "high", "title": "Prototype pollution in lodash", "package": "lodash@4.17.15", "cve": "CVE-2020-8203", "url": "https://security.example.com/advisories/1040724"}vulnerability.resolved
Marks the finding resolved and completes its task.
Fields: id, or cve + package
{ "event": "vulnerability.resolved", "id": "SNYK-JS-LODASH-1040724"}scan.completed
Logged as a summary with the counts.
Fields: critical?, high?, medium?, low?
{ "event": "scan.completed", "critical": 0, "high": 1, "medium": 4, "low": 9}AI agents
Agents never change a room themselves. Each suggestion waits for a member to approve or reject it, and expires after 14 days. Unknown fields are dropped.
agent.suggestion
Queues a suggestion for review. Approving it creates the task, applies the change or posts the comment under the approver's name.
Fields: action (create_task | update_task | comment), taskId? (for update_task and comment), fields { title?, description?, status?, priority?, dueDate?, text? }, reason?
{ "event": "agent.suggestion", "action": "create_task", "fields": { "title": "Add retry to payment webhook", "priority": "medium", "dueDate": "2026-11-20" }, "reason": "Three timeouts in the last day"}Design & Docs
Links a changed design or document to the task it belongs to.
file.updated
If taskKey matches a task in the room, adds a short note to that task, marked as from your app. Otherwise it's logged as a summary.
Fields: name, url (https, up to 500), taskKey?
{ "event": "file.updated", "name": "Checkout flow v3", "url": "https://design.example.com/file/abc123", "taskKey": "TNC-1A2B3C"}Events we send
Set an HTTPS endpoint and the events you want in the partner console. When a room installs your app, its manager approves which of them that room sends; they can change it later. Events you add later reach a room only after its manager approves them.
| Event | When |
|---|---|
| task.created | A task was added to the room's board. |
| task.updated | Title, status, priority, due date, assignees, points or column changed. Reordering a card doesn't count. |
| task.completed | A task moved to done (also sent as task.updated). |
| task.deleted | A task was removed: id, key and title only. |
| task.assigned | A task's assignees changed (also sent as task.updated). New tasks send task.created only. |
| task.time_logged | Someone logged time on a task: taskId, taskKey, userId, minutes, loggedAt. The note stays private. |
| member.joined | Someone joined the room: userId and role. |
| member.left | Someone left or was removed: userId and their last role. |
| member.role_changed | A member's role changed: userId, role, previousRole. |
| meeting.scheduled | A meeting was scheduled, with scheduledFor. |
| meeting.started | A meeting went live in the room. |
| meeting.ended | A meeting ended. |
| meeting.cancelled | A scheduled meeting was cancelled. |
| decision.created | A decision was proposed in the room's Decision Hub: id, title, status, category. |
| decision.updated | A decision's status changed (approved, rejected and so on). |
Payloads carry ids, keys, titles, statuses, priorities, dates and links — never descriptions, comments, files, emails or end-to-end encrypted content. People appear as opaque user ids. Tasks your own app created carry data.origin.integrationId, so a two-way sync can ignore its own echoes.
POST https://your-endpoint.example.com/thinkncollabX-TNC-Event: task.completedX-TNC-Delivery-Id: evt_7f3c… # the same on every retry: dedupe on itX-TNC-Timestamp: 1791720000X-TNC-Signature: v1=<hex HMAC-SHA256(partner secret, "<timestamp>.<raw body>")>X-TNC-Api-Version: 2026-10-01{ "id": "evt_7f3c…", "type": "task.completed", "apiVersion": "2026-10-01", "occurredAt": "2026-10-11T09:20:00.000Z", "installationId": "6acb…", "roomId": "6ac9…", "app": "your-app-slug", "data": { "id": "…", "key": "T-6CUPC0", "title": "Ship dark mode", "status": "completed", "priority": "high", "dueDate": null, "assignees": ["6ac9…"], "url": "https://thinkncollab.com/app/rooms/…/tasks/…", "origin": null }}Verify the signature with your partner signing secret and reject timestamps more than five minutes old. Answer with any 2xx within 10 seconds. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then given up. Answer 410 Gone to stop events for that room; after 25 failed deliveries in a day we pause sending to that room until its manager resumes it. Endpoints must be public HTTPS on port 443 or 8443; redirects are not followed.
Responses
| Status | Meaning | What to do |
|---|---|---|
| 200 | Accepted | The event was verified, recorded and applied to the room. |
| 401 | Unauthorized | Unsigned, or the signature or secret did not match, or X-TNC-Timestamp is more than five minutes off. |
| 413 | Too large | The body is over 256 KB. |
| 429 | Rate limited | More than 180 requests a minute for this integration. Retry later. |
| 403 | Paused | A room manager paused the integration. Events are refused until it is resumed. |
| 404 | Unknown URL | The integration was removed or the URL is wrong. |
| 5xx | Server error | Retry with exponential backoff, sending the same X-TNC-Delivery-Id so a retry is never applied twice. |
Examples
cURL
curl -X POST "https://www.thinkncollab.com/api/v1/integrations/webhook/conn_your_connection_key" \ -H "Content-Type: application/json" \ -H "X-TNC-Secret: $TNC_SECRET" \ -d '{"event":"test:run:completed","suiteName":"Regression","totalTests":50,"passedTests":50,"failedTests":0}'GitHub Actions
- name: Report tests to ThinkNCollab if: always() run: | curl -sS -X POST "$TNC_WEBHOOK_URL" \ -H "Content-Type: application/json" \ -H "X-TNC-Secret: $TNC_SECRET" \ -d "{\"event\":\"test:run:completed\",\"suiteName\":\"${{ github.workflow }}\",\"failedTests\":${{ steps.test.outputs.failed || 0 }}}" env: TNC_WEBHOOK_URL: ${{ secrets.TNC_WEBHOOK_URL }} TNC_SECRET: ${{ secrets.TNC_SECRET }}Python
import json, os, hmac, hashlib, time, requestsbody = json.dumps({"event": "payroll.disbursed", "employeeName": "Aarav", "action": "Salary paid"}, separators=(",", ":"), ensure_ascii=False)sig = "sha256=" + hmac.new(os.environ["TNC_SECRET"].encode(), body.encode("utf-8"), hashlib.sha256).hexdigest()requests.post(os.environ["TNC_WEBHOOK_URL"], data=body.encode("utf-8"), headers={ "Content-Type": "application/json", "X-TNC-Signature": sig, "X-TNC-Timestamp": str(int(time.time() * 1000)),}, timeout=10)OAuth & Partner API
Apps that need to read or change room data use OAuth 2.0 (authorization code with PKCE) and call the Partner API with a short-lived bearer token. A token acts for exactly one installation: one app in one room, approved by that room's owner or a manager.
- 1
Register
In the partner console, add your redirect URIs (https, matched exactly) and the scopes you need, then generate the client secret. It is shown once.
- 2
Authorize
Send the user to /oauth/authorize with a PKCE challenge. They pick a room they manage and approve the scopes. Only verified apps can be connected.
- 3
Exchange
Your server swaps the code (valid 10 minutes, single use) for an access token (1 hour) and a refresh token (30 days).
- 4
Call
Send Authorization: Bearer <access token> to /api/partner/v1. Refresh before it expires; each refresh token works once.
Scopes
| Scope | Grants |
|---|---|
| tasks:read | Tasks in the room: id, key, title, status, priority, due date, assignees (user ids), story points, dates. Never descriptions, comments or files. |
| tasks:write | Create tasks on the room board and change title, status, priority and due date. |
| members:read | Members: user id, name and role. No email addresses. |
| members:email | Adds members' email addresses. Ask for it only if you need it; it is shown separately on the consent screen. |
| meetings:read | Meetings: id, title, status, start and end times. Never participants, chat, recordings or summaries. |
| timesheets:read | Minutes people logged on tasks, per day and per task (user ids). Never log notes, and no pay data: ThinkNCollab doesn't hold any. |
| leave:read | Approved time off recorded in the room: user id, dates and kind. |
| events:subscribe | Signed webhooks for task, time, member, meeting and decision events (see Events we send) for a new installation. |
1. Send the user to authorize
import crypto from "node:crypto";const verifier = crypto.randomBytes(32).toString("base64url"); // keep server side, per loginconst challenge = crypto.createHash("sha256").update(verifier).digest("base64url");const state = crypto.randomBytes(16).toString("base64url"); // check it on returnconst url = new URL("https://www.thinkncollab.com/oauth/authorize");url.search = new URLSearchParams({ response_type: "code", client_id: process.env.TNC_CLIENT_ID, redirect_uri: "https://app.example.com/oauth/callback", scope: "tasks:read tasks:write", state, code_challenge: challenge, code_challenge_method: "S256",});On approval we redirect to your redirect URI with code and state; on denial with error=access_denied. If the client id or redirect URI is wrong we never redirect. PKCE with S256 is mandatory.
2. Exchange the code
curl -X POST "https://www.thinkncollab.com/oauth/token" \ -u "$TNC_CLIENT_ID:$TNC_CLIENT_SECRET" \ -d grant_type=authorization_code \ -d code="$CODE" \ -d redirect_uri=https://app.example.com/oauth/callback \ -d code_verifier="$VERIFIER"# {"access_token":"tnc_at_…","token_type":"Bearer","expires_in":3600,# "refresh_token":"tnc_rt_…","scope":"tasks:read tasks:write","installation_id":"…"}Authenticate with HTTP Basic (client_secret_basic) or client_id and client_secret in the body (client_secret_post), not both. Using a code twice revokes every token issued from it.
3. Refresh and revoke
curl -X POST "https://www.thinkncollab.com/oauth/token" -u "$TNC_CLIENT_ID:$TNC_CLIENT_SECRET" \ -d grant_type=refresh_token -d refresh_token="$REFRESH_TOKEN"curl -X POST "https://www.thinkncollab.com/oauth/revoke" -u "$TNC_CLIENT_ID:$TNC_CLIENT_SECRET" \ -d token="$REFRESH_TOKEN" -d token_type_hint=refresh_tokenEvery refresh returns a new refresh token; store it and drop the old one. Presenting an old refresh token again revokes the whole grant, so a leaked token can't be used quietly. Revoking a refresh token also revokes its access tokens. Uninstalling the app or a manager revoking the app's API access revokes everything for that room.
Partner API
Base URL https://www.thinkncollab.com/api/partner/v1. JSON in and out; send tokens only in the Authorization header.
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /installation | — | The installation the token acts for: room, scopes, status. |
| GET | /tasks | tasks:read | ?limit=1–100 (default 50), cursor, status, updated_since (ISO 8601). Returns { data, next_cursor }. |
| POST | /tasks | tasks:write | { title (≤ 300), status?, priority?, dueDate? (ISO 8601), columnId? }. Defaults to the first column of the room's first board. |
| PATCH | /tasks/:id | tasks:write | { title?, status?, priority?, dueDate? (ISO 8601 or null) }. Only tasks in the installation's room. |
| GET | /members | members:read | { id, name, role, roleLabel } — plus email with members:email. |
| GET | /timesheets | timesheets:read | ?from, to (YYYY-MM-DD, inclusive, at most 92 days; default the last 14). { data: { from, to, people: [{ userId, minutes, days, tasks }] } }. |
| GET | /leave | leave:read | ?from, to as above. { data: [{ userId, from, to, kind }] } overlapping the range. |
| GET | /meetings | meetings:read | ?limit, cursor, status. { id, title, status, startedAt, endedAt, scheduledFor }. |
curl "https://www.thinkncollab.com/api/partner/v1/tasks?limit=100" -H "Authorization: Bearer $ACCESS_TOKEN"curl -X POST "https://www.thinkncollab.com/api/partner/v1/tasks" \ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \ -d '{"title":"Follow up with design review","priority":"high","dueDate":"2026-11-02"}'Tasks you create carry origin.integrationId in task events, so a two-way sync can skip its own echoes. Every call is recorded in an audit log (method, path, status and time; kept 30 days).
| Status | Error | Meaning |
|---|---|---|
| 400 | invalid_request | A field is missing, unknown, too long or not in its enum. |
| 401 | invalid_token | Missing, malformed, expired or revoked token, or the installation was removed. Refresh, or ask the room to connect again. |
| 403 | insufficient_scope | The token lacks the scope named in WWW-Authenticate. |
| 403 | installation_inactive / app_unavailable | A manager paused the installation, or the app is no longer verified. |
| 404 | not_found | No such task or column in this room. |
| 429 | rate_limited | 600 requests a minute per installation. Back off and retry. |
Publishing an app for every workspace?
Join the partner program to list it in the marketplace after review.
Partner program