API Docs

Sentiment is a pay-per-request multilingual emotion API. Identity is a Base-chain wallet address; there are no API keys. Two ways to pay: top up USDC credit and sign each request with EIP-191, or skip the account entirely and pay per request with x402. Base mainnet (chain id 8453).

Authentication — EIP-191 per-request signature

Requests charged against a prepaid balance are signed with an EIP-191 (personal_sign) signature over a deterministic message. (Paying per request via x402 instead? Skip this — the payment itself is the auth.) Send four headers with each request:

X-Wallet-Address: 0x...      (lowercase, the signer)
X-Signature: 0x...          (EIP-191 signature, 65 bytes hex)
X-Nonce: <random hex>        (unique per request — replay-protected)
X-Timestamp: <unix seconds>  (±60s skew allowed)

The signed message string (exact format, newline-delimited):

sentiment-v1
<timestamp>
<nonce>
<HTTP METHOD uppercase>
<path>

Endpoints

  • GET /api/pricing — current price per message, GPU, model limits. Public.
  • POST /api/auth/nonce — SIWE nonce for dashboard login.
  • POST /api/auth/verify — body {message, signature}{token, address}.
  • GET /api/topup/quote?amount_usd=<float> — x402 payment requirements for a top-up. Public.
  • POST /api/topup/pay — x402-protected; optional header X-Topup-Amount-Usd (defaults to $5); settle → credits your balance. Idempotent on settle tx hash.
  • GET /api/balance?wallet=<addr> — spendable balance + FIFO tranches. Public.
  • POST /api/analyze — EIP-191 auth (prepaid balance) OR x402 pay-per-request (v1 X-PAYMENT or v2 PAYMENT-SIGNATURE, no account needed); body {text, client_message_id?, keep_in_queue?}{job_id, status}. Without auth or payment, replies 402 with the exact price of your request. At capacity, replies 503 no_capacity (never charged) unless keep_in_queue: true.
  • POST /api/analyze/batch — EIP-191 auth OR x402 pay-per-request; body {texts: [...], client_message_id?, keep_in_queue?} (up to MAX_MESSAGES_PER_BATCH). Priced as the sum of the batch; billed in one debit, then split across RunPod calls automatically.
  • GET /api/jobs/:id — job status + result (28 emotions + valence). 202 while queued/processing, 200 when terminal.
  • GET /api/jobs?limit=&offset= — list your own jobs, newest first (session or EIP-191 auth; only ever returns the authenticated wallet's jobs).

Response types

All responses are JSON. Success shapes below are written as TypeScript; fields are always present unless marked optional (?). Amounts ending in _atomic are USDC atomic integers (6 decimals), _usd are floats.

// POST /api/analyze — 202 Accepted (new job)
{
  job_id: string,                 // UUID — poll GET /api/jobs/:id
  status: "queued",
  client_message_id: string | null,
  cost_atomic: number,
  estimated_cost_usd: number,
  keep_in_queue: boolean
}
// Replays (same client_message_id) return 200/202 with the existing job:
// { job_id, status, client_message_id, result?, error?, replay?: true, message?: string }

// POST /api/analyze/batch — 202 Accepted
{
  jobs: Array<{
    job_id: string,
    status: "queued",
    client_message_id: string | null,   // "<batch id>#<index>"
    cost_atomic: number
  }>,
  total_cost_atomic: number,
  total_cost_usd: number,
  count: number,
  keep_in_queue: boolean
}

// GET /api/jobs/:id — 202 while queued/processing, 200 when terminal
{
  id: string,
  status: "queued" | "processing" | "succeeded" | "failed",
  client_message_id: string | null,
  input_chars: number,
  cost_atomic: number | null,
  cost_usd: number,
  result: Result | null,          // see "Result shape" below; null until succeeded
  error: string | null,           // set when status = "failed"
  created_at: string,             // "YYYY-MM-DD HH:MM:SS" (UTC)
  completed_at: string | null
}

// GET /api/jobs?limit=&offset= — 200
{
  jobs: Array<Job & { input_text: string }>,  // Job = the /api/jobs/:id body
  total: number,
  limit: number,
  offset: number
}

// GET /api/balance?wallet= — 200
{
  wallet: string,
  balance_atomic: number,
  balance_usd: number,
  tranches: Array<{ id: number, remaining_atomic: number, expires_at: string }>
}

// GET /api/pricing — 200
{
  snapshot: {
    price_per_msg_usd: number,
    char_included: number,
    price_per_extra_1k_chars_usd: number,
    updated_at: string
  },
  model_limit: { max_chars_per_message: number, char_included: number, note: string },
  quotes: Record<string, { chars: number, price_usd: number, price_atomic: number }>
}

Errors share one envelope — {error: string, code: string, ...extras} — with a machine-readable code. Branch on code, not the message:

400 bad_text | bad_texts        invalid text/texts (extras: index?, max_chars?)
401 auth_*                      missing/invalid EIP-191 signature or session
402 insufficient_balance        extras: required_atomic, balance_atomic, topup_url
                                (plus x402 accepts[] so clients can pay inline)
402 (x402 discovery)            bare POST without auth — body carries accepts[] + price
403 —                           signature/nonce replay rejected
404 job_not_found               job id unknown or owned by another wallet
409 batch_duplicate             batch client_message_id already used — retry with a new id
413 text_too_long | batch_too_large   extras: max_chars / max, got
429 rate_limited                extras: retry_after_seconds (+ Retry-After header)
503 no_capacity                 queue full, nothing charged — extras: queue_depth,
                                max_queue_depth, retry_after_seconds (+ Retry-After header)
500 internal                    unexpected server error

Capacity, cancellation & refunds

By default, when we have no capacity your request is cancelled and refunded — you never pay for work we can't do. This happens at two points:

  • At submit: if the queue is full, the request is refused with 503 {code: "no_capacity", retry_after_seconds} before anything is debited — your balance is untouched. (Paying per request via x402? The capacity check runs before your payment is settled, so nothing is taken.)
  • While processing: if an accepted job can't reach a GPU worker in time, it is cancelled — the job ends as failed with a no capacity error and the charge is automatically refunded to your balance as a 30-day credit tranche.

Prefer to wait instead? Set keep_in_queue: true on /api/analyze or /api/analyze/batch. Your job is then accepted regardless of queue depth and is never capacity-cancelled — it stays queued until a worker picks it up. Treat these submits as fully async: store the job_id, go do something else, and poll GET /api/jobs/:id (or list GET /api/jobs) later — don't block on a synchronous wait.

# Default: cancel + refund when at capacity
POST /api/analyze  {"text": "...", "client_message_id": "uuid-1"}
→ 503 {"code": "no_capacity", "retry_after_seconds": 30, ...}   # not charged

# Opt in to waiting: stays queued, poll asynchronously
POST /api/analyze  {"text": "...", "client_message_id": "uuid-1", "keep_in_queue": true}
→ 202 {"job_id": "...", "status": "queued", "keep_in_queue": true}

Rate limits

All /api/* routes are rate-limited per client IP (default 240 requests per 60s — comfortable for 1s polling). Exceeding it returns 429 {code: "rate_limited", retry_after_seconds} with a Retry-After header; current usage is reported via X-RateLimit-Limit / X-RateLimit-Remaining. Back off when you see 429s — for high-volume work, prefer /api/analyze/batch plus modest polling over hammering /api/analyze in a loop.

Result caching

Completed job responses are cached server-side (Redis): a result is served from cache until you fetch it once, or until ~10 minutes of no requests for it, whichever comes first. Eviction is purely a performance detail — every result is permanently persisted and GET /api/jobs/:id returns it again at any time. In-flight statuses are cached for ~2s, so a status you poll may lag reality by up to that long.

Result shape

The model uses sigmoid (multi-label), so scores are in [0,1] and do NOT sum to 1. There are 28 Go Emotions labels + a valence in [-1,1].

{
  "text": "...",
  "predictions": [ {"label": "joy", "score": 0.91}, ...28 total, sorted desc ],
  "top_emotion": "joy",
  "top_score": 0.91,
  "valence": 0.82
}

curl — sign & submit a job

# 1. Build the message and sign it with your wallet (e.g. viem/cast).
TS=$(date +%s); NONCE=$(openssl rand -hex 16)
MSG="sentiment-v1
$TS
$NONCE
POST
/api/analyze"
# SIG=$(cast wallet sign --message "$MSG")   # or viem signMessage

# 2. Submit.
curl -X POST https://emotions.togoder.click/api/analyze \
  -H "Content-Type: application/json" \
  -H "X-Wallet-Address: 0xYOUR_ADDR" \
  -H "X-Signature: $SIG" \
  -H "X-Nonce: $NONCE" \
  -H "X-Timestamp: $TS" \
  -d '{"text":"I love this!","client_message_id":"uuid-1"}'

# 3. Poll (also signed — GET /api/jobs/:id is auth-scoped to your wallet;
#    sign the same message shape with method GET and path /api/jobs/<job_id>).
curl https://emotions.togoder.click/api/jobs/<job_id> \
  -H "X-Wallet-Address: 0xYOUR_ADDR" -H "X-Signature: $SIG2" \
  -H "X-Nonce: $NONCE2" -H "X-Timestamp: $TS2"

Python — sentiment analysis API example

Sign requests with eth-account and call the API with requests:

# pip install eth-account requests
import time, secrets, requests
from eth_account import Account
from eth_account.messages import encode_defunct

ACCOUNT = Account.from_key("0xYOUR_PRIVATE_KEY")
HOST = "https://emotions.togoder.click"

def signed_post(path: str, body: dict) -> dict:
    ts = str(int(time.time()))
    nonce = secrets.token_hex(16)
    msg = f"sentiment-v1\n{ts}\n{nonce}\nPOST\n{path}"
    sig = ACCOUNT.sign_message(encode_defunct(text=msg)).signature.hex()
    r = requests.post(HOST + path, json=body, headers={
        "X-Wallet-Address": ACCOUNT.address.lower(),
        "X-Signature": "0x" + sig.removeprefix("0x"),
        "X-Nonce": nonce,
        "X-Timestamp": ts,
    })
    r.raise_for_status()
    return r.json()

# Analyze one message (28 emotions + valence)
job = signed_post("/api/analyze",
                  {"text": "I love this!", "client_message_id": "uuid-1"})

# Poll for the result (GET /api/jobs/:id also requires auth — sign it too)
def signed_get(path: str) -> dict:
    ts = str(int(time.time()))
    nonce = secrets.token_hex(16)
    msg = f"sentiment-v1\n{ts}\n{nonce}\nGET\n{path}"
    sig = ACCOUNT.sign_message(encode_defunct(text=msg)).signature.hex()
    r = requests.get(HOST + path, headers={
        "X-Wallet-Address": ACCOUNT.address.lower(),
        "X-Signature": "0x" + sig.removeprefix("0x"),
        "X-Nonce": nonce,
        "X-Timestamp": ts,
    })
    r.raise_for_status()
    return r.json()

while True:
    res = signed_get(f"/api/jobs/{job['job_id']}")
    if res["status"] not in ("queued", "processing"):
        break
    time.sleep(1)

print(res["result"]["top_emotion"], res["result"]["valence"])

JavaScript — viem

import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";

const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY");
const client = createWalletClient({ account, chain: base, transport: http() });

async function signedFetch(path, method, body) {
  const ts = Math.floor(Date.now()/1000).toString();
  const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16)),
    b => b.toString(16).padStart(2,"0")).join("");
  const msg = `sentiment-v1\n${ts}\n${nonce}\n${method}\n${path}`;
  const signature = await client.signMessage({ message: msg });
  const r = await fetch("https://emotions.togoder.click" + path, {
    method,
    headers: {
      "Content-Type": "application/json",
      "X-Wallet-Address": account.address.toLowerCase(),
      "X-Signature": signature,
      "X-Nonce": nonce,
      "X-Timestamp": ts,
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  return r.json();
}

const { job_id } = await signedFetch("/api/analyze", "POST",
  { text: "I love this!", client_message_id: crypto.randomUUID() });

// GET /api/jobs/:id is auth-scoped to your wallet — sign the poll too.
let job;
do {
  await new Promise(r => setTimeout(r, 1000));
  job = await signedFetch(`/api/jobs/${job_id}`, "GET");
} while (job.status === "queued" || job.status === "processing");
console.log(job.result);

Batch — many messages in one request

POST /api/analyze/batch accepts up to MAX_MESSAGES_PER_BATCH (default 10000) texts at once. The total cost of the batch is debited in a single transaction (balance is checked first — the request fails with 402 insufficient_balance before any job is created if you can't cover the whole batch). Each text becomes its own job and is enqueued independently; the dispatcher then groups jobs into RunPod forward passes of RUNPOD_MAX_BATCH_SIZE, so a batch of N is split across ceil(N / RUNPOD_MAX_BATCH_SIZE) RunPod calls automatically.

Idempotency is per batch: pass one optional client_message_id for the whole request. It is stored per job as <id>#<index>; retrying the same batch id returns 409 batch_duplicate with no re-charge — use a new id to retry. Poll each returned job_id with GET /api/jobs/:id as usual.

# Body: {"texts": [...], "client_message_id"?: "batch-1", "keep_in_queue"?: true}
curl -X POST https://emotions.togoder.click/api/analyze/batch \
  -H "Content-Type: application/json" \
  -H "X-Wallet-Address: 0xYOUR_ADDR" \
  -H "X-Signature: $SIG" \
  -H "X-Nonce: $NONCE" \
  -H "X-Timestamp: $TS" \
  -d '{"texts":["I love this!","This is awful"],"client_message_id":"batch-1"}'

# 202 Accepted — one job per text, in input order
{
  "jobs": [
    {"job_id":"...","status":"queued","client_message_id":"batch-1#0","cost_atomic":600},
    {"job_id":"...","status":"queued","client_message_id":"batch-1#1","cost_atomic":600}
  ],
  "total_cost_atomic": 1200,
  "total_cost_usd": 0.0012,
  "count": 2,
  "keep_in_queue": false
}

Top-up flow (x402 v2)

Use @x402/fetch (protocol v2) to handle the 402 → EIP-712 USDC authorization → retry automatically:

import { wrapFetchWithPaymentFromConfig } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm";

const x402Fetch = wrapFetchWithPaymentFromConfig(fetch, {
  schemes: [{ network: "eip155:8453", client: new ExactEvmScheme(account) }],
});
const r = await x402Fetch("https://emotions.togoder.click/api/topup/pay", {
  method: "POST",
  headers: { "X-Topup-Amount-Usd": "5.00" }, // optional — omit to top up the default amount
});
const { balance_atomic, settle_tx_hash } = await r.json();

Pay per request (x402 — no account needed)

No balance, no signature headers, no sign-up: POST to /api/analyze (or /analyze/batch) bare and the 402 quotes the exact price of your request — one message, or the whole batch. Pay it with any standard x402 client and the request goes through: the payment is the auth, and the verified payer address becomes your wallet identity. The settled amount is credited to that wallet's balance and the job is charged from it in the same call. Quotes are floored at $0.001 (the facilitator's minimum settlement); anything paid above the request cost stays on your balance for future requests.

Both x402 protocol generations are supported on these endpoints:

  • v2 PAYMENT-REQUIRED response header / PAYMENT-SIGNATURE request header (@x402/fetch).
  • v1 ("regular" x402) — 402 JSON body with {x402Version: 1, accepts: [...]} / X-PAYMENT request header / X-PAYMENT-RESPONSE response header (x402-fetch, x402-axios, the x402 Python package).
// v2 — @x402/fetch (same x402Fetch as in the top-up example above)
const r = await x402Fetch("https://emotions.togoder.click/api/analyze/batch", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ texts: ["I love this!", "This is awful."] }),
});
const { jobs, payment } = await r.json(); // payment.settle_tx_hash, payment.payer

// v1 — x402-fetch (the widely used legacy client) works too
import { wrapFetchWithPayment } from "x402-fetch";
import { privateKeyToAccount } from "viem/accounts";

const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY");
const fetchWithPay = wrapFetchWithPayment(fetch, account);
const r1 = await fetchWithPay("https://emotions.togoder.click/api/analyze", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ text: "I love this!" }),
});
const { job_id } = await r1.json();

Fetching the result: GET /api/jobs/:id is scoped to the wallet that paid, so poll it with an EIP-191 signature from the same key you paid with.

Amounts are USDC atomic integers (6 decimals; 1 USDC = 1,000,000 atomic). Top-up credit lasts 1 year, refund credit 30 days; debits consume the oldest tranche first (FIFO).