Query trending content, create board cards, and manage your team programmatically.
All endpoints (except this docs page) require an API key. Keys are aMember SoftSale license keys assigned to your user account.
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
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.
API requests are limited to 100 requests per hour per API key (fixed window).
Every response includes rate limit headers:
| Header | Description |
|---|---|
X-RateLimit-Limit | Max requests per window |
X-RateLimit-Remaining | Requests remaining in current window |
X-RateLimit-Reset | Unix timestamp when the window resets |
When exceeded, the API returns 429 Too Many Requests.
Errors return a JSON envelope:
{
"success": false,
"error": {
"code": "auth_required",
"message": "API key required."
}
}
| HTTP | Code | Meaning |
|---|---|---|
| 401 | auth_required | No API key provided |
| 401 | invalid_key | Key not found or revoked |
| 401 | key_expired | License expired |
| 403 | no_organization | User has no organization |
| 404 | not_found | Endpoint not found |
| 405 | method_not_allowed | Wrong HTTP method |
| 422 | validation_error | Invalid parameters |
| 429 | rate_limited | Too many requests |
| 500 | server_error | Internal server error |
Query trending and viral posts across platforms.
| Parameter | Type | Description |
|---|---|---|
source REQUIRED | string | Platform: rss, facebook, tiktok, instagram, x, pinterest, bluesky, douyin. Douyin returns every text field twice, English and Chinese (title_en/title_zh), plus a ready-to-use title; it reports no view count because Douyin does not publish one Also bluesky, douyin, reddit, mastodon, tumblr, hackernews. |
sort | string | Sort order. Options vary by platform (see below). Default: trending/viral. |
time_range | string | 6h, 12h, 24h (default), 7d, 30d, 3m, all |
min_engagement | int | Minimum engagement threshold |
keyword | string | Search in title/text |
category | string/int | Filter by category ID or name (RSS source only) |
page | int | Page number (default: 1) |
per_page | int | Items per page (default: 20, max: 100) |
| Source | Sort Values |
|---|---|
| rss | trending, engagement, growth, facebook, reddit, pinterest, recent |
viral, engagement, most_comments, most_shares, most_views, newest, oldest | |
| x | viral, most_liked, most_retweeted, most_replied, most_viewed, engagement, newest, oldest |
| tiktok | viral, most_liked, most_viewed, most_saved, engagement, newest |
viral, most_liked, most_commented, engagement, newest | |
| bluesky | viral, most_liked, most_reposted, most_replied, most_quoted, engagement, newest |
| douyin | viral, most_liked, most_commented, most_shared, engagement, newest |
viral, most_upvoted, most_commented, engagement, newest, oldest; filter subreddit=name | |
| mastodon | viral, most_liked, most_boosted, most_replied, engagement, newest |
| tumblr | viral (notes discounted by age), most_noted, newest, oldest. One metric: note_count = likes + reblogs + replies. The Tumblr corpus fills slowly (a sweep every two hours), so 7d can come back empty: use 30d or all. |
| hackernews | viral, most_points, most_commented, engagement, newest; filter type=link|ask|show|job|poll |
trending, most_saved, most_liked, most_repins, most_comments, newest |
growth_24h)Every post can carry growth_24h: how much it moved between our two most distant readings, {from, to, delta, percent, hours, measured_at, samples}. Read it by these rules:
null means we cannot say (one reading only, or a network with no snapshots: Facebook, Pinterest, Tumblr). Never render it as 0%.delta: 0 with samples ≥ 2 means measured and unchanged. Flat, not missing.hours is the real window. The comparison point is the newest reading at least 20 hours older than the latest; when none is that old yet, the oldest reading is used and hours says how short the window is. Quote it, never assume 24. full_window is true only when the window reached 20 hours; say "in 24h" only then. Posts under a day old (most of RSS trending) are read at about 1h, 2h, 5h, 11h and 23h after discovery, so short windows there are normal.percent is null when from is under 100 (from zero or from a handful of interactions a percentage is noise). delta always holds the absolute change.curl -H "Authorization: Bearer YOUR_KEY" \
"https://viralhunt.io/tool/api/v1/trending.php?source=rss&sort=trending&time_range=7d&per_page=5"
{
"success": true,
"data": {
"posts": [
{
"id": 123,
"title": "Why AI is changing everything",
"url": "https://example.com/article",
"image_url": "https://example.com/img.jpg",
"facebook_shares": 1200,
"reddit_score": 500,
"pinterest_shares": 300,
"total_engagement": 2000,
"trend_score": 85.0,
"growth_rate": 25.5,
"feed_name": "TechCrunch",
"category_id": 3,
"category_name": "Technology"
}
]
},
"meta": {
"page": 1,
"per_page": 5,
"total": 142,
"total_pages": 29,
"has_more": true
}
}
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.
| Parameter | Type | Description |
|---|---|---|
network REQUIRED | string | tiktok, instagram, x, facebook, pinterest, bluesky, mastodon, douyin, tumblr, reddit |
timezone | string | IANA zone the hours are expressed in (default UTC), e.g. America/New_York. Weekday aggregates stay in UTC. |
keyword | string | Recompute 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. |
{
"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).
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.
| Parameter | Type | Description |
|---|---|---|
network | string | tiktok, instagram, douyin or all (default) |
q | string | Words in the title or artist |
cross_only | 1 | Only sounds trending on more than one network |
slug | string | One sound (from a previous result): its detail plus posts[] that used it, with links |
page, per_page | int | Pagination (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 }
}
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.
| Parameter | Type | Description |
|---|---|---|
network | string | reddit (default) or bluesky |
q | string | Topic words matched against the community name (Bluesky: name, description and slug) |
sort | string | reddit: upside (default, = peak_per_1k), members, peak, posts. bluesky: posts (default), avg_likes, top_likes, feed_likes |
min_members, max_members | int | reddit: size bounds. Smaller rooms are easier to climb. |
subreddit | string | reddit: 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. |
feed | string | bluesky: one feed by slug. Adds top_posts, top_authors, top_hashtags. |
page, per_page | int | Pagination (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 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.
| Field | Type | Description |
|---|---|---|
title | string | Card title. Required unless post_url is provided (auto-extracted from URL). |
description | string | Card description (auto-filled from URL if not provided) |
priority | string | low, medium (default), high, urgent |
due_date | string | YYYY-MM-DD or YYYY-MM-DD HH:MM:SS |
post_url | string | Source post URL. If title is omitted, metadata is auto-extracted from this URL. |
image_url | string | Thumbnail image URL (auto-filled from URL if not provided) |
platform | string | Source platform name (auto-detected from URL domain if not provided) |
category_id | int | Board category ID |
assigned_to_user_id | int | Assign to this team member |
board_column_id | int | Target column (default: first "To Do" column) |
links | string | Additional links |
notes | string | Notes |
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
}
}
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"
}
}
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"
| Field | Type | Description |
|---|---|---|
action REQUIRED | string | move |
card_id REQUIRED | int | The card |
board_column_id REQUIRED | int | Target column, from /columns or /context |
position | int | Position inside the column, 0 = top (default 0) |
card_type / card_type_id | string / int | On create: what the card is (Post, Note, Article, Video…), see /card-types |
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).
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).
Upload one media file (multipart field file; mp4/mov/jpg/png/gif/webp, ≤50MB) → { "url", "type" }. Use that url in media. Skip if your media is already a public URL.
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"
}
Returns { id, status, targets, results, warnings }. Check status later with GET /api/v1/schedule.php?action=get&id=ID.
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
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"}
]
}
}
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"
}
}
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}
]
}
}
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"}
]
}
}
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"}
]
}
}
Get pending and in-progress cards grouped by assigned team member. Useful for bot reminders and workload overview.
| Parameter | Type | Description |
|---|---|---|
user_id | int | Filter to a specific team member |
status | string | pending, 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": [...]
}
}
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.
| Parameter | Type | Description |
|---|---|---|
status | string | pending, in_progress, completed, all (default: everything not completed) |
project / project_id | string / int | Restrict to one project (brand) by name or id |
page, per_page | int | Pagination (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 }
}
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" } ] } }
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.
| Parameter | Type | Description |
|---|---|---|
slug / id | string / int | Return ONE template with its full spec (html, css) |
category | string | image, quote, video, top3… |
media_type | string | image or video |
network | string | Only templates suited to that network |
assigned | int | 1 = only templates assigned to project_id |
project_id | int | Required when assigned=1 |
q | string | Search name / description |
full | int | 1 = 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.
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:
{{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.
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
}
}
Test API endpoints live from your browser.
Card Comments
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
#boardchannel.card_idREQUIREDcommentREQUIRED (POST)