Nexus Partner API

Connection intelligence, inside your product.

The Partner API lets your platform keep a member dataset on Nexus and query it. You sync people in. Nexus ranks them against a free-text search or against another person, and returns the ranked list with a plain-English reason for every result. No search infrastructure, no ranking code and no Nexus-branded surface on your side.

3
endpoints
JSON
over HTTPS
API key
per partner
Stateless
search and match
Your first call
curl -X POST https://www.nexus.app/api/partner/{partner}/search \  -H "x-partner-api-key: $NEXUS_PARTNER_API_KEY" \  -H "Content-Type: application/json" \  -d '{    "query": "climate fintech founders in Nairobi",    "limit": 5  }'

A self-contained brief with this page's URL and the full contract. The only step it cannot do is get your key.

Overview

How it fits together

Your system stays the source of truth for who your members are. Nexus holds a synced copy of the fields you choose to send, builds the search and matching intelligence on top of it, and hands the ranked answers back with your own identifiers. Three endpoints cover the whole surface.

A partner dataset is sealed. It is not visible on any public or guest Nexus page, it is not included in any Nexus community search, and it is not used in Nexus campaigns. The only way to read it is with your API key. Nexus never returns its own internal identifiers: theexternal_id you send is the identifier you get back, end to end.

Everything you sync is member-visible by design

Synced fields feed the ranking and are quoted in the reasons returned by search and match. Treat every field as text a member could read. Do not send admin-private notes or internal commentary. The API cannot leak what it was never sent.

Authentication

One key, one header

Every request carries your partner key in the x-partner-api-key header. Nexus issues the key when your partner tenant is provisioned and delivers it out of band. Keep it on your server. There is no query-string or body alternative, and no self-service key management.

Authenticated request
curl -X POST https://www.nexus.app/api/partner/{partner}/match \  -H "x-partner-api-key: $NEXUS_PARTNER_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "subject_external_id": "acme-1042" }'

The key is compared in constant time. A missing or incorrect key returns 401 with the standard error envelope and the code unauthorized. Authentication is checked before anything else, so an unauthenticated call never consumes budget and never touches your data.

Conventions

Shared across every endpoint

Base URL and transport

All endpoints are served over HTTPS from https://www.nexus.app. Every endpoint is a POST with a JSON body and returns JSON. Send Content-Type: application/json. Responses are computed per request and never cached.

Every path contains a partner segment, shown here as {partner}. It is the slug assigned to your tenant at provisioning and is confirmed alongside your key. Paths are otherwise identical for every partner.

Error envelope

Every non-2xx response has the same shape: an error object with a machine-readable code and a human-readable message. Branch on the code, never on the message.

400 example
{
  "error": {
    "code": "missing_query",
    "message": "query is required."
  }
}

Budget responses add one top-level field, retry_after_seconds, the number of seconds until the daily budget resets.

429 example
{
  "error": {
    "code": "search_cap_exceeded",
    "message": "Daily search budget reached."
  },
  "retry_after_seconds": 18342
}

Order of checks

Each endpoint evaluates a request in the same order. Knowing it explains which error you see first.

  1. Authentication. A bad key returns 401 immediately.
  2. Daily budget. An exhausted budget returns 429.
  3. Body validation. A missing or invalid field returns 400.
  4. Tenant lookup. A partner tenant that is not provisioned returns 500 with partner_group_missing.
  5. The work itself.

Budget is consumed before validation

Because the budget check runs before the body is validated, an authenticated call that is rejected with 400 still counts against that day's budget. Validate on your side before retrying in a loop.

Malformed and unknown input

A body that is not valid JSON is treated as an empty body, so you receive the endpoint's "required field missing" error rather than a parse error. Unknown fields in a request are ignored. String inputs are trimmed before validation, so a value of only whitespace counts as missing.

Idempotency and state

Sync is idempotent: replaying the same batch produces the same dataset. Search and match are stateless reads. Nexus stores nothing per call beyond an anonymous daily counter, so re-running either is always safe.

Endpoint 1

Sync members

Create, update or remove members in your Nexus dataset in batches of up to 200 records, each keyed by your stable external_id.

POST/api/partner/{partner}/members/sync

Request body

The body is an object with one key, members, holding 1 to 200 record objects. Each record has the fields below. Anything not listed is ignored.

