Two auth schemes, by endpoint type

Authentication.

Plugin/content endpoints (/ingest) use a per-site signed-request scheme. Everything else (/auth, /account, /admin) uses a login session — a cookie for a browser, a Bearer token for everything else.

Site-signed requests (Relevant Bot, AutoPoster)

site_id — public identifier for the connected WordPress site, issued once when the site is provisioned.
site_secret — random secret, generated on connect, never re-shown.

Every request carries three headers: X-JacAI-Site, X-JacAI-Timestamp, X-JacAI-Signature. The signature is HMAC-SHA256( timestamp + "\nPOST\n" + path + "\n" + sha256(body), site_secret ), hex-encoded. Requests outside the configured timestamp tolerance (5 minutes by default) are rejected, and an identical signed request replayed a second time is rejected too — signature claims are recorded and checked.

Session auth (login, account, admin)

POST /auth/login sets an HttpOnly jacai_session cookie for browser callers and returns a CSRF token. Non-browser clients that need a raw Bearer token request one explicitly with X-JacAI-Token-Response: 1.

A non-browser client sends that same token as a Bearer token instead of a cookie: Authorization: Bearer <session_token>. Both paths resolve to the same session — there's no separate API-key system yet.

No auth required

Health check.

GET/health
{
  "ok": true,
  "service": "api.jacai.ai",
  "phase": "ingest-slice + auth-credit-system",
  "db": "connected",
  "time": "2026-08-25T05:42:09+00:00"
}

Returns 503 with "db":"unavailable" if the database connection fails — safe to poll for uptime monitoring (see status.jacai.ai).

Relevant Bot → JacAI

Content ingest.

Relevant Bot scans a customer's WordPress site — posts, pages, WooCommerce products — and pushes normalized content items here. Re-sending an unchanged item is a no-op; a changed content_hash updates it in place.

POST/ingest

Body — an array of content items:

[
  {
    "id": "wp-382",
    "type": "post",
    "title": "How to Repair a Leaking Gutter",
    "url": "https://example.com/post",
    "excerpt": "...",
    "content": "...",
    "categories": ["Gutter Repair"],
    "tags": ["DIY", "Rain Gutters"],
    "language": "en"
  }
]

Response:

{
  "ok": true,
  "received": 1,
  "created": 1,
  "updated": 0,
  "unchanged": 0,
  "skipped": 0,
  "items": [
    { "external_id": "wp-382", "status": "created" }
  ]
}

Rate-limited per site (240 requests/minute). A replayed request with an already-seen signature returns 401; a missing or invalid signature also returns 401.

No auth required to call

Register, login, password reset.

POST/auth/register

Body: { email, password, name, org_name?, plan_code? }. Creates the user, an organization they own, grants the plan's signup-bonus credits if any, and logs them in immediately.

{
  "ok": true,
  "user": { "id": 1, "email": "...", "name": "..." },
  "organization": { "id": 1, "name": "...", "plan": "starter" },
  "csrf_token": "..."
}
POST/auth/login

Body: { email, password }. Wrong email and wrong password return the identical 401 message — no account-enumeration leak.

{
  "ok": true,
  "user": { "id": 1, "email": "...", "is_platform_admin": false },
  "organizations": [{ "organization_id": 1, "role": "owner" }],
  "csrf_token": "..."
}
POST/auth/logout

Revokes the current session only (not every session on the account). Response: { "ok": true }.

POST/auth/forgot-password

Body: { email }. Always returns 200 with a generic message, whether or not the account exists.

{ "ok": true, "message": "If that email has an account, a reset link has been sent." }
POST/auth/reset-password

Body: { token, new_password }. Consumes the token, sets the new password, and revokes every other live session on the account — a reset is treated as a signal the old sessions shouldn't be trusted.

Session required

Account.

What the WordPress plugins and any future dashboard call before allowing an action — the caller's resolved plan, limits, and credit balance for one organization.

EndpointWhat it returns
GET/accountThe logged-in user plus every organization they belong to and their role in each.
GET/account/entitlements?organization_id=Resolved plan limits — sites, social accounts, users, languages/campaign, OAuth platforms, BYOK/API access — plus credit_balance. Omit organization_id to use the caller's first membership.
GET/account/usage?organization_id=&limit=Recent credit-ledger entries and usage events for the account dashboard's usage tab. limit caps at 200.
Session required — org-scoped or platform-wide

Admin.

Every admin endpoint is org-scoped by default: pass organization_id and you need owner/admin on that org. Omit it and you need to be a platform admin — a flag reserved for JacAI's own operator account, set by hand in the database, never through an endpoint. An org's own "master" role never becomes a platform-wide superadmin.

EndpointWhat it does
GET/admin/overview[?organization_id=]Platform totals (orgs, users, credits outstanding by plan) or one org's snapshot.
GET/admin/users[?organization_id=]List users platform-wide or within one org.
POST/admin/users/editBody: { user_id, organization_id?, role?, status?, is_platform_admin? } — each field independently scope-checked. Only an org owner can promote someone else to owner; only a platform admin can suspend an account or grant the platform-admin flag.
GET/admin/transactions?organization_id=&kind=&limit=&offset=The full, filterable credit ledger.
POST/admin/transferBody: { from_organization_id, to_organization_id, credits, note? } — zero-sum org-to-org credit transfer.
POST/admin/mintBody: { organization_id, credits, revenue_category_id, note } — platform-admin only. Grants extra credits (promo, comp, referral bonus, billing correction), always tagged to a reason.
GET / POST/admin/categoriesList or create the reason tags /admin/mint grants are attributed to.
Credits are spend-only across every endpoint here — included plan allowance, purchased top-ups, and admin grants can all be spent on JacAI usage, but there is no redemption/cashout path anywhere in this API.
Consistent across every endpoint

Errors.

Every error response is JSON with the same shape, and the HTTP status carries the real meaning — this API doesn't return 200 with an error buried in the body.

{ "error": "unauthorized", "message": "invalid email or password" }
StatusMeaning
400Bad request — missing or invalid fields.
401Missing, invalid, expired, or revoked session/signature.
403Authenticated, but not allowed to do this (wrong org role, not a platform admin, suspended account).
404The referenced resource doesn't exist.
409Conflict — e.g. an account already exists with that email.
422Understood the request, but it can't be processed as given (e.g. unknown plan code, insufficient balance).
503Database temporarily unavailable — see /health.