Build on Kaam360
Connect your app to Kaam360 with OAuth 2.0, work with projects, tasks, time, customers, invoices and support tickets through a versioned API, and hear about changes the moment they happen with signed webhooks.
OpenAPI description (version 2) Register an app
Getting started
- An administrator opens Settings › Connected apps and registers your app: its name, the exact addresses people are sent back to, and the scopes it may ask for. Kaam360 shows a client id and, for a server app, a client secret once.
- Your app sends the person to Kaam360's consent screen. They see your app, what it asks for and their organization, and press Allow.
- Kaam360 sends the person back to your app with a one-time code. Your app swaps the code for an access token and a refresh token.
- Your app calls the API with the access token. Every call acts as that person, within their own rights and the scopes they allowed.
An app for every Kaam360 customer (a public app) is registered by the Kaam360 team: contact us. For a quick read-only connection to your own organization, an API key under Settings › Integrations is simpler (API version 1).
Sign-in flow (OAuth 2.0)
Kaam360 supports the authorization code flow with PKCE (RFC 6749 and RFC 7636). PKCE is required for every app, and only the S256 method is accepted. The server's description is at /.well-known/oauth-authorization-server.
| Step | Address | What to send |
|---|---|---|
| 1 | GET /oauth/authorize | response_type=code, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method=S256 |
| 2 | POST /oauth/token | grant_type=authorization_code, code, redirect_uri, code_verifier, client_id (+ client_secret) |
| 3 | POST /oauth/token | grant_type=refresh_token, refresh_token, client_id (+ client_secret) |
| 4 | POST /oauth/revoke | token, client_id (+ client_secret) |
- The redirect_uri must match one of the registered addresses exactly, character for character.
- Make a new random code_verifier (43 to 128 characters) for every sign-in, and send its SHA-256, Base64URL-encoded, as code_challenge.
- Check that the state you get back is the one you sent. The answer also carries iss, Kaam360's own address.
- A code works once, for five minutes. Using a code twice stops every token made from it.
- Server apps send the client secret with HTTP Basic authentication or in the form. Phone, desktop and browser apps have no secret and rely on PKCE.
- A person who already allowed the same access is not asked again, but only for a server app that returns to an https address. Phone, desktop and browser apps, and apps that return to http or localhost, always show the consent screen.
- If an organization lets people use Kaam360 only from approved networks, the token address and every API call for its people must come from those networks too.
https://kaam360.com/oauth/authorize?response_type=code
&client_id=k360_your_client_id
&redirect_uri=https%3A%2F%2Fyourapp.example%2Fcallback
&scope=tasks%3Aread%20tasks%3Awrite
&state=RANDOM_STATE
&code_challenge=BASE64URL_SHA256_OF_VERIFIER
&code_challenge_method=S256
Scopes
Ask only for what your app needs. A scope never gives more than the person's own role allows, and no scope can delete anything, manage people or change settings.
| Scope | What it allows |
|---|---|
projects:read | See the projects you can see |
projects:write | Create projects and change their status |
tasks:read | See tasks and their comments in your projects |
tasks:write | Add tasks, change them, complete them and comment |
time:read | See your time entries (and your team's, if you approve time) |
time:write | Log time for you |
crm:read | See customers, contacts, deals and leads |
crm:write | Add customers, contacts and leads |
invoices:read | See invoices |
invoices:write | Mark invoices as paid |
tickets:read | See support tickets |
tickets:write | Open support tickets |
updates:write | Post project updates on projects you may update |
reports:read | See key numbers you may see (the CEO briefing and unbilled work) |
webhooks:write | Set up webhooks that tell the app when things happen |
Tokens
- Access tokens start with
k360at_and work for one hour. Send them asAuthorization: Bearer k360at_.... - Refresh tokens start with
k360rt_and work for 60 days. Each one works once: the answer brings a new pair. Keep the new refresh token and throw the old one away. - If a refresh token is used a second time, Kaam360 assumes it was copied and stops every token of that connection. The person has to connect the app again.
- Kaam360 keeps only a fingerprint of each token, never the token itself. A person can disconnect your app at any time, and so can their administrator; the tokens then stop at once.
Examples
Each example swaps the code for tokens, then lists the person's open tasks.
curl
# 1. Swap the code for tokens
curl -X POST https://kaam360.com/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri=https://yourapp.example/callback \
-d code_verifier="$VERIFIER"
# 2. Call the API
curl -H "Authorization: Bearer $ACCESS_TOKEN" "https://kaam360.com/api/v2/tasks?mine=true&openOnly=true"
# 3. Later: a new pair (the old refresh token stops working)
curl -X POST https://kaam360.com/oauth/token -u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=refresh_token -d refresh_token="$REFRESH_TOKEN"JavaScript (Node.js 18+)
import crypto from "node:crypto";
// Before sending the person to /oauth/authorize: a new verifier and state every time, kept in the person's session.
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
const state = crypto.randomBytes(16).toString("base64url");
session.oauth = { verifier, state };
const authorizeUrl = "https://kaam360.com/oauth/authorize?" + new URLSearchParams({ response_type: "code", client_id: CLIENT_ID,
redirect_uri: "https://yourapp.example/callback", scope: "tasks:read", state, code_challenge: challenge, code_challenge_method: "S256" });
// Back at https://yourapp.example/callback?code=...&state=...&iss=...
const back = new URL(req.url, "https://yourapp.example").searchParams;
if (!session.oauth?.state || back.get("state") !== session.oauth.state || back.get("iss") !== "https://kaam360.com") throw new Error("Not the sign-in we started");
const code = back.get("code");
const res = await fetch("https://kaam360.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded",
Authorization: "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64") },
body: new URLSearchParams({ grant_type: "authorization_code", code, code_verifier: session.oauth.verifier,
redirect_uri: "https://yourapp.example/callback" }),
});
const { access_token, refresh_token } = await res.json();
const tasks = await fetch("https://kaam360.com/api/v2/tasks?mine=true&openOnly=true", {
headers: { Authorization: `Bearer ${access_token}` },
});
console.log(tasks.headers.get("X-RateLimit-Remaining"), (await tasks.json()).data);Python
import base64, hashlib, secrets, requests
# Before sending the person to /oauth/authorize: a new verifier and state every time, kept in the person's session.
verifier = secrets.token_urlsafe(32)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
session["oauth"] = {"verifier": verifier, "state": secrets.token_urlsafe(16)}
# Back at the callback: refuse an answer to a sign-in this person did not start.
expected = session.get("oauth", {}).get("state")
if not expected or not secrets.compare_digest(request.args.get("state", ""), expected) or request.args.get("iss") != "https://kaam360.com":
abort(400)
code = request.args["code"]
tokens = requests.post("https://kaam360.com/oauth/token", auth=(CLIENT_ID, CLIENT_SECRET), data={
"grant_type": "authorization_code", "code": code, "code_verifier": session["oauth"]["verifier"],
"redirect_uri": "https://yourapp.example/callback",
}).json()
r = requests.get("https://kaam360.com/api/v2/tasks", params={"mine": "true", "openOnly": "true"},
headers={"Authorization": "Bearer " + tokens["access_token"]})
if r.status_code == 429:
print("wait", r.headers["Retry-After"], "seconds")
print(r.json()["data"])C#
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Net.Http.Json;
// Before sending the person to /oauth/authorize: a new verifier and state every time, kept in the person's session.
var verifier = Base64Url(RandomNumberGenerator.GetBytes(32));
var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
var state = Base64Url(RandomNumberGenerator.GetBytes(16));
session.SetString("oauth_verifier", verifier); session.SetString("oauth_state", state);
// Back at the callback: refuse an answer to a sign-in this person did not start.
var returned = request.Query["state"].ToString();
var expected = session.GetString("oauth_state");
if (string.IsNullOrEmpty(expected) || string.IsNullOrEmpty(returned)
|| !CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(returned), Encoding.UTF8.GetBytes(expected)))
throw new InvalidOperationException("Not the sign-in we started");
session.Remove("oauth_state");
var code = request.Query["code"].ToString();
using var http = new HttpClient { BaseAddress = new Uri("https://kaam360.com") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic",
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")));
var answer = await http.PostAsync("/oauth/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "authorization_code", ["code"] = code, ["code_verifier"] = session.GetString("oauth_verifier")!,
["redirect_uri"] = "https://yourapp.example/callback",
}));
var tokens = await answer.Content.ReadFromJsonAsync<Dictionary<string, object>>();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens!["access_token"].ToString());
Console.WriteLine(await http.GetStringAsync("/api/v2/tasks?mine=true&openOnly=true"));
static string Base64Url(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');API reference (version 2)
All addresses start with /api/v2. Lists answer { data, page, limit, total }, single records { data }. Add page and limit (up to 200) to page through, and updatedSince to fetch only what changed. The full description, with every field, is the OpenAPI document; tools such as Postman can import it.
| Method | Address | What it does | Scope |
|---|---|---|---|
| POST | /captures | Save a web page or an email as a task, a support ticket, a lead or a contact (each kind needs its own write scope) | — |
| POST | /captures/{kind}/{id}/files | Copy one of an email's files onto the task, ticket, lead or contact it was saved as. Send the file's bytes as the body (its type as Content-Type) and its name as ?name=. Programs are refused; the record's own size limits and the storage allowance apply | — |
| GET | /contacts | List contacts (people at customers) | crm:read |
| POST | /contacts | Add a contact to a customer | crm:write |
| GET | /customers | List customers | crm:read |
| POST | /customers | Add a customer | crm:write |
| GET | /deals | List deals (status: Open, Won or Lost) | crm:read |
| GET | /invoices | List invoices (status: Draft, Sent, Paid or Void) | invoices:read |
| GET | /invoices/{id} | One invoice | invoices:read |
| POST | /invoices/{id}/mark-paid | Mark an issued invoice as paid (on paidOn, or today) | invoices:write |
| GET | /leads | List leads | crm:read |
| POST | /leads | Add a lead | crm:write |
| GET | /me | The person the token acts for, their organization, and the scopes the token has | — |
| GET | /projects | List the projects the person can see | projects:read |
| POST | /projects | Create a project (the person becomes its owner) | projects:write |
| GET | /projects/{id} | One project | projects:read |
| POST | /projects/{id}/status | Change a project's status (Active, OnHold, Delayed, Completed or Cancelled) | projects:write |
| GET | /senders | What Kaam360 knows about an email address: contacts, leads, their customer (or one at the same company domain), open deals and recent tickets | crm:read |
| GET | /tasks | List tasks in the projects the person can see | tasks:read |
| POST | /tasks | Add a task to a project (to its first task list); when the project triages new work it waits in Triage (202) | tasks:write |
| GET | /tasks/{id} | One task | tasks:read |
| PATCH | /tasks/{id} | Change a task: only the fields you send change | tasks:write |
| GET | /tasks/{id}/comments | A task's comments, oldest first | tasks:read |
| POST | /tasks/{id}/comments | Comment on a task | tasks:write |
| POST | /tasks/{id}/complete | Complete a task | tasks:write |
| GET | /tasks/{id}/links | A task's links to other tasks (blocks, relates to, duplicates...), read from this task's side | tasks:read |
| POST | /tasks/{id}/links | Link a task to another task | tasks:write |
| DELETE | /tasks/{id}/links/{linkId} | Remove one of a task's links | tasks:write |
| GET | /tickets | List support tickets | tickets:read |
| POST | /tickets | Open a support ticket | tickets:write |
| GET | /time-entries | Time entries: the person's own, or everyone's for a person who approves time | time:read |
| POST | /time-entries | Log time for the person | time:write |
| GET | /timer | The person's running timer, if any | time:write |
| POST | /timer/start | Start the timer on a task (a timer already running is saved first) | time:write |
| POST | /timer/stop | Stop the timer: the time is logged as an ordinary time entry | time:write |
| GET | /webhooks | The webhooks this app made with this connection | webhooks:write |
| POST | /webhooks | Subscribe: send signed messages about these events to a public https address (the signing secret is in the answer, once). Each event needs the read scope of its area | webhooks:write |
| DELETE | /webhooks/{id} | Unsubscribe: remove a webhook this app made | webhooks:write |
Rate limits
Each access token may make 120 requests a minute, and each app has a limit for all its tokens in one organization together (600 a minute unless agreed otherwise). Every answer says where you stand:
X-RateLimit-Limit | The limit that applies now (the token's or the app's, whichever is closer). |
X-RateLimit-Remaining | Requests left in the current minute. |
X-RateLimit-Reset | When a request is freed again, in Unix seconds. |
X-RateLimit-Scope | token or app: which limit the numbers are about. |
Over a limit the answer is 429 Too Many Requests with Retry-After (seconds). Wait that long, then try again.
Errors
A refused request answers { "error": { "code": "...", "message": "..." } }. The token endpoint answers in the OAuth form { "error", "error_description" }.
401 | No token, or it is wrong, expired or revoked. Refresh it, or ask the person again. |
403 | insufficient_scope: the token lacks the scope; forbidden: the person's role does not allow it. |
404 | No such record, or the person may not see it. |
422 | A value is missing or not valid; the message says which. |
429 | Too many requests (see Rate limits). |
Webhooks
Webhooks tell your program when something happens. Set them up under Settings › Integrations, or let your app subscribe through the API (POST /api/v2/webhooks, scope webhooks:write). Addresses must be public https:// addresses: private and internal networks are refused.
| Event | When | Scope an app needs |
|---|---|---|
customer.created | A customer was added | crm:read |
lead.created | A lead was added | crm:read |
deal.won | A deal was won | crm:read |
deal.lost | A deal was lost | crm:read |
project.created | A project was created | projects:read |
project.updated | A project was changed (name, status, dates, owner or archived) | projects:read |
task.created | A task was added | tasks:read |
task.updated | A task was changed | tasks:read |
task.completed | A task was completed | tasks:read |
invoice.created | An invoice draft was written | invoices:read |
invoice.sent | An invoice was issued (sent to a customer) | invoices:read |
invoice.paid | An invoice was marked paid | invoices:read |
invoice.voided | An invoice was voided | invoices:read |
payment.status_changed | A project payment moved to another stage | Not available to apps |
contract.signed | A contract was signed | Not available to apps |
ticket.created | A support ticket was opened | tickets:read |
- An app must list the events it wants (it cannot ask for everything), and each event needs the read scope shown above as well as
webhooks:write. The person who connected the app must be allowed to manage integrations and to read that area. - An app's webhook only hears about records its person could see through the app: for example, a task or project event only for projects that person can see. If the person's rights or the scopes change, messages stop or narrow at once.
- When the person or an administrator disconnects the app, the webhooks it made with that connection are removed. While the app is switched off, nothing is sent. An app can have up to 5 webhook addresses in an organization.
Each message is a POST with a JSON body: id (the same on every retry, so you can ignore repeats), event, createdAt, organizationId, summary and data. It carries these headers:
X-Kaam360-Event,X-Kaam360-DeliveryandX-Kaam360-Timestamp(Unix seconds).X-Kaam360-Signature:sha256=followed by the HMAC-SHA256, in hex, of the timestamp, a dot and the exact body, made with your signing secret.
Check the signature in constant time and refuse a timestamp more than five minutes away from your clock, so a copied message cannot be played again later:
JavaScript (Node.js)
import crypto from "node:crypto";
// Use the raw body exactly as it arrived (before any JSON parsing).
function isFromKaam360(secret, timestamp, rawBody, signature) {
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false; // older than 5 minutes: refuse
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(signature ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// isFromKaam360(process.env.KAAM360_SECRET, req.headers["x-kaam360-timestamp"], rawBody, req.headers["x-kaam360-signature"])Python
import hashlib, hmac, time
def is_from_kaam360(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
try:
if abs(time.time() - int(timestamp)) > 300: # older than 5 minutes: refuse
return False
except (TypeError, ValueError):
return False
expected = "sha256=" + hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")C#
using System.Security.Cryptography;
using System.Text;
static bool IsFromKaam360(string secret, string timestamp, string rawBody, string signature)
{
if (!long.TryParse(timestamp, out var seconds) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > 300) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = "sha256=" + Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(timestamp + "." + rawBody))).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature ?? ""));
}- Answer with any 2xx status within 12 seconds. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then given up.
- After 15 failures in a row the address is switched off and the organization's administrators are emailed. Every message, with the answer it got, is in the delivery log, where it can be sent again.
Changelog
- 2026-10-19 · 2.0.0 OAuth 2.0 apps (authorization code with PKCE, rotating refresh tokens), API version 2 for tasks, projects, time, CRM, invoices, support tickets and webhooks, rate limit headers, new webhook events (task changed, project changed, deal won or lost, lead added, support ticket opened), and a webhook delivery log with the receiver's answer and re-send.
- 1.0 API keys that read customers, projects, tasks, invoices, payments, leads and deals, add leads, and signed webhooks. Version 1 keeps working unchanged.