Memory API
Endpoint reference
Every route live under /v1/memory. Field names match the JSON the API sends and accepts.
Copy page
Base URL: https://api.harny.ai/v1/memory. Send Authorization: Bearer $HARNY_APP_KEY and X-Harny-User: <user> on every call, or a user key (fsk_) with no X-Harny-User. Every error body is {code, status, debugMessage, data} and every response carries X-Request-Id. Errors lists the codes.
POST /v1/memory/ingest
Adds one data point to a user's memory in an app.
| Field | Type | Notes |
|---|---|---|
content | string | Required. Not blank, no U+0000, at most 500,000 characters. |
label | string | At most 200 characters. |
id | string | No /, at most 512 characters. Defaults to the lowercase SHA-256 of content. |
timestamp | string | ISO 8601 with a time zone. Defaults to the stored value, else now. |
metadata | object | JSON object, at most 64 KiB serialized. |
audience | string | Omit it or send project. Other values return audience-unknown. |
identity | string | At most 200 characters. Must equal the acting user if sent. |
idempotencyKey | string | At most 200 characters. |
Send the X-Harny-User header with an app key. A user key needs no X-Harny-User. An unknown field returns invalid-ingestion.
Response 202: {projectId, ingestionId, sourceId, status: "queued", unchanged: null}. projectId carries the app id. Response 200 with status: "completed" and unchanged: true when the stored data point is identical. Nothing is processed again in that case.
Sending the same id with a change creates a new revision. Harny deletes the memories built on the old revision, then ingests the new text. Fields you leave out of the resend keep their stored values. If the new text is refused, the data point has no memories until you send it again, and that resend counts as a change.
Error codes: 400 content-required, id-invalid, nul-character, invalid-ingestion, audience-unknown, identity-required; 401 unauthorized; 403 identity-mismatch; 409 idempotency-conflict, data-point-deleted, data-point-processing; 413 content-too-large; 429 project-cap-reached (or bodyless 429 for the per-minute limit); 503 pipeline-disabled, organization-not-ready.
curl -X POST "https://api.harny.ai/v1/memory/ingest" \
-H "Authorization: Bearer $HARNY_APP_KEY" \
-H "X-Harny-User: user-123" \
-H "Content-Type: application/json" \
-d '{"content": "Met the client on Monday.", "label": "meeting note"}'POST /v1/memory/ingest/batch
Adds up to 1,000 data points in one request.
| Field | Type | Notes |
|---|---|---|
items | array | 1 to 1,000 single-ingest objects, without idempotencyKey. An id may appear once per batch. |
idempotencyKey | string | At most 200 characters. |
The whole batch is validated before any write. The batch is at most 16 MiB of UTF-8 in total. Each item is then written separately, so a quota refusal at item k keeps items before k.
Response 202: {projectId, ingestionId, status: "queued", unchanged: null, items: [{ordinal, sourceId, status, unchanged}]}. Response 200 with completed and unchanged: true when every item is unchanged.
On a partial refusal, the error body includes data: {ingestionId, accepted: k}. Retry with the same idempotencyKey and body to resume at k.
Error codes: 400 invalid-ingestion (empty, repeated id, or over 1,000 items), 413 content-too-large, plus the quota and availability codes from the single ingest route.
GET /v1/memory/ingest/{ingestionId}
Returns the processing status of one send.
| Field | Type | Notes |
|---|---|---|
ingestionId | path | The id returned by ingest. |
Response: {status, settled, blocked, readiness: {raw, comprehension, consolidation, failed}, failure}.
statusisprocessing,failed, orcompleted.readinesscounts the user's whole memory space, not one send.settledis true whenraw,comprehension,consolidationandfailedare zero.blockedis true whenraw,comprehensionandconsolidationare zero andfailedis above zero.failureis null or{code, message}, wherecodeiscontent-rejected,model-unavailable,index-unavailable, orinternal.
Error codes: 404 not-found. The status limiter applies.
DELETE /v1/memory/ingest/{ingestionId}
Deletes all data points that a send created.
No request body. Response 204 with no body. Repeating the call also returns 204.
Error codes: 404 not-found; 409 data-point-processing, memory-ids-unknown.
The memories the send created are deleted. If a send created none, existing memories are left in place. A data point still in the retry queue has its pending job removed. The id stays recorded as deleted, so sending it again returns 409 data-point-deleted.
DELETE /v1/memory/data-points/{sourceId}
Deletes one data point and the memories built on it. This is the call that removes one piece of text you sent. To remove everything one send created, use DELETE /v1/memory/ingest/{ingestionId}.
| Field | Type | Notes |
|---|---|---|
sourceId | path | The data point's source id: the id you gave it when you sent it. |
Response 202: {operationId, ids: [sourceId]}. The delete has finished when the call returns. operationId identifies the delete.
Error codes: 404 data-point-not-found; 409 data-point-processing, memory-ids-unknown.
POST /v1/memory/data-points/delete
Deletes up to 1,000 data points in one request.
| Field | Type | Notes |
|---|---|---|
ids | array | 1 to 1,000 source ids. A repeated id counts once. |
Response 202: {operationId, ids}. Nothing is deleted when any id is missing or any id is mid-delivery.
Error codes: 400 invalid-deletion; 404 data-point-not-found with data: {ids: [missing]}; 409 data-point-processing, memory-ids-unknown. This route is free.
POST /v1/memory/recall
Returns the memories that match a query for one user.
| Field | Type | Notes |
|---|---|---|
query | string | Required. Error recall-query-required if missing. |
identity | string | Required. The user whose memory is read. A body identity must match the acting user, else 403 identity-mismatch. |
limit | integer | 1 to 200. Default 100. |
level | string | low (8,000 tokens, default), medium (16,000), high (24,000), extra-high (32,000). Do not send with maxTokens. |
maxTokens | integer | Floor 256. Do not send with level. |
queryTimestamp | string | ISO 8601 with zone. Drops results whose occurredAt is later. Unknown times are kept. |
occurredFrom | string | Inclusive start of the occurredAt window. Unknown times are dropped. |
occurredTo | string | Inclusive end of the window. |
sort | string | relevance (default) or chronological (occurredAt ascending, unknown last). |
Response 200:
{
"asOf": "<queryTimestamp or current time>",
"usedEvidenceTokens": 0,
"results": [
{
"id": "<memory id>",
"rank": 1,
"occurredAt": "<ISO 8601>",
"content": "<text>",
"artifact": {"id": "<memory id>", "kind": "observation", "occurredAt": "<ISO 8601>"},
"sources": [
{"sourceId": "<id>", "occurredAt": "<ISO 8601>", "label": "<label>", "metadata": {}, "excerpts": []}
]
}
],
"pending": "<present only while this user has queued items>"
}kind is observation. excerpts is []. sources is empty for a memory that did not come from a data point sent through this API. pending holds {ingestions, dataPoints, since, message} and is absent when nothing is queued.
Errors:
400 recall-range-invalidwhenoccurredFromis afteroccurredTo.400 recall-query-required,recall-sort-invalid,recall-level-invalid,recall-level-conflict,recall-budget-invalid.403 identity-mismatch.429with an empty body on the per-minute limit.503 organization-not-readyorpipeline-disabled.
GET /v1/memory/usage
Returns the month's usage for the app. It needs an app key.
{
"month": { "from": "2026-10-01T00:00:00Z", "to": "2026-11-01T00:00:00Z", "resetsAt": "2026-11-01T00:00:00Z" },
"balance": { "left": 320, "included": 500, "bought": 0, "rolledOver": 0, "spent": 180 },
"project": { "slug": "my-app", "credits": 180, "ingestions": 412, "recalls": 1904,
"creditCap": null, "creditCapLeft": null, "ingestionCap": null, "ingestionCapLeft": null },
"metered": false
}Harny meters monthly-active end users. balance.spent and project.credits carry this month's count of them, and balance.included is the allowance of the plan. left is the allowance minus the count, never below 0. ingestions counts the sends of this API this month, and recalls counts the app's recalls. month is the UTC calendar month.
GET /v1/memory/users/{identity}/export
Exports one person's memory as JSON. It needs an app key; the person is named in the path, so no X-Harny-User header is sent.
| Field | Type | Notes |
|---|---|---|
identity | path | The person's id in your app. A person this app does not have, or whose erasure was requested, returns 404 not-found. |
dataPointsAfter | query | The previous page's next.dataPointsAfter. |
memoriesAfter | query | The previous page's next.memoriesAfter. |
A call with no cursor starts the export and returns the first page of both lists. Follow next.dataPointsAfter and next.memoriesAfter until both are null; a call that sends one cursor returns only that list.
Response 200:
{
"identity": "user-123",
"generatedAt": "<ISO 8601>",
"dataPoints": [
{"id": "<id>", "label": "<label>", "occurredAt": "<ISO 8601>", "metadata": {}, "createdAt": "<ISO 8601>", "updatedAt": "<ISO 8601>"}
],
"memories": [{"id": "<memory id>", "content": "<text>"}],
"next": {"dataPointsAfter": null, "memoriesAfter": null},
"complete": true
}dataPoints lists the person's data points that are not deleted, 500 to a page, in id order. Harny does not keep the text you sent, so a data point carries its label, metadata and times. memories lists the memories stored from them, with their text and nothing else. complete is present on a call with no cursor and is true when both lists ended on that page.
Starting an export spends one of 5 exports of one person per hour; a page that follows a cursor does not. Error codes: 400 identity-invalid, cursor-invalid; 401 unauthorized; 404 not-found; 429 export-rate-limited (with Retry-After); 503 organization-not-ready.
curl "https://api.harny.ai/v1/memory/users/user-123/export" \
-H "Authorization: Bearer $HARNY_APP_KEY"