Data connector spec
Eno’niva is a private relationship network. A data connector lets a member (or a company workspace) bring their contacts in from your system: a CRM, a booking or practice-management tool, a recruiting system, your own app. You build a small MCP server with one tool, eno_get_contact_changes. The member adds its URL in Eno’niva, and Eno’niva calls it once a day (and when they click “Update now”) for the contacts added, updated or deleted since the last call.
Two ways to connect. Custom connector: a member pastes your MCP URL (and an API key) into Eno’niva. Directory service: you register your service once, we approve it, and members find it under Connectors and click Connect; Eno’niva then calls eno_connect with their verified emails and phone (§17). Both use the same eno_get_contact_changes tool for the contacts.
Fastest path: put CLAUDE.md in your repository and ask Claude Code to “implement the Eno’niva data connector described in CLAUDE.md”. The reference server (connector.js, ~100 lines, no dependencies) is what Eno’niva’s own test suite runs against.
Contents
- How it works
- Transport (MCP)
- Authentication and consent
- The tool
- Sequence numbers
- Paging and full exports
- Deletes
- Contact record
- Errors
- Personal and company connectors
- Schedule and rate limits
- Security and privacy
- Versioning
- Signature verification code
- Testing checklist
- Why it’s designed this way
- Directory services:
eno_connect
1. How it works
- In your product, the user turns on “Connect to Eno’niva”. You give them your MCP URL and, ideally, an API key that identifies their account.
- In Eno’niva (Settings → Data connectors, or Workspace → Connectors for companies) they add a connector: a name, your MCP URL and the optional API key. Eno’niva shows a signing secret once. The user pastes it into your product, which stores it.
- They click Test connection: Eno’niva calls
initializeandtools/list, which must includeeno_get_contact_changes. Until a test passes, Eno’niva never calls the tool. - Eno’niva calls the tool with the user’s verified phone and/or email, a
sequencenumber andsince_sequence. The first call hassince_sequence: 0: send everything (paged). After that you send only what changed.
2. Transport (MCP Streamable HTTP)
Your server is an MCP server over Streamable HTTP (MCP spec 2025-06-18): JSON-RPC 2.0 messages sent with POST to one endpoint, for example https://api.example.com/mcp. Any MCP SDK works (TypeScript, Python, Go, …), or handle the four messages by hand as the reference server does.
- Eno’niva sends
Accept: application/json, text/event-stream. Answer withapplication/json(simplest) or an SSE stream that contains the response. - Sequence per run:
initialize→notifications/initialized(answer202) →tools/call(one per page).tools/listis used by Test connection. If yourinitializeresponse setsMcp-Session-Id, Eno’niva sends it back on every later request and ends withDELETE. - Only
https://on a public address. Eno’niva does not follow redirects; give the final URL. - Answer within 30 seconds per request and keep each response under 8 MB. Use paging for more.
3. Authentication and consent
A phone number or email alone doesn’t prove that a request is allowed to read someone’s contacts. Every request carries:
| Header | Value |
|---|---|
Authorization | Bearer <API key>, when the user entered one. Recommended: issue a per-user (or per-company) key in your product and map it to the account. |
X-Enoniva-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + raw body)>. The secret is the signing secret shown once in Eno’niva (it starts with ensec_; use the whole string as the key, UTF-8). |
X-Enoniva-Connector-Id | The connector’s public id (dc_…), or for a directory service your service id (svc_…, the same on every call). Use it to look up which stored secret to check against. |
User-Agent | Enoniva-DataConnector/1 (+https://enoniva.com/connectors/spec.html) |
You must:
- Verify the signature over the exact raw bytes of the body (before JSON parsing), with a constant-time comparison, and reject timestamps more than 5 minutes from your clock. Answer
401when it fails. (DELETErequests that end a session carry no body and no signature; just close the session.) - Check the API key, if you issued one.
- Return data only for a user who authorized Eno’niva in your system (or whose identity they linked there), matched on the verified identity Eno’niva sends and, when you use keys, on the key’s account. Otherwise answer the
unknown_usererror. Never return contacts of a different user because a phone number or email happens to match.
OAuth. v1 uses an API key plus the signature. If your MCP server requires OAuth (the MCP authorization spec: 401 with WWW-Authenticate: Bearer resource_metadata="…"), Test connection detects it and tells the user that OAuth connectors arrive in v1.1, when Eno’niva will run the standard OAuth 2.1 + PKCE flow with discovery through /.well-known/oauth-protected-resource. Until then, please offer an API key.
4. The tool: eno_get_contact_changes
List it in tools/list with the input and output schemas from schema.json ($defs.input, $defs.output) and annotations.readOnlyHint: true.
Input
{
"protocol": "enoniva.contacts/1",
"user": { "phone": "+15551234567", "phone_verified": true,
"email": "dana@example.com", "email_verified": true },
"sequence": 8, // this call: last acknowledged + 1
"since_sequence": 7, // send changes made after call 7 (0 = full export)
"page_cursor": "…" // only on page 2+ of the same call
}
user contains a phone, an email, or both, and only ones Eno’niva has verified (the *_verified flags are always true). Company connectors in workspace mode send {"workspace_domain": "acme.com"} instead (see §10).
Output (structuredContent)
{
"sequence": 8, // echo
"next_page_cursor": "eyJvIjo1MDB9", // omit on the last page
"changes": [
{ "op": "upsert", "external_id": "c-100", "updated_at": "2026-10-11T08:12:00Z",
"contact": { "full_name": "Jim Halpert", "emails": ["jim@dunder.example"], "phones": ["+15705550100"],
"company": "Dunder Mifflin", "title": "Sales", "owner_relationship": "customer",
"last_interaction_at": "2026-10-09T15:00:00Z",
"interaction_counts": { "calls": 4, "emails": 9 },
"url": "https://crm.example.com/contacts/c-100" } },
{ "op": "delete", "external_id": "c-090", "updated_at": "2026-10-10T17:40:00Z" }
]
}
Also put the same JSON in a text content block for older clients (content: [{"type":"text","text":"…"}]); Eno’niva reads structuredContent first.
A full exchange
→ POST /mcp {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"enoniva","title":"Eno’niva","version":"1.0.0"}}}
← 200 Mcp-Session-Id: 4f1c…
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},
"serverInfo":{"name":"acme-crm-eno","version":"1.2.0"}}}
→ POST /mcp {"jsonrpc":"2.0","method":"notifications/initialized"}
← 202
→ POST /mcp {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"eno_get_contact_changes",
"arguments":{"protocol":"enoniva.contacts/1","user":{"phone":"+15551234567","phone_verified":true},
"sequence":8,"since_sequence":7}}}
← 200 {"jsonrpc":"2.0","id":2,"result":{
"content":[{"type":"text","text":"{\"sequence\":8,\"changes\":[…]}"}],
"structuredContent":{"sequence":8,"changes":[…]}}}
→ DELETE /mcp (Mcp-Session-Id: 4f1c…)
5. Sequence numbers
Eno’niva keeps, per connector and user, the last acknowledged sequence A. Each call sends sequence = A + 1 and since_sequence = A. Eno’niva advances A only after it ingested every page of that call. If anything fails (network, timeout, a bad page, our own outage), the next attempt sends the same two numbers again.
What you implement: a checkpoint per user and sequence.
- The first time you see
sequence = Nfor a user, recordcheckpoint[N] = your current change position(a change-log id, or a timestamp with a tiebreaker). If you already havecheckpoint[N](a retry), reuse it: never move it. - Return the changes in the window
(checkpoint[since_sequence], checkpoint[N]], collapsed to the latest change perexternal_id. Changes made aftercheckpoint[N]wait for callN + 1. since_sequence = 0means “from the beginning”: a full export of contacts that exist atcheckpoint[N](no deletes needed).- You can delete checkpoints older than
since_sequence; that number is acknowledged and will never be asked for again. If you no longer havecheckpoint[since_sequence](history pruned, restored backup), answerresync_requiredand Eno’niva follows with a full export.
A simple schema: contact_changes(user_id, pos bigserial, external_id, op, changed_at) written by your app (or a trigger), plus eno_checkpoints(connector_id, user_id, sequence, pos).
Because the window for a given sequence is fixed on first sight, a retry returns exactly the same data: nothing is lost (a failed call is never acknowledged) and nothing is doubled (Eno’niva applies upserts and deletes idempotently by external_id).
6. Paging and full exports
- Return at most 5,000 changes per page (500–1,000 is a good size). If more remain, include
next_page_cursor; Eno’niva calls again with the samesequence/since_sequenceandpage_cursor. Omit it on the last page. - Cursors are opaque strings up to 1,000 characters. They must be stable for the fixed window of that sequence (an offset into a deterministic order works). If a cursor expired, answer
cursor_expired: Eno’niva restarts the call from page 1. - An interrupted multi-page call resumes from the last page Eno’niva saved, with the same sequence numbers. Very large exports (over 200 pages) continue on the next run a minute later.
- After a full export (
since_sequence: 0), Eno’niva removes any record from your connector that the export didn’t list. That keeps both sides consistent after a resync.
7. Deletes
Send {"op":"delete","external_id":"…","updated_at":"…"} when a contact is deleted in your system, the user loses access to it, or it no longer belongs to the user. A delete removes only your connector’s contribution: if the member also has that person from their phone, LinkedIn or another connector, the person stays. People who came only from you are deleted, unless the member rated them, pinned them or wrote a note. Deleting an id Eno’niva doesn’t know is fine (no-op).
An upsert always carries the full current record, not a diff. Use stable external_ids and never reuse one for a different person.
8. Contact record
Validation is strict: values that break a rule are dropped, unknown fields are ignored, and a change with no usable contact is skipped (counted as “skipped” in the member’s run log). A contact needs at least a name, a phone or an email.
| Field | Type and limits | Used for |
|---|---|---|
full_name | string ≤ 200 | Name on the card |
phones | ≤ 20, E.164 (+15705550100); others dropped | Matching and the member’s private contact details |
emails | ≤ 20, valid addresses ≤ 254 | Matching and private contact details |
company, title | string ≤ 200 each | Card (fills gaps; never overwrites a member’s own card) |
linkedin_url | https://…linkedin.com/in/… ≤ 300 | Matching and card |
city, country | city ≤ 100; country ISO 3166-1 alpha-2 (names accepted) | Card, map |
tags | ≤ 20 strings ≤ 50 | Stored privately for the owner |
last_interaction_at | ISO 8601 (future dates clamp to now) | Relationship recency |
interaction_counts | {calls, messages, emails, meetings}, integers ≥ 0, totals (not increments) | Relationship strength (private) |
owner_relationship | customer, client, prospect, lead, partner, vendor, supplier, investor, advisor, colleague, ex_colleague, friend, family, acquaintance, community, other | Suggested category (only while the member hasn’t set one) |
url | https:// ≤ 500 | “Open in <connector>” link on the person page, for the owner only |
notes | string ≤ 2,000 | Ignored in v1 (free text from another system is never imported) |
9. Errors
Return a normal tool result with isError: true and structuredContent: {"error": {"code", "message", "retry_after?"}}. Use HTTP 401 only for a bad signature or API key, and JSON-RPC errors only for malformed requests.
| Code | When | What Eno’niva does |
|---|---|---|
unknown_user | No user who authorized Eno’niva matches | Marks the member “not linked”, tries again tomorrow; not counted as a failure |
not_authorized | The user revoked access, or the key doesn’t belong to them | Same as unknown_user |
resync_required | You can’t compute changes since since_sequence | Next call (within a minute): since_sequence: 0 full export, then a sweep |
cursor_expired | The page cursor is no longer valid | Restarts the same call from page 1 |
rate_limited | Slow down; set retry_after (seconds) | Retries after retry_after (default 1 h) |
unsupported_protocol | Unknown protocol value | Counted as a failure |
bad_request, internal | Anything else | Counted as a failure, retried with backoff |
Failures (also timeouts, 5xx, invalid JSON, a wrong sequence echo) are retried with backoff (15 min, 1 h, 4 h, 12 h, 24 h). After 6 consecutive failures spanning more than a day, Eno’niva pauses the connector and emails its owner. Nothing is deleted when that happens.
10. Personal and company connectors
- Personal (added by a member): what you send is the member’s own, private data in Eno’niva. It leaves with them.
- Company (added by a workspace admin): the contacts belong to the company workspace (the company’s data policy applies; when an employee leaves, company data stays with the company and the employee keeps nothing of it). By default Eno’niva calls once per active member, with that member’s verified work email (and their verified phone only if the admin allows it). Members who leave are never called again.
- Workspace mode (optional for providers): the admin can choose one call for the whole company, with
"user": {"workspace_domain": "acme.com"}. Support it only if your API key is company-scoped and you can verify that the key’s account owns that domain. If you don’t support it, answerunknown_user.
11. Schedule and rate limits
- Each connector-user is called once a day, at a time spread across the day, plus “Update now” (at most once per 10 minutes per connector).
- Eno’niva sends at most 2 requests at a time to one connector. A company connector with 500 members means up to 500 calls a day, spread out.
- Per request: 30-second timeout, 8 MB response cap, up to 2 quick retries on network errors,
429and5xx(honoringRetry-Afterup to 60 s).
12. Security and privacy
What Eno’niva sends you: only the protocol fields, the user’s verified phone and/or email (or the workspace domain), the API key you issued and the signature. Never their name, their other contacts, ratings, notes or anything else from Eno’niva.
What Eno’niva does on its side: https only; DNS is resolved and every address checked before connecting (no private, loopback, link-local or reserved addresses, IPv4 and IPv6); no redirects; capped responses; the API key and signing secret are encrypted at rest; each run is logged (counts, duration, error) and visible to the connector’s owner.
What we expect from you:
- Verify the signature and timestamp on every request; keep the signing secret like a password (encrypted at rest, never logged). Let users rotate it: Eno’niva can issue a new one at any time.
- Return only contacts the user is entitled to see in your system, only for users who authorized Eno’niva, and honor revocation right away (
not_authorized). - Send only the fields above. Don’t send special-category data (health, religion, …), message contents or notes about people.
- Don’t log full request bodies (they contain the user’s phone/email); log the connector id and sequence numbers instead.
- Tell your users what is shared with Eno’niva. People who don’t want to be in Eno’niva can use enoniva.com/remove-me.
13. Versioning
The protocol id is enoniva.contacts/1. Within version 1, Eno’niva only adds optional fields (to the input, the output or the contact) and new error codes; ignore what you don’t know. A breaking change gets a new id (enoniva.contacts/2), announced at least 6 months ahead; during the overlap, Eno’niva sends the newest version your server accepts (answer unsupported_protocol to the others). This page’s date changes with every edit.
14. Signature verification code
Node.js
import crypto from 'node:crypto';
// rawBody: the exact request bytes as a string (e.g. express.text({ type: '*/*' }))
export function verifyEnoniva(secret, rawBody, header, now = Math.floor(Date.now() / 1000)) {
const p = Object.fromEntries(String(header || '').split(',').map((kv) => kv.trim().split('=')));
const t = Number(p.t);
if (!Number.isInteger(t) || Math.abs(now - t) > 300 || !/^[0-9a-f]{64}$/.test(p.v1 || '')) return false;
const want = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return crypto.timingSafeEqual(want, Buffer.from(p.v1, 'hex'));
}
app.post('/mcp', express.text({ type: '*/*', limit: '1mb' }), (req, res, next) => {
const secret = secretFor(req.get('x-enoniva-connector-id')); // your lookup
if (!secret || !verifyEnoniva(secret, req.body, req.get('x-enoniva-signature'))) return res.status(401).json({ error: 'bad_signature' });
req.rpc = JSON.parse(req.body);
next();
});
Python
import hashlib, hmac, time
def verify_enoniva(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
try:
parts = dict(p.strip().split("=", 1) for p in (header or "").split(","))
t, v1 = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
want = hmac.new(secret.encode(), str(t).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, v1)
# Flask: verify_enoniva(secret, request.get_data(), request.headers.get("X-Enoniva-Signature", ""))
# FastAPI: body = await request.body(); verify_enoniva(secret, body, request.headers.get("x-enoniva-signature", ""))
15. Testing checklist
initializeanswers withcapabilities.tools;notifications/initializedgets202;tools/listincludeseno_get_contact_changeswith both schemas.- A wrong or missing signature, a timestamp 6 minutes old, or a changed body →
401. - An unverified identity (
phone_verifiedmissing) or a user who didn’t authorize Eno’niva →unknown_user. sequence 1, since 0returns everything, paged; concatenating the pages gives eachexternal_idonce.- Calling
sequence 1, since 0again returns the same result, even after you changed data in between. - Change one contact, delete one, add one →
sequence 2, since 1returns exactly those three. - Asking for a
since_sequenceyou don’t have →resync_required. - All phones are E.164, all timestamps ISO 8601, each page ≤ 5,000 changes, each response < 8 MB and < 30 s.
- Then add the connector in Eno’niva, paste the secret into your product and click Test connection, then Update now; the run log shows the counts.
curl smoke test
URL=https://api.example.com/mcp; SECRET=ensec_...; KEY=your-api-key
sign() { t=$(date +%s); printf 't=%s,v1=%s' "$t" "$(printf '%s' "$t.$1" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)"; }
post() { curl -s -D /tmp/h "$URL" -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
-H "authorization: Bearer $KEY" -H 'x-enoniva-connector-id: dc_test' -H "x-enoniva-signature: $(sign "$1")" \
${SID:+-H "mcp-session-id: $SID"} -d "$1"; echo; }
post '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
SID=$(grep -i '^mcp-session-id:' /tmp/h | cut -d' ' -f2 | tr -d '\r')
post '{"jsonrpc":"2.0","method":"notifications/initialized"}'
post '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
post '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"eno_get_contact_changes","arguments":{"protocol":"enoniva.contacts/1","user":{"email":"you@example.com","email_verified":true},"sequence":1,"since_sequence":0}}}'
16. Why it’s designed this way
- Acknowledge after ingest, retry the same numbers. A counter that simply increments on every call loses data when a response is lost in transit (the provider moved on, Eno’niva never got the page). Advancing only after the last page is stored, and retrying with identical numbers, makes every call safe to repeat.
- Fixed checkpoints per sequence. Paging over a window that keeps moving skips or repeats records. Freezing the window on first sight keeps pages and retries consistent without timestamps races.
- Signature plus API key. A phone number is not a secret. The HMAC proves the call comes from Eno’niva and wasn’t replayed (timestamp), the API key ties it to an account in your system, and the authorization rule ties it to a user who said yes.
- Verified identities only, nothing else. You can match users safely, and Eno’niva never leaks anything about its members to a third party beyond what is needed to find the account.
- Deletes are scoped. Your system owns its contribution, not the person: a delete can’t erase what a member knows from elsewhere.
- Full export + sweep. When history is lost on either side, one full export restores a consistent state without manual cleanup.
17. Directory services: eno_connect
A directory service is listed in Eno’niva’s Connectors menu. Members don’t paste URLs or keys: they click Connect, agree to share their verified details with you, and you tell Eno’niva whether they have an account with you.
Registering
- Sign in to Eno’niva and open /connectors/register.html: service name, website, a short description, logo (https PNG/JPEG/WebP/GIF), your MCP endpoint URL, a support email and your privacy policy URL.
- You get a service id (
svc_…) and one signing secret (ensec_…, shown once) for your whole service. Every call for every member is signed with it and carriesX-Enoniva-Connector-Id: svc_…. No API key is sent. You can rotate the secret any time on the same page; the new one is used at once. - Click Test connection:
initialize+tools/listmust listeno_connectandeno_get_contact_changes(eno_disconnectis optional). - Eno’niva reviews and approves the listing. Until then members don’t see it and Eno’niva never calls it. A suspended listing is hidden and not called; members’ imported data stays.
The connect handshake
→ tools/call eno_connect
{ "protocol": "enoniva.contacts/1",
"enoniva_user": "enu_Qm9vS2l0dGVuX3Byb2ZpbGVfMDAwMDAx", // opaque, stable for you, different for every service
"emails": ["dana@example.com", "dana@acme.com"], // only addresses Eno’niva verified
"phones": ["+15551234567"], // only a verified phone, E.164
"user_name": "Dana Levi" } // display only, never match on it
← { "status": "not_registered" }
← { "status": "consent_required", "consent_url": "https://app.example.com/enoniva/approve/7f3c…" }
← { "status": "connected", "account_label": "Dana @ Acme team", "total_contacts": 874 }
not_registered: no account with you matches. The member sees “You don’t have a <Service> account with these details” and a link to your website.consent_required: the account must approve on your side first. Return an httpsconsent_urlto a page in your product where the signed-in account approves linking Eno’niva. Eno’niva opens it for the member and callseno_connectagain (every ~10 s for a few minutes, and when they click “I’ve confirmed”), with the sameenoniva_user. Keep answeringconsent_required(same URL is fine) until they approve, then answerconnected.connected: you storedenoniva_user→ that account. Eno’niva immediately starts the first import witheno_get_contact_changes(since_sequence: 0, paged) and shows the member “Importing <Service> contacts (n/total_contacts)”. After that it syncs once a day like any connector.
After connecting, every eno_get_contact_changes call for that member sends only "user": {"enoniva_user": "enu_…"}: no phone or email. Answer not_authorized if the account revoked the link on your side (the member sees “Not linked” and can connect again).
Optional eno_disconnect {protocol, enoniva_user} → {"ok": true}: called (if you list it) when the member disconnects your service in Eno’niva. Forget the link.
Rules for eno_connect
- Reject unsigned calls. Verify
X-Enoniva-Signaturewith your service secret on every request (§14) and answer401when it fails. Never act on an unsigned or stale request: an attacker could otherwise link any email to an account. - Match only what Eno’niva sent. All identifiers in the call are verified by Eno’niva; unverified ones are never sent. Never return data for an identifier that isn’t in the call, and never link a different account than the one that matched.
- You decide whether a match is enough. A verified email or phone match proves the member controls that address, not that the account holder wants Eno’niva to read their contacts. Answer
connectedon a match alone only if your terms allow it; for anything sensitive (shared team inboxes, customer data, regulated data) answerconsent_requiredso the account owner approves in your product. We recommendconsent_requiredby default. - When several accounts match, ask for consent (or pick the one whose owner approves); never merge accounts on Eno’niva’s word.
- Don’t log the identifiers in full; log the service id,
enoniva_userand the result. - You are an independent controller for what you receive (the member asked us to share it with you), under your own privacy policy, which members can read before they connect.
Limits
- A member can make up to 10 connect attempts per hour per service (plus consent polls); a connection counts toward the member’s 5 data connectors.
- Same transport limits, retries, backoff and auto-pause as §11 and §9: 6 consecutive failures over more than a day pause that member’s connection (they see “Paused after errors” and can connect again).
Questions or a connector to list? hello@enoniva.com