ViralHunt API v1

Query trending content, create board cards, and manage your team programmatically.

Authentication

All endpoints (except this docs page) require an API key. Keys are aMember SoftSale license keys assigned to your user account.

Sending Your Key

Recommended: Use the Authorization header:

Authorization: Bearer YOUR_API_KEY

Alternative: Query parameter (less secure, useful for quick tests):

GET /api/v1/trending.php?source=rss&api_key=YOUR_API_KEY

Getting a Key

Contact your admin to generate a SoftSale license key in the aMember admin panel. The key is tied to your user account and inherits your organization membership.

Rate Limits

API requests are limited to 100 requests per hour per API key (fixed window).

Every response includes rate limit headers:

HeaderDescription
X-RateLimit-LimitMax requests per window
X-RateLimit-RemainingRequests remaining in current window
X-RateLimit-ResetUnix timestamp when the window resets

When exceeded, the API returns 429 Too Many Requests.

Error Codes

Errors return a JSON envelope:

{
  "success": false,
  "error": {
    "code": "auth_required",
    "message": "API key required."
  }
}
HTTPCodeMeaning
401auth_requiredNo API key provided
401invalid_keyKey not found or revoked
401key_expiredLicense expired
403no_organizationUser has no organization
403insufficient_scopeThe token's permission group does not include what this request needs (details.required: read, write, review, publish or manage; details.group). Groups: reader read · writer read, write · reviewer read, review · publisher read, write, review, publish · full everything. GET /account returns the token's own group under permissions.
404not_foundEndpoint not found
405method_not_allowedWrong HTTP method
422validation_errorInvalid parameters
402insufficient_creditsThe request costs credits the account does not have (free plan allowance spent). details.required and details.balance say how many.
429daily_limit_reachedThe free plan's daily query quota is used; details.reset is the unix time it resets.
429rate_limitedToo many requests
500server_errorInternal server error

Best Time to Post

GET /api/v1/best-time.php

When the posts that went viral on a network were published, measured on the posts of the last 365 days that carry their own publish time. Same engine as viralhunt.io/best-time-to-post. Every response says how many posts it rests on; read confidence before quoting an hour.

ParameterTypeDescription
network REQUIREDstringtiktok, instagram, x, facebook, pinterest, bluesky, mastodon, douyin, tumblr, reddit
timezonestringIANA zone the hours are expressed in (default UTC), e.g. America/New_York. Weekday aggregates stay in UTC.
keywordstringRecompute the slots on posts whose caption contains this word. Under 200 matching posts the response returns the network-wide slots with fallback: true and keyword_sample.

Response

{
  "success": true,
  "data": {
    "network": "instagram", "timezone": "Europe/Madrid", "utc_offset_hours": 2,
    "confidence": "high",
    "sample": { "posts": 18432, "window": "365d", "from": "2025-09-08", "to": "2026-09-07", "min_posts_per_slot": 37, "top10_threshold": 41200 },
    "best_slots": [
      { "weekday": 4, "weekday_name": "Thursday", "hour": 20, "weekday_utc": 4, "hour_utc": 18,
        "posts": 112, "avg_engagement": 96410, "hit_rate_pct": 19.6 }
    ],
    "worst_slot": { "...": "same shape" },
    "today": { "weekday_name": "Tuesday", "best_hours": [ { "hour": 21, "posts": 88, "avg_engagement": 71020 } ] },
    "by_hour": [ { "hour": 0, "hour_utc": 22, "posts": 640, "avg_engagement": 30110 } ],
    "by_weekday": [ { "weekday_utc": 4, "weekday_name_utc": "Thursday", "posts": 2610, "avg_engagement": 48900 } ],
    "proof_posts": [ { "engagement": 2140000, "date": "2026-06-12", "caption": "…", "url": "https://…", "handle": "…" } ],
    "how_to_read": "…",
    "page_url": "https://viralhunt.io/best-time-to-post/instagram"
  }
}

hit_rate_pct is the share of a slot's posts that reached the network's top 10%. An average can be carried by one giant post; a hit rate cannot. A slot is only named with a real sample (min_posts_per_slot).

Top Hashtags

GET /api/v1/hashtags.php

Hashtags extracted from the captions of every post we hold, rolled up per network. Totals are over the whole corpus (window.type = "all_time", with updated_at); there is no per-day hashtag history.

