Skip to content
Contextely

Machine surface

REST API

A thin mirror of the MCP surface for callers that speak HTTP but not MCP. Both go through the same retrieval pipeline, so the entitlement and freshness guarantees are identical. There is no second, laxer path.

Authentication

Authorization: Bearer ctx_sk_...
# or
X-API-Key: ctx_sk_...

The key resolves to a member. Everything you retrieve is scored against that member's entitlement scopes. CORS is open, because the credential is the key rather than the origin.

Endpoints

POST /api/v1/search

MCP tool: context_searchmetered: query

The main call. Searches the workspace's condensed working memory and returns what this asker is entitled to see, refreshing anything past its freshness TTL from its system of record first.

body: {"query":"string","limit":"integer","skip_refresh":"boolean","subject":"string","filters":"object"}

GET /api/v1/memory/{id}/related

MCP tool: context_related

Walk from one memory object to its neighbours, ranked by shared entities, then topics, then keywords. Use it when one search result is the right thread and you want the rest of it. A neighbour outside the asker's scopes is never named or counted.

POST /api/v1/memory/{id}/expand

MCP tool: context_expandmetered: refresh

Read the full current record from the system of record a memory object was condensed from, for when the summary is not enough. No model is involved, so it returns exactly what the source says. Costs one source round-trip, metered against the monthly refresh allowance.

GET /api/v1/memory/{id}

MCP tool: memory_get

Fetch one memory object by id. Returns not_found rather than forbidden when the asker is not entitled to it, so probing for ids cannot reveal what exists.

GET /api/v1/sources

MCP tool: sources_list

List the connected systems of record with their freshness policies, sync status, and whether this asker is entitled to anything from each.

POST /api/v1/memory

MCP tool: memory_add

Store a memory directly, without connecting a system of record. It is kept as written rather than condensed, its searchable terms are extracted locally with no model call, and it carries both a share list and whatever entitlement scopes you give it. It has no source, so it is never re-fetched and context_expand reports that there is nothing fuller to read. Give it review_after_seconds and it will be labelled overdue once nobody has confirmed it for that long. Its relations are built shortly after it is stored rather than during the call, so it returns immediately and reports graph_state.

body: {"text":"string","title":"string","scopes":"array","topics":"array","key":"string","review_after_seconds":"integer","visibility":"string","share_with_teams":"array","subject":"string","metadata":"object"}

GET /api/v1/source-tools

MCP tool: source_tools_list

The passthrough reads this caller may make. A tool an admin has not classified as a read never appears, and neither does one whose entitlement scopes the caller does not hold.

POST /api/v1/source-tools/{name}/call

MCP tool: source_tool_callmetered: refresh

Performs one passthrough read. Entitlement is checked at entry and again immediately before the round-trip, the allowance is charged only when an answer came back, and every call, refusals included, is recorded in the passthrough log. The result is not ranked and not stored.

body: {"name":"string","args":"object"}

GET /api/v1/usage

MCP tool: workspace_usage

Plan, metered usage and remaining allowance for the month. Call it before a batch run to decide whether to proceed or back off.

GET /api/v1/actions

MCP tool: actions_list

The enabled actions the caller may invoke. Proposed and disabled actions never appear, and neither do actions whose required write scopes the caller does not hold.

POST /api/v1/actions/{name}/preview

MCP tool: action_preview

Validates arguments and returns a rendered description plus the request hash, without contacting the source. It reports upstream_dry_run:false rather than simulating an effect, because a fabricated preview is indistinguishable from a real one.

body: {"name":"string","args":"object"}

POST /api/v1/actions/{name}/invoke

MCP tool: action_invokemetered: action

Performs one enabled action. Entitlement is checked at entry and again immediately before the effect; the allowance is charged only when it reaches upstream; and every attempt, refusals included, is recorded in the action log.

body: {"name":"string","args":"object","idempotency_key":"string"}

POST /api/v1/graph/search

MCP tool: graph_searchmetered: query

