API Reference

Betahunter API

The main endpoint takes the conversation so far and returns the next message to send, human-paced, on-persona, and ready to deliver. Balance and statistics endpoints expose credits and funnel rollups. Plain JSON over HTTPS, no SDK required.

Introduction

Send the thread you have so far for a lead. Betahunter decides whether it is the creator's turn, generates a reply in her voice, and returns the message (or burst of messages) to send back.

Base URLhttps://api.betahunter.app
TransportJSON over HTTPS
AuthSecret API key as a Bearer token

Authentication

Every request authenticates with your secret API key, sent as a Bearer token in the Authorization header:

Authorization header
Authorization: Bearer bh_live_xxxxxxxxxxxxxxxxxxxx

Keys start with bh_live_. You can also send the key as an optional JSON body field named licensekey on POST endpoints for drop-in compatibility. The Authorization header wins when both are present, and GET /v1/balance requires the header. Keep it server-side, anyone who has it can spend your credits. A missing, unknown, or disabled key returns 401.

No key yet? Get one on Telegram.

Quickstart

One call generates the next reply for a lead who just messaged you:

curl
curl https://api.betahunter.app/v1/generateResponse \
  -H "Authorization: Bearer bh_live_xxxxxxxxxxxxxxxxxxxx" \
  -H "content-type: application/json" \
  -d '{
    "accountId": "acct_42",
    "recipient": { "id": "u_88", "name": "Bob" },
    "chatHistory": [
      { "role": "user", "content": "hey", "timestamp": 1730000000 }
    ],
    "persona": { "name": "Ava", "ctaLink": "https://onlyfans.com/ava" }
  }'

How billing works

You are billed in conversations, not messages or tokens.

  • A conversation is one lead in one of your accounts, identified by accountId + recipient.id. The API returns its conversationId.
  • You are charged one credit the first time the engine returns a successful non-empty bot turn in a conversation. Every later reply in that same conversation is free, even after your balance reaches zero.
  • If you enable a lead filter and a conversation is skipped by it (tierFiltered or genderFiltered), you are charged 0.2 credit once for that conversation, far less than a full reply and never more than once.
  • Turns that generate nothing (not your turn, capped, out of credits, blocked, or abandoned) are never charged.
  • Because of the 0.2 filter charge, creditsRemaining can be fractional (e.g. 919.8).
  • Check your remaining credits any time with GET /v1/balance.

Generate a reply

POST/v1/generateResponse

Send the recent thread you have so far. The engine appends only the genuinely new messages, decides whether to respond, and returns the reply to send.

The engine replies only when it is the creator's turn, that is, when the last message in chatHistory is from the lead ("role": "user"). If the thread is empty or the last message is the creator's, you get notOurTurn: true and nothing to send, unless you set generateOpener: true on an empty thread, in which case it writes the first message (the opener) instead.

Request body

FieldTypeDescription
accountIdstringrequiredYour account or sub-account id. Alias accountID is accepted. Together with recipient.id it identifies the conversation.
recipientobjectrequiredThe lead you are talking to. Alias userInfos is accepted. See Recipient.
chatHistoryarrayThe recent conversation in send order. Each item is a Message (max 200). Defaults to [] for a brand-new thread.
personaobjectThe creator the bot is playing. See Persona.
presetIdstringStored dashboard preset id. When present, Betahunter loads that preset's persona, CTA rotation, lead controls, follow-up setup, and media vault; inline persona fields override the stored preset for this call.
modelIdstringOptional model id from GET /v1/getModelsAndPresets. Today the default is betahunter-v3.
isFollowUpbooleanMark the turn as a follow-up to a lead who went quiet. Default false.
directionstringWho started the conversation: "outbound" (default, you cold-messaged the lead) or "inbound" (the lead reached out first, so she never pretends she messaged first). Booleans isInbound / isOutbound are accepted as aliases.
generateOpenerbooleanWhen true and chatHistory is empty, generate the first message (opener) instead of returning notOurTurn. Default false.

Recipient object

FieldTypeDescription
idstringrequiredStable unique id for the lead. Alias useridentifier is accepted inside userInfos.
namestringThe lead's display name.
usernamestringThe lead's handle.
biostringThe lead's bio, used to personalize.
locationstringThe lead's location, used to personalize.

Message object (each chatHistory item)

