RAG & Search
Two complementary query APIs for your synced data. Use Semantic Search to find messages by meaning, or the Query DSL for precise structured queries across messages, chats, fans, and chunks.
Authentication
X-API-Key header. The key determines the organization scope — all results are automatically restricted to models belonging to your organization. You cannot query data outside your org regardless of filter values.Semantic Search
Find messages by meaning, not keyword. Queries are embedded using OpenAI's text-embedding-3-small model and matched against stored message chunk embeddings via vector similarity.
curl -X POST "https://api.onlyfans-api.ai/api/models/MODEL_UUID/rag/search" \
-H "X-API-Key: sk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"query": "what does this fan like", "fan_id": "553393565", "top_k": 5}'| Parameter | Type | Description |
|---|---|---|
queryrequired | string | Natural language search query. The gateway embeds this and finds semantically similar messages. |
fan_id | string | OnlyFans fan platform ID. When provided, search is scoped to that fan's conversation only. |
top_k | integer | Number of top results to return.Default: 5 |
similarity_threshold | float | Minimum similarity score (0.0–1.0). Results below this score are excluded. 0.0 returns all results.Default: 0.0 |
before_context | integer | Number of messages to include before each anchor result.Default: 3 |
after_context | integer | Number of messages to include after each anchor result.Default: 3 |
include_raw_payload | boolean | When true, each anchor result includes the full raw OnlyFans message payload.Default: false |
{
"results": [
{
"anchor": {
"id": "9d5b817a-eced-46b6-831c-f68fbbaed798",
"chat_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"fan_platform_id": "553393565",
"chunk_type": "single",
"chunk_text": "I love outdoor hiking and photography",
"message_text": "I love outdoor hiking and photography",
"is_from_model": false,
"created_at": "2026-05-10T14:32:00+00:00",
"is_tip": false,
"price": 0
},
"context": [
{
"id": "8c3a506b-fba1-4d22-9e17-b2c1d7e89f01",
"message_text": "What are your hobbies?",
"is_from_model": true,
"created_at": "2026-05-10T14:31:30+00:00"
},
{
"id": "1e7f209c-dca2-4b33-af28-c3d2e8f90g12",
"message_text": "That's amazing! We should talk more about this.",
"is_from_model": true,
"created_at": "2026-05-10T14:32:45+00:00"
}
],
"distance": 0.1823,
"chunk_type": "single"
}
],
"query": "what does this fan like",
"model_id": "16f3d13b-9415-4e6b-babe-e8db6b31bd97",
"fan_platform_id": "553393565"
}| Field | Description |
|---|---|
results[].anchor | The matching message. All standard message fields plus chunk_text (the specific chunk that matched). |
results[].anchor.raw_payload | Full raw OnlyFans message payload. Only present when include_raw_payload: true. |
results[].context | Messages surrounding the anchor, ordered by created_at. Count controlled by before_context and after_context. |
results[].distance | Vector distance (0 = identical, 1 = completely different). Lower is more similar. |
results[].chunk_type | The type of embedding chunk that matched: single (full message) or metadata (structured fields). |
query | The original query string echoed back. |
model_id | Model UUID the search was scoped to. |
fan_platform_id | Fan platform ID if fan_id was provided, otherwise null. |
Similarity threshold
similarity_threshold to filter weak matches. A value of 0.7 is a good starting point — it corresponds to a distance of 0.3 or less. The formula is: similarity = 1 - distance.Query DSL
Structured queries across four whitelisted tables. All results are scoped to your organization — model_id values are validated against your org's connected models.
curl -X POST "https://api.onlyfans-api.ai/api/query" \
-H "X-API-Key: sk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"table": "messages", "filters": {"fan_platform_id": {"eq": "553393565"}, "is_from_model": false}, "sort": {"field": "created_at", "direction": "desc"}, "limit": 5}'| Parameter | Type | Description |
|---|---|---|
tablerequired | string | Table to query. One of: messages, chats, fans, message_chunks. |
filters | object | Filter conditions keyed by field name. Each value is an operator object e.g. {"gte": "2026-01-01"}. See filter operators below. |
fields | array | List of field aliases to return. Omit to return all default fields for the table. |
sort.field | string | Field to sort by. Must be one of the table's sortable fields.Default: created_at |
sort.direction | string | Sort direction: asc or desc.Default: asc |
limit | integer | Number of rows to return. Maximum 1000.Default: 20 |
offset | integer | Pagination offset.Default: 0 |
include_raw_payload | boolean | messages table only. When true, each row includes the full raw OnlyFans payload.Default: false |
{
"table": "messages",
"total": 1482,
"limit": 5,
"offset": 0,
"data": [
{
"message_id": "9d5b817a-eced-46b6-831c-f68fbbaed798",
"platform_message_id": "9130375619415",
"fan_platform_id": "553393565",
"model_id": "16f3d13b-9415-4e6b-babe-e8db6b31bd97",
"created_at": "2026-05-10T14:32:00+00:00",
"message_text": "I love outdoor hiking and photography",
"price": 0,
"is_tip": false,
"media_count": 0,
"is_from_model": false
}
]
}curl -X POST "https://api.onlyfans-api.ai/api/query" \
-H "X-API-Key: sk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"table": "fans", "filters": {"total_spent": {"gte": 100}}, "sort": {"field": "total_spent", "direction": "desc"}, "limit": 3}'{
"table": "fans",
"total": 358,
"limit": 3,
"offset": 0,
"data": [
{
"fan_id": "c1d2e3f4-a5b6-7890-cdef-012345678901",
"fan_platform_id": "553393565",
"model_id": "16f3d13b-9415-4e6b-babe-e8db6b31bd97",
"display_name": "Harry",
"username": "sirharoldprince",
"total_spent": 2171.50,
"tips_spent": 1460.00,
"messages_spent": 597.50,
"subscribed_on": true,
"last_seen": "2026-06-18T09:14:00+00:00",
"can_receive_message": true,
"is_blocked": false,
"created_at": "2025-10-02T11:00:00+00:00",
"updated_at": "2026-06-18T09:14:00+00:00"
}
]
}Available Tables
Each table exposes a fixed set of filterable and returnable fields. You cannot query tables or fields not listed here.
messagesSynced message records from fan conversations.| Filter fields | created_at, is_from_model, price, is_tip, media_count, message_text, fan_platform_id, model_id |
| Sort fields | created_at, price, media_count |
| Return fields | message_id, platform_message_id, fan_platform_id, model_id, created_at, message_text, price, is_tip, media_count, is_from_model |
| Opt-in fields | raw_payload (via include_raw_payload: true) |
chatsChat thread records — one per fan–model pair.| Filter fields | created_at, updated_at, fan_platform_id, model_id |
| Sort fields | created_at, updated_at |
| Return fields | chat_id, platform_chat_id, fan_platform_id, model_id, created_at, updated_at |
| Opt-in fields | — |
fansEnriched fan profiles with spending and subscription data.| Filter fields | fan_platform_id, model_id, is_verified, subscribed_on, subscribed_by, can_receive_message, is_blocked, is_restricted, tips_enabled, last_seen, created_at, updated_at, total_spent, messages_spent, tips_spent, subscribe_price, tips_min, tips_max |
| Sort fields | total_spent, messages_spent, tips_spent, subscribe_price, last_seen, created_at, updated_at |
| Return fields | fan_id, fan_platform_id, model_id, display_name, username, name, avatar, is_verified, subscribed_on, subscribed_by, last_seen, can_receive_message, is_blocked, total_spent, messages_spent, tips_spent, posts_spent, subscriptions_spent, created_at, updated_at |
| Opt-in fields | — |
message_chunksVector embedding chunks derived from messages. Used for semantic search.| Filter fields | message_id, chunk_type, created_at, model_id, fan_platform_id |
| Sort fields | created_at |
| Return fields | chunk_id, message_id, chunk_type, text, normalized_text, embedding, chunk_metadata, created_at |
| Opt-in fields | — |
Filter Operators
Filters are expressed as an object keyed by field name. Each value is either a direct value (shorthand for eq) or an operator object.
{
"filters": {
"fan_platform_id": "553393565", // shorthand eq
"created_at": {
"gte": "2026-01-01T00:00:00Z",
"lte": "2026-06-30T23:59:59Z"
},
"total_spent": { "gte": 50 },
"is_from_model": { "eq": false },
"message_text": { "like": "%birthday%" },
"fan_platform_id": { "in": ["553393565", "123220869", "37098033"] }
}
}| Operator | Supported types | Description |
|---|---|---|
eq | string, integer, float, bool, datetime | Exact match. Shorthand: pass a value directly without wrapping in an object. |
gte | integer, float, datetime | Greater than or equal to. |
lte | integer, float, datetime | Less than or equal to. |
gt | integer, float, datetime | Strictly greater than. |
lt | integer, float, datetime | Strictly less than. |
in | string | Match any value in a list. Maximum 500 values. |
like | string | Case-insensitive substring match. Use % as wildcard: %keyword%. |
DSL Error Codes
All DSL validation errors return HTTP 400 with a code field identifying the rejection reason.
{
"error": "Filter field 'unknown_field' not allowed for table 'messages'",
"code": "DSL_ERROR"
}| Code | Status | Description |
|---|---|---|
MISSING_TABLE | 400 | The table field was not provided in the request body. |
INVALID_TABLE | 400 | The requested table is not in the allowed list. |
INVALID_FILTERS | 400 | The filters field is not a valid object. |
INVALID_FIELDS | 400 | The fields field is not a valid array. |
INVALID_PAGINATION | 400 | limit or offset is not an integer. |
DSL_ERROR | 400 | A filter field, operator, or return field is not allowed. The error message specifies which field caused the rejection. |
Org scoping is always enforced
model_id filter that belongs to another organization, the query will return zero rows — not an error. All results are silently restricted to models in your org before any user-provided filters are applied.Pagination
The DSL query endpoint uses offset-based pagination.
| Field | Description |
|---|---|
total | Total number of rows matching the filters (before limit/offset). Use this to calculate page count. |
limit | The limit applied to this request (echoed back). |
offset | The offset applied to this request (echoed back). |
data | Array of result rows for this page. |
// Page through all results
let offset = 0
const limit = 100
let allData = []
do {
const res = await fetch(`${BASE_URL}/api/query`, {
method: "POST",
headers: { "X-API-Key": apiKey, "Content-Type": "application/json" },
body: JSON.stringify({ table: "messages", filters: {}, limit, offset }),
})
const { data, total } = await res.json()
allData.push(...data)
offset += limit
} while (offset < total)Maximum limit is 1,000