ParameterTypeDescription
networkstringOne of tiktok, instagram, x, facebook, pinterest, bluesky, mastodon, douyin, tumblr. Omit for all networks summed (rows then carry networks[]).
qstringOnly hashtags containing this word (niche filter).
hashtagstringOne tag (with or without #): its breakdown by network plus top_posts. Ignores the other filters.
sortstringengagement (default), posts, per_post
min_postsintMinimum posts a tag needs to be listed (default 3)
page, per_pageintPagination (max 100)
{
  "success": true,
  "data": {
    "hashtags": [
      { "hashtag": "fyp", "network": "tiktok", "posts": 14210, "total_engagement": 912400000, "per_post": 64207,
        "page_url": "https://viralhunt.io/hashtag/fyp" }
    ],
    "network": "tiktok", "query": null, "sort": "engagement", "min_posts": 3,
    "window": { "type": "all_time", "updated_at": "2026-09-08 03:10:12", "note": "…" }
  },
  "meta": { "page": 1, "per_page": 20, "total": 38120, "total_pages": 1906, "has_more": true }
}

Trending Sounds

GET /api/v1/sounds.php

Audio trending on TikTok, Instagram Reels and Douyin, ranked by the engagement of the posts that used it, cross-network sounds first. A sound is listed only when several different accounts used it: audio used by one account is that account's voiceover, whatever language names it. Same engine as viralhunt.io/sounds.

ParameterTypeDescription
networkstringtiktok, instagram, douyin or all (default)
qstringWords in the title or artist
cross_only1Only sounds trending on more than one network
slugstringOne sound (from a previous result): its detail plus posts[] that used it, with links
page, per_pageintPagination (max 100)
{
  "success": true,
  "data": {
    "sounds": [
      { "slug": "espresso", "title": "Espresso", "artist": "Sabrina Carpenter",
        "networks": { "tiktok": { "posts": 212, "creators": 180, "eng": 48100000, "per_post": 226886 },
                      "instagram": { "posts": 9, "creators": 7, "eng": 1210000, "per_post": 134444 } },
        "cross": true, "stronger": "tiktok",
        "sample": { "posts": 221, "creators": 187 }, "total_engagement": 49310000,
        "page_url": "https://viralhunt.io/sounds/espresso" }
    ],
    "window": { "type": "all_time", "note": "…" }
  },
  "meta": { "page": 1, "per_page": 20, "total": 150, "total_pages": 8, "has_more": true }
}

Best Communities

GET /api/v1/communities.php

Where to post a topic, from our own measurements: subreddits (network=reddit) and Bluesky custom feeds (network=bluesky). Same engines as reddit/communities and bluesky/feeds.

ParameterTypeDescription
networkstringreddit (default) or bluesky
qstringTopic words matched against the community name (Bluesky: name, description and slug)
sortstringreddit: upside (default, = peak_per_1k), members, peak, posts. bluesky: posts (default), avg_likes, top_likes, feed_likes
min_members, max_membersintreddit: size bounds. Smaller rooms are easier to climb.
subredditstringreddit: one community by name. Adds timing (best UTC hours and day the climbing posts were posted, with sample), pace, flairs, type_mix, top_posts, similar.
feedstringbluesky: one feed by slug. Adds top_posts, top_authors, top_hashtags.
page, per_pageintPagination (max 100)
{
  "success": true,
  "data": {
    "communities": [
      { "subreddit": "running", "slug": "running", "members": 3900000, "peak_per_1k": 14,
        "peak_score": 54800, "peak_comments": 2100,
        "sample": { "posts": 412 }, "window": { "from": "2019-03-02", "to": "2026-09-05" },
        "url": "https://www.reddit.com/r/running/", "page_url": "https://viralhunt.io/reddit/communities/running" }
    ],
    "network": "reddit", "sort": "upside",
    "gates": { "min_posts_held": 25, "min_members": 5000, "min_peak_score": 100, "adult_excluded": true },
    "how_to_read": "…"
  },
  "meta": { "page": 1, "per_page": 20, "total": 5987, "total_pages": 300, "has_more": true }
}

peak_per_1k is the best score we hold divided by members in thousands: upside relative to size. No average score is published, because for a swept community our sample is its greatest hits and an average of those is comparable to nothing.

Create Card

POST /api/v1/cards.php

Create a new card on the Editorial Board.

Auto-extraction: If title is omitted but post_url is provided, the API will fetch the URL and extract the title, description, image, and platform automatically from Open Graph / meta tags. If extraction fails, a 422 error is returned asking for a manual title.

FieldTypeDescription
titlestringCard title. Required unless post_url is provided (auto-extracted from URL).
descriptionstringCard description (auto-filled from URL if not provided)
prioritystringlow, medium (default), high, urgent
due_datestringYYYY-MM-DD or YYYY-MM-DD HH:MM:SS
post_urlstringSource post URL. If title is omitted, metadata is auto-extracted from this URL.
image_urlstringThumbnail image URL (auto-filled from URL if not provided)
platformstringSource platform name (auto-detected from URL domain if not provided)
category_idintBoard category ID
assigned_to_user_idintAssign to this team member
board_column_idintTarget column (default: first "To Do" column)
linksstringAdditional links
notesstringNotes

Example: With Title

curl -X POST -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Review viral TikTok video","priority":"high","post_url":"https://tiktok.com/...","platform":"tiktok"}' \
  https://viralhunt.io/tool/api/v1/cards.php
{
  "success": true,
  "data": {
    "card_id": 42,
    "title": "Review viral TikTok video",
    "status": "pending",
    "board_column_id": 1,
    "position": 5
  }
}

Example: URL Only (Auto-Extract)

curl -X POST -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"post_url":"https://techcrunch.com/2026/02/19/some-article/","assigned_to_user_id":7,"priority":"high"}' \
  https://viralhunt.io/tool/api/v1/cards.php
{
  "success": true,
  "data": {
    "card_id": 43,
    "title": "Some Article Title Extracted From Page",
    "status": "pending",
    "board_column_id": 1,
    "position": 6,
    "auto_extracted": true,
    "description": "Description extracted from og:description...",
    "image_url": "https://techcrunch.com/img/hero.jpg",
    "platform": "web"
  }
}

