zvena 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, and announced as they change.
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.
Plans
The Ecosystem API is part of zvena Pro. On another plan every request answers 402 plan_required. When a workspace leaves Pro nothing is deleted: objects your apps added keep their cards and links in zvena; the API, the event feed and webhooks pause until the plan runs again. Self-hosted servers without billing have it for every workspace.
Getting started
- Settings → API → “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.app/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 — and it stops working if they leave the workspace.
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.products— any of docs, projects, calendar, design. Refs of other products are left out of every answer.
Endpoints
Base URL https://zvena.app/api. The full description, with every shape, is at /api/v1/eco/openapi.json (OpenAPI 3.1).
GET | /v1/eco/me | The integration, its workspace, plan and scopes | read |
GET | /v1/eco/search | Search every product and other apps' objects at once | read |
POST | /v1/eco/cards | Cards for refs (the ones the maker may see) | read |
POST | /v1/eco/resolve | What links name: zvena links, KEY-12, refs, and URLs of other apps' objects | read |
GET | /v1/eco/related | Everything an object is linked with, both ways | read |
GET | /v1/eco/links | An object's links as stored | read |
POST | /v1/eco/links | Link two objects by hand | write |
DELETE | /v1/eco/links | Remove a link made by hand | write |
GET | /v1/eco/teams | Teams issues can be filed in | read |
GET | /v1/eco/projects | Projects | read |
GET | /v1/eco/issues | A project's issues, or the maker's own | read |
POST | /v1/eco/issues | File an issue | write |
PATCH | /v1/eco/issues/{id} | Change an issue (status, title, priority, assignee, due date, project) | write |
GET | /v1/eco/issues/{id}/statuses | The statuses an issue can move to | read |
GET | /v1/eco/objects | This integration's objects, newest change first | objects |
PUT | /v1/eco/objects/{external_id} | Create or replace an object of your app | objects |
GET | /v1/eco/objects/{external_id} | One object of your app | objects |
DELETE | /v1/eco/objects/{external_id} | Delete an object (its links go too) | objects |
POST | /v1/eco/objects/batch | Create or replace up to 100 objects | objects |
GET | /v1/eco/events | The event feed (poll with a cursor) | events |
Search and the graph
GET https://zvena.app/api/v1/eco/search?q=onboarding&kinds=issue,note,ext
GET https://zvena.app/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": { "kind": "note", "title": "Onboarding spec", "url": "/docs?ws=…#…", … } },
{ "object": "related", "ref": "ext:Qm9…", "via": "mention", "direction": "in", "manual": false,
"card": { "kind": "ext", "title": "Add onboarding emails", "sub": "GitHub · Open", "source": "GitHub", … } }
]
}
POST https://zvena.app/api/v1/eco/resolve
{ "urls": ["ENG-12", "https://zvena.app/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, a status with its category (backlog, unstarted, started, completed, canceled), assignee, due date, icon. 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.app/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.app/docs#onboarding-41ab…",
"links": ["project:5d1e…"]
}- 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: a key of a team they cannot see 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, 30 properties, 100 links, 50,000 objects per integration.
Issues
GET https://zvena.app/api/v1/eco/teams
POST https://zvena.app/api/v1/eco/issues { "team_id": "…", "title": "Flaky checkout test", "priority": 2, "links": ["ext:Qm9…"] }
GET https://zvena.app/api/v1/eco/issues/{id}/statuses
PATCH https://zvena.app/api/v1/eco/issues/{id} { "status_id": "…" }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 may see, of the products it was given, and never about its own objects' writes. The feed keeps 7 days.
Types: document.changed, issue.changed, issue.commented, issue.deleted, project.changed, project.deleted, event.changed, design.changed, links.changed, object.changed, object.deleted.
GET https://zvena.app/api/v1/eco/events → { "results": [], "next_cursor": "1842" } (starts now)
GET https://zvena.app/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 → API, the same events are POSTed to your https endpoint, signed: X-Zvena-Signature: sha256=<HMAC-SHA256 of the body with the signing secret>. Any 2xx acknowledges; failures are retried after 1 s, 10 s and 1 min; private addresses are never called. Use the feed to catch up on anything a webhook missed.
// Node: check a delivery
import { createHmac, timingSafeEqual } from "node:crypto";
const expected = "sha256=" + createHmac("sha256", process.env.ZVENA_SIGNING_SECRET).update(rawBody).digest("hex");
const ok = timingSafeEqual(Buffer.from(expected), Buffer.from(req.headers["x-zvena-signature"] ?? ""));Plug it in anywhere
- 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: 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; over it, 429 with Retry-After.
- Lists: page_size up to 100, start_cursor / after, next_cursor, has_more.
- Errors: { object: "error", status, code, message } — unauthorized (401), plan_required (402), restricted_resource (403), object_not_found (404), validation_error and invalid_json (400), conflict_error (409), rate_limited (429).
- Changes made through the API carry the maker's name in the history; links your objects make carry the integration's.