REST API
The versioned /v1 HTTP surface — search and search history, self-enrichment, community members, events, analytics, and enrichment.
The public REST API is a versioned /v1 HTTP surface served by the api-gateway edge
function. Business logic lives in transport-agnostic handlers shared with the
MCP server, so the two never diverge.
Base URL
Endpoints are served under the api-gateway function on the app's Supabase project:
https://ohtxbovwsyktbjldbjao.supabase.co/functions/v1/api-gatewayAuthenticate with an API key (see Authentication). An
unauthenticated request to any path, known or not, returns 401 — routes are not
enumerable without a key. The CORS preflight is the one exception: an OPTIONS request is
answered 204 before authentication runs, so a browser client can preflight without a key.
Credits
The API draws on the same credit balances as the app. Which owner pays depends on the
operation and its community, not on the key. A community enrichment run is charged to the
target community's billing owner. Search and self-enrich fall back to your own billing
owner, but not unconditionally: when the operation names a community, the usage record carries
that community and the payer is resolved from it, so whoever pays for the community can cover
the charge before it reaches you: an organization through standing funding or a delegation
budget, a person who pays for it with their own credits through a delegation rule of their own. A
search that names no community — an all scope search — is not automatically yours
either: it is treated as a global search, and the wallet is resolved from your saved global
search preference if you have one, else the earliest organization you joined that has credits
to spend, else your personal balance. So an organization can pay for a keyless-of-community
search too. (This resolution needs a user behind the key. An organization-owned key has no
community it can search, so it cannot start a search at all.) Three operations consume credits
today:
- Search — a
simplesearch is billed at the in-app simple search rate once its result is stored, whether that happens inside the initial call or after a202while you poll. Anautoordeepsearch, a follow-up and a proposal run are billed as the app bills what ran: a Deep Search only when its lanes ran, and team composition only when teams were composed. A search that ran through the app can also be billed for a step it finished, such as a web lookup, whether the search then completed or failed: a completedsimplesearch can cost more than the simple search rate. A search that fails or is refused is not billed as a search. Metering is live: the post-hoc biller debits real credits from the completed search's usage record. - Self-enrich — see the note under Identity.
- Community enrichment — each profile a
POST /v1/communities/{id}/enrichrun enriches is billed one marker for the run's depth tier (enrichment_quick,enrichment_standard, orenrichment_deep), to the community's billing owner; see Enrichment (write).
Read GET /v1/me for your live balance, and Settings → Billing in the app for the current
rate card. A search whose payer cannot cover it is refused before it runs. The refusal is not
a 402: the search ends with 200, status: "failed" and an error of insufficient_credits
(the paying wallet cannot cover it) or community_over_quota (the community is read-only until
its billing is renewed), in the initial call or on a later poll; see
Search. A self-enrich is charged 1 credit whatever the owner's plan (on the free
plan, from the monthly free credits when the account receives them, or bought ones), with no
cooldown, and is refused with a 402 when its payer cannot cover it (see
Identity). A community
enrichment run is not refused for credit today, but its community's billing can already
refuse it with 402: community_over_quota while the community is read-only because its owner's
plan lapsed or because its owner holds more communities than the plan allows (the error message
says which, and what restores it; searches there are not refused for either reason), and
coverage_required where the plan coverage gate is armed and the community's billing owner has
no live plan or trial (see Enrichment (write)).
Error envelope
Every non-2xx response is a uniform envelope:
{ "error": { "code": "not_found", "message": "…", "request_id": "…" } }| Status | Codes |
|---|---|
| 400 | bad_request, invalid_request |
| 401 | unauthorized |
| 402 | insufficient_credits, coverage_required, community_over_quota (the body also names the payer; enrichment calls only, since a search reports these as a failed status) |
| 403 | forbidden |
| 404 | not_found |
| 409 | conflict; on the search history routes also history_withheld, and a proposal run's already_run, proposal_busy, already_claimed, plan_unrunnable |
| 422 | unprocessable, owner_consent_required |
| 429 | rate_limited |
| 500 | internal |
| 502 | search_unavailable |
| 503 | busy, timeout (file imports: send the same request again); ai_unavailable (enrichment starts, while AI processing is out for the region the run would use: no run was started, send the same request again later) |
This list is not exhaustive — new error codes can be added over time (additive, see
Versioning), so parse unknown codes leniently. Every response carries an
X-Request-Id header — quote it when reporting an issue.
Endpoints
Identity
| Method & path | Scope | Purpose |
|---|---|---|
GET /v1/me | any | Key identity, owner, scopes, usage, credit balance, coverage (plan, trial or none) |
GET /v1/me/profile | profile:read | Read your own profile |
GET /v1/me/connections | profile:read | The people you know: your JOGL connections, the contacts of your LinkedIn imports and those imported from your mailbox |
POST /v1/me/contacts | member:write | Import the people you deal with, read from your mailbox and calendar by your own assistant, into your personal network |
POST /v1/me/enrich | self:enrich | Start a self-enrich run (target-locked; see billing note below) |
GET /v1/me/enrich/runs/{run_id} | self:enrich | Poll a self-enrich run |
Self-enrich billing. A self-enrich via the API is treated as an intentional, cost-aware
call: it is billable at 1 credit via the enrichment_self operation whatever the key
owner's plan (an owner on the free plan pays from their monthly free credits, when their
account receives them, or credits they bought), with no cooldown. It debits real credits, and
a call whose payer cannot cover it is refused with a 402. Don't assume it is free.
GET /v1/me/profile and the two self-enrich calls need a personal key: a legacy
organization-owned key gets 422 unprocessable from all three. A self-enrich also needs you to
be a member of at least one community, whichever it is, archived
ones included (for a key created with a community allowlist, one on that list); otherwise it
returns 422 unprocessable. The run is attached to the community you joined first, which only
gives the enrichment its context.
GET /v1/me/connections lists the people you know, one row per person, sorted by name: your
accepted JOGL connections whose profile the app shows you, the contacts of the LinkedIn
imports you ran yourself, the ones each import brought into JOGL or found already a member, the
LinkedIn connections you imported with POST /v1/me/contacts (source linkedin), and the
contacts imported from your mailbox or another source with that same call. A LinkedIn contact is
listed only while they are still a member of the community the import put them in or found them
in (your personal network, for a connection that call imported), and only when your key reads
that community; someone that call found already on JOGL, whom it leaves out of your network, and
a mailbox contact, while they are a member of a community your key reads. Each row is
{ profile_id, name, headline, organization, status, community_id, relationship }:
relationshipsays how you know them:jogl_connectionis{ connected_at }, when the connection was accepted, andlinkedin_contactis{ connected_on, community_id }, the day you connected on LinkedIn as your export records it (YYYY-MM-DD, or null when it does not read as a date) and the community the import put them in or found them in (for someone found already on JOGL, the first community your key reads that they belong to). Either is null when you do not know them that way.mailbox_contactis true for a contact imported from your mailbox or another source but LinkedIn, andinteractionsis{ first_interaction_at, last_interaction_at, messages_count, meetings_count }, how you dealt with them over every source your imports recorded but LinkedIn, or null when none did.community_idis a community where reading one member accepts thatprofile_id. It is null for a JOGL connection who is in none of the communities your key reads.
Three filters narrow the list:
?name=keeps the people whose name contains it, accents and case set aside.?organization=keeps the people who work or worked at an organisation whose name contains it, or whose profile organisation or headline names it, accents, case and punctuation set aside (liberte living labfinds Liberté Living-Lab). A filter of fewer than six letters and digits, such as an acronym, matches whole words only (INCadoes not find Poincaré). Through JOGL's employer list it also finds positions written under another spelling of an employer whose name contains it or whose acronym it is. Each row then carriesmatching_positions, up to 10, current ones first, so you can tell who works there now from who worked there before.?source=isall(the default),jogl,linkedinormailbox.
?sort=last_interaction lists the most recent interaction first, people with none recorded last;
the default is name. The list pages like the roster (limit, after, next_cursor; pass a
cursor back with the sort it came from), and total_count counts the matches over every page. It
records how you know someone, not how well, and when you last dealt with them only for the people
a mailbox import recorded.
POST /v1/me/contacts imports the people you deal with into a private community of yours, your
personal network: JOGL never reads your mailbox, your own assistant does, with its own
connectors, and sends what it read. It invites and contacts nobody. The body is
{ "contacts": [...] }, at most 100 a call and 300 addresses in all, each with a name, emails (every address the person
wrote from) and, when known, organization, title, city, phone, a linkedin URL and the
person's own website (their own site, never their employer's: it tells them from a namesake, so
someone who writes only from a personal address can still be enriched), plus
how you dealt with them: source (gmail, calendar or other), messages_count,
meetings_count, first_interaction_at and last_interaction_at. A contact carrying any other
field is refused, so no message subject, body or attachment is ever stored. An automated sender's
address (no-reply, notifications, mailer-daemon, bounces) is dropped, and a contact left with no
other key is refused. The first call creates
your personal network if you have none yet, which needs community:write too. Adding people to it
needs a free trial or plan: without one the call answers 402 coverage_required and imports nobody. It does not
count against your plan's community limit. You have one: if you archive it, imports are refused
until you unarchive it. Contacts never go into a community other people belong
to, where every member would see the addresses and phone numbers, and your personal network stays
private to you: nobody can be invited to it, join it or claim one of its profiles, and it cannot be
made public. The contacts are imported most frequent first, so a network that
reaches its plan's member limit (200 on the free trial) keeps the people you deal with most. Each
contact comes back as created, already_imported, already_on_jogl (someone with their own
account you can already reach in JOGL, a connection or a member of a community you administer:
not added, with connection saying whether you are connected and, if you are not, profile_url,
their profile page in the app, where you can send them a connection request), linkedin_contact
(one of your LinkedIn contacts outside your network, recognized by the same first and last name
and an organization their profile or LinkedIn experience shows: not added again and not planned
for enrichment, with community_id saying where you can read them), possible_linkedin_contact
(the same, but the LinkedIn URL you sent is not the one their profile carries, most likely an older
link of theirs: nothing is stored, and the contact sent again with same_as_linkedin_contact set to
true or false is matched or imported as someone else; a true answer is kept for that link, so
you are not asked again), self, possible_self
(someone new with your own first and last name, most likely an address of yours: nothing is stored,
and a contact sent again with not_self: true is imported; never for a LinkedIn connection), suppressed
(someone who asked JOGL not to record them: they had their data erased, or objected to being
recorded in other people's contact lists), capacity or invalid. Your interactions with each one are stored for you alone, and a repeated call merges
them rather than counting twice. The answer carries an enrichment_plan: the new profiles grouped
by the cheapest enrichment depth that can work, with the credits each group would use at today's
price. Nothing is enriched or billed until you run community enrichment on them,
which takes the plan's groups as its plan and runs them all in one call.
A key owned by an organisation, or created with a community allowlist, gets 422 unprocessable.
Private notes
| Method & path | Scope | Purpose |
|---|---|---|
GET /v1/me/notes | notes:read | Read your private notes on the people you can see in JOGL |
POST /v1/me/notes | notes:write | Add a private note on someone you can see in JOGL |
These are the notes you keep on a person's profile page in the app, which only you can read. Both
calls reach only the people you can see in JOGL: members of a community your key reads, and anyone
whose profile page the app shows you (a public profile, or one visible to members and connections
that you share a community or an accepted JOGL connection with). A note on anyone else is left out
of the list, and adding one returns 404. Neither scope is in a preset: tick them under
Advanced when you create a key.
GET /v1/me/notes lists your notes newest first, one row per note:
{ note_id, profile_id, name, headline, community_id, text, withheld, facts, created_at, updated_at }.
When you run AI structuring on a note in the app, JOGL extracts facts from it and marks how
sensitive each one is. A note with any fact marked sensitive (special-category data such as
health, religious beliefs or political opinions) comes back withheld, with text null and no
facts, whether or not you approved that fact, and it stays withheld when you edit the note. Any
other note carries its text and the facts you approved, as [{ type, value, sensitivity }]. AI
structuring is off unless you turn it on, so most notes have no facts, and a note never run
through AI structuring comes back as you wrote it: JOGL cannot tell whether its text holds such
details. The same goes for a detail you add to a note after its last structuring. community_id is a community where
reading one member accepts that profile_id, null for a JOGL connection in
none of the communities your key reads.
?profile_id= keeps one person's notes (404 when you cannot see them) and ?name= the notes on
people whose name contains it, accents and case set aside. The list pages like the roster
(limit, 20 by default and at most 100, after, next_cursor), and total_count counts the
matches over every page.
POST /v1/me/notes takes { "profile_id": "…", "text": "…" }, the text 1 to 10,000 characters
once trimmed, and returns 201 with { "note": { note_id, profile_id, created_at, replayed } }.
The same text on the same person within 10 minutes returns the note already added, with
replayed: true and 200, instead of a second one, so a retry is safe. No AI structuring runs on
a note added this way; the app shows it like any other note, and your data export includes it.
Adding needs your account's Write access on (422 while it is off), as sending a message
does. For a key that also holds notes:read, the answer carries notes_as_context beside
note: whether you use your notes as context (see Account settings), null
while you never answered, which counts as off, so that your agent can ask you once and record your
answer.
A key owned by an organisation, or created with a community allowlist, gets 422 unprocessable
from both.
Account settings
| Method & path | Scope | Purpose |
|---|---|---|
GET /v1/me/settings | profile:read or notes:read | Read the settings of yours an agent may change for you |
PATCH /v1/me/settings | settings:write or notes:write | Change one or more of them |
So that you can reach a few of your own settings through an agent, without opening the app:
| Setting | Values | Read with | Changed with | In the app |
|---|---|---|---|---|
notes_as_context | true, false, null (never answered, counts as off) | notes:read | notes:write, and settings:write too to turn it on | Settings → Network notes, Use my notes as context |
profile_visibility | public, communities_and_connections | profile:read | settings:write | Settings → Account |
who_can_message | anyone, community, connections, no_one | profile:read | settings:write | Settings → Privacy |
who_can_connect | anyone, community_extended, community, no_one | profile:read | settings:write | Settings → Privacy |
email_notifications | true, false | profile:read | settings:write | Settings → Notifications |
admin_reminder_emails | true, false | profile:read | settings:write | Settings → Notifications |
Nothing else can be changed here: write access itself, data residency, billing, keys and deleting
your account stay in the app. notes_as_context is whether your private notes serve as context for
JOGL's search and AI, as described in Private notes. It does not decide
what a key with notes:read reads: GET /v1/me/notes returns your notes either
way. A profile can be made public only once JOGL has verified your account (422 visibility_requires_verification otherwise); making it private is never refused.
GET /v1/me/settings returns { "settings": { "<setting>": { value, description, choices, can_change, reason, app_url } } }, each setting listed only to a key holding its read scope.
description says what the setting does, for your agent to show you before it changes one,
choices lists the values of a setting that is not on or off, and app_url is its page in the
app. can_change is false, with a reason, when the key lacks a
scope the change from the current value needs (no_write_scope: turning your notes as context on
needs settings:write, turning it off only notes:write), while your account's Write access
is off (owner_writes_off), or for a profile JOGL cannot make public yet
(account_not_verified).
PATCH /v1/me/settings takes the settings to change, such as { "who_can_message": "connections" }. The whole request is refused when a setting is unknown or its value out of range (400), when
the key lacks a scope a change needs (403, naming it), and while your account's Write access
is off (422). It returns each setting named, as the read shows it, and changed, the settings
whose value moved among those the key can read; a value already set writes nothing, so a retry is safe. The tools' descriptions
tell your agent to ask you before every change and never to change a setting on its own
initiative, but JOGL cannot check that it did, so give settings:write only to an agent you trust
to act as you.
A key owned by an organisation, or created with a community allowlist, gets 422 unprocessable
from both.
Search
POST /v1/search (scope search:read) runs expert search and returns ranked matches.
{ "query": "battery electrochemistry researchers", "scope": "community",
"community_id": "…", "limit": 20, "wait_seconds": 20 }Two engines. Where it is turned on (environment by environment), a search from a key owned
by a user, with no community allowlist, runs through the app's own search, and is then also a
conversation in that user's search history in the app. A search from a key narrowed by an
allowlist, or where this is not turned on yet, runs on the API's own engine. A legacy
organization-owned key cannot search on either: community returns 403 and all returns
422. Several options below need the search to run through the app.
scopeis required and is one ofcommunity(needscommunity_id) orall. On the API's own engine,allcovers your authorized communities plus public claimed profiles; through the app, it covers what search in the app covers: the communities you belong to, the ones you manage as an owner or admin of the organization that runs them, your connections, and public claimed profiles. Usecommunityif you want a single-community corpus with no public matches mixed in. The formerpublicscope (public claimed profiles only) has been retired and returns400— see Versioning.- On the API's own engine,
allneeds at least one authorized community. Authorized means your direct memberships plus the communities owned by organizations you manage, archived ones excluded — so belonging to no community directly is not by itself the failing case; managing an organization that owns one is enough. When that combined set is empty, or an allowlist narrows it to empty,allreturns422 unprocessableand searches nothing — it does not fall back to the public corpus. Through the app, a key owner with no community gets the search over their connections and public claimed profiles instead. - Scopes are fail-closed and fenced server-side to what your key is authorized for; an
unauthorized
community_idreturns403, never a silent empty result. limitdefaults to 20 and is clamped to a maximum of 50.modeissimple(the default: ranked matches),auto(the app chooses, so a result can also be a Deep Search proposal, which you can then run, see Running a proposal; a key withouthistory:readcannot, and its proposal carries amessagepointing atapp_url, where the app runs it, or a text answer) ordeep(a Deep Search), andinclude_teams: trueasks adeepsearch for teams.auto,deepand teams need the search to run through the app; elsewhere they are rejected with400.include_teams: truewith any mode butdeepis always a400;include_teams: falseis accepted with every mode.event_id, withscope: community, keeps the search to one event's attendees, as the app's event search does: an event of that community (from the events list) whose attendee list you can read, because you attend it or act as the community's admin (403otherwise;404for another community's event or a draft). It needs the search to run through the app, and is a400otherwise or withscope: all. A named person is looked up among the attendees only, and the search's follow-ups and proposal runs keep to the event.locationkeeps the search to the members whose stored location matches:country(an ISO 3166-1 alpha-2 code),city(in the geocoder's spelling,Munichrather thanMünchen) andnear({ "lat", "lng", "radius_km" }, at most 500 km). Every field given must match, and a member with no geocoded location matches none of them. It replaces any place named in the query, works only withmode: simpleand only through the app (400otherwise), and a follow-up or a proposal run does not keep it.
Async by design. A full search runs embed → retrieve → rerank and can take 35–60s+, longer
than most agent clients' request timeout. So the call waits only up to wait_seconds
(0–25, default 20) and then returns one of:
200 { "status": "complete", "search_id", "kind": "results", "matches": [...] }— finished in time.kindsays what a completed search holds; read it first.200 { "status": "failed", "search_id", "error", "message" }— the search failed. Start a new one, unless it names aproposal: a Deep Search that did not run (refused for credit, for example) and can run again, see Running a proposal.202 { "status": "pending", "search_id", "poll_after_seconds" }— still running; pollGET /v1/search/{search_id}until itsstatusiscompleteorfailed(while it runs the poll reportsrunning). Search jobs are fenced to the key that started them and expire, so an unknown, foreign, or expired id is a404. Asearch_idcan be polled for about an hour, or about 72 hours for a search that runs through the app.
Each match carries { profile_id, user_id, name, headline, organization, location, avatar_url, score, semantic_score, explanation, shared_communities }. shared_communities
is empty for a match found only among public claimed profiles; its ids are valid community_id
values for the member read below. A match the app did not score has a null score.
semantic_score is null for that match and for one found by keyword rather than by embedding
similarity, whose score is still a number.
A search that runs through the app also returns conversation_id and app_url, which opens the
conversation in the app, once its conversation exists: a 202 returned before that, and a search
refused before its conversation was created, carry neither. With ranked matches it carries the app's summary of them,
summary_match_count (how many matches the summary describes, which limit does not bound) and
hidden_below_floor (matches under the app's display floor, which are never returned). The
summary is null, with summary_withheld: true, when someone it may name has since been erased
or hidden, when you can no longer access the community a community search ran in, or when it
drew on knowledge documents not shown to belong to that community (for an all search, any
knowledge document). A deep search with include_teams: true carries teams, or teams: null with a
teams_reason. Teams are drawn from every match above the display floor, which limit does not
bound, so a team member may be missing from matches: each member carries its own profile_id,
user_id and name. A team member who has since been erased or hidden, or who is not one of the
search's matches above the display floor, is left out. When that happens, or when the summary is
withheld, the teams still list their remaining members, but their prose is null (each team's
name, rationale, coverage and gaps, and each member's sub-need, role and reason), with
teams_text_withheld: true.
Search history
Scope history:read. Your past searches, whatever ran them: the app, a chat bot, the API or
MCP. History belongs to a person, so an organization-owned key, or a key narrowed to some
communities, gets 422.
| Method & path | Purpose |
|---|---|
GET /v1/searches | Your search conversations, newest first |
GET /v1/searches/{conversation_id} | One conversation's questions and answers, oldest first |
- Both take
limit(list: 1–50, default 20; conversation: 1–20, default 5) andcursor. Pass a response'snext_cursorback ascursoruntil it isnull; a list page can hold fewer thanlimitwhile more remain. - A list entry carries
conversation_id,title,scope(communityorall),community_id,event_id(for a search kept to an event),source(web, a chat bot,api,mcp), its dates and anapp_url.titleisnullwithtitle_withheld: truewhen the app wrote it from something other than your own first question, such as a chat about a person. - Each exchange of a conversation is
{ status, question, answer }.answer.kindisresults(matches in the same shape as a search),proposal(a Deep Search proposal),failedoranswer(any other reply). An API or MCP search still running ispending, with thesearch_idto poll. - Every read is fresh: someone erased or hidden since drops out, and a summary or reply text
that could name someone you can no longer see is withheld (
*_withheld: true) rather than returned. - Profile chats and list analyses are not search history and are left out, and so is a
conversation kept to an event whose attendees you can no longer search. A conversation in a
community you can no longer access is not returned: an unknown, foreign or inaccessible
conversation is a
404. - Reading history costs no credits.
Keys created on the API keys page with the Read or Read + Write preset carry
history:read; a key created before the scope existed does not, and needs replacing to read
history.
Follow-up questions
POST /v1/searches/{conversation_id}/messages asks a follow-up question in one of your search
conversations, as you would in the app: the question is added to the conversation and answered
from its earlier turns. It needs search:read and history:read, and runs only where search
runs through the app (see Search): an organization-owned key, or one narrowed by an
allowlist, gets 422, and where search through the app is not turned on it is a 400.
{ "query": "Which of them have worked on sodium-ion cells?", "wait_seconds": 20 }- It follows up the result set named by
parent_message_id(a result set of the conversation, or an answer in one of its threads) or, without one, the conversation's latest result set while it is still what the conversation is about, as the app does. A follow-up that failed does not change that, so asking it again continues the same result set. mode,include_teams,limitandwait_secondswork as for a search, and so does the response: a completed result with itskind, a failure, or a202with asearch_idto poll. A follow-up can be answered from the results it follows up, so any mode can return anykind. It is billed from what ran, as in the app.- A conversation in which history withholds anything (someone you can no longer see, text whose
sources were not recorded, a chat about one person) or that holds no earlier answer returns
409witherror.codehistory_withheldand areason. Nothing is created or charged. - If the conversation changes between your request and the follow-up running (the answer it
follows up was deleted, or, when it follows up none, a result set became the
conversation's subject), the follow-up fails with
errorconversation_changed, and you can ask it again. - Someone else's conversation is a
404; one in a community you can no longer access is a403.
Running a proposal
POST /v1/searches/{conversation_id}/proposals/{message_id}/run runs a Deep Search proposal
already in one of your search conversations, as the app's Run button does: a proposal an
auto search returned (kind: "proposal"), or one whose Deep Search did not run and can run
again (a failed search that names a proposal, refused for credit for example). message_id
is the proposal's proposal.message_id. It needs search:read and history:read, and runs
only where search runs through the app, with the same 422 and 400 as a follow-up.
{ "include_teams": true, "wait_seconds": 20 }- The body is optional; when sent, it must be a JSON object.
include_teams,limitandwait_secondswork as for a search;mode, when given, must bedeep. The response is a search's: a completed result with itskind, a failure, or a202with asearch_idto poll. It is billed as a Deep Search when its lanes ran, as in the app. - A proposal that cannot run now returns
409and creates nothing:already_run(its result is already in the conversation),proposal_busy(a search is running it; ask again once it has finished),already_claimed(it is being run in the app) orplan_unrunnable. A conversation in which history withholds anything returns409history_withheld, as for a follow-up. - A
message_idthat is not a Deep Search proposal of the conversation, like someone else's conversation, is a404; one in a community you can no longer access is a403. - With an
Idempotency-Key, a retry returns the same run. A refusal is not kept under the key (see Idempotency), so asking again with it asks again.
Community members
| Method & path | Scope | Purpose |
|---|---|---|
GET /v1/communities | (none) | List the communities you can read, with your role, write access and upcoming events |
POST /v1/communities | community:write | Create a community you own and pay for (see Creating a community) |
GET /v1/communities/{id}/members | profile:read | List the roster (keyset pagination) |
GET /v1/communities/{id}/members/{profile_id} | profile:read | Read one member, with what search ranks them on |
POST /v1/communities/{id}/members/{profile_id}/messages | contact:write | Send a member a direct message in JOGL, after a preview (see Direct messages) |
POST /v1/communities/{id}/members | member:write | Add people (bulk, per-item results) |
DELETE /v1/communities/{id}/members/{profile_id} | member:write | Community-scoped erasure (DSAR) |
POST /v1/communities/{id}/import-uploads | member:write | Get a link to upload a spreadsheet to, for a file you would rather not send as text (see Importing a spreadsheet) |
POST /v1/communities/{id}/imports | member:write | Send a spreadsheet of people as CSV and get its columns, a suggested mapping and the community's fields (see Importing a spreadsheet) |
POST /v1/communities/{id}/imports/{import_id}/run | member:write | Start the import with your mapping |
GET /v1/communities/{id}/imports/{import_id} | member:write | Follow an import: profiles created, merged and failed, and the rows skipped or failed with why |
GET /v1/communities/{id}/schema | member:write | List the community's custom fields and tags, to plan an import or check one |
GET /v1/communities/{id}/events | (none) | List the community's events, past ones included (?when=all, upcoming or past) |
GET /v1/communities/{id}/events/{event_id}/attendees | profile:read | List an event's attendees, with what each one is looking for |
POST /v1/communities/{id}/events/{event_id}/attendees/{profile_id}/meeting-requests | contact:write | Ask an attendee for a meeting at the event, after a preview (see Meeting requests) |
POST /v1/communities/{id}/events/{event_id}/guest-syncs | member:write and enrich:write | Pull a Luma event's new guests into the community, after a dry run (see Event guest sync) |
GET /v1/communities/{id}/events/{event_id}/guest-syncs/{sync_id} | member:write | Follow a guest sync and the enrichment it starts |
GET /v1/communities/{id}/analytics | profile:read | The community's breakdown: employers, sectors, roles, countries, schools, skills, research domains |
The roster, the events list and the attendee list use keyset pagination: pass
?limit= (default 50, max 200) and ?after= (an opaque cursor), and page until
next_cursor is null — that is the only exhaustion signal. An event's attendee list
answers only its attendees and the community's admins, as in the app; anyone else gets
403. The communities list is not paged: one response carries every community, with no
next_cursor, and it ignores limit and after.
In the communities list, linkedin_import marks a community where contacts from your own
LinkedIn imports are members: { contact_count, imported_at }, how many of them are members there
and when the latest import into it ran. It is null for every other community, for a key without
profile:read and for a key owned by an organisation, and on every community when it cannot be
read. A community search there ranks every member, your contacts among them and possibly others,
so compare contact_count with profile_count and read each match's relationship by
reading the member, or list the contacts themselves with
GET /v1/me/connections.
The roster also takes a location filter: ?country= (an ISO 3166-1 alpha-2 code), ?city= (in
the geocoder's spelling) and ?near=lat,lng,radius_km (at most 500 km). Every one given must
match, and a member with no geocoded location matches none of them. Keep the same filter on every
page: a page filtered by near can hold fewer than limit rows while next_cursor is set. A
parameter that looks like a misspelt filter (Country, lat, region and the like) is a 400
rather than being ignored.
Each event in the events list carries the meeting settings its organiser chose:
networking_windows (the times set aside for attendees to meet, in UTC, earliest first; an
empty list means the app offers meetings across the whole event, or none when the event has no
end time), meeting_slot_minutes (the step meetings are booked on, 30 when the organiser set none:
a meeting lasts a standard length, 5, 10, 15, 20, 30, 45 or 60 minutes, that is a whole multiple
of it, so at 15 it can last 15, 30, 45 or 60 minutes) and meeting_locations (the places the
organiser suggests).
Reading one member returns, beyond the profile, the evidence the app's search ranks them on:
their tags in the community, skills, experience, education, publications, patents, projects and
the research text a search in this community reads for them, so you can check why a search
matched them. That text was written either for this community or for no community in particular.
tags is null, not a list, when the community shows tags only to its admins and your key does
not act as one of them there. A search on the API's own engine reads only the profile, skills,
experience and publications, so for its matches the rest is context. An all search through the
app reads a member's newest research text from any community, which can differ from the one
returned here.
Reading one member also returns custom_fields: the member's values in the community's custom
fields that the app shows you, each as { key, label, type, visibility, value }, in the order the
app shows them. As in the app, a community admin sees every field, another member the fields
visible to all members, and a member also their own values in fields visible to the admins and the
member. A field marked as holding sensitive data (health, religious beliefs and the like) is never
returned, to anyone, and a field with no value is left out. A multiple-choice value comes as a list:
the choices it names, then any answer that names none of the field's current choices, which
unmatched also lists (an answer naming a choice removed since is listed there too). It is null for a key owned by an organization.
Reading one member also returns contacts: the member's email addresses and LinkedIn links that
the app shows you, each as { type, value, label } with type either email or linkedin.
Which ones depends on your role in the community, as in the app. If you are one of its admins,
you get what the app's admin screens show: all of them, whatever the member's sharing settings,
and the email they sign in with when they have none on file. Otherwise you get what their
profile page shows you, by the sharing setting on each, through any community you share with
them: a key narrowed to some communities still gets what the member shares in another community
you have in common. A key owned by an organisation gets none. Phone numbers and websites are not
returned, and
the list is empty when there is nothing to show. To write to a member who has signed in to
JOGL, you can also send them a direct message through the API, or, at an
event you both attend, a meeting request; anyone else you write to from
your own mailbox.
Reading one member also returns relationship: how you know them, as in
GET /v1/me/connections, with both jogl_connection and linkedin_contact null
when you know them neither way. It is null for a key owned by an organisation or created with a
community allowlist.
Adding members takes { "members": [ … ], "role": "member" | "admin", "invite": true | false } with at most 100
items per request. Each item identifies a person by email, linkedin_url, orcid, or a
phone in international form with their name; failing those, by a name with both an
organization and a headline. A name alone is refused. Each item can also be seeded with
headline, bio, organization, and location. Results are
reported per item; a bulk call is never all-or-nothing. The owner role cannot be granted
through the API. A person is found among the community's members by the email they sign in with
and by their contact emails. A LinkedIn finds a member only when enrichment confirmed that link on
them or the item's name agrees with theirs (for a link enrichment rejected, only when both are
named), and an item whose LinkedIn finds nobody that way is added as someone new. When you send invite: true, someone who matches no member but signs in with that email, and whom
you could find by it when adding members in the app (one of your connections, or a member of a
community you administer), is sent the community's usual invitation with the role
instead, and joins when they accept: the item comes back invited, or already_invited when an
invitation to the community is already waiting for them, whoever sent it (so does someone you
could not find by that email who already has one). Both carry the role the invitation gives: an invitation
sent earlier keeps its role, and a role_error says so when you asked for another. Only a key you own sends invitations; an organization's key
adds such a person as a new member, as before, and refuses them for the admin role. One call sends at most 20 invitations. Without invite: true (the default is false), no one is
sent anything, and everyone who is not already a member is added as a new member, or refused for the
admin role, as an organization's key adds them. The admin role goes
only to someone who signs in with their own account: an item that matches no current member and no
one you can invite comes back refused, with nothing written, and
an item that matches a member by the email they sign in with makes them an admin when they have
their own account (promoted). A match by a contact email, LinkedIn or ORCID, which members enter
themselves, changes no role, and neither does a member without an account of their own; both come
back with a role_error saying why, unless the person is already an admin or the owner. The API
never demotes anyone.
Each item may also carry tags: at most 20 tag names of at most 100 characters each, as an admin
gives them in the app ("Class of 2026", "Speaker"). They are added to the person's tags in the
community and never replace them, also when the person was already a member. A name finds one of
the community's tags as the CSV import finds it, by its letters a to z and its digits: case does
not matter, nor does an accent that comes off its letter (é, ü, ç), and any run of other
characters counts as one space, so "class of 2026" and "Class-of-2026" both find
"Class of 2026". A letter with no such accent to drop, such as ø, ß or ł, or a letter of
another script, counts as a space too: "Øresund" reads as "resund" and does not find
"Oresund". The other way round, names that differ only in such letters find one tag:
"Promo 東京" and "Promo 大阪" both read as "promo", so the second lands on the first one's tag,
or on a tag named "Promo". The answer says which: added and already_had give each tag under the
community's own name for it. A name with no letter a to z or digit left, such as "研究", matches
only as written, apart from case.
A tag's aliases are not matched, so give the tag's own name. A name that finds no tag becomes a
new one, with no search boost until an admin sets one, unless an admin renamed the tag that name
once created, in which case it lands on that tag. A name held only by tags in two or more of
the community's tag families matches none of them and becomes a new tag with no family, so the API
never picks one of several families for you; a name held in one family lands on that family's tag.
Each item that carried tags and left
the person a member reports tags, as { added, already_had, created }, or tags_error, a
sentence saying why the tags were not written; the person stays added either way, and sending the
item again with an email, a LinkedIn URL or an ORCID adds them. tags: null makes the item
invalid: tags are only ever added, so there is nothing for it to clear. Removing a tag from a
member, or deleting a tag, is done in the app. An admin who saves the community's tag editor
after the API created a tag keeps that tag: the save folds in what was added since the editor
opened.
Erasure removes the person's membership, this community's enrichment data and embeddings, and
the community's source identifier for them. A claimed person's account is never deleted. For
an unclaimed placeholder that exists in no other community the handler also tries to remove
the placeholder's auth account; that step is best-effort, and the response reports the
outcome in auth_account_deleted (false when the account could not be removed, with the
erasure itself still complete).
Creating a community
POST /v1/communities creates a community owned by you and billed to you, as the app's New
community page does, and returns 201 with its community_id, which the routes above take. It
needs community:write on a key of your own with no community allowlist, and your account's
Write access on (422 otherwise). The body takes name (required, at most 100 characters;
another community may have the same name), description (at most 5,000 characters), visibility
(private by default, or public), data_residency (global by default, or eu) and tags,
the tag names the community starts with (at most 50), which adding members then matches by name.
The app's rules apply. Your free trial or plan must cover the new community: the API cannot start
a trial, so without one the call returns 402 coverage_required with an app_url where you start
one, after which the same call succeeds. Past your plan's number of communities it returns 402 community_limit_reached, and a public community needs a verified account (422 visibility_requires_verification; create it private instead). A private community starts with
the same rule as one created in the app: what its members spend in it is charged to your credits,
up to 20 credits each, which you can change in the community's admin tab. Creating a community
costs no credits.
A key keeps the rate tier it was minted with, so a key minted before you owned a community stays
on the restricted tier; the response's rate_limit_note says so, and minting a new key moves you
to the standard tier (see Rate limits). Send an
Idempotency-Key so that a retry after a lost response returns the community already created,
with replayed: true, instead of a second one (see Idempotency).
Importing a spreadsheet
The import routes bring a spreadsheet of people into a community, with the community fields the
app's import fills, for more people than adding members takes in one call. They run the app's own
file import, for you: the same column detection, mapping targets and member cap, and the values
are recorded as imported, so enrichment does not overwrite them. They need member:write under
the same rule as adding members, and a key of your own (an organization's key gets 422).
POST /v1/communities/{id}/importstakes{ csv, file_name? }: the file as CSV text, its first row the column headers, at most 10 MB and 10,000 data rows (convert an Excel file to CSV first). Or it takes{ upload_id, file_name? }for a file uploaded through an upload link (below). It returns201with animport_id, the headers as the import reads them, the mapping the app would suggest, three sample rows,list_columns,column_types, the community's fields (key, label, type, choices, andchoice_aliases: each choice that has other spellings, as{ "label": …, "aliases": […] }) and its tag names. Nothing is imported yet. It also givesduplicate_rows, the groups of rows that share an email, LinkedIn or ORCID (at most 200 groups, withtotal_groupscounting them all), andchecks, what to look at before the run: a list column mapped to a field that holds one value, names that look wrong, and the cells a community field would keep as written (values_kept_as_written, below). Whenchecks_unavailableistrue, a preview sent again could not read the file again:list_columns,column_types,duplicate_rows,checksandwarningsare then empty because they were not read, not because there is nothing to check, so send the preview again later or rely on the run'schecks.list_columnsnames each column whose cells hold several answers separated by commas, semicolons or line breaks, as form tools such as Tally export a multiple-choice question, with every answer in the file and how many rows give it: the choices for amulti_selectfield. Spellings that differ only by an apostrophe (l'économie,l’économie) are one answer, shown as most often written, with the others in itsaliases: give the choice as{ "label": …, "aliases": […] }so cells written either way are read as it. A piece with no letter or digit, such as the)of a;), is not an answer, and an answer whose parentheses hold a comma is kept whole, as is one a comma splits before a linking word its first part also uses (de,du,des,d',à,au,auxorof), such asAnalyse d'un secteur, d'une pratique. In a column separated by commas, any other answer that holds a comma is listed as its parts, so give the whole answer as the choice. An answer longer than 200 characters, the most a choice holds, is only counted, inlong_answers, and a cell holding a link with an access key is left out before it is split, so none of its answers is listed or counted, however long: the run refuses such a link as a choice, since a field shows its choices. A column of numbers, such as decimals written with a comma, is not listed. The columns the suggested mapping leaves unmapped are read first, then the mapped ones, those with the fewest different cells first, up to 200 list columns and 50,000 different cells, and for 0.2 s at most, so a large file whose list cells nearly all differ, or one of long free-text answers, is read in part, and not always the same part: a column that would pass them, or comes once that time is up, is skipped and named inlist_columns_unread, and may hold lists without being listed and has nocolumn_typesentry either.list_columns_partialistruewhen one of those is a column the suggested mapping leaves unmapped: take its choices from the user.column_typesgives each column the suggested mapping leaves unmapped the type the app would propose for a field made from it, read over the whole file (suggested_value_type), withfilled_rows,rows_not_fitting(the values that type would keep as written, such as the few answers of a website column that are not links: a web link is proposed when at least 90% of the values are links) and, for a text orselectcolumn whose values can be the choices of aselectfield (at most 20), those values aschoices, most given first, with how many rows give each. A column whose every value is a yes or a no (oui,non,true, or thexof a ticked box) is proposed asboolean, and a column of only1and0as a number. A column of months and years written with a separator (2.2022,02/2022,2022-03), or of numbers under a header that names a date, month or year and no count, rate or amount (12023under "Month of arrival"), is proposed as short text, withlooks_like_month_year: true. Types are read from the unmapped columns first, then the mapped ones, up to 200 columns and 100,000 filled cells, for 0.3 s at most with the lists, and not for a column left unread as a list; a column past them has no entry, andcolumn_types_partialistruewhen it is one the suggested mapping leaves unmapped.POST /v1/communities/{id}/imports/{import_id}/runtakesmapping(a target, such asfirst_name,email,contact:linkedin,tagsorinternal:<field key>, to a column header),new_fields(community fields to create, visible to the community's admins only unless a field'svisibilitysays otherwise; each may also giveuse_in_filtersandsensitive, as below),existing_members(smartby default,skip,fillto fill empty fields, oroverwrite; on a profile nobody has claimed,fillalso adds the row's text to a filled headline or bio, keeping every sentence already there, adds another organization beside the one there and gives a location that names only a country the row's city;smartmerges asfillwhere JOGL's merge step is not available yet, and where it is, the merge step decides what the row changes),exclude_rows(data row numbers to leave out, the first row under the headers being 1),value_formats(per field,date_orderdmyormdy,decimal_mark.or,) andduplicates(keep_firstby default, orkeep_lastto import the last of the rows that share an email, LinkedIn or ORCID, such as a form's latest submission). It answers202and the import runs on the server. Withdry_run: trueit answers200with thechecksthe run would give for this mapping, thesenew_fieldsand thesevalue_formats, over the rows the run would import: it matches the rows against the members and the file's duplicates as the run will, with theseexisting_membersandduplicates, and counts the rows it would skip (rows_to_import,rows_skipped,rows_superseded,rows_over_limit,members_matched). It creates no field, saves no mapping and stages no row, so the import can then be run with any settings.GET /v1/communities/{id}/imports/{import_id}returns its status, the profiles created, merged and failed so far, the rows left, up to 50 skipped and 50 failed rows with the reason for each, and, once its rows are staged, the run'svalues_kept_as_writtenand its wholechecks(the names to check included).
GET /v1/communities/{id}/schema, under the same rule, lists the community's custom fields
(key, label, description, value_type, its live choices, choice_aliases (each choice
that has other spellings, which an import and a search filter read as that choice, as
{ "label": …, "aliases": […] }), unit, visibility,
sensitivity, and whether it is used in search, in filters, on member cards and in the profile
interview) and its tags with their family, without starting anything: read it before an import to
map a column to an existing field, and after one to check what it created. Reading a member then
shows the values the import wrote, in custom_fields.
PATCH /v1/communities/{id}/fields/{key}, with the same permission, changes a community field's
settings afterwards, each left as it is when the body leaves it out: visibility (admins, the
community's admins only; admins_and_subject, the admins and each member their own value; or
members, every member), use_in_filters (whether search can narrow its results by the field's
values; members other than the admins can filter only by a field they can see) and sensitive
(health, religion, ethnic origin, political opinions, sexual orientation and the like). A
sensitive field is never shown to other members, never used by search and never asked in the
profile interview, so marking one turns use_in_filters off and narrows members to
admins_and_subject, and visibility: members or use_in_filters: true is refused for it.
use_in_filters: true is refused for a boolean field too, since search does not filter by one
yet; such a field starts with it off and is always shown with it off. A
field marked sensitive is unmarked only in the app's Fields tab, so sensitive: false is refused
for one. It returns 200 with the field as it stands. A field's name, type and choices are changed in the
app.
To send the file without putting it in the request, first call
POST /v1/communities/{id}/import-uploads. It returns 201 with an upload_id, an
upload_page_url, an upload_url and expires_at. Open upload_page_url in a browser and drop
the file there, a .csv or an .xlsx whose first sheet the page turns into CSV; or PUT the CSV
to upload_url with Content-Type: text/csv, which needs no key:
curl -X PUT -H "Content-Type: text/csv" --data-binary @members.csv "$UPLOAD_URL"A link takes one file of at most 10 MB, for 2 hours; storage refuses a larger one. Then send its
upload_id to the preview. The preview reads the uploaded file once and empties it, since the
import keeps its own copy, and the link then takes no other file: for another file, create a new
link. A second preview with the same upload_id answers the first one's
preview again, with the same import_id, so a preview whose answer you lost is safe to send again
(read its checks_unavailable, above);
once that import has run it answers 409 naming it. A preview sent while another of the same
upload is still reading it answers 503 busy: send it again a moment later. A file the preview
refuses for what it holds (empty, over 10 MB, an .xlsx sent as it is, CSV it cannot read) is
deleted, and the same link then takes a corrected one. An upload left untouched for 3 days is
deleted with its file: a preview after that answers 404, unless one of your earlier previews
already read it, in which case it still answers that import.
A select or multi_select field you create needs its choices. A select cell that names one
of the field's choices, ignoring case and accents, is stored as that choice. A multi_select cell
whose answers each name one of its choices is stored as those choices, each once, separated by
, ; a choice whose name holds a comma is still read whole, and a piece with no letter or digit,
such as the ) of a ;), is no answer here either, unless it is the name of one of the field's
choices, archived ones included. A cell with an answer that names no
choice, such as an "Other" answer typed in a form, is stored as written, whole. So is a cell
whose stored choices would read back as other ones, which happens only when one choice's name is
made of others ("Research", "Innovation" and "Research, Innovation"), and a cell of more than
1,000 answers. The preview, a dry run and the run's answer count, per field, the cells naming no
choice in checks.values_kept_as_written, with the numbers or dates that do not read as one and
the links or emails that are not one, so check it before you run the import.
A row whose email, LinkedIn profile or ORCID matches a current member is merged into that member
with smart (the default), fill or overwrite, or skipped with skip, and so is a row whose first and last name are those of
exactly one member when its organization agrees with theirs. Any other row whose name alone
matches a member is skipped whatever the mode, since nobody confirms the match: no organization,
another organization, several members with that name, a LinkedIn or ORCID in the row that differs
from the member's, or a member another row of the file names by email, LinkedIn or ORCID. Only an
email, LinkedIn or ORCID the member also holds confirms it is them; a different person with that
name is added in the app, which shows such matches for review, or with
POST /v1/communities/{id}/members. A row naming two different members, a later row repeating
an earlier row's email, LinkedIn or ORCID (with duplicates: keep_last, the earlier rows
instead, when none of them matches a member or someone another running import adds), and a row
naming someone another running import of the community is about to add are skipped too. A
member's emails are their contact
emails and the email they sign in with, and an ORCID matches bare or as an orcid.org link. A run answered
503 (busy while another import of the community is being prepared, which a dry run answers
too, or timeout) goes on when
you send the same request again; once part of the file is staged, a request with other settings
gets 409. Send an Idempotency-Key on both POSTs (see Idempotency).
CRM syncs
The CRM routes map and run the syncs of the CRMs (Airtable, HelloAsso, Odoo) an admin of the
community connected in the app, as the app's mapping step and Sync button do. They need
crm:write under the same rule as adding members, and, to read the CRM, save a mapping or run a
sync, a key of your own (an organization's key gets 422). No route takes or returns a CRM key: they work only with the key the connection
saved, and refuse a request that carries one. Connecting a CRM, or reconnecting one whose key no
longer works, is done by an admin of the community who types the key on a JOGL page: in the app,
or on the page a connect link opens (below).
| Method & path | Purpose |
|---|---|
GET /v1/communities/{id}/crm-connections | List the connections: each one's CRM and source, whether its key can be used (key_usable), whether a mapping is saved, and its latest sync. With ?intent_id=, also the state of that connect link |
POST /v1/communities/{id}/crm-connect-links | Make a link to a JOGL page where you connect a CRM, or give a connection a new key |
GET /v1/communities/{id}/crm-connections/{connection_id}/fields | The CRM's fields and up to three records, read on the saved key, with the saved mapping as the next sync reads it, the one the app would suggest, and the community's fields and tag names; for HelloAsso, the organization's forms. For Airtable, field_warnings lists each mapped field renamed, deleted or changed since the mapping was saved |
POST /v1/communities/{id}/crm-connections/{connection_id}/mapping | Save the mapping its syncs read |
POST /v1/communities/{id}/crm-connections/{connection_id}/syncs | Run the saved sync (dry_run: true only counts the records) |
GET /v1/communities/{id}/crm-connections/{connection_id}/syncs/{sync_id} | Follow a sync and the import it started |
The mapping takes mapping (a target, as in Importing a spreadsheet,
to the name of one of the CRM's fields), new_fields (as for a spreadsheet, settings included) and existing_members (skip, fill,
overwrite or smart), plus forms for HelloAsso and partner_scope (contacts, companies or all)
and require_email for Odoo. A CRM field feeds one target only, and what you leave out keeps the
saved choice. The key and the source it reaches (the Airtable base and table, the HelloAsso
organization, the Odoo database) stay as the admin saved them. A run answers 202 with a
sync_id and the import runs on the server; the values are recorded as coming from the CRM, so
enrichment does not overwrite them.
A 409 is a key to reconnect, a connection with no mapping yet, or a sync already running; a
429 means the CRM was read for the community less than a second ago, so send the same request
again. Send an Idempotency-Key on the three POSTs (see Idempotency).
Connect links. POST /v1/communities/{id}/crm-connect-links takes platform (airtable,
helloasso or odoo) and, to give an existing connection a new key, its connection_id; a CRM
the community already has gets 409 without it. It needs a key of your own, and you must be an
admin of the community (403 otherwise), because only you can use the link. It answers 201
with intent_id, connect_url and expires_at, 30 minutes on. Open connect_url in your
browser, signed in to JOGL: the page asks for the CRM's key and source (the Airtable base and
table, the HelloAsso forms, the Odoo partners) and saves the connection, as the app's own first
two steps do. It maps nothing; your agent does that with the routes above. A connection given a
new key keeps its saved mapping when its source stays the same (an Odoo one always does). The link works once, and you can
decline it on the page. GET …/crm-connections?intent_id=<intent_id> then carries intent, whose
state is waiting, connected (with the connection_id to map), disconnected (the connection it
made was deleted since: ask for a new link), declined or expired.
Direct messages
POST /v1/communities/{id}/members/{profile_id}/messages sends a member of the community a
direct message in JOGL, from you, as the app's Message button does. It needs the
contact:write scope on a key of your own, with your account's Write access on (422
while it is off; see Scopes), and takes two calls, so that your agent can
show you each message before it goes:
{ "text": "…" }sends nothing. It answers200withstatus: "pending_confirmation", aconfirmation_id, the recipient's name and the text. The preview can be confirmed for 15 minutes.{ "confirmation_id": "…" }, from the same key, sends the previewed text once and answers201withstatus: "sent",message_id,conversation_idandsent_at. The member is notified as for any message. The body may repeat thetext, which must then be the same.
JOGL sends exactly the text the first call previewed, to the member it named, but it cannot
tell whether you approved it, since the same key makes both calls. Give contact:write only to
an agent you trust to send messages as you.
Confirming again with the same confirmation_id is always safe. Once the message is sent, it
answers 200 with the same message and replayed: true, and sends nothing (409 if the body
repeats a text other than the one sent); if the send failed, the preview is still unsent and the
retry sends it.
The member must have signed in to JOGL (status claimed; otherwise 422, since nobody would
read it) and accept messages from you, by their message settings in the app and only while
neither of you has blocked the other
(403; the confirm checks again, so a block made after the preview refuses the send). You can
send at most 25 messages through the API in 24 hours and hold at most 50 previews unconfirmed at
once, all your keys counted; past either, the call answers 429 with available_at.
Meeting requests
POST /v1/communities/{id}/events/{event_id}/attendees/{profile_id}/meeting-requests asks an
attendee of one of the community's events for a meeting at it, from you, as the Request
meeting button of the app's event page does; they accept or decline it in the app. It needs
the same contact:write scope as a direct message and works the same way, with one more call
to find a time:
- A body with neither
slot_startnorconfirmation_id(optionally{ "minutes": 30 }) sends and stores nothing. It answers200withstatus: "choose_slot": the event's time zone, the meeting lengths the event offers (meeting_lengths), the length listed (minutes, the shortest by default) andavailable_starts, the starts of a meeting of that length that are still free for both of you, earliest first and at most 200, withmorecounting the rest. These are the meetings the app's picker offers you: inside the event's networking windows and clear of any meeting either of you already holds. { "slot_start": "…", "minutes": 30, "topic": "…", "location": "…", "location_type": "…" }, withslot_startone ofavailable_starts(with its time-zone offset) and the rest optional, sends nothing. It answers200withstatus: "pending_confirmation", aconfirmation_id, the meeting'sslot_startandslot_end, the topic and the place.location_typeisvideo,phone,whatsapp,in_personorcustom, and comes only with alocation. The preview can be confirmed for 15 minutes.{ "confirmation_id": "…" }alone, from the same key, sends the previewed request once and answers201withstatus: "sent",meeting_request_idandsent_at. The attendee is notified as for a request made in the app. To change the time, the topic or the place, preview again.
As for a direct message, JOGL sends exactly the request that was previewed, but cannot tell
whether you approved it. Confirming again with the same confirmation_id is always safe: once
the request is sent, it answers 200 with replayed: true and sends no second request.
You must attend the event (403 otherwise), and the attendee must attend it too, be a member
of the community (404) and have signed in to JOGL (422). The event's networking must be
open (409). While either of you has
blocked the other, the request cannot be
sent: listing the times and the preview still answer, but the confirm returns 404, as for an
attendee who is no longer there. A start that is no longer free, because one of you booked that
time meanwhile or the organiser changed the event's meeting times, is a 409 that lists the starts still free; a length the event does not offer is a 400 that
lists the ones it does, and a length with no free time left is a 409 that lists the starts of the shortest one. You can send at most 25 meeting requests through the API in 24 hours,
apart from your messages, and hold at most 50 previews unconfirmed at once; past either, a
preview or a send answers 429 with available_at, while a call that only lists the times
still answers, with your count as sent_today beside daily_limit. Confirming a request
already sent answers with it as long as you still attend the event and the attendee still
attends it, is a member of the community and has a profile that is not archived; the other
checks do not run.
Event guest sync
POST /v1/communities/{id}/events/{event_id}/guest-syncs does what the Re-sync button on a
Luma event's card in the app's Events tab does: it reads the event's approved guests from Luma
again, as the app reads them, and imports the ones who do not attend it yet as
members of the community and attendees of the event. A guest who is already a member is made an
attendee rather than added twice. A guest with a JOGL account of their own who is not a member
is not added: they are sent the community's invitation, from you, and a later sync makes them an
attendee once they accept. JOGL then starts a Smart Enrich run on the community, leaving
out members who signed in, billed to the community's owner like any
enrichment. That run takes every member not enriched yet, not only the
new guests: a member imported earlier and never enriched is enriched with them. It only adds: a guest no longer on Luma's list stays an
attendee. Guests Luma has no full name for are left out (skipped_no_name), and so are new guests
past your plan's member limit (skipped_at_cap).
It needs member:write and enrich:write, a key of your own (an organization's key gets
422), and the same rule as adding members: you are the community's owner
or one of its admins, with write access on. The body is optional:
{ "dry_run": true }reads Luma and changes nothing. It answers200withapproved_guests(on Luma now),new_guests(those a sync would import),skipped_no_name,skipped_at_cap,invited_at_most(of the new guests, those with a JOGL account of their own who would be sent the community's invitation from you rather than added;nullwhen they could not be counted) andenrichment: the tier the run bills,eligible_members_now(the members it would take now besides the new guests),max_profiles(those and the new guests) and the most credits it may use (estimated_max_credits_per_profile,estimated_max_credits), fewer when some of them have signed in or have nothing to go on. With no guest to import nothing is enriched, and the counts are0. When the members cannot be counted,eligible_members_now,max_profilesandestimated_max_creditsarenull.- Without it, the sync also brings the check-in times Luma records across
(
checked_in_updated), and answers202withstatus: "importing"and async_id, or200withstatus: "up_to_date",sync_id: nulland amessagewhen no guest can be imported now.
GET /v1/communities/{id}/events/{event_id}/guest-syncs/{sync_id} follows the import: its
status, the guests it reads, the profiles created, updated and skipped (members already, people
invited instead, profiles unchanged), and, once it
has completed, enrichment, the run JOGL started, whose run_id you poll with
GET /v1/communities/{id}/enrich/runs/{run_id}. enrichment.status is pending for the moment
before the run is written, skipped when nobody was left to enrich and not_started when the run
could not start; start one with joined_since then.
Only a Luma event syncs. An event whose guests come from Eventbrite, HelloAsso or a file gets
409, saying what an admin does in the app instead, and so does an event still being set up, an
event whose guests are already being imported, and a community with no Luma key that works. A
community whose plan does not cover the import gets 402 before Luma is read, dry run included;
an import refused later carries refused and the reason in the follow-up. A draft or another
community's event is 404: a draft is re-synced from the app's Events tab.
Community analytics
GET /v1/communities/{id}/analytics returns a community's breakdown in one call: members
(how many, and the share with experience, education, skills, a location, a headline and
publications), employers (where members work now, with how many ever worked there),
sectors (members by the sector of the employers they work at now), roles, schools,
skills, research_domains, countries and custom_fields (the community's own list fields,
broken down by choice as the app's Community fields section shows them to you, and null
for an organization's key). All but countries, sectors and custom_fields are the figures
the app's analytics page shows its members; those three are counted over the roster on each
call. Anyone who can list the roster can read it.
Each section carries a status (ok, computing when another request is computing it
right now and you can ask again in a few seconds, failed, or for research_domains alone
not_computed, when the app has not computed it yet) and a computed_at, so one section that
cannot be computed never fails the whole call. Most figures are at most a week old, and the
app usually refreshes them the night after the community's data changes; research_domains
can be older.
No member field records an industry, so sectors reads it from the employers: JOGL looks
each employer up on the web and files it in one of 17 sectors, only for employers that 2 or
more JOGL members work at now, counted across every community. A member counts in a sector
once their current employer has one, and with_current_position_count and
classified_count say how many members the items cover.
A skill counts in one of twelve fixed clusters, such as "Research & Innovation" or
"Leadership & Management", when the spelling a member wrote is on that cluster's list or, for a
skill in JOGL's skill catalog, its English or French label is: the cluster's skills item
carries included_skills, the spellings it gathers, and a null key. Outside the clusters, a
skill in the catalog counts once across the spellings the catalog recognizes (its English and
French labels and some common variants, whatever their case or accents): its item reads as the
English label and carries a skill_key, the same for each of those spellings
(molecular_biology for both "Molecular biology" and "Biologie moléculaire"). One recognized
spelling is itself on a list while its skill's labels are not: members who wrote "Agile" count
in "Project Management", while "Agile methodologies" carries agile_methodologies. Any other
skill outside the clusters, such as a typo or a third language, has a null key and counts once
across the spellings that differ only in case, accents, apostrophes or spaces ("Créativité" and
"Creativite"), and its item reads as the spelling its members use most. A translation still
counts on its own.
Enrichment (write)
| Method & path | Scope | Purpose |
|---|---|---|
POST /v1/communities/{id}/enrich | enrich:write | Start a Smart Enrich run on members |
GET /v1/communities/{id}/enrich/runs/{run_id} | enrich:read | Poll a community enrichment run |
The enrich body is all-optional and composable: depth (quick | standard | deep),
profile_ids, joined_since, include_already_enriched, workflow_key (currently only
smart_enrich), dry_run, and plan. A dry run returns 200 with eligible_count,
total_members, billing_tier (the tier the run would bill: the depth you sent or, with
no depth, deep, since the default step set includes LinkedIn discovery), and a credit
estimate for that tier (estimated_max_credits_per_profile and estimated_max_credits, both
null when the tier is not priced in the catalog) instead of starting anything. Despite the max in the field names, the
estimate is the exact flat per-tier charge: every enriched profile bills the tier rate
whatever optional steps ran for it. See Enrichment for what each
depth tier does. plan takes an import's enrichment_plan.groups (each a depth and its
profile_ids) and runs every group in one call, each billed at its own depth: the call starts the
first group, and each next group starts on its own when the run before it completes, since a
community runs one enrichment at a time. With depth beside it, every profile runs at that one
depth. Its dry run prices each group and the whole plan, and under if_one_depth what every
profile would cost at each single depth, so you can choose; the run poll's plan gives the next
group's run id once it starts. A run on a read-only community returns 402 community_over_quota, with a
message that says why: its owner's plan lapsed, and choosing a plan restores it once the payment
is recorded, or its owner holds more communities than the plan allows, and upgrading the plan or
transferring or deleting a community restores it. A transfer, a deletion or a change to a larger
plan on the billing page does not bring the community back at once: the nightly billing run does
(03:50 UTC, normally the next one), so a run retried right after it is still refused. A run the
community's billing owner cannot cover returns 402 once credit enforcement (or the plan
coverage gate) is armed; today, with enforcement off, the run starts and is debited post-hoc
like every other metered operation (see Credits).
With no profile_ids, a run leaves out the claimed members (people who signed in and own their
profile, the community's owner among them), as the app's run after an import does; send
include_claimed: true to take them too. Members with nothing to go on but a name and an email
are always left out. A dry run counts the same selection with the same function as the run, and
also returns claimed_excluded, not_enough_info and with_linkedin_profile_url (how many of
the eligible members have a LinkedIn profile link the scrape can use). Beside its counters, the
run poll returns phase (where the run is, such as research, linkedin, fill_gaps,
finalizing or done), phases, and outcome: how many profiles got LinkedIn data, how many did
not and why, and how many have a photo, the figures the app's run screen shows.
processed_count reaches total_count when the research phase ends, minutes before the run
does, so read status and phase to know whether a run is finished. Pass ids_per_reason (0 to
50, default 5) to list that many profile ids per reason.
Idempotency
The community write POSTs honor an optional Idempotency-Key header (1–255
printable chars, otherwise 400): POST /v1/communities/{id}/enrich,
POST /v1/communities/{id}/members, the two import POSTs,
POST /v1/communities/{id}/imports and …/imports/{import_id}/run, and the three CRM
POSTs, …/crm-connections/{connection_id}/mapping, …/syncs and …/crm-connect-links. A replay with the same key + same body returns the
original stored response verbatim; the same key with a different body returns 422; the
same key while the first call is still in flight returns 409 conflict. Stored responses are
kept for 48 hours, and a 5xx outcome is not stored, so a retry after a server error runs
again. Nor is a CRM POST's 402, 409 or 429, which clear on their own.
POST /v1/communities honors it too, with two differences, because a lost response there
would otherwise create a second community of the same name. The request is compared on its
fields once normalized (name, description, visibility, data_residency and tags), the
same over HTTP and through the MCP create_community tool's idempotency_key, rather than on its
raw body. And the creation records the key with the new community in the same transaction, so
every retry with the same key and request returns that community (200, replayed: true) for
48 hours, instead of a copy of the first response. A refusal (402, 403, 422) is not kept,
so asking again after starting a trial in the app creates the community.
Everything else ignores the header — including POST /v1/me/enrich, the
DELETE …/members/{profile_id} erasure, POST /v1/me/notes (which returns the note already
added for the same text instead, see Private notes) and the reads — with one exception: a search that runs
through the app, a follow-up and a proposal run honor it too. A retry with the same key returns
the same search, read fresh, for up to about 72 hours, instead of starting and billing a second
one, and the same key with a different request is a 422. A search is compared on its query,
scope, community_id, limit and mode, plus include_teams outside simple, and
event_id and location when given; a follow-up on its conversation, query,
parent_message_id, limit, mode and include_teams; a proposal run on its conversation,
proposal, limit and include_teams. So a retry that only raises limit is a 422 too.
wait_seconds is not compared, so a retry can wait longer than the first call did, and a
location is compared once normalized, so the same place written in another case is the same
search. Only a request that started a search is kept under the key: one refused before that,
such as a follow-up's or a proposal run's 409 history_withheld, or a proposal run's other
409s (see Running a proposal), is not, so asking again with the same
key asks again and, once nothing refuses it, starts and bills a search. In particular, retrying
POST /v1/me/enrich with the same key is not de-duplicated, so a retry after a timeout can
start a second self-enrich run; handle that on the client.
Versioning
/v1 is the stability contract. Only backward-compatible changes ship within it (new
endpoints/fields/enum values/error codes); clients must tolerate unknown fields. Breaking
changes ship only under a new major version (/v2), served side-by-side. When a version or
endpoint is scheduled for retirement, responses carry Deprecation, Sunset, and Link
headers ahead of time — watch for them.
One exception is on record. To bring API and MCP search onto the same search as the app, a
one-time waiver lets a few changes ship inside /v1, and every current key holder is told
directly before each one. The first step retires the public search scope and stops new keys
from carrying a community allowlist. Later steps will change what an all search covers: it
will include the searcher's connections and no longer cover communities you only manage
through an organization. They will also apply the app's
minimum match score and ranking. The history:read scope (Search history)
joined the scope list under the same waiver. Nothing else outside those steps is exempt from
the rules above.