Move a card

Same endpoint, action: "move". Any member of the organization may move a card. Moving into the column flagged is_done completes the card (sets status: completed and completed_at); a comment saying "done" does not.

curl -X POST -H "Authorization: Bearer YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"action":"move","card_id":42,"board_column_id":3,"position":0}' \
  "https://viralhunt.io/tool/api/v1/cards.php"
FieldTypeDescription
action REQUIREDstringmove
card_id REQUIREDintThe card
board_column_id REQUIREDintTarget column, from /columns or /context
positionintPosition inside the column, 0 = top (default 0)
card_type / card_type_idstring / intOn create: what the card is (Post, Note, Article, Video…), see /card-types

Schedule / Publish to Social Networks

Publish or schedule a post — text plus media (e.g. a video) — to a project's connected social accounts. Fans out to one post per target, like the in-app composer. Requires the org to have connected accounts (Agency plans).

GET /api/v1/schedule.php?action=targets

Discover where you can post. Optional: &project=NAME or &project_id=ID (defaults to the org's first project). Returns projects[] — EVERY project in the org, each with {id, name, timezone, networks[], account_count} so you can pick which project + networks — plus project (the resolved one) and accounts[] (its postable accounts: account_id, network, name).

POST /api/v1/schedule.php?action=upload

Upload one media file (multipart field file; mp4/mov/jpg/png/gif/webp, up to 50 MB through the API) → { "url", "type" }. Use that url in media. Skip if your media is already a public URL; a bigger video must already live at a public https URL. Check media_limits on targets before sending a big video: an image over a network's ceiling is re-encoded to fit, a video over its size or length is refused for that network only, with the numbers.

POST /api/v1/schedule.php

Publish now or schedule. JSON body:

{
  "project": "Viral Hunt",                 // or "project_id": N; omit = first project
  "body": "your caption",
  "media": ["https://.../video.mp4"],      // omit for text-only
  "target_account_ids": [12, 15],          // omit = ALL connected accounts in the project
  "scheduled_at": "2026-08-01T15:30:00Z",  // ISO-8601 UTC; omit = publish now
  "first_comment": "optional first comment",
  "draft": true,                           // save in Drafts, not sent, until a person approves
  "overrides": {"bluesky": {"body": "…300 chars…", "thread": [{"body": "part 2"}]}, "12": {"media": ["…"], "first_comment": "…"}},  // per network or per account_id: body, media, first_comment, thread
  "card_id": 123,                          // the board card it comes from
  "design": {"template": "studio-news-frame", "format": "feed", "variables": {"text": "…", "image": "https://…"}}  // the picture as a filled template; the app renders it on approval (draft only)
}

Returns { id, status, targets, results, warnings }. Check status later with GET /api/v1/schedule.php?action=get&id=ID (its answer also carries events[], the delivery log of the post, and support_text, the text to paste into a support message). With draft: true (or when the organization's review mode is "all") it returns status: "draft" and a review_url; nothing is sent.

Drafts and review

Why the flow. Hand publishing to several accounts a day lets the expensive mistakes through (an unverified claim, someone else's clip, the wrong language, a spammy caption), and the networks answer with lost reach, removed posts and strikes. A link in the body cuts the reach on every network, so the body names the source and the link goes in author_notes (private, read by the reviewer) or in the first comment. Every review is stored against the author with its scores and codes (review_stats), so the owner fixes habits, not posts.

The reviewer job, every ten minutes. A token of its own (API Access → Create access: a new agent "Reviewer" with the Reviewer permission group, which grades but cannot draft or approve; tick "Their OK publishes" there or on Team, and on Team turn on "an OK in Review publishes by itself") runs this round on a schedule: GET /policy.php (the content rule, read first every time), GET ?action=drafts&stage=review&all=1&needs_review=1 (the posts with no verdict on their current content), one POST ?action=review per post with verdict, scores, warnings per network and a note the author reads. The server recalculates the verdict from the scores, ignores an OK on a post the same user created (by user, not token: the reviewer is its own agent member), releases only on a trusted token's OK with the time at least 15 minutes away, drops the OK when the post is edited, and keeps a post whose scores sit outside thresholds.auto_send (risk10 > 5, fake_news < 70, image_match < 60) for a person even on a trusted OK (sent.skipped names the rule: risk_threshold). Drafts rows carry project_lang, the project's declared language: a post in another language is fix_language. The prompt that sets it up is in the agent guide inside the app and in the skill, section 5c.

CallWhat it does
GET ?action=drafts[&all=1]The drafts waiting (this project, or every project), each with its review {score, verdict, entries[]}
POST ?action=update {id, body?, media?, overrides?, scheduled_at?, target_account_ids?|networks?, first_comment?}Edit a draft in place. overrides is merged per account (send only the accounts you change; a key set to null removes that copy; overrides_replace: true replaces the whole map)
update by a trusted reviewerA reviewer marked "Their OK publishes" may change only overrides (a per-account short copy for a network that cuts) and kind on someone else's post; the body, the picture, the accounts and the time stay the author's (403 otherwise). Every account in targets carries text_limit
kind on create and updateWhat the post IS: fact, meme, quote, opinion or promo. The reviewer applies only that kind's rules (a meme is never asked for a source or a DOI). A post made from a board card takes the card's category when omitted; a person can change it on the Drafts page. Returned on drafts and get
author_notes on create and updateWhat the author tells the reviewer and nobody publishes: the source link above all (links in the body cut the reach on every network, so the body names the source and the link lives here or in the first comment), where the picture comes from, what was verified. Returned on drafts and get; part of the content hash, so adding it asks the reviewer for a fresh verdict
POST ?action=review {id, verdict: ok|fix|block, score?, scores?, warnings?[{code,network,text,severity}], note?, content_hash?}Append a review (an agent checking terms, facts, tone, grammar). With content_hash (the drafts row's value when the post was read) the review is written only if the post is still that content; otherwise 409 content_changed with the current hash and nothing is stored
POST ?action=approve {id, translations?: [{project_id, lang?, account_ids?, image?, send_mode?}]}Send the draft (owner/admin token; or a member token on its OWN post once the reviewer graded it ok or fix, when the owner ticked "Publishes own posts after the grade" for that member on Team; never a block). A draft blocked by its last review cannot be approved. translations makes the translated draft in each linked project at the same time (see translate)
GET ?action=targets → media_limitsPer network: image_bytes (a bigger image is re-encoded to fit before sending), video_bytes and video_seconds (a bigger or longer video is refused for that network only, the post still goes to the others, the result says so), images_max, text. Bluesky 2 MB images, 300 MB / 10 min video; X 512 MB / 2:20 without Premium; Threads 1 GB / 5 min; Instagram 300 MB; LinkedIn 5 GB / 15 min; TikTok 4 GB / 10 min
POST ?action=translate {id, project_id, lang?, account_ids?, image?, send_mode?}The same post in a linked project's language: the copy, the first comment, the per-network copies, the thread and the text variables of a design are translated (adapted, hashtags included; links, mentions and numbers kept). A design keeps its picture and comes back with needs_png + render: render it, update {id, png}, then approve if wanted. A plain picture stays unless image hands the translated one. send_mode: send (owner/admin) sends at once when there is nothing to render. targets lists each project's lang and translates_to
POST ?action=cancel {id}Drop a draft
GET ?action=edit_log[&since=ISO][&id=N]What people changed in drafts, field by field (before and after), with who and when. Drafts with submitted_via: api came from an agent: those edits are the lesson for the next batch
GET ?action=drafts&stage=review&all=1&needs_review=1The reviewer's queue: posts in Review with no verdict on their current content (never reviewed, or edited after the last verdict). Every draft row carries needs_review, valid_verdict, content_hash and updated_at
GET ?action=review_stats&days=7|14|30Flags per collaborator (owner, admin, or a reviewer marked "Their OK publishes"): per author, reviews, ok / fix / block, flags, codes (how many of each rule, e.g. fix_doi), avg_risk10, last_flag_at. The same table the Team page shows by week, fortnight and month
GET /api/v1/policy.phpThe content rule a reviewer applies before any OK: what blocks, what goes back to Drafts, the scores and thresholds the server recalculates the verdict with, the extra readings a review carries (risk10 1 safe to 10 breaks a network's terms, image_match, ai_written), the DOI rule (fix_doi), what changes per network. Read it at the start of every review pass; a change here reaches every agent at its next pass

Example: publish a video now

curl -X POST -H "Authorization: Bearer YOUR_KEY" \
  -F "file=@video.mp4" \
  "https://viralhunt.io/tool/api/v1/schedule.php?action=upload"
# then, with the returned url:
curl -X POST -H "Authorization: Bearer YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"project":"Viral Hunt","body":"New drop","media":["URL_FROM_UPLOAD"]}' \
  https://viralhunt.io/tool/api/v1/schedule.php

Team Members

GET /api/v1/members.php

List members of your organization.

curl -H "Authorization: Bearer YOUR_KEY" \
  https://viralhunt.io/tool/api/v1/members.php
{
  "success": true,
  "data": {
    "members": [
      {"user_id": 1, "user_name": "Jane Doe", "user_email": "jane@example.com", "role": "owner"},
      {"user_id": 5, "user_name": "John", "user_email": "john@example.com", "role": "member"}
    ]
  }
}

Organization Info

GET /api/v1/organization.php

Get details about your organization.

curl -H "Authorization: Bearer YOUR_KEY" \
  https://viralhunt.io/tool/api/v1/organization.php
{
  "success": true,
  "data": {
    "id": 1,
    "name": "My Team",
    "team_size": 5,
    "timezone": "America/New_York",
    "niche_tags": ["tech", "AI", "startups"],
    "created_at": "2024-06-15 10:00:00"
  }
}

Board Columns

GET /api/v1/columns.php

List Editorial Board columns. Auto-creates defaults if none exist.

curl -H "Authorization: Bearer YOUR_KEY" \
  https://viralhunt.io/tool/api/v1/columns.php
{
  "success": true,
  "data": {
    "columns": [
      {"id": 1, "name": "To Review", "color": "#6366f1", "position": 0, "is_default": true, "is_done": false},
      {"id": 2, "name": "In Progress", "color": "#f59e0b", "position": 1, "is_default": false, "is_done": false},
      {"id": 5, "name": "Published", "color": "#10b981", "position": 4, "is_default": false, "is_done": true}
    ]
  }
}

Board Categories

GET /api/v1/categories.php

List active board categories for tagging cards.

curl -H "Authorization: Bearer YOUR_KEY" \
  https://viralhunt.io/tool/api/v1/categories.php
{
  "success": true,
  "data": {
    "categories": [
      {"id": 1, "name": "General", "color": "#6b7280", "icon": "fa-folder"},
      {"id": 2, "name": "Urgent", "color": "#ef4444", "icon": "fa-fire"},
      {"id": 3, "name": "Ideas", "color": "#8b5cf6", "icon": "fa-lightbulb"}
    ]
  }
}

News Categories

GET /api/v1/news-categories.php

The categories of the news corpus (source=rss), with how many feeds carry each and how many articles arrived in the last 7 and 30 days. Pass a name or id as category= on trending.php?source=rss. Not the Editorial Board categories above. Technology and Artificial Intelligence are separate: for AI content ask for both.

curl -H "Authorization: Bearer YOUR_KEY" \
  https://viralhunt.io/tool/api/v1/news-categories.php
{
  "success": true,
  "data": {
    "categories": [
      {"id": 4, "name": "Politics", "feeds": 18, "posts_7d": 1240, "posts_30d": 5310},
      {"id": 3, "name": "Technology", "feeds": 22, "posts_7d": 410, "posts_30d": 1790},
      {"id": 15, "name": "Artificial Intelligence", "feeds": 6, "posts_7d": 120, "posts_30d": 530}
    ],
    "how_to_use": "trending.php?source=rss&category= ..."
  }
}

Workspace Context

GET /api/v1/context.php

Get combined workspace context in a single call: organization info, team members, board columns, and categories. Ideal for bot integrations that need full context before taking actions.

curl -H "Authorization: Bearer YOUR_KEY" \
  https://viralhunt.io/tool/api/v1/context.php
{
  "success": true,
  "data": {
    "organization": {
      "id": 1,
      "name": "My Team",
      "team_size": 5,
      "timezone": "America/New_York",
      "niche_tags": ["tech", "AI", "startups"],
      "created_at": "2024-06-15 10:00:00"
    },
    "members": [
      {"user_id": 1, "user_name": "Jane Doe", "user_email": "jane@example.com", "role": "owner"},
      {"user_id": 5, "user_name": "John", "user_email": "john@example.com", "role": "member"}
    ],
    "columns": [
      {"id": 1, "name": "To Review", "color": "#6366f1", "position": 0, "is_default": true, "is_done": false},
      {"id": 5, "name": "Published", "color": "#10b981", "position": 4, "is_default": false, "is_done": true}
    ],
    "categories": [
      {"id": 1, "name": "General", "color": "#6b7280", "icon": "fa-folder"},
      {"id": 2, "name": "Urgent", "color": "#ef4444", "icon": "fa-fire"}
    ]
  }
}

Pending Cards

GET /api/v1/pending.php

Get pending and in-progress cards grouped by assigned team member. Useful for bot reminders and workload overview.

ParameterTypeDescription
user_idintFilter to a specific team member
statusstringpending, in_progress, or omit for all non-completed
curl -H "Authorization: Bearer YOUR_KEY" \
  https://viralhunt.io/tool/api/v1/pending.php
{
  "success": true,
  "data": {
    "summary": {
      "total_pending": 8,
      "total_urgent": 2,
      "total_overdue": 1,
      "total_unassigned": 1
    },
    "members": [
      {
        "user_id": 7,
        "user_name": "Alejandra Galaz",
        "total": 3,
        "urgent": 1,
        "overdue": 1,
        "cards": [
          {
            "card_id": 42,
            "title": "Review: NASA Starliner Report",
            "priority": "high",
            "status": "pending",
            "due_date": "2026-02-18",
            "is_overdue": true,
            "column_name": "To Review",
            "post_url": "https://nasa.gov/..."
          }
        ]
      }
    ],
    "unassigned": [...]
  }
}

GET /account

GET /api/v1/account.php

Who the key belongs to, the plan, and the credit balance. Call it first: it tells an agent what it may do before it tries.

Two meters. Daily quota: free accounts may make 24 content queries a day (/trending, /best-time, /hashtags, /sounds, /communities, /url-meta), counted together; the response carries X-Daily-Limit, X-Daily-Remaining and X-Daily-Reset, and the 25th answers 429 daily_limit_reached. Paid plans have no daily cap, only their hourly rate limit. Credits: aMember credits charged per successful call on endpoints that do real work (template renders, images, video, when they exist); queries never spend credits. A call that costs credits carries X-Credits-Cost and X-Credits-Balance, and answers 402 insufficient_credits when the balance is short. Free accounts also get an API key with 60 requests an hour, see trending content with a 72 hour delay and with engagement numbers withheld (metrics_hidden: true), the same as the web app; publishing needs a paid plan.

{
  "success": true,
  "data": {
    "user_id": 12, "organization_id": 7, "role": "owner",
    "plan": {"key": "free", "label": "Free", "group": "free", "requests_per_hour": 60, "can_publish": false},
    "daily_queries": {"limit": 24, "used_today": 3, "remaining": 21, "resets_at": "2026-09-15T00:00:00+00:00"},
    "credits": {"enabled": true, "balance": 50, "free_monthly_allowance": 50, "note": "Credits pay for rendering work (templates, images, video). Queries do not spend credits."},
    "endpoints": {"trending": {"credits": 0, "counts_toward_daily_quota": true}, "templates": {"credits": 0, "counts_toward_daily_quota": false}},
    "free_plan_notes": ["Trending content is served with a 72 hour delay on the free plan.", "Publishing and scheduling need a connected account, which paid plans include."]
  }
}

My Cards

GET /api/v1/my-cards.php

The cards assigned to the member the token belongs to. When the token belongs to an agent member (created in Account → API Access), this is the agent's own workload: the loop is read this list, validate the source, comment progress, move the card to the is_done column.

ParameterTypeDescription
statusstringpending, in_progress, completed, all (default: everything not completed)
project / project_idstring / intRestrict to one project (brand) by name or id
page, per_pageintPagination (max 100)
{
  "success": true,
  "data": {
    "cards": [
      { "card_id": 42, "title": "…", "description": "…", "status": "pending", "priority": "high", "due_date": "2026-09-12",
        "post_url": "https://…", "image_url": "https://…", "platform": "tiktok",
        "board_column_id": 1, "column_name": "To Do", "column_color": "#6b7280",
        "category_id": 3, "category_name": "Memes", "comment_count": 2,
        "project_id": 1, "project_name": "My Brand", "created_at": "…" }
    ]
  },
  "meta": { "page": 1, "per_page": 20, "total": 3, "total_pages": 1, "has_more": false }
}

Card Comments

GET /api/v1/comments.php?card_id=N
POST /api/v1/comments.php

Any active member may read and comment on any card of the organization, like the web board. A new comment notifies the card's assignee (push, and email when they opted in) and is mirrored to Team Chat's #board channel.

curl -X POST -H "Authorization: Bearer YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"card_id":42,"comment":"Source checked, drafting now."}' \
  "https://viralhunt.io/tool/api/v1/comments.php"
FieldTypeDescription
card_id REQUIREDintThe card (GET: query string, POST: JSON body)
comment REQUIRED (POST)stringThe text. Plain text; @mentions are not parsed here.
{ "success": true, "data": { "card_id": 42, "comments": [ { "id": 7, "user_id": 900000001, "user_name": "Radar agent", "comment_text": "…", "created_at": "…" } ] } }

Card Types

GET /api/v1/card-types.php

What a card is (Post, Note, Article, Video…), next to the category that says what it is about. Pass the name as card_type or the id as card_type_id when creating a card. The four defaults are seeded on first use; owners and admins add more from the board settings. Before the migration runs the list is empty and carries unmigrated: true.

{ "success": true, "data": { "types": [ { "id": 1, "name": "Post" }, { "id": 2, "name": "Note" }, { "id": 3, "name": "Article" }, { "id": 4, "name": "Video" } ] } }

What Performed

GET /api/v1/stats.php[?project=NAME|project_id=N][&network=tiktok][&days=30][&limit=20]

The engagement of the posts published through ViralHunt, as the networks report it back: by_network, by_project and top_posts (with permalinks). ready: false means nothing has been collected yet for this organization. The way to answer "what worked", and to look for more like the winners with /search.

{ "success": true, "data": { "ready": true, "days": 30,
    "by_network": [ { "network": "instagram", "posts": 18, "impressions": 412000, "likes": 9300, "comments": 410 } ],
    "by_project":  [ { "project_id": 1, "project": "Cerebro Digital", "posts": 42, "impressions": 980000 } ],
    "top_posts":   [ { "id": 787, "network": "facebook", "permalink": "https://facebook.com/…", "impressions": 106556, "body": "…" } ] } }

Calendar

GET /api/v1/calendar.php?date=today&days=7[&category=science,technology][&kind=observance|event|birth|death][&min_importance=2][&lang=en|es][&q=][&unused=1]

The calendar base: international days (UN, UNESCO, WHO) and anniversaries of science, technology, space, health, the environment and civilization (Wikipedia and a verified seed), each with why: what is celebrated or what happened, and why it matters. Write the caption from it. An observance repeats every year (since_year); an event, birth or death carries year and years_ago. importance 3 = most brands can post about it, 2 = worth it inside its category, 1 = niche. image_url is a free Wikipedia thumbnail when there is one (check the article's licence before using it under a brand).

GET ?stats=1 counts the rows per category and kind. POST {"action":"mark_used","calendar_id":N,"note":"…"} records that this organization posted about it this year, so unused=1 skips it. Unknown parameters and categories are a 422.

Quotes

GET /api/v1/quotes.php?category=stoicism[&categories=a,b][&lang=en|es][&max_chars=180][&rights=public_domain|any][&author=][&sort=score|random][&unused=1][&per_page=10]

A quotes base for daily-quote series, ranked by our popularity score. Each quote carries text, author, author_context (description, born, died), work, categories, rights, popularity (high, medium, low) and author_image (portrait url, licence, credit, source page). Default rights=public_domain: authors dead more than 70 years, the safe set to publish under a brand; rights=any adds the rest, flagged restricted. max_chars defaults to 180, what the quote templates fit. unused=1 leaves out what this organization already used.

GET ?categories_list=1 lists the categories held with their counts. POST {"action":"mark_used","quote_id":N,"note":"…"} marks a quote used for this organization, so tomorrow's pick is a different one.

Recipes (standing orders)

GET /api/v1/recipes.php[?project_id=N][&active=1]

What a person saved in the app as a standing order for the brand: each recipe carries source (quotes, trending, manual) with source_params and source_call (the exact request that fetches the content), the template (its slug, for /templates?slug=), format, networks, cadence (daily, weekdays, weekly, manual), post_time in the project's timezone (null = the network's best time), caption_brief, is_active and last_run_at. Nothing runs on the server: the agent does the loop (fetch, fill, render, schedule, mark the quote used) and then POST {"action":"ran","recipe_id":N,"note":"…"} so the person sees it happened.

Content Templates

GET /api/v1/templates.php

On-brand layout templates (HTML + CSS + a variable manifest) that an agent fills and renders into a finished graphic. ViralHunt stores the layout; your agent renders it — see render_tech on each template.

ParameterTypeDescription
slug / idstring / intReturn ONE template with its full spec (html, css)
categorystringimage, quote, video, top3…
media_typestringimage or video
networkstringOnly templates suited to that network
assignedint1 = only templates assigned to project_id
project_idintRequired when assigned=1
favoritesint1 = only the templates the brand starred in its gallery. Every row carries favorite, and favourites come first
qstringSearch name / description
fullint1 = include html/css in the list (heavy)

The list omits html/css on purpose — they run several KB each. Browse the list, then fetch the one you'll render by slug.

curl -H "Authorization: Bearer YOUR_KEY" \
  "https://viralhunt.io/tool/api/v1/templates.php?slug=vh-image-card"
{
  "success": true,
  "data": {
    "template": {
      "id": 1,
      "slug": "vh-image-card",
      "name": "Viral image card",
      "scope": "global",
      "category": "image",
      "media_type": "image",
      "aspect": "4:5",
      "canvas_w": 1080,
      "canvas_h": 1350,
      "formats": [
        {"name": "feed",  "w": 1080, "h": 1350, "aspect": "4:5",  "default": true,
         "networks": ["facebook", "instagram", "threads", "bluesky"]},
        {"name": "story", "w": 1080, "h": 1920, "aspect": "9:16", "networks": ["instagram", "tiktok"]}
      ],
      "palette": {"magenta": "#ff159a", "cyan": "#1edbee", "bg": "#000000"},
      "fonts": [{"family": "Inter", "weight": 400, "url_woff2": "https://..."}],
      "variables": [
        {"key": "text", "label": "Hook", "type": "text",
         "description": "One line, faithful to the original post, punchy.",
         "rules": {"max_chars": 85, "no_em_dash": true},
         "highlightable": true, "required": true,
         "example": "Meet Kio, a lion cub with a big destiny"},
        {"key": "color", "type": "token", "rules": {"enum": ["cyan", "magenta"], "default": "cyan"}}
      ],
      "html": "<div class=\"vh-card\" data-format=\"{{format}}\">…",
      "css": "@font-face{…}",
      "render_tech": "HTML→PNG (Playwright/Puppeteer): set the viewport to the chosen format's w×h, load html+css, wait for [data-vh-ready=\"1\"], screenshot the .vh-card element."
    }
  }
}

Rendering contract: replace every {{key}} in html, honour each variable's rules as hard constraints, set the viewport to the chosen format's size, and wait for [data-vh-ready="1"] before screenshotting .vh-card — the template sets that flag once its fonts and images have loaded and the headline has auto-shrunk. Colors are palette tokens ("cyan"), never raw hex.

Author or edit a template

POST /api/v1/templates.php

Templates are built by agents that already write layouts in code, so authoring is an API call. Owner/admin only. Editing a curated/global template clones it into your organization's own copy — the global is never modified. On update send only the fields that change.

curl -X POST -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "action": "create",
        "slug": "my-quote-card",
        "name": "My quote card",
        "category": "quote",
        "media_type": "image",
        "canvas_w": 1080, "canvas_h": 1350,
        "formats": [{"name":"feed","w":1080,"h":1350,"aspect":"4:5","default":true}],
        "html": "<div class=\"vh-card\"><p>{{quote}}</p></div>",
        "css":  ".vh-card{width:1080px;height:1350px;background:#000;color:#fff}",
        "palette": {"cyan": "#1edbee"},
        "fonts": [{"family":"Inter","weight":400,"url_woff2":"https://..."}],
        "variables": [
          {"key":"quote","label":"Quote","type":"text","required":true,
           "description":"A verified quote, max 180 chars.",
           "rules":{"max_chars":180,"no_em_dash":true}}
        ]
      }' \
  "https://viralhunt.io/tool/api/v1/templates.php"

Two rules are enforced, so design for them:

  • Every {{placeholder}} in html must be declared in variables — otherwise the call is rejected with validation_error. An undeclared hole would render blank for whoever fetches the template.
  • html and css are capped at 256KB each. Reference images and fonts by URL instead of inlining them.

Returns 201 with the stored template (and cloned_from when a global was cloned), 200 on a plain update.

Assign a template to a project

POST /api/v1/template-assignments.php

Controls what ?assigned=1&project_id=N returns for that project. Owner/admin only — it decides what every agent on that brand may publish with. Humans do the same from Templates in the app.

curl -X POST -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"project_id":1,"template_id":7,"action":"add"}' \
  "https://viralhunt.io/tool/api/v1/template-assignments.php"
{
  "success": true,
  "data": {
    "project":  {"id": 1, "name": "My Brand"},
    "template": {"id": 7, "slug": "vh-image-card", "name": "Viral image card"},
    "assigned": true
  }
}

Try It

Test API endpoints live from your browser.