Sync request fields
FieldTypeRequiredConstraintsDescription
external_idstringRequiredNon-empty after trimming. Must be stable across calls.Your identifier for the person. It is the upsert key and the identifier returned by every read endpoint.
emailstringRequiredMust look like an email address. Stored trimmed and lower-cased.Used to link a record to a person Nexus already knows when no record with this external_id exists yet.
first_namestringOptionalNoneGiven name.
last_namestringOptionalNoneFamily name.
titlestringOptionalTruncated to 100 characters with a warning.Job title or role.
organizationstringOptionalTruncated to 100 characters with a warning.Company or organisation name. Returned as display.organization.
aboutstringOptionalNoneFree-text bio. No length cap.
match_contextstringOptionalTruncated to 2,000 characters with a warning.Free text describing what this person needs, offers or is working on. The highest-leverage field for search and match quality.
skillsstring[]OptionalDeduplicated case-insensitively, capped at 20 with a warning. A non-array is treated as empty.Expertise tags. Casing is normalised for display.
interestsstring[]OptionalDeduplicated case-insensitively, capped at 20 with a warning. A non-array is treated as empty.Interest tags. Casing is normalised for display.
citystringOptionalNoneCity. Combined with country to form display.location.
countrystringOptionalNoneCountry.
linkedinstringOptionalMust be a linkedin.com/in/ profile URL, otherwise dropped with a warning.LinkedIn profile URL.
websitestringOptionalNormalised to an absolute URL; https:// is added when missing.Personal or company website.
status"active" | "removed"OptionalDefaults to "active". Any other value rejects the record."removed" takes the person out of your dataset and out of every search and match result.
Request
{
  "members": [
    {
      "external_id": "acme-1042",
      "email": "priya.raman@example.com",
      "first_name": "Priya",
      "last_name": "Raman",
      "title": "Co-founder & CTO",
      "organization": "Northwind Grid",
      "about": "Building battery storage for rural micro-grids.",
      "match_context": "Pre-seed. Wants a power-electronics advisor and hardware angel intros.",
      "skills": ["embedded systems", "power electronics"],
      "interests": ["climate tech", "hardware fundraising"],
      "city": "Nairobi",
      "country": "Kenya",
      "linkedin": "https://www.linkedin.com/in/priya-raman-example",
      "website": "northwindgrid.example.com",
      "status": "active"
    },
    {
      "external_id": "acme-0871",
      "email": "tomas.lindqvist@example.com",
      "status": "removed"
    }
  ]
}

How a record is applied

Nexus first looks for a member in your dataset with the same external_id. If none exists, it looks for a person with the same email. What happens next depends on what it finds.

  • Nobody found. A new member is created from the record and reported as created.
  • A person that only your dataset knows. Your record is the source of truth: name and profile fields are overwritten verbatim and the skills and interests lists are replaced.
  • A person who also exists elsewhere on Nexus. The membership in your dataset is attached, only fields that are currently empty are filled, tags are added rather than replaced, and the person's name is left alone. The result carries linked_existing_user: true.

In both of the last two cases the result is created when the person was not previously in your dataset and updated when they were. After every record in the batch has been applied, each created or updated member is re-indexed before the response is sent, so a successful result is searchable and matchable as soon as the call returns.

A status: "removed" record deletes the person's membership in your dataset. Removal is idempotent: removing someone who is not in your dataset still reports removed. Removing an external_id and email Nexus has never seen is rejected with not_found. Re-sending a removed person as active restores them.

Each record is applied in its own transaction. A record that fails is reported as rejected and the rest of the batch continues.

Response

200 response
{
  "batch_id": "6f1c9c2e-3b7a-4d0e-9a1b-2c3d4e5f6a7b",
  "results": [
    {
      "external_id": "acme-1042",
      "status": "created",
      "embedding": "refreshed"
    },
    {
      "external_id": "acme-0871",
      "status": "removed",
      "embedding": "skipped"
    }
  ],
  "summary": {
    "created": 1,
    "updated": 0,
    "removed": 1,
    "rejected": 0
  }
}
Sync response fields
FieldTypeMeaning
batch_idstring (UUID)Identifier of this batch. Quote it when contacting support about a sync.
resultsobject[]One entry per record, in the same order as the request.
results[].external_idstring | nullYour identifier. Null when the record had no usable external_id (invalid_payload or missing_external_id).
results[].status"created" | "updated" | "removed" | "rejected"What happened to the record.
results[].codestringPresent only when status is rejected. One of the rejection codes below.
results[].messagestringPresent only on rejected records. Human-readable explanation.
results[].warningsstring[]Present only when a field was truncated or dropped. The record was still applied.
results[].linked_existing_usertruePresent only when the record was linked to a person who also exists elsewhere on Nexus.
results[].embedding"refreshed" | "failed" | "skipped"Present on created, updated and removed records. refreshed means the member is live in search and match. failed means the index update did not complete: re-send the record to retry. skipped is the value for removals.
summaryobjectCounts of created, updated, removed and rejected records in this batch.

