Developers · free on every plan
Ecosystem API
One graph of your workspace for any software: documents, issues, projects, events and designs, the links between them, and objects of your other apps — searchable together, linked both ways, announced as they change, and linked app to app: when something happens in one, zvena does what you set up in another.
On this page
- What it is
- Price and limits
- Getting started
- Scopes
- Endpoints
- Search and the graph
- Your apps' objects
- Connect any service
- Link any app with any other
- Software that knows nothing of zvena
- zvena links what belongs together
- Bridges between companies
- Issues
- Events and webhooks
- AI agents (MCP)
- OAuth: let people connect your app
- Plug it in anywhere
- Rules
What it is
Everything in zvena has an address, a ref: note:<id> for a page or a database row, issue:<id>, project:<id>, design:<file>[/<frame>], event:<id>, user:<id>. zvena keeps the links between them — a mention, a pasted link, a live block, a relation, a conversion, a link made by hand — in both directions. The Ecosystem API opens that graph to your code, and lets your own apps put their objects into it: a pull request, a deal, a support ticket, a build becomes ext:<id>, with a card, a place in search, and links to the issues and documents it is about.
The Docs API (Notion-compatible pages, databases and blocks) stays what it is. The Ecosystem API works across products, and uses the same integrations and secrets.
Price and limits
The Ecosystem API is free on every plan, the free plan included: no charge per request, per task or per run. Paid plans add only AI credits, AI models and storage. The limits below are the same for every workspace and keep the server quick for everyone. A server's operator can turn the whole ecosystem off (ZVENA_ECO_API=off); then every request answers 403 restricted_resource.
Requests per integration: 10 a second (bursts of 50). Per workspace: 20 a second (bursts of 100) — X-RateLimit-Limit says the burst. Connected services: 60. zvena Links: 30. Flow and action runs: 120 a minute. Objects synced from connected services: 200,000. Requests zvena makes to one connected service: 300 a minute (900 for the workspace).
Getting started
- Settings → Ecosystem → Developers → “New integration”. Copy the secret: it is shown once.
- Choose what it may do (scopes) and which products it reaches. Mail is personal and never reachable.
- Send the secret from your server in the Authorization header.
curl https://zvena.jevflow.net/api/v1/eco/me \ -H "Authorization: Bearer zv_secret_…"
An integration acts as the person who made it. It never sees more than they do — a private team's issue, a page not shared with them, a calendar they cannot read is simply not there. Writes (the write and objects scopes) need them to edit the workspace: a maker who becomes a viewer keeps reading and stops writing. If they leave the workspace the integration stops altogether, webhooks included. The workspace's owner may turn someone else's integration off, remove its webhook or revoke it, but only its maker widens it or takes a new secret.
Scopes
read— Search, cards, related objects and links of the products it was given. Always on.write— Link objects by hand, file issues, change them and move them between statuses.objects— Keep its own app's objects in zvena (external objects).events— Read the event feed and receive webhooks.actions— Run other connected apps' actions and keep flows between apps (rules).any— Any integration with the ecosystem API: its own manifest and the runs asked of it.products— any of docs, projects, calendar, design. Refs of other products are left out of every answer.
Endpoints
Base URL https://zvena.jevflow.net/api. The full description, with every shape, is at /api/v1/eco/openapi.json (OpenAPI 3.1).
GET | / | The integration, its workspace, plan and scopes | read |
GET | / | Search every product and other apps' objects at once | read |
POST | / | Cards for refs (the ones the maker may see) | read |
POST | / | What links name: zvena links, KEY-12, refs, and URLs of other apps' objects | read |
GET | / | Everything an object is linked with, both ways | read |
GET | / | An object's links as stored | read |
POST | / | Link two objects by hand | write |
DELETE | / | Remove a link made by hand or automatically (it is never made automatically again) | write |
GET | / | Links suggested for an object (a person, or the app, says yes or no) | read |
POST | / | Answer a suggestion: yes links the two, no keeps them from being suggested again | write |
GET | / | Teams issues can be filed in | read |
GET | / | Projects | read |
GET | / | A project's issues, or the maker's own | read |
POST | / | File an issue | write |
GET | / | One issue, with its description | read |
PATCH | / | Change an issue (status, title, priority, assignee, due date, project, links) | write |
GET | / | The statuses an issue can move to | read |
GET | / | This integration's objects, newest change first | objects |
PUT | / | Create or replace an object of your app | objects |
GET | / | One object of your app | objects |
DELETE | / | Delete an object (its links go too) | objects |
POST | / | Create or replace up to 100 objects | objects |
GET | / | The event feed (poll with a cursor) | events |
POST | / | Send an event of your app (something that happened: a payment arrived, a build failed) | objects |
GET | / | Your app's manifest: what it keeps, sends and does | any |
PUT | / | Say what your app is: object types, events, actions | any |
DELETE | / | Remove your app's manifest (its objects stay) | any |
GET | / | The workspace's apps and what each offers | read |
POST | / | Run another app's action | actions |
GET | / | Runs asked of your app; claim=1 collects the ones waiting | any |
GET | / | One run asked of your app, or one it started | any |
POST | / | Say how a run went | any |
GET | / | Rules on apps, and rules that run apps' actions | actions |
PUT | / | Create or replace a rule across apps | actions |
DELETE | / | Delete a rule across apps | actions |
GET | / | Services connected to the workspace (any software) | read |
POST | / | Find how to connect a service | write |
POST | / | Start connecting a found service | write |
GET | / | One connection | read |
POST | / | A connection's next step: access, signin, try, connect, sync | write |
PATCH | / | Who sees a connection, how often it syncs, which lists, paused | write |
GET | / | Your zvena Link agents (services on your machine or network) | read |
POST | / | A new zvena Link: its token and the command that starts it (shown once) | write |
DELETE | / | Remove a zvena Link | write |
DELETE | / | Remove a connection and what it brought in | write |
Search and the graph
GET https://zvena.jevflow.net/api/v1/eco/search?q=onboarding&kinds=issue,note,ext
GET https://zvena.jevflow.net/api/v1/eco/related?ref=issue:8f2c…
{
"object": "list",
"ref": "issue:8f2c…",
"results": [
{ "object": "related", "ref": "note:41ab…", "via": "embed", "direction": "in", "manual": false,
"card": { "object": "card", "ref": "note:41ab…", "kind": "note", "title": "Onboarding spec",
"url": "https://zvena.jevflow.net/docs?ws=…#…", "subtitle": "Product", "status": null, … } },
{ "object": "related", "ref": "ext:Qm9…", "via": "mention", "direction": "in", "manual": false,
"card": { "object": "card", "ref": "ext:Qm9…", "kind": "ext", "title": "Add onboarding emails",
"subtitle": "GitHub · Open", "source": "GitHub", "ext_type": "github.pull_request", … } }
],
"next_cursor": null,
"has_more": false
}
POST https://zvena.jevflow.net/api/v1/eco/resolve
{ "urls": ["ENG-12", "https://zvena.jevflow.net/projects/issue/ENG-12", "https://github.com/acme/app/pull/42"] }A card is what a person sees of any object, whatever product it comes from: title, where it opens (an absolute URL), a status with its category (backlog, unstarted, started, completed, canceled), assignee, due date, icon. Every card has every field, null when the object has none. Search returns the best few matches of each kind; has_more says page_size cut the list. resolve turns any link people paste — zvena's own, a KEY-12, or the URL of one of your objects — into a ref.
Your apps' objects
PUT an object by your own id; PUT again to replace it (unchanged input changes nothing). It shows to the workspace's members (not guests) in search and in the Related panel of everything it links to, and opens at its own URL.
PUT https://zvena.jevflow.net/api/v1/eco/objects/pr%2F42
{
"title": "Add onboarding emails",
"type": "github.pull_request",
"url": "https://github.com/acme/app/pull/42",
"icon": "🔀",
"status": { "name": "Open", "category": "started" },
"assignee": "Maya",
"properties": { "repo": "acme/app", "additions": 412, "draft": false },
"text": "Closes ENG-12. Spec: https://zvena.jevflow.net/docs#onboarding-41ab…",
"links": ["project:5d1e…"]
}- external_id goes in the path URL-encoded (pr/42 → pr%2F42); it may hold any visible ASCII character.
- links: refs, zvena links, KEY-12 or URLs of other objects — kept as sent (via relation).
- text: searchable; the KEY-12, @mentions and zvena links inside it become links too (via mention, link).
- Everything is resolved as the integration's maker, within the products it was given: a key of a team they cannot see, or an issue when the integration has no Projects, links nothing.
- A link to the object's URL pasted anywhere in zvena resolves to it, however the URL is written (case, trailing slash, utm_ parameters).
- POST /v1/eco/objects/batch takes up to 100 objects; a bad one gets its own error and the rest are saved. Revoking the integration removes its objects.
- Limits: title 300 characters, text 20,000 (longer text is cut), 30 properties, 100 links, 50,000 objects per integration (then 409).
Keeping two systems in step
- version: send the object's version in your app (a number that grows, or its updated_at as a date-time). A write older than the version zvena keeps changes nothing and answers 409 with stale: true and current (the object as kept), so retries and deliveries that arrive out of order never put an older state back.
- DELETE /v1/eco/objects/{id}?version=… remembers the deletion for a week: a write older than it, arriving late, does not bring the object back.
- ETag and If-Match: GET an object or an issue and keep its ETag; send it as If-Match with PUT, DELETE or PATCH and the write happens only if nobody changed it since (else 412 precondition_failed: read it again, merge, write). If-None-Match: * on PUT only creates.
- You never hear your own writes (feed and webhooks), so writing back what you were told does not echo; and the same content again is no change, so two apps that copy each other settle at once.
Connect any service
A person connects any service they work in by its name or address — a SaaS, an internal tool, an MCP server, an OpenAPI description, a service on their own computer — in Settings → Ecosystem → Services, or from your code or an AI client with the endpoints below. Nothing has to be on a list: zvena finds how to reach it, signs in, reads each of its lists once to show what comes, and from then on keeps it in step. Its records are then in search, cards, Related panels, flows, smart links and this API like any app's objects, and its actions run as app actions (POST /v1/eco/apps/{connection id}/actions/{action}).
- POST /v1/eco/connections/find { q } — the ways to connect it, best first: registry (connected before somewhere: its recipe is shared, never a secret or data), mcp (its MCP server), oauth (sign in with it), spec (its OpenAPI description), key, basic, docs (zvena AI reads its API docs), link (only a zvena Link reaches it).
- POST /v1/eco/connections { found, way } — the next step: signin (give the person the URL: they sign in on the service's own page, one click), key (POST …/{id}/access with the key they give), site (a SaaS with its own host per customer), or try.
- POST /v1/eco/connections/{id}/try — each list's first page, read on the person's account (reads only): counts and a few titles.
- POST /v1/eco/connections/{id}/connect { visibility, readers, every } — it syncs now and every 15 minutes (5 to 1440).
- One click: when a service's MCP server or API names its authorization server (RFC 9728, RFC 8414) and lets apps register themselves (RFC 7591), zvena registers once and every workspace signs in in one click (authorization code with PKCE S256, the resource of RFC 8707). Where zvena has its own app at the service, that app is used.
- Who sees it: only the person who connected it, by default (a personal account's mail, calendar and messages stay theirs); the whole workspace, chosen teams or chosen people. Search, cards, Related, this API and flows follow it. Its actions use the account of whoever connected it: only they run them or put them in flows.
- zvena Link: a service on someone's computer or network (localhost, a NAS, an office server, anything behind NAT). POST /v1/eco/agents gives a token and one command to run next to it (Node.js 18+, no dependencies): it connects out to zvena — no port to open — and makes only requests under the addresses its person allows (--allow). Then find { q: "http://localhost:8080", link }.
- Readers follow pages (Link headers, cursors, next URLs, page and offset numbers, starting_after), take up to 5,000 records per list per pass, and remove what a complete read no longer finds. Requests go over HTTPS to public hosts only (through a Link, the person's own addresses), with no redirects, 20 s and a size cap.
Link any app with any other
An app can do more than keep objects here. It can say what it is — its object types, the events it sends, the actions it takes — and then zvena links it with every other app of the workspace: when the CRM says a deal is won, the billing app makes the invoice; when a build fails, an issue is filed; when an issue is done, a third app is told. Each link is a flow (a rule): when → if → then. Flows are built in Settings → Ecosystem → Automations or through the API, and the if can be a question to zvena's decision model about the fields you choose.
Say what your app is
PUT https://zvena.jevflow.net/api/v1/eco/app
{
"name": "Acme Billing", "icon": "🧾", "url": "https://billing.acme.example",
"objects": { "invoice": { "title": "Invoice", "fields": { "amount": { "type": "number" }, "email": { "type": "string", "personal": true } } } },
"events": { "invoice.paid": { "title": "Invoice paid", "object": "invoice" } },
"actions": {
"invoice.create": {
"title": "Create invoice",
"input": { "amount": { "type": "number", "required": true }, "customer": { "type": "string", "required": true }, "currency": { "type": "string", "enum": ["USD", "EUR"] } },
"url": "https://billing.acme.example/zvena/run"
},
"ledger.export": { "title": "Export to the ledger", "input": { "month": { "type": "date" } } }
}
}
→ { "object": "app", …, "signing_secret": "zv_actsec_…" } (once: runs POSTed to you are signed with it)- Names of object types, events and actions: lower-case letters, digits, '.', '_' or '-'. Fields marked personal (and an object's assignee) are never sent to the decision model.
- An action with a url is pushed to you; one without is collected by your app (for software nobody can reach: a desktop app, a server behind NAT, a script).
- GET /v1/eco/apps lists the workspace's apps and what each offers (without where their runs go).
Send events
POST https://zvena.jevflow.net/api/v1/eco/events
{ "type": "invoice.paid", "external_id": "INV-7", "data": { "amount": 1500, "method": "card" } }Flows on your app hear it once; other apps read it in their feed as app.event. Changes of your objects (PUT) start flows too.
Take runs
A run is an action asked of your app — by a flow, a person, or another app with the actions scope (POST /v1/eco/apps/{app_id}/actions/{action}). A pushed run comes as a signed POST to the action's url, with Idempotency-Key set to the run's id: do the action once per id. Answer 2xx when done — optionally with a result and an object, which becomes yours and is linked to what started the run — or 202 to report later; 4xx when it cannot be done (the error's message is shown); no answer, 408, 429 or 5xx are tried again after 5 s, 30 s and 2 min.
POST https://billing.acme.example/zvena/run
Idempotency-Key: run_Jx9… X-Zvena-Timestamp: 1792771528 X-Zvena-Signature-V2: sha256=…
{ "object": "run", "id": "run_Jx9…", "action": "invoice.create",
"input": { "amount": 1500, "customer": "Globex", "currency": "EUR" },
"requested_by": { "type": "rule", "id": "won-deals" },
"subject": { "ref": "ext:Qm9…", "url": "https://zvena.jevflow.net/eco/object/Qm9…" }, … }
← 200 { "result": { "invoice": "INV-7" }, "object": { "external_id": "INV-7", "title": "Invoice INV-7", "type": "invoice" } }
// An app nobody can reach:
GET https://zvena.jevflow.net/api/v1/eco/runs?claim=1 → its queued runs, now claimed
POST https://zvena.jevflow.net/api/v1/eco/runs/run_Jx9… { "status": "succeeded", "result": { … } } or { "status": "failed", "error": "…" }Check a pushed run's signature exactly like a webhook's (above), with the action signing secret. A claimed run not reported in 10 minutes is offered again, 5 times at most.
Flows
PUT https://zvena.jevflow.net/api/v1/eco/rules/won-deals (the actions scope)
{
"name": "Won deals get an invoice",
"when": { "type": "app", "app": "<the CRM's integration id>", "on": "event", "event": "deal.won" },
"if": { "kind": "ask", "question": "Is this a new customer's first deal?", "fields": ["title", "amount", "segment"] },
"then": [{ "do": "app.run", "app": "<the billing app's id>", "action": "invoice.create",
"input": { "amount": "{amount}", "customer": "{title}", "currency": "EUR" } }]
}- when: an app's event (on: event, one or any) or a change of its objects (on: object, one type or any); or a Docs row, an issue, a number.
- if: always; a comparison of one field; or a yes/no question to the decision model about the chosen fields — sure, it acts; less sure, a person confirms; unsure or unavailable, nothing happens.
- then: another app's action ("{field}" in an input takes it from what happened), an issue (made from an app's object, it stays linked to it), a field of a Docs row.
- A flow that acts in another app asks a person in zvena before each run (an action in another app cannot always be taken back). Only the workspace's owner lets a flow act by itself, in zvena; a flow changed by anyone else asks again.
- Limits: 120 runs a minute per workspace, 600 events a minute per app, 1,000 runs waiting per app; runs are kept 30 days.
Software that knows nothing of zvena
Not every program will call this API. Settings → Ecosystem → Developers → Connect software makes a connector: an app zvena runs for that software. It gets a webhook URL (POST any JSON, a form or text, up to 256 KB) and zvena keeps its latest deliveries so you can see what it sends; zvena suggests which field is the id, the title, the link, the status and the event (the decision model picks where the workspace's owner left smart suggestions on), and one click keeps that as the connector's mapping. From then on every delivery becomes the connector's object and event, and flows use them like any app's.
- Presets: any webhook; GitHub (issues, pull requests, pushes, workflow runs; comment on, file and close issues with a token); Stripe (invoices, payments, subscriptions, checkouts); Telegram (messages to a bot; send messages; zvena sets the bot's webhook itself); Slack (post to a channel); Google Calendar (create and delete events with your own Google OAuth client; changes come in through the Apps Script below).
- Services retry, reorder and delete. A delivery's own id (GitHub's X-GitHub-Delivery, Stripe's event id, Telegram's update_id, or a Webhook-Id / X-Delivery-Id / Idempotency-Key header) is remembered for 3 days: the same delivery again answers duplicate and nothing runs twice. A route's object may name its version (GitHub's updated_at): an older delivery arriving late changes nothing (stale). A route's remove says when a delivery means the object is gone (GitHub's deleted or transferred, Stripe's invoice.deleted, a cancelled calendar event): it is deleted.
- Signatures are checked where the service signs: GitHub's X-Hub-Signature-256 with a secret zvena makes, Stripe-Signature within 5 minutes, Telegram's secret token header. A delivery that fails is turned away (401) and only that it came is kept.
- Actions are HTTPS requests zvena makes for a run: method, URL, headers and a JSON or form body, with {{input.x}}, {{secret.x}} and {{subject.url}} filled in. The host is written out in the action (templates fill only the path and query), private addresses are never called, and 10 s and 64 KB are the most a request takes. An answer can be kept as one of the connector's objects, linked to what started the run. Only whoever made the connector runs its actions (directly or in their own flows): they use that person's keys. Each request carries Idempotency-Key: <run id>; a POST or PATCH that gets no answer is not sent again (it may have happened there), only a 429 is.
- Keys (an API token, a bot token, a signing secret) are kept sealed with AES-256-GCM bound to the workspace and never shown again; only whoever made the connector changes what it sends where. Answers: 200 kept or not matched, 401 bad signature, 403 the ecosystem is turned off on this server, 404 no such hook, 410 its maker has left the workspace, 413 over 256 KB, 422 a mapping that found no id or title, 429 over 1,200 deliveries a minute (a hook, or one sender).
zvena links what belongs together
When your app sends an object (or a connector turns a delivery into one), zvena looks for what it is about: issues, projects, pages and other apps' objects that share a reference with it (an order or invoice number, a customer id, an e-mail) or words of its title, among what your integration's maker may see. For each of up to five it asks the decision model whether they are about the same thing. Sure (90% unless the workspace's owner set another bar): the two are linked, with via "smart". Less sure (65%): a suggestion that a person, or your app, answers. While the workspace has smart suggestions off, a shared reference is still suggested and nothing is linked by itself.
GET https://zvena.jevflow.net/api/v1/eco/links?ref=ext:Qm9… → { "via": "smart", … } among the links
GET https://zvena.jevflow.net/api/v1/eco/suggestions?ref=ext:Qm9… → [{ "id": "sug_…", "ref": "issue:…", "confidence": 0.82, "card": { … } }]
POST https://zvena.jevflow.net/api/v1/eco/suggestions/sug_… { "accept": true } → { "result": "linked" }
DELETE https://zvena.jevflow.net/api/v1/eco/links?src=ext:Qm9…&dst=issue:… the automatic link goes, and is never made again- What goes to the model: titles, types, statuses and up to 10 properties of each record. A property whose name says it is a person (owner, assignee, email, phone, name…) is never sent; e-mails and phone numbers inside values are masked. Name personal fields plainly and they stay home.
- A pair a person turned down or unlinked is never brought up again, and one the model found unlikely not for 30 days; a new status or a changing property is no reason to look again. It runs on its own share of the workspace's free allowance of model questions (150 a day), never its credits; where your manifest describes an object type, only fields not marked personal go to the model.
- The owner turns it off or moves both bars in Settings → Ecosystem → Automations → Smart links, where everything waiting for a yes, and what was linked automatically lately, is listed.
Bridges between companies
Two workspaces can work together by mutual consent: an owner invites (Settings → Ecosystem → Automations → Bridges, a link that works once), the other workspace's owner accepts, and each side picks the projects it shows the other. Their issues arrive as the objects of an app named after the partner ("Agency (bridge)", type bridge.issue) — title, key, status, priority, due date and project, never people or descriptions — kept in step as they change. Flows and automatic links treat them like any app's objects, and so does this API (GET /v1/eco/apps, the graph). Either owner closes the bridge at any time, and everything shown across goes on both sides. Only issues of open teams cross, and one whose team turns private leaves the other side.
Requests across: an owner may let the other side send requests into the projects it shows (off at first). The other side's bridge app then has one action, issue.request ("Send a request": project — one of those shown — title, description). A request waits for a person there to accept or decline it (Settings → Bridges) unless that owner chose to take requests at once; accepted, it becomes an issue there, in the team's triage when the team has it, written "Via <company> (bridge) from <person> at <company>", and comes back as one of the bridge's records. Run it like any app's action (POST /v1/eco/apps/{app_id}/actions/issue.request, or a flow, which asks a person first); the run stays sent until the other side answers, then succeeds (with the issue) or fails (declined, or not answered in 14 days).
Issues
GET https://zvena.jevflow.net/api/v1/eco/teams
POST https://zvena.jevflow.net/api/v1/eco/issues { "team_id": "…", "title": "Flaky checkout test", "priority": 2, "links": ["ext:Qm9…"] }
Idempotency-Key: 7c0e… (sent again, the first answer comes back: one issue, not two)
GET https://zvena.jevflow.net/api/v1/eco/issues/ENG-12 → the issue with its description (an id works too)
GET https://zvena.jevflow.net/api/v1/eco/issues/ENG-12/statuses
PATCH https://zvena.jevflow.net/api/v1/eco/issues/ENG-12 { "status_id": "…" }Google Calendar
Actions sign in with your own OAuth client: zvena keeps its client id, secret and refresh token sealed, and gets a short-lived access token before each action. To bring a calendar's changes in, add this Apps Script (script.google.com → New project; Services → Google Calendar API), fill in HOOK and SECRET from the connector, and add a trigger: sync, From calendar, Calendar updated.
// zvena ← Google Calendar: each changed event of one calendar goes to the connector's hook.
const HOOK = "https://zvena.jevflow.net/api/hooks/zvh_…"; // the connector's hook URL
const SECRET = "…"; // the connector's hook secret
const CALENDAR = "primary";
function sync() {
const props = PropertiesService.getScriptProperties();
let token = props.getProperty("syncToken");
let page = null;
do {
const opts = token ? { syncToken: token, showDeleted: true } : { timeMin: new Date().toISOString(), showDeleted: true };
if (page) opts.pageToken = page;
let res;
try {
res = Calendar.Events.list(CALENDAR, opts);
} catch (e) {
if (String(e).includes("410")) { props.deleteProperty("syncToken"); return sync(); } // start again
throw e;
}
(res.items || []).forEach(send);
page = res.nextPageToken;
if (res.nextSyncToken) token = res.nextSyncToken;
} while (page);
props.setProperty("syncToken", token);
}
function send(ev) {
const change = ev.status === "cancelled" ? "cancelled"
: Math.abs(new Date(ev.updated) - new Date(ev.created)) < 2000 ? "created" : "updated";
const when = (t) => (t && (t.dateTime || t.date)) || "";
UrlFetchApp.fetch(HOOK, {
method: "post", contentType: "application/json", muteHttpExceptions: true,
headers: { "X-Zvena-Hook-Secret": SECRET },
payload: JSON.stringify({
kind: "google.event", delivery: ev.id + "@" + ev.updated, calendar: CALENDAR, change,
id: ev.id, summary: ev.summary || "(no title)", htmlLink: ev.htmlLink, status: ev.status,
updated: ev.updated, start: when(ev.start), end: when(ev.end),
}),
});
}Events and webhooks
Events name refs, never content: read what you need through the API, with the same rules. An integration hears only about objects its maker could see when it happened and still may, of the products it was given, and never about its own objects' writes; it hears from the moment it has the events scope. The feed keeps 7 days: poll at least that often, or a cursor older than the feed answers 410 cursor_expired (read again without after, and re-read what you keep). An event has the same id (evt_<seq>) in the feed and in a webhook, so the two can be told apart from repeats.
Types: document.changed, issue.changed, issue.commented, issue.deleted, project.changed, project.deleted, event.changed, design.changed, links.changed, object.changed, object.deleted, app.event.
GET https://zvena.jevflow.net/api/v1/eco/events → { "results": [], "next_cursor": "1842" } (starts now)
GET https://zvena.jevflow.net/api/v1/eco/events?after=1842 → the events since, oldest first
{
"object": "event", "id": "evt_1843", "seq": 1843,
"type": "issue.changed", "timestamp": "2026-10-22T12:00:00.000Z",
"workspace_id": "…", "integration_id": "…",
"refs": ["issue:8f2c…"],
"actor": { "id": "…", "type": "person" },
"data": {}
}With a webhook set in Settings → Ecosystem → Developers, the same events are POSTed to your https endpoint (port 443), signed: X-Zvena-Signature-V2 = sha256=<hex HMAC-SHA256 of "<X-Zvena-Timestamp>.<raw body>" with the signing secret>; turn away timestamps more than 5 minutes off. (X-Zvena-Signature, the HMAC of the body alone, is sent too.) X-Zvena-Delivery names one delivery (the same on each retry of it). Answer 2xx within 10 seconds — do the work after. A delivery that fails is kept and tried again after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h and 12 h, across server restarts, so an endpoint that is down for a while misses nothing; private addresses are never called. Deliveries may overlap: order by seq. The feed has the same events for a week if you need to catch up further.
// Node: check a delivery (rawBody: the body exactly as received)
import { createHmac, timingSafeEqual } from "node:crypto";
function verified(rawBody, headers, secret = process.env.ZVENA_SIGNING_SECRET) {
const ts = headers["x-zvena-timestamp"] ?? "";
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // too old: a replay
const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex"));
const got = Buffer.from(headers["x-zvena-signature-v2"] ?? "");
return got.length === expected.length && timingSafeEqual(got, expected);
}AI agents (MCP)
The same API is an MCP server, so Claude, ChatGPT, Cursor or an agent of your own can work in the workspace: search it and read any item in full, write and edit documents and databases, file many issues at once, comment, make projects and post their updates, plan calendar events, answer suggested links, keep your app's objects in step and run other apps' actions. It acts as the person who connected it, with their permissions; tools the integration's scopes and products do not allow are not offered. It speaks Streamable HTTP (MCP 2025-03-26 to 2025-11-25) without sessions or a stream.
Clients that sign in by themselves (Claude, ChatGPT and other MCP clients with OAuth) need only the address: add a custom connector with it, sign in to zvena, pick the workspace and what the app may do, and allow. With a secret instead:
# Claude Code
claude mcp add --transport http zvena https://zvena.jevflow.net/api/v1/eco/mcp --header "Authorization: Bearer zv_secret_…"
# Any client that takes a JSON configuration
{ "mcpServers": { "zvena": { "type": "http", "url": "https://zvena.jevflow.net/api/v1/eco/mcp",
"headers": { "Authorization": "Bearer zv_secret_…" } } } }- Tools: search, fetch, read, get_cards, resolve, get_related, list_links, list_suggestions, workspace_info, list_teams, list_projects, list_issues, get_issue, list_issue_statuses, list_events, tables_list, tables_read, tables_query, tables_describe, tables_rows, list_apps, list_runs (read); create_doc, edit_doc, create_database, add_rows, update_rows, update_database, create_issue, create_issues, update_issue, comment, create_project, update_project, post_project_update, create_event, update_event, tables_create_report, tables_add_records, tables_update_records, tables_delete_records, tables_create_field, tables_run_field_agent, tables_create_automation, tables_build_base, link, unlink, answer_suggestion (write); list_objects, get_object, put_object, delete_object, send_event (objects); read_events (events); run_action, list_rules (actions).
- search and fetch answer in the shape ChatGPT's connectors and deep research read: each search result has an id (its ref), and fetch returns { id, title, text, url, metadata }.
- Tools that change things outside zvena or cannot be taken back are marked destructive, so a client asks its person first; run_action also says so in its description. Give an agent its own integration with only the scopes it needs.
- Requests from a browser page on another site are turned away (Origin), and a request names a protocol version the server speaks (MCP-Protocol-Version) or none.
- Without a token the server answers 401 with WWW-Authenticate naming its protected resource metadata and scope="eco:read eco:write". Clients may name themselves with a Client ID Metadata Document (an https client_id) or register (RFC 7591). A rate limit or a maker who left is answered as a JSON-RPC error with the reason, e.g. data.code rate_limited.
OAuth: let people connect your app
Instead of asking people to paste a secret, send them to zvena to allow your app: they sign in, pick a workspace (one where they can edit), choose what your app may do there, and come back to you with a code. The app acts as that person, never with more access than they have, and they can turn it off in Settings → Ecosystem → Developers.
GET https://zvena.jevflow.net/.well-known/oauth-authorization-server endpoints, scopes (eco:read eco:write eco:objects eco:events eco:actions)
POST https://zvena.jevflow.net/api/oauth/register { "client_name": "My app", "redirect_uris": ["https://my.app/zvena/callback"] } → { "client_id": "zvc_…" }
GET https://zvena.jevflow.net/oauth/authorize?response_type=code&client_id=zvc_…&redirect_uri=…&state=…
&code_challenge=…&code_challenge_method=S256&scope=eco:read%20eco:write&resource=https://zvena.jevflow.net/api/v1/eco
POST https://zvena.jevflow.net/api/oauth/token grant_type=authorization_code&code=…&code_verifier=…&redirect_uri=…&client_id=zvc_…
→ { "access_token": "zv_oat_…", "expires_in": 3600, "refresh_token": "zv_ort_…" }
POST https://zvena.jevflow.net/api/oauth/token grant_type=refresh_token&refresh_token=…&client_id=zvc_… (a new pair; the old refresh token is used up)
POST https://zvena.jevflow.net/api/oauth/revoke token=…&client_id=zvc_…- PKCE (S256) is required for every client; redirect URIs are matched exactly (a loopback http://127.0.0.1 one on any port); https, loopback or your app's own scheme only.
- Public clients (no secret) are the default; register with token_endpoint_auth_method client_secret_basic to get a secret. A code works once, within a minute; access tokens last an hour, refresh tokens 30 days. A code or a refresh token used twice revokes everything it gave out.
- The consent screen says plainly when zvena has not reviewed an app. The access token works for the whole API and the MCP endpoint; a 401 from the MCP endpoint points to its metadata (RFC 9728).
Register your app (a client id, a secret unless it is a public client) at Developers → Your apps, and submit it for the catalog: once zvena reviews what it says about itself and where it sends people, it is listed and its consent screen says it was reviewed. Changing its name, description, addresses or scopes sends it back to review.
Plug it in anywhere
Nobody has to work in zvena's own workspace to use the ecosystem. Every place below signs the person in with their zvena account and sees only what they see; Settings → Ecosystem → Use anywhere has each one ready to copy.
- Claude, ChatGPT and any AI app that takes a custom connector: add https://zvena.jevflow.net/api/v1/eco/mcp and sign in (OAuth; no secret to paste).
- Claude Code, Cursor, VS Code, Windsurf: claude mcp add --transport http zvena https://zvena.jevflow.net/api/v1/eco/mcp, or { "mcpServers": { "zvena": { "url": "https://zvena.jevflow.net/api/v1/eco/mcp" } } } in their MCP settings.
- The terminal: curl -fsSLO https://zvena.jevflow.net/cli/zvena.mjs && node zvena.mjs login (Node.js 18+, one file, no dependencies), then node zvena.mjs search …, connections, connect <service>, and call <tool> for anything else.
- Slack: /zvena and what you are looking for, from any channel, once zvena is added to the Slack workspace and the person has linked their account; only they see the answer.
- Any language: generate a client from the OpenAPI description (openapi-generator, oapi-codegen, openapi-typescript).
- No-code: import the OpenAPI URL as a custom app or HTTP request in Zapier, Make or n8n; use the event feed as a polling trigger, or a webhook trigger.
- AI agents: connect over MCP (above), or hand the OpenAPI description to an agent framework as tools; the integration's scopes and its maker's access bound what it can do.
- Your own apps: sync objects with PUT on every change in your app, link them with text people already write (“Fixes ENG-12”), and react to zvena with events.
Rules
- JSON only, bodies up to 2 MB. Bearer token from a server: browsers get no CORS grant.
- About 10 requests a second per integration, bursts up to 50; every answer says where you are (X-RateLimit-Limit, -Remaining, -Reset); over it, 429 with Retry-After.
- Every answer carries X-Request-Id, and every error its request_id: name it when you write to us.
- POSTs take an Idempotency-Key header (any unique string, kept a day): a retried request returns the first answer with Idempotent-Replayed: true; the same key with another body is 409. PUT objects is idempotent by itself.
- Versions: every answer names its version (Zvena-Version: 2026-10-22). Send the same header to pin it; another version answers 400. Fields may be added to answers within a version — ignore the ones you do not know.
- Lists: page_size up to 100; objects and events page with start_cursor / after and next_cursor; has_more.
- Errors: { object: "error", status, code, message, request_id } — validation_error and invalid_json (400), unauthorized (401), restricted_resource (403), object_not_found and invalid_request_url (404), method_not_allowed (405), conflict_error (409), cursor_expired (410), validation_error (413, body too large), rate_limited (429), internal_server_error (500).
- Changes made through the API carry the maker's name in the history; links your objects make carry the integration's.
Build on your own workspace. Start free. Add more when you need it.