FieldTypeDescription
rolestringrequired"user" for the lead, "bot" or "assistant" for the creator.
contentstringThe message text. Missing or null is treated as an empty string.
timestampintegerrequiredUnix time in seconds. Used for validation and de-duplication.
idstringYour own id for the message, if you have one. Used to de-duplicate when you resend the window.

Persona object

FieldTypeDescription
namestringThe creator's name. Default "her".
ageintegerThe creator's age. Default 22.
citystringThe creator's city.
userInfostringShort self-description for the persona (look, vibe, backstory).
ctaInfostringWhat you are selling, used when she pitches the link.
ctaLinkstringThe link to close the lead to. Leave blank to disable pitching.
ctaMinExchangesintegerHow many lead replies before she may pitch the link. Default 8.
photosPhoto[]Inline media catalog she can send. Empty is allowed; a nonempty configured library requires 1–5 Main and 1–5 Sexy images, with nonblank, unique trimmed URLs. Dashboard-managed media is normally stored on a preset instead. Each item is a Photo.
phasesobject[]Optional engine phase config, passed through as provided.
matchLocationbooleanWhen true, she grounds in the lead's location and plays up being near him; if his location is unknown she asks early. Default false. Aliases matchCity / matchHisLocation accepted.
tierFilterstringCountry-tier lead filter: "off" (default), "T3" (skip lower-income / tier-3 countries), or "T2T3" (skip tier-2 and tier-3). A matched lead is skipped before generation. See Lead filters.
filterFemalesbooleanWhen true, female leads are skipped before generation. Default false. See Lead filters.

Photo object (each persona.photos item)

FieldTypeDescription
urlstringrequiredThe media URL. Alias photoUrl is accepted.
typestringCatalog bucket. Accepts "Primary"/"main" and "Sexy"/"sexy"/"specific". Alias photoType is accepted. Default "Primary".
priorityintegerOwner order: ranked first, lower first; ties retain original library order.

Lead filters (optional)

Skip leads you do not want to engage, before any reply is generated. Set persona.tierFilter (country tier) and/or persona.filterFemales (gender). A skipped lead comes back with tierFiltered / genderFiltered set and no content, and is billed the 0.2 filter charge once (see How billing works).

For drop-in Capital compatibility the API also accepts these at the top level of the request: filterMaleOnly (boolean, maps to filterFemales), filterTiers (the country tiers to keep, 1 = highest income … 3 = lowest; e.g. [1, 2] keeps tiers 1–2 and skips tier 3, equivalent to tierFilter: "T3"), and nameForFilter (a name for the classifier when recipient.name is absent). Explicit persona values win over these aliases.

Response

Successful generate calls return 200. The body tells you what to do through content plus outcome fields. When notOurTurn, timewaste, aiCreditOver, orunderage is true, content is empty and you were not charged. tierFiltered and genderFiltered also return empty content, but a filtered conversation is billed 0.2 credit once. The compatibility key converted reports a chat-confirmed subscription inferred from lead text or an operator update; it is not payment confirmation.

FieldTypeDescription
contentarrayThe messages to send, in order. Each item is a Content item. May be empty.
convertedbooleantrue when the conversation is marked as a chat-confirmed subscription from the lead's text or an operator update. It does not confirm payment.
conversationIdstringcustomerId:accountId:recipient.id conversation key. Use it to correlate turns.
notOurTurnbooleantrue when it is not the creator's turn (empty thread, or the last message was already hers). Nothing to send. With generateOpener: true, an empty thread generates an opener instead.
timewastebooleantrue when the conversation hit the message cap (50 lead messages) and was stopped.
aiCreditOverbooleantrue when you are out of credits on a new conversation, so nothing was generated. Top up to continue.
underagebooleantrue when the lead tripped the age-safety check. The thread is blocked permanently. Nothing to send.
tierFilteredbooleantrue when the lead was skipped by the tierFilter country-tier filter. Nothing to send; billed 0.2 credit once.
genderFilteredbooleantrue when the lead was skipped by the filterFemales filter. Nothing to send; billed 0.2 credit once.
messagedAlreadybooleantrue when preset lead controls found the same lead recently engaged by another account. Nothing to send.
engagedUserobject | nullDetails about the existing engagement when messagedAlready is true.
internalAccountbooleantrue when preset lead controls identify the recipient as one of your own accounts. Nothing to send.
statisticsobjectCapital-style phase metadata, including currentPhase, currentCycle, and stage.