Two more shapes a result entry can take:

Result entries with warnings, and a rejection
{
  "external_id": "acme-2210",
  "status": "updated",
  "linked_existing_user": true,
  "warnings": [
    "title truncated to 100 characters",
    "linkedin dropped: not a linkedin.com/in/ profile URL"
  ],
  "embedding": "failed"
}

{
  "external_id": "acme-3305",
  "status": "rejected",
  "code": "invalid_email",
  "message": "email \"not-an-address\" is not a valid address."
}

Rejection codes

A rejected record never fails the batch. The HTTP status is still 200 and the other records are applied.

  • invalid_payloadThe record is not a JSON object.
  • missing_external_idexternal_id is absent or empty.
  • missing_emailemail is absent or empty.
  • invalid_emailemail does not look like an address.
  • invalid_statusstatus is something other than "active" or "removed".
  • not_foundA removal named a person Nexus has never seen.
  • internal_errorThe record failed to process. Its transaction was rolled back; the rest of the batch continued.

Errors

Sync error responses
StatusCodeWhenBody
401unauthorizedMissing or incorrect API key.{ "error": { "code": "unauthorized", "message": "Invalid or missing x-partner-api-key." } }
429sync_cap_exceededDaily sync budget reached.{ "error": { "code": "sync_cap_exceeded", "message": "Daily sync budget reached." }, "retry_after_seconds": 18342 }
400invalid_batchBody is not { members: [...] }, or the array is empty or longer than 200.{ "error": { "code": "invalid_batch", "message": "Body must be { members: [...] } with 1 to 200 records." } }
500partner_group_missingYour partner tenant is not provisioned.{ "error": { "code": "partner_group_missing", "message": "Partner group is not provisioned." } }
500internal_errorThe batch failed as a whole. Records already committed stay committed.{ "error": { "code": "internal_error", "message": "Sync failed; quote the batch_id in support.", "batch_id": "6f1c9c2e-3b7a-4d0e-9a1b-2c3d4e5f6a7b" } }

Endpoint 3

Match

Rank people in your dataset against one member, the subject, and get back the strongest pairings with a reason for each.

POST/api/partner/{partner}/match

Request body

Match request fields
FieldTypeRequiredConstraintsDescription
subject_external_idstringRequiredNon-empty after trimming. Must be a member of your dataset.The person to find matches for.
candidate_external_idsstring[]OptionalWhen present: 1 to 200 non-empty strings. Duplicates are removed.Rank exactly these people. Every id you send is accounted for in the response. Omit to rank your whole dataset.
limitintegerOptional1 to 100. Default 36. Clamped, truncated and defaulted the same way as search.Maximum results when candidate_external_ids is omitted. Ignored when candidates are supplied: the whole list is ranked.
include_reasonsbooleanOptionalDefault true. Only an explicit false disables reasons.Set false to skip reason generation.
Request, explicit candidates
{
  "subject_external_id": "acme-1042",
  "candidate_external_ids": ["acme-1187", "acme-2210", "acme-1042", "acme-9999"],
  "include_reasons": true
}
Request, whole dataset
{
  "subject_external_id": "acme-1042",
  "limit": 10
}

Two modes

  • Candidates supplied. Nexus ranks exactly that list. Every requested id appears either in results or in unmatched with a code. Nothing is silently dropped, and limit does not apply.
  • Candidates omitted. Nexus ranks every eligible member of your dataset, excluding the subject, and returns the top limit. unmatched is always empty in this mode.

Ranking is deterministic: the same people with the same synced data produce the same order. Ties are broken in a stable order.

Response

200 response
{
  "subject_external_id": "acme-1042",
  "results": [
    {
      "external_id": "acme-1187",
      "rank": 1,
      "relevance": 0.6934,
      "reason": "Both are building climate-focused companies in Nairobi, and Amara's experience raising for hardware-adjacent products speaks to Priya's stated fundraising need.",
      "reason_source": "llm",
      "display": {
        "name": "Amara Okonkwo",
        "title": "Founder & CEO",
        "organization": "SunLedger",
        "location": "Nairobi, Kenya",
        "tags": ["Climate Finance", "Mobile Payments", "Angel Investing"],
        "avatar_url": "https://cdn.example.com/avatars/acme-1187.png"
      }
    }
  ],
  "unmatched": [
    { "external_id": "acme-1042", "code": "is_subject" },
    { "external_id": "acme-2210", "code": "no_embedding" },
    { "external_id": "acme-9999", "code": "unknown_external_id" }
  ]
}
Match response fields
FieldTypeMeaning
subject_external_idstringThe subject, echoed back trimmed.
resultsobject[]Ranked candidates, strongest first. The subject never appears.
results[].external_idstringYour identifier for the candidate.
results[].rankinteger1-based position in this response.
results[].relevancenumber0 to 1, four decimal places. Higher means a stronger pairing. Apply your own minimum before surfacing a match. Nexus ranks, you gate.
results[].reasonstringOne neutral, third-person sentence naming a checkable fit between the two people. Safe to show to either side.
results[].reason_source"llm" | "overlap"llm for a model-written sentence, available for up to the first 15 results. overlap for a deterministic sentence built from what the two profiles share.
results[].displayobjectSame card block as search: name, title, organization, location, tags, avatar_url.
unmatchedobject[]Requested candidates that could not be ranked, each with external_id and a code.