Runs a normal search, then follows the relationships outward from the memory it found and returns them as triples. Every triple carries the memory object that asserted it, so nothing here is a claim without a source. The walk is bounded (depth, and a cap on how many relationships are followed from any one thing) so its cost does not grow with the size of your graph, and a relationship you are not entitled to read is never followed, never returned, and never uses up that budget.

body: {"query":"string","depth":"integer","limit":"integer","include_structural":"boolean"}

POST /api/v1/graph/neighbours

MCP tool: graph_neighbours

The neighbourhood of one memory object or one named entity, as triples with the memory that asserted each. Give it an id when you have one and a name when you do not; a name resolves to the closest few nodes by how connected they are. Nothing is returned that the asker could not have read directly.

body: {"id":"string","name":"string","depth":"integer","limit":"integer"}

GET /api/v1/memory/{id}/history

MCP tool: memory_history

The version chain behind one memory object. Each entry carries valid_from and valid_to, so you can see exactly what was believed when, and an object still in force has a null valid_to. Absent or unentitled answers not_found, like every other read.

POST /api/v1/memory/{id}/forget

MCP tool: memory_forget

Removes a memory object, or only the relationships derived from it. It needs the same memory-write permission as storing one, because a key that can erase a company's memory is a key that can change what its agents believe. It cannot reach a system of record, so a sourced memory returns on the next sync.

body: {"id":"string","graph_only":"boolean"}

POST /api/v1/sessions/record

MCP tool: session_record

Opens or continues a session and records one turn in it, with the memory objects the answer used. Sessions are what the activity dashboard groups by and what feedback attaches to. Nothing here changes a ranking on its own: it becomes a weight only once somebody rates the turn and the improve pass runs, and only if the deployment has set a feedback influence.

body: {"session":"string","kind":"string","query":"string","used_memory_ids":"array","client":"string"}

POST /api/v1/sessions/feedback

MCP tool: session_feedback

Rates one turn. Ratings are folded into the memory objects that turn used by the improve pass, as an exponential moving average, so one rating nudges and five consistent ones move a weight visibly. A rating can demote a memory and can never remove it, because removal is an entitlement decision and this is not one.

body: {"turn":"string","score":"integer","note":"string"}

GET /api/v1/account

Identify the account and the member a key speaks as, and which product it is scoped to. An integration calls this once after connecting to confirm it reached the account it expected; it returns no secret and no memory. It also reports `limits.memory_text_max_chars`, the longest text `memory_add` accepts, so a client can chunk to it.

body: {"source_id":"string","limit":"integer"}

GET /api/v1/memory

List stored memory for one subject, newest first, as {data: [{id, title, summary, subject, metadata, key, created_at}], next_cursor}. Without `subject` it lists only memory that has none, so a partition's memory is never listed by accident. Narrow by metadata with `metadata.<key>=<value>` query parameters (ANDed; repeat a key for any-of; `42` and `true` are read as a number and a boolean). Page with `cursor` from the previous response's `next_cursor`.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/memory/forget

Delete all memory carrying `subject`, optionally narrowed by `filters` (same shape as search), together with its earlier versions, its relationships and its embeddings. For erasure on request of one end customer or one document. `subject` is required, so memory with no subject can never be reached. Needs a key with memory writing enabled. Answers {forgotten: n}. Not an MCP tool, on purpose.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/handoff

Mint a short-lived, single-use URL that lands this key's member in the Contextely dashboard. Lets an app offer a 'manage this in Contextely' button without asking the user to sign in again. The link expires in five minutes and works once.

body: {"source_id":"string","limit":"integer"}

GET /api/v1/connections

The connected systems of record with their health, scoped the same way retrieval is, plus any authorization somebody has started and not finished (status awaiting_authorization). Those are included rather than hidden: an API that omitted them would tell a calling app a connection does not exist while its user is looking at the consent screen. No credential appears in this response.

body: {"source_id":"string","limit":"integer"}

GET /api/v1/connections/catalogue

