HTTP inbound

Push events from any system that can make an HTTP request — the ingest envelope, its auth, what happens after acceptance, and how backpressure works.

Any system that can POST JSON gets a permissioned feed without waiting for someone to write its connector. Internal tools, a CRM export, a scheduled job, a load harness — all the same shape, and all crossing exactly the same authenticated ingest, org fence and visibility rules as a native connector.

Set up the connection

In Connections at /app/connections, add an HTTP inbound (custom source) connection. It has one field, workspace — the label that groups everything this source sends. Use the same label as a related connection if you want their history in one graph.

The ingest token is shown once, at creation. Store it as a secret in the system that will push. If you lose it, rotate rather than guess.

There is no runner to supervise, so nothing has to be enabled for the endpoint to work — the connection is a credential and a permission boundary. To cut a source off, rotate its token or delete the connection.

Endpoints and auth

MethodPathPayload
POST/v1/ingestone event
POST/v1/ingest/batch{ "events": [ … ] }, 1–500 events
authorization: Bearer louvainc_deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef
content-type: application/json

The API resolves the org and the connection from the token, never from anything in the payload. A missing token is 401 missing_ingest_token; an unrecognised one is 401 invalid_ingest_token. There is no unauthenticated path and no silent downgrade.

The token also bounds what the payload is allowed to say, which is what stops one compromised feed from granting itself access elsewhere. An event is refused with 403 if it declares a different source.connector, declares a different source.workspace than the one this connection was created with, or names a container outside its own connector namespace — an http token cannot write container:slack/general. Keep source.workspace equal to the workspace field you set on the connection, and keep every resource under container:http/….

Request bodies are capped at 8 MB by default (LOUVAIN_BODY_LIMIT_BYTES). A firehose should batch, not send novels: one request per message spends its life on sockets, which is what /v1/ingest/batch exists for. The 500-event cap is deliberate — an unbounded batch is an unbounded transaction.

The event envelope

{
  "id": "01K2ZC7Q3M8XN4V0PB6RT9WHDA",   // ULID
  "idempotencyKey": "crm-note-8891",     // source-derived; retries MUST collide
  "source": {
    "connector": "http",
    "workspace": "acme",
    "container": "crm-notes",            // the ACL-bearing unit at the source
    "ref": "https://crm.example.com/notes/8891"  // optional deep link, must be a URL
  },
  "kind": "message",
  "occurredAt": "2026-08-14T09:12:00.000Z",   // when it happened at the source
  "observedAt": "2026-08-14T09:12:04.000Z",   // when you saw it
  "actor": {
    "sourceUserId": "rena",
    "email": "rena@example.com"          // optional identity-resolution hint
  },
  "body": { "text": "…" },               // shape depends on kind
  "permissions": {
    "containerTuples": [],               // who can see this container
    "visibility": "container"            // "container" | "workspace" | "public"
  }
}

Every field above is required except source.ref and actor.email. id must be a ULID and the timestamps must be ISO-8601 datetimes; anything else comes back as 400 invalid_event with the failing paths listed in issues.

idempotencyKey is the deduplication key and it must be derived from the source, not generated per attempt. A retried or replayed event has to collide with the original, or you will ingest the same knowledge twice.

container is the ACL-bearing unit — the channel, thread, folder or record whose membership decides who may see what was said in it. louvain stores it as <connector>/<container>, so crm-notes under the http connector becomes the container http/crm-notes.

Body by kind

kind accepts message, record.change, membership.change, file and reaction. Two of them have a defined body and a pipeline behind them today:

message — the knowledge path. This is what gets extracted.

{ "text": "Nimbus moved to legal review; renewal is $410k/yr from October." }

membership.change — permission sync, riding the same firehose as content rather than sitting in a side channel.

{
  "op": "add",
  "tuple": {
    "resource": "container:http/crm-notes",
    "relation": "member",
    "subject": "user:http/rena"
  }
}

op is add or remove. The other three kinds are accepted and recorded, but nothing extracts from them — use message for anything you want answerable.

Permissions

louvain never widens access. An event whose container nobody is a member of is visible to nobody. That is the correct outcome, not a failure — but it does mean membership has to arrive, either as membership.change events or as permissions.containerTuples carried on the events themselves.

Three rules, and breaking any of them produces a feed that reports healthy and reads empty:

  1. resource must be container:<connector>/<container>, matching the container your events actually use — here, container:http/crm-notes. You write the short form; louvain expands it server-side to the full identity, which also carries your organization and workspace, so a reference you send can only address your own.
  2. subject must be connector-namespaced: user:http/rena, not user:rena. A person's readable set resolves their linked source identities as connector/sourceUserId. A bare subject is a valid reference, so it commits and syncs without complaint, and is then never looked up.
  3. Every reference must be type:id. A reference with no type is rejected at ingest with a 400, because a malformed tuple reaching the permission relay stops every permission change propagating, forever, while ingest keeps answering 200.

visibility is recorded on the event. What a reader can actually see is decided by container membership.

What happens after acceptance

Acceptance means stored and acknowledged, nothing more. In async mode (LOUVAIN_ASYNC_EXTRACTION=true) the accept path is one insert plus the permission intents; the admission gate, embedding and extraction all happen behind a durable queue. Nothing expensive runs on an accept path.

/v1/ingest answers:

{ "status": "accepted", "claims": 0 }