Content item

FieldTypeDescription
typestring"text", "image", "gif" or "voice". Live voice requires the server gate; presets default on unless explicitly opted out.
contentstringMessage text, or the media URL. For "voice", fetch the MP4 audio and send it as a native voice note; do not send the URL as chat text. Relative URLs resolve against the API origin.
mediaPoolstring | nullFor a catalog image: "Primary" or "Sexy". null for text and voice.

Example

Request
{
  "accountId": "acct_42",
  "presetId": "pre_7f2a9c",
  "recipient": { "id": "u_88", "name": "Bob" },
  "chatHistory": [
    { "role": "assistant", "content": "heyy what are you up to", "timestamp": 1730000000 },
    { "role": "user", "content": "just relaxing, you?", "timestamp": 1730000120 }
  ],
  "persona": { "name": "Ava", "ctaLink": "https://onlyfans.com/ava" }
}
200 Response
{
  "content": [
    { "type": "text", "content": "mmm relaxing sounds nice", "mediaPool": null },
    { "type": "text", "content": "i could think of a few better ways to relax tho 😉", "mediaPool": null }
  ],
  "converted": false,
  "notOurTurn": false,
  "timewaste": false,
  "aiCreditOver": false,
  "underage": false,
  "conversationId": "cust_123:acct_42:u_88"
}

Send each content item as its own message, in order, with natural delays. That multi-message burst is the human cadence the engine is built around.

Dashboard presets

Use presets when you do not want to rebuild creator setup in your own product. A preset stores persona info, CTA rotation, follow-up messages, duplicate-lead controls, phases, and the media vault. Then your integration only passes presetId on POST /v1/generateResponse.

POST/v1/presets

Create an inactive draft, populate both Main and Sexy categories, then activate it before calling generation with its presetId.

Create preset
curl https://api.betahunter.app/v1/presets \
  -H "Authorization: Bearer bh_live_xxxxxxxxxxxxxxxxxxxx" \
  -H "content-type: application/json" \
  -d '{
    "name": "Ava main funnel",
    "persona": {
      "name": "Ava",
      "age": 22,
      "city": "Miami",
      "userInfo": "playful gym girl",
      "ctaInfo": "VIP page with daily drops",
      "ctaMinExchanges": 8
    },
    "ctas": [
      { "platform": "OnlyFans", "cta": "https://onlyfans.com/ava" }
    ],
    "leadControls": {
      "skipRecentlyMessagedLeads": true,
      "duplicateLeadCooldownDays": 30,
      "internalUsernames": ["ava_backup"]
    },
    "followUps": {
      "enabled": true,
      "maxFollowUps": 2,
      "messages": [
        { "message": "still thinking about you", "delayHours": 24 }
      ]
    }
  }'
POST/v1/presets/{presetId}/photos/upload

Upload images as JSON base64, with type (Primary = Main or Sexy) and optional priority. Active presets require 1–5 images per category (2–10 total); new presets default to inactive drafts and may be incomplete. Create accepts bulk photos: each entry requires a unique, nonblank id and url, with type (Primary or Sexy) and optional integer priority. For example: {"id":"main-1","url":"https://your-cdn.example/main.jpg","type":"Primary"}. IDs are required for bulk create, not for URL/upload endpoints. Update does not replace photos. Photo endpoints return the full preset and enforce limits and duplicate rejection. Images never repeat in a conversation, including selected objection and follow-up attachments. Objection uploads share the same library. Existing oversized libraries require explicit curation; nothing is automatically removed.

Alternatively, use hosted URLs as below. Replace the preset ID with the Create response’s id and the example URLs with your own. Both requests must succeed before activation.

Populate Main and Sexy
# Replace pre_7f2a9c with the id returned by Create, and use your hosted image URLs.
for type in Primary Sexy; do
  curl https://api.betahunter.app/v1/presets/pre_7f2a9c/photos \
    -H "Authorization: Bearer bh_live_xxxxxxxxxxxxxxxxxxxx" \
    -H "content-type: application/json" \
    -d "{\"url\":\"https://your-cdn.example/$type.jpg\",\"type\":\"$type\"}"
