Quickstart
- Make a key. An owner or admin opens Settings → Developers → API keys → New key in the web app, names it (“Zapier”, “Power BI”), picks its role, the projects it may see and its scopes, and copies it. The key starts with
kvg_live_and is shown once: keep it in your secret store, never in code or a browser. - Check it.
GET /v1/meanswers with the key, its company, role, scopes and rate limits.
curl https://api.getkonverge.com/v1/me \
-H "Authorization: Bearer $KONVERGE_API_KEY"List open tasks, then create one. Send an Idempotency-Key with every write, so a retry after a network error never makes a second task.
curl "https://api.getkonverge.com/v1/tasks?status=pending,in_progress&limit=50" \
-H "Authorization: Bearer $KONVERGE_API_KEY"curl https://api.getkonverge.com/v1/tasks \
-H "Authorization: Bearer $KONVERGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7f1c2e0a-5b6d-4c3e-9a8b-1d2e3f4a5b6c" \
-d '{"project_id": "3f6c2a9e-8b1d-4c7e-9a52-6d0e4b1f7a38", "title": "Fix the fire door on level 2", "priority": "high"}'A create answers 201 with the record and a Location header. The reference lists every resource, field, filter and action.
Authentication
API keys
Send the key on every request as Authorization: Bearer <key>. Each key has its own account in your company, so everything it does is recorded as “API: <key name>” and it can never do more than a person with the same role:
- Role — admin, manager, member or viewer. Only the company owner can make an admin key.
- Projects — member and viewer keys see only the projects you pick.
- Scopes — narrow the role further; each is checked on its own (write does not include read).
- Expiry — a year by default. Rotate a key to get a new secret with the same account; revoke it to cut it off at once.
| Scope | Allows |
|---|---|
records:read | List and read records. |
records:write | Create and change records, and run actions (submit, approve, set status…). |
records:delete | Delete records. |
files:read | Download documents and photos. |
files:write | Upload documents and photos. |
webhooks:manage | Subscribe addresses to events and remove them (REST hooks). Needs records:read too. |
A missing, mistyped, revoked or expired key always gets the same 401. Keys from a test environment start with kvg_test_ and only work there.
OAuth (acting as a person)
An app that works as the person using it, such as an AI assistant, signs them in with Konverge’s OAuth 2.1 server instead of holding a key. The person sees a consent page in Konverge, and the app then has exactly their permissions; they can remove it any time in Settings. For now OAuth is offered through the MCP server. A person in several companies names one with the Konverge-Company: <company id> header.
Records and formats
- Paths are the record type in plural:
/v1/tasks,/v1/rfis,/v1/daily_logs,/v1/punch_items(snags).GETlists,GET /{id}reads one,POSTcreates,PATCH /{id}changes,DELETE /{id}deletes,POST /{id}/actions/{action}runs an action such assubmit,approveorset_status. - Fields are the database column names in
snake_case; ids are UUIDs; timestamps are RFC 3339 in UTC; dates areYYYY-MM-DD; amounts are JSON numbers with at most two decimals, always next tocurrency. - One record is
{ "data": { … } }; a list is{ "data": [ … ], "has_more": …, "next_cursor": … }. - Your company is implied by the key: no company id in paths. Bank details, National Insurance numbers and dates of birth are never returned.
Pagination and filters
Lists are newest first and come in pages of limit records (1 to 100, default 25). When has_more is true, pass next_cursor back as cursor with the same filters:
{
"data": [
{ "id": "b1e0d7c4-…", "title": "Fix the fire door on level 2", "status": "pending", … }
],
"has_more": true,
"next_cursor": "eyJ2IjoiMjAyNi0xMC0wNFQwOTozMDowMFoiLCJpZCI6ImIxZTBkN2M0In0"
}curl "https://api.getkonverge.com/v1/tasks?status=pending,in_progress&limit=50&cursor=eyJ2IjoiMjAy…" \
-H "Authorization: Bearer $KONVERGE_API_KEY"- Filter on the fields each resource lists:
?status=open,in_progress(a comma means “any of”),?project_id=…,?due_date[gte]=2026-10-01,?assigned_to=null. ?updated_since=<timestamp>returns what changed since your last sync;?q=searches text fields.- An unknown parameter is an error (
400 unknown_filter), never quietly ignored.
Idempotency
Send Idempotency-Key: <any unique string> (a UUID is ideal) on every POST, PATCH and DELETE. For 24 hours:
- The same key with the same request returns the first answer again, with
Idempotent-Replayed: true, and does nothing twice. - The same key with a different request is
409 idempotency_key_reused; while the first is still running,409 idempotency_in_progress. - A request refused before anything was written (a
422, a429, a5xx) is not kept, so fix it and retry with the same key.
Rate limits
Every request takes a token from two buckets that refill every second: the key’s own (writes per minute as set on the key, reads five times that) and your plan’s, which all of your company’s keys share. The request goes ahead only when both have a token. GET /v1/me shows both.
| Header | Means |
|---|---|
RateLimit-Limit / X-RateLimit-Limit | The size of the bucket this request drew from. |
RateLimit-Remaining / X-RateLimit-Remaining | Requests left in it right now. |
RateLimit-Reset | Seconds until it is full again. |
X-RateLimit-Reset | The same moment as a Unix time (seconds). |
Konverge-RateLimit-Scope | key (this key’s own bucket) or plan (the bucket your plan shares between all your keys). |
Retry-After | On a 429 or 503 only: seconds to wait before trying again. |
The headers describe whichever bucket is tighter. When one is empty the answer is 429 rate_limited with Retry-After, and the message says whether it was the key or the plan. A key’s own limit is set when the key is made (120 writes a minute unless agreed otherwise; ask support for more); the plan’s grows with a larger plan, in Settings → Billing.
Errors
Every error has a stable code, a message written for people (never database text) and a request_id to quote to support; 422 adds each field’s problem.
{
"error": {
"code": "validation_failed",
"message": "Some fields are not valid. See details.",
"request_id": "req_4c1f9b2d7e6a8035c2d1e9f0",
"details": [{ "field": "project_id", "message": "No project with this id in your company, or this key cannot see it." }]
}
}| Status | Codes | Means |
|---|---|---|
| 400 | bad_request · unknown_filter · invalid_cursor | The request itself is malformed: a query parameter, the cursor, the JSON or the Idempotency-Key. |
| 401 | unauthenticated | No valid key: missing, mistyped, revoked or expired (one answer for all). |
| 403 | insufficient_scope · forbidden · company_read_only | The key may not do this: a missing scope, its role, or a company whose subscription has ended. |
| 404 | not_found | No such record in your company, or this key cannot see it. |
| 405 | method_not_allowed | The resource does not support that method or action. See the Allow header. |
| 409 | conflict · idempotency_key_reused · idempotency_in_progress | The record’s state does not allow it yet, or the Idempotency-Key was reused. |
| 422 | validation_failed | A field is missing or wrong. details lists each one. |
| 429 | rate_limited | Too many requests. Wait Retry-After seconds. |
| 500 | internal_error | Our fault. A write may or may not have happened: check before retrying. |
| 503 | unavailable · api_not_configured | Busy or switched off here. Retry after Retry-After seconds. |
Webhooks
Konverge POSTs an event to your HTTPS address within about a minute of a change. Add a webhook in Settings → Developers → Webhooks, or from code with a key that has webhooks:manage (a REST hook: the way Zapier, Make and n8n subscribe):
curl https://api.getkonverge.com/v1/webhook_subscriptions \
-H "Authorization: Bearer $KONVERGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"target_url": "https://hooks.example.com/konverge", "events": ["task.created", "task.status_changed"]}'The answer includes the signing secret once; store it. GET /v1/webhook_subscriptions lists the key’s webhooks and DELETE /v1/webhook_subscriptions/{id} removes one. The address must be public HTTPS on port 443: private, loopback and cloud-metadata addresses are refused, and so are redirects.
Events
Event names are <object>.<verb>, <object>.* or *. Verbs: created, status_changed, assigned, deleted (an edit that changes neither the status nor the assignee sends no event). Objects: project, task, daily_log, punch_item, inspection, incident, field_note, milestone, comment, contact, transaction, change_order, payment_application, purchase_order, po_receipt, subcontract, bill, location, site_induction, site_access_override, permit, schedule_baseline, task_dependency, delivery, material_request, meeting, rfi, submittal, workflow, technical_query, early_warning, compensation_event, instruction, ncr, project_contract, inbound_email, bid_package, bid, service_request, handover_pack, site_attendance, estimate, estimate_award, client_payment, selection_item, project_allowance, client_update.
{
"id": "evt_3f6c2a9e-8b1d-4c7e-9a52-6d0e4b1f7a38",
"type": "task.status_changed",
"api_version": "v1",
"created_at": "2026-10-04T10:00:00Z",
"company_id": "b1e0d7c4-5a3f-4e2b-8c19-0f6a7d2e9b54",
"actor": { "type": "user", "id": "…", "name": "Sam Carter" },
"data": {
"object": "task",
"id": "3f6c2a9e-8b1d-4c7e-9a52-6d0e4b1f7a38",
"project_id": "b1e0d7c4-5a3f-4e2b-8c19-0f6a7d2e9b54",
"changes": { "status": { "from": "pending", "to": "completed" } },
"record": { "id": "3f6c2a9e-…", "title": "Fix the fire door on level 2", "status": "completed", … }
}
}record is read when the event is sent, as the webhook’s API key: an event about a record that key cannot see is never sent. estimate, estimate_award, client_payment and project_allowance are money records: they go only to a key that sees money figures. Deliveries are at least once, so ignore an id you have already handled.
Check the signature
Konverge-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of <t>.<raw body> keyed with the whole secret (whsec_ included). Use the body exactly as received, compare in constant time, and refuse a t more than five minutes old. For a day after you rotate the secret there are two v1 values; accept either.
const crypto = require('crypto');
// header: the Konverge-Signature request header
// rawBody: the request body exactly as received (Buffer or string), before JSON parsing
// secret: the whsec_… value Konverge showed when you created the webhook
function verifyKonvergeSignature(header, rawBody, secret, toleranceSeconds = 300, nowSeconds = Math.floor(Date.now() / 1000)) {
if (typeof header !== 'string' || !secret) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const eq = part.indexOf('=');
const name = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (name === 't' && /^\d+$/.test(value)) timestamp = Number(value);
if (name === 'v1' && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
return signatures.some((sig) => crypto.timingSafeEqual(Buffer.from(sig, 'hex'), expected));
}app.post('/konverge', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyKonvergeSignature(req.get('Konverge-Signature'), req.body, process.env.KONVERGE_WEBHOOK_SECRET)) {
return res.status(400).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// Ignore an event you have already handled: event.id (= Konverge-Event-Id).
res.status(204).end();
// …then process `event`.
});import hashlib
import hmac
import time
def verify_konverge_signature(header, raw_body, secret, tolerance_seconds=300, now=None):
"""header: the Konverge-Signature header; raw_body: the request body as bytes, exactly as received;
secret: the whsec_... value Konverge showed when you created the webhook."""
if not header or not secret:
return False
timestamp, signatures = None, []
for part in header.split(","):
name, _, value = part.strip().partition("=")
if name == "t" and value.isdigit():
timestamp = int(value)
elif name == "v1" and len(value) == 64:
signatures.append(value)
if timestamp is None or not signatures:
return False
now = int(time.time()) if now is None else now
if abs(now - timestamp) > tolerance_seconds:
return False
expected = hmac.new(secret.encode("utf-8"), str(timestamp).encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)Retries
Answer any 2xx within 10 seconds and do slow work afterwards. A failed delivery is tried eight times in all over about three days (after 5 and 30 minutes, then 2, 6, 12, 24 and 24 hours); after 20 events in a row fail, the webhook is turned off and owners are told. You can replay any delivery from the log. Answer 410 Gone to unsubscribe: a webhook made through the API is then deleted.
MCP: connect AI agents
Konverge is a remote Model Context Protocol server at https://api.getkonverge.com/mcp. An AI app signs the person in with OAuth and then searches, reads and drafts changes as that person, with exactly their permissions. The model never writes on its own: every change is a proposal with a preview, and bigger changes (status, money, deletions) need the person to approve it in the AI app or type a short confirmation phrase.
- Claude (claude.ai and Claude Desktop): Settings → Connectors → Add custom connector → paste the address → Connect, then allow Konverge on its consent page.
- ChatGPT: Settings → Apps & Connectors → Advanced → turn on Developer mode → Create a connector → MCP server URL = the address, authentication OAuth.
- Cursor: add Konverge to
~/.cursor/mcp.json, then Settings → MCP → Konverge → Log in.
{
"mcpServers": {
"konverge": { "url": "https://api.getkonverge.com/mcp" }
}
}claude mcp add --transport http konverge https://api.getkonverge.com/mcpAn agent that runs with no person present uses an API key instead; the key’s role and scopes apply, and its changes still go through the proposal step.
claude mcp add --transport http konverge https://api.getkonverge.com/mcp \
--header "Authorization: Bearer $KONVERGE_API_KEY"Menu names change between app versions: look for “custom connector”, “MCP server” or “integrations”. Each person sees and removes the apps they connected in Konverge’s settings.
Zapier, Make and n8n
Zapier
The Konverge app for Zapier has instant triggers for new and changed projects, tasks, daily logs, snags, contacts, invoices, change orders and bills, polling triggers for RFIs, searches for projects and contacts, and actions to create tasks, snags, daily logs and contacts and to comment on or answer an RFI. Connect it with an API key that has records:read, webhooks:manage and, for actions, records:write. The app is in private testing: ask support for an invite until it is listed in the Zapier directory.
Make, n8n and everything else
- Actions: use an HTTP module (Make: HTTP → Make a request; n8n: HTTP Request with a Header Auth credential) with
Authorization: Bearer <key>and a JSON body, as in the quickstart. - Triggers: create a webhook in the tool (Make: Webhooks → Custom webhook; n8n: Webhook node), then subscribe its address to the events you want, in Settings → Developers or with
POST /v1/webhook_subscriptions. - OpenAPI: getkonverge.com/openapi.json imports into Postman, Insomnia, Bruno and OpenAPI code generators.
Versioning and support
/v1 only changes by adding things: new resources, fields, filters and values. Anything retired is announced first with Deprecation and Sunset headers, and a change that would break an integration becomes /v2. Releases are in the changelog.
Questions, or something not working? Email support@getkonverge.com with the request_id (also the Request-Id header) of the call.