status is accepted or duplicate. claims counts what was extracted inline, so in async mode it is 0 by definition — the work has not happened yet. /v1/ingest/batch answers with counts instead:

{ "accepted": 500, "duplicates": 0, "claims": 0 }

Every event then terminates in exactly one recorded outcome, drawn from a closed set enforced by a database constraint:

OutcomeMeaning
claimstyped claims were written
memoriesno typed claim, but open-vocabulary knowledge was kept
no_claimsnothing durable in this message
acla permission tuple was applied
acl_noopthe tuple was already in that state
gatedthe admission gate declined to extract it; the text is kept
quarantinedthe text tried to instruct the extractor; refused, kept, visible
budgetedthe account hit its limit; the message is fine and becomes extractable when the budget is raised or resets
errorextraction failed; retried, then dead-lettered visibly
pendingqueued — the one non-terminal state

Unexplained gaps are bugs, not noise. If you accepted an event and it never reached one of these, something is wrong.

Backpressure

Past LOUVAIN_MAX_QUEUE_DEPTH (default 50,000 pending) ingest refuses rather than degrading:

HTTP/1.1 429 Too Many Requests
retry-after: 12
{ "error": "backlog_full", "pending": 120000, "retryAfterSeconds": 12 }

Retry-After scales with how far behind the queue is, floored at 1 second and capped at 60. Wait exactly that long and retry — a client that retries immediately turns a polite refusal into a denial-of-service against the thing it is feeding.

There is a second 429 that means something completely different:

{ "error": "quota_exceeded", "reason": "…" }

It carries no Retry-After, because retrying changes nothing. backlog_full is the deployment saying "not right now"; quota_exceeded is the account saying "not this month". A client that treats them alike either hammers a limit it can never pass or abandons a backlog it should have waited out.

A well-behaved sender therefore:

  • honours Retry-After on backlog_full and resumes,
  • stops and surfaces quota_exceeded to an operator,
  • retries 5xx with capped exponential backoff,
  • and never retries a 4xx other than 429 — that payload will never be accepted.

Error responses

StatusBodyCause
400{ "error": "invalid_event", "issues": [ … ] }schema validation failed on /v1/ingest
400{ "error": "invalid_batch", "issues": [ … ] }schema validation failed on /v1/ingest/batch
400{ "error": "malformed permission reference(s): … — expected type:id, e.g. user:slack/U042" }a tuple resource or subject is not type:id
400{ "error": "unknown relation(s): … " }a tuple names a relation the schema does not define (use member)
401{ "error": "missing_ingest_token" }no bearer token
401{ "error": "invalid_ingest_token" }the token does not resolve to a connection
403{ "error": "connector mismatch: …" }source.connector is not this connection's connector
403{ "error": "workspace mismatch: …" }source.workspace is not the workspace this connection declares
403{ "error": "connector '…' may not assert reference(s) outside its namespace: …" }a tuple names another connector's container
429{ "error": "backlog_full", … }queue depth exceeded; has retry-after
429{ "error": "quota_exceeded", … }account limit; no retry-after

A worked example

Send membership first, so the knowledge is readable by someone the moment it lands. Replace the base URL with your deployment (http://localhost:8080 for a local dev API) and the token with the one your connection issued.

LOUVAIN_API=http://localhost:8080
TOKEN=louvainc_deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef

curl -sS -X POST "$LOUVAIN_API/v1/ingest" \
  -H "authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d '{
    "id": "01K2ZC8B5R1YQ7C3JF0N2XMDPE",
    "idempotencyKey": "crm-member-add-rena-crm-notes",
    "source": { "connector": "http", "workspace": "acme", "container": "crm-notes" },
    "kind": "membership.change",
    "occurredAt": "2026-08-14T09:00:00.000Z",
    "observedAt": "2026-08-14T09:00:00.000Z",
    "actor": { "sourceUserId": "rena", "email": "rena@example.com" },
    "body": {
      "op": "add",
      "tuple": {
        "resource": "container:http/crm-notes",
        "relation": "member",
        "subject": "user:http/rena"
      }
    },
    "permissions": { "containerTuples": [], "visibility": "container" }
  }'
{ "status": "accepted", "claims": 0 }

Then the content:

curl -sS -X POST "$LOUVAIN_API/v1/ingest" \
  -H "authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d '{
    "id": "01K2ZC7Q3M8XN4V0PB6RT9WHDA",
    "idempotencyKey": "crm-note-8891",
    "source": {
      "connector": "http",
      "workspace": "acme",
      "container": "crm-notes",
      "ref": "https://crm.example.com/notes/8891"
    },
    "kind": "message",
    "occurredAt": "2026-08-14T09:12:00.000Z",
    "observedAt": "2026-08-14T09:12:04.000Z",
    "actor": { "sourceUserId": "rena", "email": "rena@example.com" },
    "body": { "text": "Nimbus moved to legal review; renewal is $410k/yr from October." },
    "permissions": {
      "containerTuples": [
        {
          "resource": "container:http/crm-notes",
          "relation": "member",
          "subject": "user:http/rena"
        }
      ],
      "visibility": "container"
    }
  }'

Batching is the same envelope wrapped in events:

curl -sS -X POST "$LOUVAIN_API/v1/ingest/batch" \
  -H "authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d '{ "events": [ { … }, { … } ] }'
{ "accepted": 2, "duplicates": 0, "claims": 0 }

Re-send either request and it returns duplicate — the idempotency key is source-derived, so replays cost nothing and change nothing.