done
Activate populated preset
curl -X PUT https://api.betahunter.app/v1/presets/pre_7f2a9c \
  -H "Authorization: Bearer bh_live_xxxxxxxxxxxxxxxxxxxx" \
  -H "content-type: application/json" \
  -d '{ "active": true }'

Voice notes

Production: the operator enables VOICE_LIVE_ENABLED=true on the backend and reloads it. Personas default on, including existing preview-era presets; no per-persona activation is needed. To opt out, uncheck “Enable voice notes for real leads” and save the persona (voiceSettings.enabled=false). Newly saved opt-outs persist. The server gate still defaults off and cannot be bypassed. The sender must support native type:"voice" delivery. Saved Objections overrides have separate controls.

Playground: “Test voice notes” affects only that session, independently of the live switches. Testing, uploading or selecting a recording does not enable voice for real leads.

The engine normally voices one early reply and one pitch; unused library recordings may answer naturally at other moments. Each voice reply is one recording. Limits are five general generations plus one reserved for the pitch, and ten total voice sends per conversation. Generation requires the backend’s ElevenLabs key and uses real provider credits. Explicit requests such as “in voice note” also work after the pitch. “Again” after audio, or “send the voice note again”, can replay the last confirmed recording without another generation; each replay uses a send slot and does not resend the link/photos.

Evergreen pitch: set voiceSettings.pitchReplacementNoteId to a recording from this preset’s library, or upload/select one beside “Exact pitch text sent with the link” and save the persona. At the first actual pitch it sends that recording, then the bare CTA link: no generated introduction or duplicate pitch text. Other media stays separate. The clip is reserved from earlier replies and reused across conversations without another provider call, once automatically per conversation; explicit replays still use a send slot. Keep the pitch text as the fallback; an empty ID restores automatic behavior. Live voice remains gated.

New speech defaults to Eleven v4; the playground shows the actual server model and delivery-tag status. Delivery tags use the existing reply-model call, without a separate speech rewrite or extra model request. Saved recordings retain their original sound.

POST /v1/presets/{presetId}/voice-notes/upload accepts filename, contentType (audio/mpeg or audio/mp3 for MP3; audio/mp4 or video/mp4 for MP4), dataBase64, an exact transcript, and optional name/whenToUse. MP3 or MP4 with audio, up to 25 MB. Both are packaged as audio-only MP4 for playback and delivery.

POST /v1/presets/{presetId}/voice-notes/generate accepts text plus optional name/whenToUse and saves an ElevenLabs recording. Both endpoints return the full preset with voiceNotes. Metadata can be edited with PUT and entries removed with DELETE /v1/presets/{presetId}/voice-notes/{noteId}.

Use the existing delivery acknowledgement endpoints for mixed voice/text turns. Retries reuse stored audio; never generate again or resend confirmed items. AI-identity verification auto-voicing is not included in this version.

You can also attach an already-hosted URL with POST /v1/presets/{presetId}/photos. Deleting a photo or preset removes library records, not image files: uploaded image bytes are retained for shared copies and existing conversations. Remote URLs are left untouched.

GET/v1/getModelsAndPresets

Returns the callable model ids and your saved presets, useful for a Capital-style dropdown before sending modelId and presetId.

Models and presets
curl https://api.betahunter.app/v1/getModelsAndPresets \
  -H "Authorization: Bearer bh_live_xxxxxxxxxxxxxxxxxxxx"
GET/v1/dashboard/analytics

Returns dashboard metrics by period, account, or preset, including follow-ups, CTA phase, chat-confirmed subscriptions, conversation origins, duplicate skips, internal-account skips, and lead filters. Chat-confirmed subscription metrics come from lead text or operator updates. They do not represent payment data.

Dashboard analytics
curl "https://api.betahunter.app/v1/dashboard/analytics?period=30days&presetId=pre_7f2a9c" \
  -H "Authorization: Bearer bh_live_xxxxxxxxxxxxxxxxxxxx"
GET/v1/dashboard/conversations

Returns recent conversation summaries for the dashboard activity table and for operators who want to inspect why a lead was skipped or marked as a chat-confirmed subscription.

Recent conversations
curl "https://api.betahunter.app/v1/dashboard/conversations?period=30days&limit=50" \
  -H "Authorization: Bearer bh_live_xxxxxxxxxxxxxxxxxxxx"

