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-gateway

Authenticate 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 simple search is billed at the in-app simple search rate once its result is stored, whether that happens inside the initial call or after a 202 while you poll. An auto or deep search, 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 completed simple search 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}/enrich run enriches is billed one marker for the run's depth tier (enrichment_quick, enrichment_standard, or enrichment_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": "…" } }
StatusCodes
400bad_request, invalid_request
401unauthorized
402insufficient_credits, coverage_required, community_over_quota (the body also names the payer; enrichment calls only, since a search reports these as a failed status)
403forbidden
404not_found
409conflict; on the search history routes also history_withheld, and a proposal run's already_run, proposal_busy, already_claimed, plan_unrunnable
422unprocessable, owner_consent_required
429rate_limited
500internal
502search_unavailable
503busy, 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 & pathScopePurpose
GET /v1/meanyKey identity, owner, scopes, usage, credit balance, coverage (plan, trial or none)
GET /v1/me/profileprofile:readRead your own profile
GET /v1/me/connectionsprofile:readThe people you know: your JOGL connections, the contacts of your LinkedIn imports and those imported from your mailbox
POST /v1/me/contactsmember:writeImport the people you deal with, read from your mailbox and calendar by your own assistant, into your personal network
POST /v1/me/enrichself:enrichStart a self-enrich run (target-locked; see billing note below)
GET /v1/me/enrich/runs/{run_id}self:enrichPoll 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 }:

  • relationship says how you know them: jogl_connection is { connected_at }, when the connection was accepted, and linkedin_contact is { 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_contact is true for a contact imported from your mailbox or another source but LinkedIn, and interactions is { 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_id is a community where reading one member accepts that profile_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 lab finds Liberté Living-Lab). A filter of fewer than six letters and digits, such as an acronym, matches whole words only (INCa does 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 carries matching_positions, up to 10, current ones first, so you can tell who works there now from who worked there before.
  • ?source= is all (the default), jogl, linkedin or mailbox.

?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 & pathScopePurpose
GET /v1/me/notesnotes:readRead your private notes on the people you can see in JOGL
POST /v1/me/notesnotes:writeAdd 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 & pathScopePurpose
GET /v1/me/settingsprofile:read or notes:readRead the settings of yours an agent may change for you
PATCH /v1/me/settingssettings:write or notes:writeChange one or more of them

So that you can reach a few of your own settings through an agent, without opening the app:

SettingValuesRead withChanged withIn the app
notes_as_contexttrue, false, null (never answered, counts as off)notes:readnotes:write, and settings:write too to turn it onSettings → Network notes, Use my notes as context
profile_visibilitypublic, communities_and_connectionsprofile:readsettings:writeSettings → Account
who_can_messageanyone, community, connections, no_oneprofile:readsettings:writeSettings → Privacy
who_can_connectanyone, community_extended, community, no_oneprofile:readsettings:writeSettings → Privacy
email_notificationstrue, falseprofile:readsettings:writeSettings → Notifications
admin_reminder_emailstrue, falseprofile:readsettings:writeSettings → 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.

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.

  • scope is required and is one of community (needs community_id) or all. On the API's own engine, all covers 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. Use community if you want a single-community corpus with no public matches mixed in. The former public scope (public claimed profiles only) has been retired and returns 400 — see Versioning.
  • On the API's own engine, all needs 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, all returns 422 unprocessable and 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_id returns 403, never a silent empty result.
  • limit defaults to 20 and is clamped to a maximum of 50.
  • mode is simple (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 without history:read cannot, and its proposal carries a message pointing at app_url, where the app runs it, or a text answer) or deep (a Deep Search), and include_teams: true asks a deep search for teams. auto, deep and teams need the search to run through the app; elsewhere they are rejected with 400. include_teams: true with any mode but deep is always a 400; include_teams: false is accepted with every mode.
  • event_id, with scope: 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 (403 otherwise; 404 for another community's event or a draft). It needs the search to run through the app, and is a 400 otherwise or with scope: all. A named person is looked up among the attendees only, and the search's follow-ups and proposal runs keep to the event.
  • location keeps the search to the members whose stored location matches: country (an ISO 3166-1 alpha-2 code), city (in the geocoder's spelling, Munich rather than München) and near ({ "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 with mode: simple and only through the app (400 otherwise), 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. kind says 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 a proposal: 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; poll GET /v1/search/{search_id} until its status is complete or failed (while it runs the poll reports running). Search jobs are fenced to the key that started them and expire, so an unknown, foreign, or expired id is a 404. A search_id can 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 & pathPurpose
GET /v1/searchesYour 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) and cursor. Pass a response's next_cursor back as cursor until it is null; a list page can hold fewer than limit while more remain.
  • A list entry carries conversation_id, title, scope (community or all), community_id, event_id (for a search kept to an event), source (web, a chat bot, api, mcp), its dates and an app_url. title is null with title_withheld: true when 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.kind is results (matches in the same shape as a search), proposal (a Deep Search proposal), failed or answer (any other reply). An API or MCP search still running is pending, with the search_id to 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, limit and wait_seconds work as for a search, and so does the response: a completed result with its kind, a failure, or a 202 with a search_id to poll. A follow-up can be answered from the results it follows up, so any mode can return any kind. 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 409 with error.code history_withheld and a reason. 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 error conversation_changed, and you can ask it again.
  • Someone else's conversation is a 404; one in a community you can no longer access is a 403.

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, limit and wait_seconds work as for a search; mode, when given, must be deep. The response is a search's: a completed result with its kind, a failure, or a 202 with a search_id to poll. It is billed as a Deep Search when its lanes ran, as in the app.
  • A proposal that cannot run now returns 409 and 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) or plan_unrunnable. A conversation in which history withholds anything returns 409 history_withheld, as for a follow-up.
  • A message_id that is not a Deep Search proposal of the conversation, like someone else's conversation, is a 404; one in a community you can no longer access is a 403.
  • 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 & pathScopePurpose
GET /v1/communities(none)List the communities you can read, with your role, write access and upcoming events
POST /v1/communitiescommunity:writeCreate a community you own and pay for (see Creating a community)
GET /v1/communities/{id}/membersprofile:readList the roster (keyset pagination)
GET /v1/communities/{id}/members/{profile_id}profile:readRead one member, with what search ranks them on
POST /v1/communities/{id}/members/{profile_id}/messagescontact:writeSend a member a direct message in JOGL, after a preview (see Direct messages)
POST /v1/communities/{id}/membersmember:writeAdd people (bulk, per-item results)
DELETE /v1/communities/{id}/members/{profile_id}member:writeCommunity-scoped erasure (DSAR)
POST /v1/communities/{id}/import-uploadsmember:writeGet 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}/importsmember:writeSend 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}/runmember:writeStart the import with your mapping
GET /v1/communities/{id}/imports/{import_id}member:writeFollow an import: profiles created, merged and failed, and the rows skipped or failed with why
GET /v1/communities/{id}/schemamember:writeList 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}/attendeesprofile:readList an event's attendees, with what each one is looking for
POST /v1/communities/{id}/events/{event_id}/attendees/{profile_id}/meeting-requestscontact:writeAsk an attendee for a meeting at the event, after a preview (see Meeting requests)
POST /v1/communities/{id}/events/{event_id}/guest-syncsmember:write and enrich:writePull 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:writeFollow a guest sync and the enrichment it starts
GET /v1/communities/{id}/analyticsprofile:readThe 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).

  1. POST /v1/communities/{id}/imports takes { 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 returns 201 with an import_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, and choice_aliases: each choice that has other spellings, as { "label": …, "aliases": […] }) and its tag names. Nothing is imported yet. It also gives duplicate_rows, the groups of rows that share an email, LinkedIn or ORCID (at most 200 groups, with total_groups counting them all), and checks, 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). When checks_unavailable is true, a preview sent again could not read the file again: list_columns, column_types, duplicate_rows, checks and warnings are 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's checks. list_columns names 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 a multi_select field. Spellings that differ only by an apostrophe (l'économie, l’économie) are one answer, shown as most often written, with the others in its aliases: 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, aux or of), such as Analyse 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, in long_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 in list_columns_unread, and may hold lists without being listed and has no column_types entry either. list_columns_partial is true when one of those is a column the suggested mapping leaves unmapped: take its choices from the user. column_types gives 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), with filled_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 or select column whose values can be the choices of a select field (at most 20), those values as choices, most given first, with how many rows give each. A column whose every value is a yes or a no (oui, non, true, or the x of a ticked box) is proposed as boolean, and a column of only 1 and 0 as 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 (12023 under "Month of arrival"), is proposed as short text, with looks_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, and column_types_partial is true when it is one the suggested mapping leaves unmapped.
  2. POST /v1/communities/{id}/imports/{import_id}/run takes mapping (a target, such as first_name, email, contact:linkedin, tags or internal:<field key>, to a column header), new_fields (community fields to create, visible to the community's admins only unless a field's visibility says otherwise; each may also give use_in_filters and sensitive, as below), existing_members (smart by default, skip, fill to fill empty fields, or overwrite; on a profile nobody has claimed, fill also 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; smart merges as fill where 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_order dmy or mdy, decimal_mark . or ,) and duplicates (keep_first by default, or keep_last to import the last of the rows that share an email, LinkedIn or ORCID, such as a form's latest submission). It answers 202 and the import runs on the server. With dry_run: true it answers 200 with the checks the run would give for this mapping, these new_fields and these value_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 these existing_members and duplicates, 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.
  3. 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's values_kept_as_written and its whole checks (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 & pathPurpose
GET /v1/communities/{id}/crm-connectionsList 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-linksMake 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}/fieldsThe 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}/mappingSave the mapping its syncs read
POST /v1/communities/{id}/crm-connections/{connection_id}/syncsRun 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:

  1. { "text": "…" } sends nothing. It answers 200 with status: "pending_confirmation", a confirmation_id, the recipient's name and the text. The preview can be confirmed for 15 minutes.
  2. { "confirmation_id": "…" }, from the same key, sends the previewed text once and answers 201 with status: "sent", message_id, conversation_id and sent_at. The member is notified as for any message. The body may repeat the text, 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:

  1. A body with neither slot_start nor confirmation_id (optionally { "minutes": 30 }) sends and stores nothing. It answers 200 with status: "choose_slot": the event's time zone, the meeting lengths the event offers (meeting_lengths), the length listed (minutes, the shortest by default) and available_starts, the starts of a meeting of that length that are still free for both of you, earliest first and at most 200, with more counting 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.
  2. { "slot_start": "…", "minutes": 30, "topic": "…", "location": "…", "location_type": "…" }, with slot_start one of available_starts (with its time-zone offset) and the rest optional, sends nothing. It answers 200 with status: "pending_confirmation", a confirmation_id, the meeting's slot_start and slot_end, the topic and the place. location_type is video, phone, whatsapp, in_person or custom, and comes only with a location. The preview can be confirmed for 15 minutes.
  3. { "confirmation_id": "…" } alone, from the same key, sends the previewed request once and answers 201 with status: "sent", meeting_request_id and sent_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 answers 200 with approved_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; null when they could not be counted) and enrichment: 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 are 0. When the members cannot be counted, eligible_members_now, max_profiles and estimated_max_credits are null.
  • Without it, the sync also brings the check-in times Luma records across (checked_in_updated), and answers 202 with status: "importing" and a sync_id, or 200 with status: "up_to_date", sync_id: null and a message when 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 & pathScopePurpose
POST /v1/communities/{id}/enrichenrich:writeStart a Smart Enrich run on members
GET /v1/communities/{id}/enrich/runs/{run_id}enrich:readPoll 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.

Sur cette page