Search the catalogue of services. Every row says whether it is connectable, and a row that is not carries the reason, so a search for something Contextely cannot reach answers the question rather than looking broken. An empty query returns the catalogue in order, because this backs a browse surface as well as a search box.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/connections/{connection_id}/manage-link

Mint a short-lived, single-use URL landing this key's member on the Contextely sources page, signed in. How an app offers 'reconnect' without owning the OAuth flow.

body: {"source_id":"string","limit":"integer"}

GET /api/v1/connections/{connection_id}/accounts

Lists the real accounts this connection's authorization reaches, for an app that wants to line each of them up with one of its own products rather than treat a whole estate as one. Resolved the same way the connection's own sync resolves it, so it cannot drift from what actually becomes memory. Where a system nests accounts -- a Sentry organization holds projects -- both levels are listed, each row naming its parent, because one shared organization commonly holds a project per product and 'which organization' cannot tell them apart. A connection pinned to one account reports exactly that one; a connection with no notion of an account reports none. Names kinds of account, never a credential, so it needs no capability beyond being able to see the connection.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/connections/{connection_id}/reveal

Returns the stored configuration for one connection, including its secret. Needs a key with the reveal-credentials capability, which is off by default and is never granted by a role. Refused outright for an OAuth-backed connection: that token was granted to Contextely under scopes its owner agreed with Contextely, so it is not ours to hand on. Every call is written to the audit log before the answer is assembled, including the refusals.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/connections/{connection_id}/disable

Needs a key with the manage-connections capability, granted through the connections.manage OAuth scope and off by default otherwise. A disabled connection stops being health-checked, stops answering source_tool_call, and refuses a manual sync, but keeps its memory and its row, so it can be re-enabled later.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/connections/{connection_id}/enable

Restores a disabled connection to active. Needs the same manage-connections capability as disable. Restores status, not health: the next check or sync is what tells the truth about whether the connection actually still works.

body: {"source_id":"string","limit":"integer"}

DELETE /api/v1/connections/{connection_id}

Needs the manage-connections capability. Deletes the connection's memory and health history along with it, and there is no undo, unlike disable. Never create: making a connection stays an OAuth flow through Contextely's own registration, and nothing on this surface can start one.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/products

A product partitions this account: memory written under one is private to it unless shared, and a key can be scoped to exactly one. Idempotent on external_ref, so an app can call this on every provisioning run and get the same product back rather than splitting its memory in half. Needs a key with the administer capability.

body: {"source_id":"string","limit":"integer"}

GET /api/v1/products

Every product, or just the one this key is scoped to. A product-scoped key sees only its own, for the same reason it reads only its own memory.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/products/{product_id}/keys

Create an API key that reads as this product and nothing else. The plaintext is returned once and never stored. The minted key can never itself administer the account, whatever the key that created it holds. Needs a key with the administer capability.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/products/{product_id}/write-scopes

Top up every active key of this product with write scopes, for when a new connection's actions were enabled after the keys were minted. Union only; never removes. Each scope must already be held by the calling key's member, so this can never grant more than the member has. Needs a key with the administer capability.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/products/connection-accounts/map

For a connection covering several accounts (several Sentry organizations, several GA4 properties), pin ONE of them to a product of your own, get-or-created by external_ref exactly as POST /products does. Creates (or repoints) a second, product-scoped source reading only that account, sharing the connection's own credential -- the parent connection's own coverage of every account is left unchanged. Needs a key with the administer capability, the same one POST /products already needs.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/products/connection-accounts/unmap

Deletes the account's product-scoped source. Silent on an account that was never mapped, the same as disabling an already-disabled connection: the caller asked for an end state that already holds. It does record WHEN, reported afterwards as mapping_cleared_at, so an app that routes accounts automatically can tell an account nobody has reached yet from one somebody deliberately unrouted.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/ingest

Read records from a connected source and condense them into memory objects. Owner or admin only, because ingesting spends the workspace's condensation budget.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/actions/discover

Enumerate the actions a connected source offers and record them as PROPOSED. Owner or admin only, and it enables nothing: each proposed action has to be enabled by hand, with the write scopes it requires. Deliberately not a tool, for the same reason /ingest is not.