Preset lead controls return messagedAlready or internalAccount on generateResponse when a lead should not be messaged. Those are normal 200 outcomes with empty content, so your sender can skip cleanly.

Check balance

GET/v1/balance

Returns the remaining conversation credits for your key.

curl
curl https://api.betahunter.app/v1/balance \
  -H "Authorization: Bearer bh_live_xxxxxxxxxxxxxxxxxxxx"
200 Response
{ "creditsRemaining": 920 }
FieldTypeDescription
creditsRemainingnumberConversation credits left on your key. Normally a whole number, but can be fractional (e.g. 919.8) once the 0.2 lead-filter charge has been applied.

Funnel statistics

POST/v1/statistics

Returns a funnel rollup for the authenticated key. Omit the body for all-time, all-account stats, or pass an account and period to narrow the result. Headline rates use engine-authored opener conversations. Subscription outcomes are inferred from chat text or operator updates and do not confirm payment.

curl
curl https://api.betahunter.app/v1/statistics \
  -H "Authorization: Bearer bh_live_xxxxxxxxxxxxxxxxxxxx" \
  -H "content-type: application/json" \
  -d '{ "period": "7days", "account": "acct_42" }'
200 Response
{
  "conversations": 10,
  "messages": 134,
  "ctaShared": 6,
  "engineAuthoredOpenerConversations": 10,
  "chatConfirmedSubscriptions": 3,
  "chatConfirmedSubscriptionRate": 0.3,
  "postCtaChatConfirmedSubscriptions": 2,
  "postCtaChatConfirmedSubscriptionRate": 0.2,
  "originCounts": {
    "engineAuthoredOpeners": 10,
    "clientAuthoredOpeners": 2,
    "leadFirst": 1,
    "other": 0
  },
  "conversions": 3,
  "conversionRate": 0.3,
  "blocked": 1
}

Request body

FieldTypeDescription
accountstringOptional accountId to scope the rollup to one account.
periodstring"today", "7days", "30days", or "all". Default "all".

Response

FieldTypeDescription
conversationsintegerCompatibility alias for engineAuthoredOpenerConversations.
messagesintegerStored lead-message exchanges in engine-authored opener conversations.
ctaSharedintegerEngine-authored opener conversations where the CTA link was sent at least once.
engineAuthoredOpenerConversationsintegerConversations whose stored first message was authored by the engine.
chatConfirmedSubscriptionsintegerLeads marked subscribed from their chat text or an operator update. This is not payment data.
chatConfirmedSubscriptionRatenumberChat-confirmed subscriptions divided by engine-authored opener conversations.
postCtaChatConfirmedSubscriptionsintegerChat-confirmed subscriptions in conversations where the CTA was already shared.
postCtaChatConfirmedSubscriptionRatenumberPost-CTA chat-confirmed subscriptions divided by engine-authored opener conversations.
originCountsobjectSeparate engine-authored opener, client-authored opener, lead-first, and other conversation counts.
conversionsintegerCompatibility alias for chatConfirmedSubscriptions.
conversionRatenumberCompatibility alias for chatConfirmedSubscriptionRate.
blockedintegerUnderage-blocked engine-authored opener conversations.

Errors

Authentication, validation, and capacity problems use standard HTTP status codes. Generate business-stop outcomes (not your turn, capped, out of credits, blocked) are not errors; they come back as 200 with a flag set, as described above.

StatusMeaningWhen
200OKA reply was generated, an empty turn was abandoned, or a business-stop flag is set.
401UnauthorizedMissing, unknown, or disabled API key.
422Unprocessable EntityA required field is missing or malformed (e.g. no recipient.id, a message without a timestamp, or an invalid period).
429Too Many RequestsOver 6,000 generate requests/min, or over 3,000 media-bearing generate requests/min, on one key.
503Service UnavailableA temporary global capacity guard. Back off and retry.
500Internal ErrorAn unexpected server error.

Error bodies are JSON: { "error": "unauthorized", "message": "..." }. Validation errors (422) also include a details array of { field, message } entries.

Rate limits

  • POST /v1/generateResponse: 6,000 requests per minute, per key.
  • POST /v1/generateResponse: 3,000 media-bearing requests per minute, per key.

Exceeding either limit returns 429. Need higher limits? Ask on Telegram.