Codes in unmatched:

  • is_subjectThe candidate is the subject.
  • unknown_external_idNo member of your dataset has this id.
  • not_searchableThe member exists but is not currently eligible to appear in results.
  • no_embeddingThe member has not been indexed yet. Re-sync the record; a successful sync reports embedding: "refreshed".

Note the differences from search: match results carry no type and no display_score, and the deterministic reason source is overlap rather than evidence. As with search, reasons degrade to the deterministic sentence and never cause an error.

Errors

Match error responses
StatusCodeWhenBody
401unauthorizedMissing or incorrect API key.{ "error": { "code": "unauthorized", "message": "Invalid or missing x-partner-api-key." } }
429match_cap_exceededDaily match budget reached.{ "error": { "code": "match_cap_exceeded", "message": "Daily match budget reached." }, "retry_after_seconds": 18342 }
400missing_subjectsubject_external_id is absent, not a string, or empty after trimming.{ "error": { "code": "missing_subject", "message": "subject_external_id is required." } }
400invalid_candidatescandidate_external_ids is present but not an array of 1 to 200 non-empty strings.{ "error": { "code": "invalid_candidates", "message": "candidate_external_ids must be 1 to 200 non-empty strings when provided." } }
404subject_not_foundNo member of your dataset has the subject id.{ "error": { "code": "subject_not_found", "message": "No synced member has this external_id." } }
422subject_not_embeddedThe subject exists but has not been indexed. Re-sync that record.{ "error": { "code": "subject_not_embedded", "message": "Re-sync this member to generate their embedding." } }
500partner_group_missingYour partner tenant is not provisioned.{ "error": { "code": "partner_group_missing", "message": "Partner group is not provisioned." } }
500internal_errorThe match failed. Safe to retry.{ "error": { "code": "internal_error", "message": "Match failed." } }

Limits

Sizes and budgets

Request sizes

Request size limits
LimitValueOver the limit
Records per sync call1 to 200400 invalid_batch
Tags per list (skills, interests)20Truncated with a warning
title, organization length100 charactersTruncated with a warning
match_context length2,000 charactersTruncated with a warning
Search results (limit)1 to 36, default 36Clamped
Search candidate fence1 to 1,000 ids400 invalid_candidates
Match results when candidates omitted (limit)1 to 100, default 36Clamped
Match candidate list1 to 200 ids400 invalid_candidates
Model-written reasons per responseFirst 15 resultsDeterministic sentence for the rest

Daily budgets

Each endpoint has a budget of calls per UTC day. Budgets reset at 00:00 UTC, and a 429 tells you exactly how long to wait in retry_after_seconds. Budgets are counted per endpoint for your tenant, not per IP address.

Daily budgets
BudgetCalls per UTC dayWhen exhausted
Sync500429 sync_cap_exceeded
Search5,000429 search_cap_exceeded
Match5,000429 match_cap_exceeded
Search reasons (model-written)5,000Reasons fall back to evidence sentences. No error.
Match reasons (model-written)5,000Reasons fall back to overlap sentences. No error.

The values above are the current defaults.

Errors

Every code in one place

HTTP-level errors use the envelope described under Conventions. Per-record sync rejections arrive inside a 200 body and are listed under Sync.

