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. 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. 2

    Send

    Your system POSTs JSON events to the room's URL with the secret or an HMAC signature.

  3. 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

code
POST https://www.thinkncollab.com/api/v1/integrations/webhook/:connectionKey
Content-Type: application/json

The 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.

HeaderPurpose
X-TNC-SignatureRecommended: 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-TimestampUnix time in seconds (milliseconds also accepted). Required with v1; requests more than five minutes off are rejected.
X-TNC-Delivery-IdOptional, recommended: your unique id for this event. A retry with the same id is acknowledged without running twice.
X-TNC-SecretDeprecated: 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.

code
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

code
{
"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

code
{
"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

code
{
"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

code
{
"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)

code
{
"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

code
{
"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?

code
{
"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

code
{
"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)

code
{
"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)

code
{
"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?

code
{
"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)

code
{
"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

code
{
"event": "vulnerability.resolved",
"id": "SNYK-JS-LODASH-1040724"
}

scan.completed

Logged as a summary with the counts.

Fields: critical?, high?, medium?, low?

code
{
"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?

code
{
"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?

code
{
"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.

EventWhen
task.createdA task was added to the room's board.
task.updatedTitle, status, priority, due date, assignees, points or column changed. Reordering a card doesn't count.
task.completedA task moved to done (also sent as task.updated).
task.deletedA task was removed: id, key and title only.
task.assignedA task's assignees changed (also sent as task.updated). New tasks send task.created only.
task.time_loggedSomeone logged time on a task: taskId, taskKey, userId, minutes, loggedAt. The note stays private.
member.joinedSomeone joined the room: userId and role.
member.leftSomeone left or was removed: userId and their last role.
member.role_changedA member's role changed: userId, role, previousRole.
meeting.scheduledA meeting was scheduled, with scheduledFor.
meeting.startedA meeting went live in the room.
meeting.endedA meeting ended.
meeting.cancelledA scheduled meeting was cancelled.
decision.createdA decision was proposed in the room's Decision Hub: id, title, status, category.
decision.updatedA 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.

code
POST https://your-endpoint.example.com/thinkncollab
X-TNC-Event: task.completed
X-TNC-Delivery-Id: evt_7f3c… # the same on every retry: dedupe on it
X-TNC-Timestamp: 1791720000
X-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

StatusMeaningWhat to do
200AcceptedThe event was verified, recorded and applied to the room.
401UnauthorizedUnsigned, or the signature or secret did not match, or X-TNC-Timestamp is more than five minutes off.
413Too largeThe body is over 256 KB.
429Rate limitedMore than 180 requests a minute for this integration. Retry later.
403PausedA room manager paused the integration. Events are refused until it is resumed.
404Unknown URLThe integration was removed or the URL is wrong.
5xxServer errorRetry with exponential backoff, sending the same X-TNC-Delivery-Id so a retry is never applied twice.

Examples

cURL

bash
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

bash
- 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

code
import json, os, hmac, hashlib, time, requests
body = 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. 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. 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. 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. 4

    Call

    Send Authorization: Bearer <access token> to /api/partner/v1. Refresh before it expires; each refresh token works once.

Scopes

ScopeGrants
tasks:readTasks in the room: id, key, title, status, priority, due date, assignees (user ids), story points, dates. Never descriptions, comments or files.
tasks:writeCreate tasks on the room board and change title, status, priority and due date.
members:readMembers: user id, name and role. No email addresses.
members:emailAdds members' email addresses. Ask for it only if you need it; it is shown separately on the consent screen.
meetings:readMeetings: id, title, status, start and end times. Never participants, chat, recordings or summaries.
timesheets:readMinutes people logged on tasks, per day and per task (user ids). Never log notes, and no pay data: ThinkNCollab doesn't hold any.
leave:readApproved time off recorded in the room: user id, dates and kind.
events:subscribeSigned webhooks for task, time, member, meeting and decision events (see Events we send) for a new installation.

1. Send the user to authorize

code
import crypto from "node:crypto";
const verifier = crypto.randomBytes(32).toString("base64url"); // keep server side, per login
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
const state = crypto.randomBytes(16).toString("base64url"); // check it on return
const 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

bash
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

bash
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_token

Every 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.

MethodPathScopeNotes
GET/installation—The installation the token acts for: room, scopes, status.
GET/taskstasks:read?limit=1–100 (default 50), cursor, status, updated_since (ISO 8601). Returns { data, next_cursor }.
POST/taskstasks:write{ title (≤ 300), status?, priority?, dueDate? (ISO 8601), columnId? }. Defaults to the first column of the room's first board.
PATCH/tasks/:idtasks:write{ title?, status?, priority?, dueDate? (ISO 8601 or null) }. Only tasks in the installation's room.
GET/membersmembers:read{ id, name, role, roleLabel } — plus email with members:email.
GET/timesheetstimesheets:read?from, to (YYYY-MM-DD, inclusive, at most 92 days; default the last 14). { data: { from, to, people: [{ userId, minutes, days, tasks }] } }.
GET/leaveleave:read?from, to as above. { data: [{ userId, from, to, kind }] } overlapping the range.
GET/meetingsmeetings:read?limit, cursor, status. { id, title, status, startedAt, endedAt, scheduledFor }.
bash
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).

StatusErrorMeaning
400invalid_requestA field is missing, unknown, too long or not in its enum.
401invalid_tokenMissing, malformed, expired or revoked token, or the installation was removed. Refresh, or ask the room to connect again.
403insufficient_scopeThe token lacks the scope named in WWW-Authenticate.
403installation_inactive / app_unavailableA manager paused the installation, or the app is no longer verified.
404not_foundNo such task or column in this room.
429rate_limited600 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