Journey. As a developer, I want my app to match people or things to each
other — caretakers to households, mentors to mentees, applicants to openings —
by indexing each side as a JSON profile and ranking them against a query,
without running my own vector database.
The Profile Matching service turns arbitrary JSON payloads into ranked similarity searches. You create a collection (a named vector space with a vectorizer configuration), index a profile — any JSON object — into it, and then search the collection by posting another JSON object as the query. Every profile is scored for cosine similarity against the query and the top-K come back ranked, each with its similarity score so your app can apply its own threshold. This guide walks create → index → search → threshold → cleanup, verified against the local stack (profile-matching :45093); the shared dev subdomain behaves identically.
Prerequisites
- Profile Matching is reachable (local stack
:45093, or the shared dev
subdomain).
- Your tenant key pair in
INAYA_API_KEY/INAYA_APP_KEY, and a user JWT
in TOKEN (obtain one via guide G-01; the full example below fetches one via the QA sign-in shortcut). The POST routes require the key pair + JWT; only /v1/health is public.
curlpluspython3for the JSON-handling steps (any HTTP client works; the
same calls translate directly).
Concepts (only what this journey needs)
- Collection = a named match space. A collection carries the vectorizer
settings every profile indexed into it will use: vectorizer_kind (simple_json_v1 is the only kind today) and embedding_dim (default 256). Pick one collection per matching domain — "caretakers", "mentors", "listings" — not one for the whole app.
- Profile = any JSON object. The payload is flattened into
path=value features and feature-hashed into the collection's embedding at index time. Shared key=value pairs are what make two profiles similar; numbers contribute by key path (so the same numeric key reinforces), booleans by true/false, arrays by position (keep array shapes consistent across profiles or indexes misalign), and null contributes nothing.
- Search ranks, it does not threshold.
POST /v1/searchreturns the top-K
results sorted by descending cosine similarity, each with its similarity score, plus total_candidates (how many profiles were scored). There is no server-side threshold parameter — apply your own cutoff on the score (similarity >= 0.5, say) once results are back.
external_keyis your correlation handle. Indexing accepts an optional
external_key — your app's own id for the entity (a user id, a listing id). It is echoed back in every result so you can join matches to your own records.
- No update endpoint — re-index instead. To change a profile, index a new
one and DELETE the old; there is intentionally no PUT. DELETE is idempotent (204 even for an already-gone id).
- Reads are scoped to what you keep. There is no "list collections" or
"list profiles" route — your app remembers the ids it created (or stores them via external_key). Collections cannot be deleted in V1; only their profiles can. Treat collection creation as provisioning, not churn.
Step 1 — Create a collection
One POST provisions the match space. The response echoes the id every later call needs:
curl -s -X POST "${PM_URL}/v1/collections" \
-H "Authorization: Bearer ${TOKEN}" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Content-Type: application/json" \
-d '{"name": "g32-caretakers", "embedding_dim": 256}' \
| tee /tmp/g32-collection.json \
| python3 -c 'import json,sys; c=json.load(sys.stdin); print("collection:", c["id"], c["vectorizer_kind"], c["embedding_dim"])'Expected response — 201 with the collection: id, name, vectorizer_kind: "simple_json_v1", embedding_dim, created_at. Both fields are optional; omitting them gives the defaults (simple_json_v1 / 256).
Step 2 — Index profiles
Index each candidate as a JSON payload with your own external_key. Here we seed three caretakers — two shepherds and an accountant — so the search in Step 3 has something meaningful to rank:
COLLECTION_ID=$(python3 -c 'import json; print(json.load(open("/tmp/g32-collection.json"))["id"])')
AUTH=(-H "Authorization: Bearer ${TOKEN}"
-H "x-api-key: ${INAYA_API_KEY}"
-H "x-client-app-key: ${INAYA_APP_KEY}")
for profile in \
'{"external_key": "caretaker-amina", "payload": {"role": "shepherd", "experience_years": 5, "looking_for": "pasture-work"}}' \
'{"external_key": "caretaker-bo", "payload": {"role": "shepherd", "experience_years": 3, "looking_for": "pasture-work"}}' \
'{"external_key": "caretaker-cy", "payload": {"role": "accountant", "experience_years": 10, "looking_for": "desk-work"}}'
do
curl -s -o /dev/null -w "indexed -> HTTP %{http_code}\n" \
-X POST "${PM_URL}/v1/collections/${COLLECTION_ID}/profiles" "${AUTH[@]}" \
-H "Content-Type: application/json" -d "$profile"
doneExpected response — each call returns 201 with the stored profile: a server-generated id, the collection_id, your external_key, the payload as stored, and timestamps. Keep the profile id (or your external_key) — that is how you delete the profile later.
Step 3 — Search and rank
Post a query payload in the same shape as your profiles. The service vectorizes it exactly like an indexed profile and scores every profile in the collection; the top-K come back ranked with their similarity:
COLLECTION_ID=$(python3 -c 'import json; print(json.load(open("/tmp/g32-collection.json"))["id"])')
curl -s -X POST "${PM_URL}/v1/search" \
-H "Authorization: Bearer ${TOKEN}" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Content-Type: application/json" \
-d "{\"collection_id\": \"${COLLECTION_ID}\", \"query\": {\"role\": \"shepherd\", \"looking_for\": \"pasture-work\"}, \"top_k\": 3}" \
| python3 -c 'import json,sys
r = json.load(sys.stdin)
print("candidates:", r["total_candidates"])
for x in r["results"]:
print("%s: %.3f" % (x["profile"]["external_key"], x["similarity"]))'Expected response — 200 with total_candidates: 3 and the two shepherds ranked above the accountant (caretaker-amina and caretaker-bo both share the query's role and looking_for features; the accountant shares none and scores 0.0). A profile identical to the query scores 1.0; completely disjoint payloads score 0.0. top_k defaults to 5 when omitted.
Step 4 — Apply your threshold
The API stops at ranking; the cutoff is your product decision. Fetch the top-K you can afford, then keep only what clears your bar — here 0.2:
COLLECTION_ID=$(python3 -c 'import json; print(json.load(open("/tmp/g32-collection.json"))["id"])')
curl -s -X POST "${PM_URL}/v1/search" \
-H "Authorization: Bearer ${TOKEN}" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Content-Type: application/json" \
-d "{\"collection_id\": \"${COLLECTION_ID}\", \"query\": {\"role\": \"shepherd\", \"looking_for\": \"pasture-work\"}, \"top_k\": 10}" \
| python3 -c 'import json,sys
r = json.load(sys.stdin)
THRESHOLD = 0.2
matches = [x["profile"]["external_key"] for x in r["results"] if x["similarity"] >= THRESHOLD]
print("matched:", matches)'Expected response — matched: ['caretaker-amina', 'caretaker-bo'] (both shepherds clear 0.2; the accountant at 0.0 does not). Tune THRESHOLD to your domain: with the V1 vectorizer, well-matched categorical profiles land around 0.3–0.7, partial overlaps lower, disjoint payloads at 0.0. Validate against your own data before shipping a cutoff.
NOTE:total_candidatestells you how many profiles were actually
scored — if it approachestop_k, raisetop_k(or accept you are only
seeing the best of a small pool).
Step 5 — Remove a profile (and why re-index beats update)
DELETE /v1/profiles/{id} removes a single profile and is idempotent — a repeat delete (or an unknown id) still returns 204. To "edit" a profile, index the replacement and delete the original:
COLLECTION_ID=$(python3 -c 'import json; print(json.load(open("/tmp/g32-collection.json"))["id"])')
AUTH=(-H "Authorization: Bearer ${TOKEN}"
-H "x-api-key: ${INAYA_API_KEY}"
-H "x-client-app-key: ${INAYA_APP_KEY}")
# Bo gained experience: index the updated profile, then drop the stale one.
NEW_ID=$(curl -s -X POST "${PM_URL}/v1/collections/${COLLECTION_ID}/profiles" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{"external_key": "caretaker-bo", "payload": {"role": "shepherd", "experience_years": 6, "looking_for": "pasture-work"}}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "re-indexed as ${NEW_ID}"
curl -s -o /dev/null -w "delete stale -> HTTP %{http_code}\n" \
-X DELETE "${PM_URL}/v1/profiles/${NEW_ID}" "${AUTH[@]}"
curl -s -o /dev/null -w "delete again (idempotent) -> HTTP %{http_code}\n" \
-X DELETE "${PM_URL}/v1/profiles/${NEW_ID}" "${AUTH[@]}"Expected response — re-indexed as <new id>, then delete stale -> HTTP 204 and delete again (idempotent) -> HTTP 204. (We delete the fresh copy so the demo data stays stable for re-runs — in a real app you would delete the old profile id here and keep the new one.)
Error contract
Three failures worth asserting in your client: a malformed collection body, a nonexistent collection, and a malformed profile id.
AUTH=(-H "Authorization: Bearer ${TOKEN}"
-H "x-api-key: ${INAYA_API_KEY}"
-H "x-client-app-key: ${INAYA_APP_KEY}")
# embedding_dim must be positive -> 400 with a JSON error body.
curl -s -w "\ncreate bad dim -> HTTP %{http_code}\n" \
-X POST "${PM_URL}/v1/collections" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{"name": "g32-bad", "embedding_dim": 0}'
# Unknown collection id -> 404 on search.
curl -s -w "\nsearch unknown collection -> HTTP %{http_code}\n" \
-X POST "${PM_URL}/v1/search" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{"collection_id": "00000000-0000-0000-0000-000000000000", "query": {"role": "shepherd"}}'
# Malformed profile id -> 400 (ids must be UUIDs).
curl -s -w "\ndelete malformed id -> HTTP %{http_code}\n" \
-X DELETE "${PM_URL}/v1/profiles/not-a-uuid" "${AUTH[@]}"Expected response — create bad dim -> HTTP 400 with {"error":"Invalid input: embedding_dim must be positive"}; search unknown collection -> HTTP 404 with {"error":"Collection not found"}; delete malformed id -> HTTP 400 with {"error":"Invalid profile id"}.
Full working example
A complete create → index → search → threshold → cleanup cycle (profile_matching.sh) — self-contained, fetching its JWT via the QA shortcut (local/QA environments only; production uses the G-01 flows):
#!/usr/bin/env bash
set -euo pipefail
# 0. Obtain a user JWT (QA shortcut — see G-01 for production flows)
TOKEN=$(curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g32-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g32-demo-run\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
export TOKEN
AUTH=(-H "Authorization: Bearer ${TOKEN}"
-H "x-api-key: ${INAYA_API_KEY}"
-H "x-client-app-key: ${INAYA_APP_KEY}")
# 1. Create the collection
COLLECTION_ID=$(curl -sf -X POST "${PM_URL}/v1/collections" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{"name": "g32-demo-caretakers", "embedding_dim": 256}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "collection: ${COLLECTION_ID}"
# 2. Index profiles
for profile in \
'{"external_key": "caretaker-amina", "payload": {"role": "shepherd", "experience_years": 5, "looking_for": "pasture-work"}}' \
'{"external_key": "caretaker-bo", "payload": {"role": "shepherd", "experience_years": 3, "looking_for": "pasture-work"}}' \
'{"external_key": "caretaker-cy", "payload": {"role": "accountant", "experience_years": 10, "looking_for": "desk-work"}}'
do
curl -sf -o /dev/null -X POST "${PM_URL}/v1/collections/${COLLECTION_ID}/profiles" \
"${AUTH[@]}" -H "Content-Type: application/json" -d "$profile"
done
echo "3 profiles indexed"
# 3. Search and apply a threshold
curl -sf -X POST "${PM_URL}/v1/search" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d "{\"collection_id\": \"${COLLECTION_ID}\", \"query\": {\"role\": \"shepherd\", \"looking_for\": \"pasture-work\"}, \"top_k\": 10}" \
| python3 -c 'import json,sys
r = json.load(sys.stdin)
keys = [x["profile"]["external_key"] for x in r["results"] if x["similarity"] >= 0.2]
assert set(keys) == {"caretaker-amina", "caretaker-bo"}, keys
print("matched:", keys)'
# 4. Cleanup: delete every profile we created via the collection-wide
# ranking (search always scores every profile, so top_k >= count
# returns them all regardless of the query shape).
IDS=$(curl -sf -X POST "${PM_URL}/v1/search" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d "{\"collection_id\": \"${COLLECTION_ID}\", \"query\": {\"role\": \"shepherd\"}, \"top_k\": 100}" \
| python3 -c 'import json,sys
r = json.load(sys.stdin)
print(" ".join(x["profile"]["id"] for x in r["results"]))')
for id in ${IDS}; do
curl -sf -o /dev/null -X DELETE "${PM_URL}/v1/profiles/${id}" "${AUTH[@]}"
done
echo "cleaned up profiles; the collection itself remains (V1 has no delete-collection)"(AUTH_URL is the Auth Service base URL — http://127.0.0.1:40004 on the local stack; PM_URL is the Profile Matching base URL — http://127.0.0.1:45093 locally; TENANT_ID is your tenant's id.)
Try it live
Open the Playground → Users & Profile Matching — the Profile Matching console exercises the same create → index → match cycle against the live service.
Copy agent prompt
You are working with the Inaya Labs Profile Matching service. The base URL is
in PM_URL; the tenant key pair is in INAYA_API_KEY / INAYA_APP_KEY and
TENANT_ID; the Auth Service is at AUTH_URL. Obtain a user JWT via POST
/v3/auth/qa/login {"device_id","locale","tenant_id","username"} if TOKEN is
not already set. Steps: (1) POST /v1/collections {"name":"agent-caretakers",
"embedding_dim":256} (expect 201) and remember the id. (2) Index three
profiles into it via POST /v1/collections/{id}/profiles: caretaker-amina
{"role":"shepherd","experience_years":5,"looking_for":"pasture-work"},
caretaker-bo {"role":"shepherd","experience_years":3,"looking_for":
"pasture-work"}, caretaker-cy {"role":"accountant","experience_years":10,
"looking_for":"desk-work"} — each with an external_key and expect 201. (3)
Search via POST /v1/search {"collection_id": id, "query": {"role":"shepherd",
"looking_for":"pasture-work"}, "top_k": 10} and report each result's
external_key and similarity; confirm both shepherds outrank the accountant and
note total_candidates. (4) Repeat the search keeping only similarity >= 0.2
and report the matched external_keys. (5) Delete one profile by id (expect
204), then delete the same id again and report that it is still 204
(idempotent). Never log the JWT; report status codes per step.API reference
Next journeys
- Managing users & profiles (G-30) — the user records whose ids make ideal
external_keys. - Federated sign-in for your app (G-31) — turn the users you just signed in into matchable profiles.
- Authentication & sessions (G-01) — the token lifecycle that every call in this guide depends on.