body: {"source_id":"string","limit":"integer"}

GET /api/v1/action-runs

Every invocation that ran and every invocation that was refused, newest first, with the refusal reason. Not gated behind the paid plan: what an agent changed in your systems is a safety artefact, not a compliance nicety.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/action-runs/{id}/approve

Perform an action whose policy holds it for approval. The APPROVER's entitlement is checked, because releasing it is authorising it. Owner or admin only, and deliberately not a tool: an agent holding a key must never see its own approval bypass in its tool list.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/action-runs/{id}/reject

Mark a held action rejected. Nothing upstream is contacted, then or ever.

body: {"source_id":"string","limit":"integer"}

GET /api/v1/webhooks/endpoints

The endpoints registered to receive events. The signing secret is never in this response — it is returned once, by the call that minted it.

body: {"source_id":"string","limit":"integer"}

POST /api/v1/webhooks/endpoints

Register an https endpoint to receive events. The response carries the signing secret, and is the only place it ever appears: every delivery is signed with it as HMAC-SHA256 over the raw body, sent as sha256=<hex> in X-Contextely-Signature. Registering the same url again replaces the subscription and MINTS A NEW SECRET, which is almost always why somebody registers again. A delivery that fails is retried with backoff for about a day; an endpoint that loses several events in a row that way is switched off, and re-registering revives it.

body: {"source_id":"string","limit":"integer"}

DELETE /api/v1/webhooks/endpoints/{endpoint_id}

Removes the endpoint and anything still queued for it. An unsubscribe that leaves a backlog to be delivered later is not an unsubscribe.

body: {"source_id":"string","limit":"integer"}

GET /api/v1/webhooks/events

Events are recorded whether or not anything was subscribed, so this answers both “are events happening at all” while somebody is wiring up a receiver, and “what did I miss” for an endpoint registered after the fact.

body: {"source_id":"string","limit":"integer"}

GET /api/v1/connections/{connection_id}/inbound-webhook

The address to paste into the provider's own webhook form, and — where the provider accepts one — the secret it should sign with. Both are stable for the life of the connection, so this can be read again by somebody who lost the value rather than forcing a rotation. Where the provider MINTS its own secret instead (Sentry), `secret` is null, `secret_origin` is “theirs”, and `secret_stored` says whether Contextely has been given it yet — see the PUT below. Only answers for providers Contextely can verify a delivery from; everything else returns 404, because an endpoint that accepts an unsigned body is not a webhook.

body: {"source_id":"string","limit":"integer"}

PUT /api/v1/connections/{connection_id}/inbound-webhook

For a provider that generates its own signing secret and offers no field to set one — a Sentry internal integration's Client Secret is minted by Sentry and shown once. Create the integration, then send the secret here and deliveries are verified against it from then on. Refused for a provider that accepts a secret, because two values meant to be the same is how “I pasted the right secret and it still 401s” happens.

body: {"source_id":"string","limit":"integer"}

Example

curl -s https://www.contextely.com/api/v1/search \
  -H "Authorization: Bearer ctx_sk_..." \
  -H "Content-Type: application/json" \
  -d '{"query":"acme renewal","limit":5}'

Machine-readable spec

An OpenAPI 3.1 document is generated from the same tool catalogue this page renders, so it cannot describe an endpoint that does not exist. Point a client generator, an agent or Postman at it directly.

curl -s https://www.contextely.com/api/v1/openapi.json

Status codes

200Success.
400bad_request: malformed arguments. The message says which.
401unauthorized: missing, invalid or revoked API key.
402quota_exceeded: the month's metered allowance is spent. Back off or upgrade.
404not_found: no such object, or none this asker is entitled to.
500internal: reported to us with a reference id you can quote.
502upstream_failure: a system of record or the condenser could not be reached.
503unconfigured: a capability this deployment has not been given credentials for.

Every error body is {"error": {"code", "message"}}. Branch on code, never on the message text.