All HTTP error codes
StatusCodeWhenBody
401unauthorizedAny endpoint. Missing or incorrect x-partner-api-key.{ "error": { "code": "unauthorized", "message": "..." } }
429sync_cap_exceededSync. Daily budget reached.{ "error": { ... }, "retry_after_seconds": 18342 }
429search_cap_exceededSearch. Daily budget reached.{ "error": { ... }, "retry_after_seconds": 18342 }
429match_cap_exceededMatch. Daily budget reached.{ "error": { ... }, "retry_after_seconds": 18342 }
400invalid_batchSync. members is missing, empty or longer than 200.{ "error": { "code": "invalid_batch", "message": "..." } }
400missing_querySearch. query is missing or empty.{ "error": { "code": "missing_query", "message": "..." } }
400missing_subjectMatch. subject_external_id is missing or empty.{ "error": { "code": "missing_subject", "message": "..." } }
400invalid_candidatesSearch or match. candidate_external_ids present but malformed or over the cap.{ "error": { "code": "invalid_candidates", "message": "..." } }
404subject_not_foundMatch. The subject is not in your dataset.{ "error": { "code": "subject_not_found", "message": "..." } }
422subject_not_embeddedMatch. The subject has not been indexed yet.{ "error": { "code": "subject_not_embedded", "message": "..." } }
500partner_group_missingAny endpoint. Tenant not provisioned.{ "error": { "code": "partner_group_missing", "message": "..." } }
500internal_errorAny endpoint. Unexpected failure. Sync includes batch_id.{ "error": { "code": "internal_error", "message": "..." } }

Retry guidance: 429 after retry_after_seconds; 500 on search and match immediately, since both are stateless; 500 on sync by re-sending the batch, since every record is idempotent and records that already committed are unaffected. Never retry a 400, 401, 404 or 422 without changing the request.

Operations

Running it in production

Timeouts

A sync call indexes every created or updated member before it responds, one at a time, so a full batch of 200 can take minutes. The sync handler may run for up to 300 seconds; set your client timeout at least that high, and prefer smaller batches when you need faster feedback. Search and match handlers may run for up to 60 seconds; typical responses are far quicker, and sending include_reasons: false removes the slowest step.

Freshness

A member whose sync result reports embedding: "refreshed" is live in search and match the moment the call returns. A member reporting embedding: "failed" keeps their previous index state until you re-send the record. A removed member disappears from both surfaces immediately.

Identifiers

Stable external_id values are load-bearing. Nexus matches by id first and by email second, so an id that changes on your side creates a second person on the next sync. If ids can change, say so before go-live.

What is never returned

No Nexus internal identifiers, no email addresses, no raw synced text other than what appears in display and the reason sentence, and no member who is not in your dataset. Search and match responses only ever contain people carrying one of your external ids.

Support

Quote the batch_id from a sync response when reporting a sync problem. It lets Nexus find the exact batch, its per-record outcomes and its timing.

Patterns

What you can build with this

Four integration patterns that sit directly on these three endpoints. Each one is a few dozen lines on your side.

1. A "find me someone who" search box

Wire a text input to /search. Render each result straight from display and reason, show display_score as the badge, and you have semantic people-search in your product with no index to run. Because the reason sentence is generated from the member's own synced fields, it doubles as the card's subtitle.

2. Connecting two groups of people with your own rules

Whoever you are pairing, advisors with companies, buyers with suppliers, new members with established ones, your eligibility logic already exists: language, capacity, programme, geography. Keep it. Filter to the eligible ids on your side, send them as candidate_external_ids, and let /match rank and explain the pairings. Apply your own threshold to relevance, write the outcome to your own matches table, and re-run whenever the roster changes. Nothing is stored on the Nexus side, so the engine is always safe to call again.

Pseudocode
# 1. Your eligibility rules pick who is allowed to be matched.eligible_ids = pool.filter(p => p.accepting && p.languages.includes(person.language)) # 2. Nexus ranks exactly that list and explains each pairing.POST /api/partner/{partner}/match{ "subject_external_id": person.id, "candidate_external_ids": eligible_ids } # 3. Your threshold decides what gets surfaced. Store the outcome on your side.matches = response.results.filter(r => r.relevance >= YOUR_MIN_SCORE).slice(0, 3)

3. Saved lists as search scopes

If your product has segments, cohorts or saved lists, each one is a candidate fence. Pass the list's ids to /search and the query ranks only inside it: "investors who have done hardware" over the 200 people in your investor list, not over everyone. The fence accepts up to 1,000 ids per call.

4. "People you should meet" at onboarding

Sync a new member, then call /match with their id and a small limit in whole-dataset mode. The top few results, each with a neutral one-sentence reason, are a welcome email or a first-session screen that took one extra request to build. Because a successful sync is live immediately, both calls can run in the same job.

Ready to integrate?

Partner tenants and API keys are provisioned by the Nexus team. Get in touch and we will set up your dataset, confirm your endpoint paths and agree budgets.

Contact the Nexus team

The prompt tells a coding agent such as Claude Code or Cursor to build a typed client, error handling and a smoke test against this contract, and leaves the key as the only manual step.