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 |
| 403 | insufficient_scope | The 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. |
| 404 | not_found | Endpoint not found |
| 405 | method_not_allowed | Wrong HTTP method |
| 422 | validation_error | Invalid parameters |
| 422 | text_too_long | The post's text passes a network's limit (details.blockers[]: network, account, length, limit, over). Send that account's short copy in overrides, drop the account, or save a draft |
| 413 | upload_too_large | The upload's body is over post_max_size or the file over upload_max_filesize (details carries both and content_length). Compress it or pass a public URL in media |
| 400 | upload_not_parsed | The body arrived under the size limits but PHP parsed no multipart file out of it: cut short, or slower than max_input_time. Not the file size. Retry once; if it repeats, pass a public URL in media |
| 402 | insufficient_credits | The request costs credits the account does not have (free plan allowance spent). details.required and details.balance say how many. |
| 429 | daily_limit_reached | The free plan's daily query quota is used; details.reset is the unix time it resets. |
| 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 | Any number with a unit (24h, 7d, 2w, 3m, 1y), a bare number of days (30), or all. Unknown values answer 422. Default 24h. Also: 6h, 12h, 30d, 3m, all |
min_engagement | int | Minimum engagement threshold |
keyword | string | Search in title/text |
author | string | One page or account: a Facebook page name, an X handle or name, a TikTok or Instagram username (contains-match). source=facebook&author=Comunidad Biológica&time_range=1y&sort=viral |
category | string/int | News category, source=rss only: a name or id from news-categories.php (exact name, case-insensitive; one per request). An unknown value is 422 unknown_category with the valid names. Technology and Artificial Intelligence are two categories: for AI content ask for both. |
An unknown parameter is 422 unknown_parameter listing the accepted names, never ignored: topic= and section= used to answer 200 with the unfiltered corpus. | ||
page | int | Page number (default: 1) |
per_page | int | Items per page (default: 20, max: 100) |
Every row carries metrics = {likes, comments, shares, views, saves, engagement}, the same names on every source, next to the source's own columns (X says likes/retweets/views, TikTok likes_count/plays_count, Facebook reactions, Reddit score/num_comments, Hacker News points, Tumblr note_count). Read metrics; a number the source does not have is null. An unknown sort is 422 unknown_sort listing the valid ones. | ||
| 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_commented, most_saved, engagement, newest |
most_commented is accepted on every network that counts comments (it maps to most_comments on facebook and pinterest and to most_replied on x, bluesky and mastodon), so one sort name ranks the discussions everywhere. | |
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
}
}
meta.total stops counting at 10,000 and reports 10001 beyond it: a page needs to know whether there is more, not how many million matched. has_more is always exact.
One keyword, every network, one answer. Each network returns its own top rows for the keyword; they come back normalized to one shape, merged and ranked, with a per-network total that shows where the topic lives. Use it for the research pass on a brand or a niche; use /trending when you want one network with its full sort options and paging.
| Parameter | Type | Description |
|---|---|---|
keyword REQUIRED | string | Words to search for. All words must appear, in any order (q is accepted too). |
sources | string | all (default) or a comma list: pinterest,x,reddit. Same names as /trending. |
time_range | string | Any number with a unit (24h, 7d, 30d, 2w, 3m, 1y), a bare number of days (30), or all. Default: 30d. Anything else answers 422. |
per_source | int | Rows from each network, 1 to 50. Default: 10. |
sort | string | engagement (default) or recent, for the merged list. |
min_engagement | int | Minimum engagement threshold, applied per network. |
Every row has the same fields whatever the network: platform, id, title, description, post_url, thumbnail_url, author_name, author_handle, engagement_score, stats (the network's own counts), growth_24h, created_at, and the network's full row under raw. sources[] reports, per network, how many posts matched (total), how many were returned and how long it took.
{
"success": true,
"data": {
"keyword": "montessori playroom",
"posts": [
{
"platform": "pinterest",
"id": "1234567890",
"title": "calming corner: cozy ideas for montessori room",
"post_url": "https://www.pinterest.com/pin/1234567890/",
"thumbnail_url": "https://i.pinimg.com/....jpg",
"author_name": "Rachel",
"author_handle": "confidentlymom1",
"engagement_score": 17858.5,
"stats": {"aggregated_saves": 3899, "total_reaction_count": 197, "repin_count": 6832, "comment_count": 0},
"growth_24h": null,
"created_at": "2024-06-28 11:43:47",
"rank": 1,
"raw": { "...the row /trending returns..." }
}
],
"sources": [
{"source": "pinterest", "total": 220, "returned": 10, "ms": 41},
{"source": "x", "total": 0, "returned": 0, "ms": 12}
]
},
"meta": {"total": 10, "time_range": "30d", "per_source": 10, "sort": "engagement"}
}
A network with total: 0 has nothing on the keyword in that window: the corpus, not the request. Searches are logged and drive what we extract next.
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 | int / string | Who takes the card: a member id, the member's name as the Team page shows it, or their email (/members lists them). assigned_to_user_id, assignee and assigned_user_id are taken as the same field. The answer carries assigned_to_user_id and assigned_to_name read back from the card. An unknown field name answers 422 instead of being ignored. |
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 |
| Call | What it does |
|---|---|
GET /cards.php?card_id=N or POST {action: "get", card_id} | One card: title, column (name, is_done), status, assignee (id and name), priority, due date, links, notes, archived |
GET /cards.php?action=list[&board_column_id][&status][&assigned_to][&unassigned=1][&archived=1] | The organization's cards, newest first, up to 200 |
POST {action: "assign", card_id, assigned_to} | Who takes the card: id, name or email; assigned_to: null unassigns. The member is notified (push and email per their preferences) |
POST {action: "archive", card_id} (also delete) | The card leaves the board; nothing is deleted, a person restores it from the board's Archived view |
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, up to 50 MB through the API) → { "url", "type" }. Use that url in media. Files are stored in the organization's cloud storage, never on the app server. Add the form field purpose=brand for a logo or a picture you will place INSIDE a design (a free image on a draft, a card or a template): PNG, JPG or WebP, up to 2 MB, never SVG; that URL is the only kind the design editor accepts. 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.
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 ("stage": "draft" | "review" says the same in one word)
"assigned_to": 7, // the member who takes it over: their board card, their notification (members.php lists the ids)
"overrides": {"bluesky": {"body": "…300 chars…", "thread": [{"body": "part 2"}]}, "12": {"media": ["…"], "first_comment": "…"}}, // per network or per account_id: body, media, first_comment, thread. first_comment: absent = inherits the post's, "" = none on this account, text = its own
"card_id": 123, // the board card it comes from
"design": {"template": "studio-news-frame", "format": "feed", "variables": {"text": "…", "image": "https://…"}} // ALWAYS for a template-made picture (with the PNG in media too when you rendered it): it is what makes the image editable in the app; a PNG alone is flat. Without a PNG 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", project_id, project_name, url (the post's detail, opens from any project) and drafts_url (the Drafts page on that project); nothing is sent. Hand the person the url, the id and the project name: the Drafts page shows one project at a time, so a post named without its project is a post they will not find. Drafts rows and get carry url and drafts_url too.
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.
| Call | What it does |
|---|---|
GET ?action=drafts[&all=1] | The drafts waiting (this project, or every project), each with its review {score, verdict, entries[]} |
| How an agent hands a post over | The working agreement teams use: a post the person asked for goes to Drafts complete (draft: true; text, image, accounts, tentative time, in the project they named; no picture with rights = the organization's template with text only, the permission option noted in author_notes; every picture is made on one of the organization's templates and when none fits the agent creates the template (POST templates.php, the owner's "Creates templates" tick on Team); a picture made on a template is complete only with its design in the post, a PNG alone is a flat file nobody can edit, and the answer says so with picture_editable and picture_note), never to Review unless asked. The hand-over is one five-line message: the id and project_name, the url the answer gives (never a composed link), the angle, what was verified and the source, the decision needed. Everything else goes in author_notes. The Drafts page shows one project at a time (no author filter), and media_removed after a week is retention, not a failure. An unexpected API answer is one message with the exact call and answer, never a workaround |
| Every draft is a board card | A post created with draft: true (or landing in Review) answers card_id: the Editorial Board card it became (pass your own card_id to use an existing card). The card moves by itself to the board's Drafts, In review, Scheduled and Published columns as the post moves, so a collaborator finishes the picture and the text from the card |
POST ?action=update {id, design_fields?, png?} | Fix the texts inside the post's image when it was made on a template: design_fields = {"variable key": "new text"}, text variables only (never a picture variable, never caption, which is the body). The template, format, pictures and layers stay. Without png every picture leaves the post (the old render, yours or the app's, is stale) and the answer carries needs_png: true and the new render; a post with a design is never sent without its picture, and never with two: a png replaces the picture the post had. Several pictures on any post need carousel: true (else 422 multiple_pictures): the mark is stored on the post (carousel on every drafts row and on get) and EVERY send, from the app too, refuses a second picture without it; carousel: false on a post that holds several pictures is refused until the extra ones leave. A flat picture answers 422 no_design |
POST ?action=update {id, body?, media?, overrides?, scheduled_at?, target_account_ids?|networks?, first_comment?, author_notes?, kind?, design?, assigned_to?} | Three states take an update. A draft or a post in review changes in place and nothing is told to PostProxy (the author, an owner or an admin may change any field, design included); a scheduled post is changed on PostProxy too, per account (each account's own text when it has an override, its pictures fitted for that network, the time; a changed thread on X or Threads makes that network's post again; added accounts are created, dropped ones deleted; the answer lists patched, recreated, added, removed, refused), and nothing is touched under 6 minutes before the post's time (PostProxy refuses under 5: 409 not_editable); processing, published, partial, failed and canceled take no update (use retry, delete or a new post). Edit a draft or a post in review in place; the answer carries the stage read back after the edit (an edit never moves a post out of Review by itself; only a fix verdict does, and the review answer says returned: true). 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) |
assigned_to on create and update, GET ?action=drafts&assigned_to=N | Who takes the post over: its board card is assigned to that member (an id from members.php; agents count), they get the assignment notification and see it in my-cards.php; null unassigns. Drafts rows and get carry assigned_to and assigned_to_name; the filter narrows the list to one member's posts. A trusted reviewer may assign on someone else's post |
GET ?action=get&id=N | The same shape as a drafts row, whatever the status: stage, targets, media, overrides (what each account really sends), thread, design (and render on a draft or a post in review), card_id, kind, author_notes, lang, assigned_to, review {score, verdict, entries[]}, content_hash, needs_review, plus events[] and support_text |
422 text_too_long | A complete post (Review, or a direct send) whose text passes a network's limit is refused with the network, the account, the length and the excess (details.blockers[]): send that account's short version in overrides in the same call, drop the account, or send draft: true and finish it with update. A working draft may still be long |
update by a trusted reviewer | A reviewer marked "Their OK publishes" may change only overrides (a per-account short copy for a network that cuts), kind and the texts inside the image (design_fields, with its png) 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 update | What 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 update | What 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, allow_duplicate?, translations?: [{project_id, lang?, account_ids?, image?, send_mode?}]} | The duplicate firewall: each account is checked against the organization's posts of the last 72 hours; one that another post already carries the same text for (a PostProxy id, or a cut send still being checked) is refused with duplicate_of in its result while the others go out, and when every account is one the call answers 409 duplicate_post naming the other post (create, retry and the sync's retries run the same check). allow_duplicate: true is the deliberate second copy. A send whose every network was cut answers status: processing with checking: true, never failed: PostProxy is being checked for up to six minutes, do not create the post again. 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 → projects[].voice | The brand's voice: what the owner wrote on the Projects page about how that brand speaks (audience, tone, the hooks that work, how a post is built, the X / Bluesky / Threads versions, what it never says, how claims are verified). An agent reads it at the start of every job for that project and follows it over the general rules. Up to 12,000 characters; null when the owner has not written one yet. |
GET ?action=targets → media_limits | Per 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 or a post in review; on a scheduled post, cancel the targets not yet published. There is no delete: this is it (an unknown action answers 400 with the list of actions) |
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=1 | The 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 |
publishes[] on every drafts row | Per account: text (the override's body when the account has one, else the post's), from (body | override), chars, limit, over, first_comment (own | inherit | none) and media. Judge this, never body alone: an error already fixed in an override is not an error |
POST ?action=retry {id, account_id[, force]} | Owner, admin or the author: send the post again to ONE network whose results[account_id].status is failed, with the same fits. Anything else answers 409 not_retriable. A target whose create timed out (timed_out: true, no PostProxy id) answers 409 maybe_live unless force: true: the post may already be on the network. The sync looks such a post up on PostProxy (three checks, two minutes apart) and adopts it when it is there; nothing is resent by itself. While every network of a send is cut the post reads processing with error_message saying PostProxy is being checked: never create it again. Pinterest, like Bluesky, takes no comments through PostProxy: a first comment or thread for it is reported followups_unsupported at send time |
POST ?action=cancel {id, account_id} | Stop ONE network: a pending automatic retry or a timed-out create is halted, a not-yet-published PostProxy post is canceled. A published one answers 409 and points at delete. Without account_id: the whole post, as before |
GET ?action=list[&status=][&all=1][&since=][&account_id=][&limit=][&offset=] | What went out and what did not: every post of the project (or the organization with all=1) in the given statuses (default published, partial, failed, processing, scheduled), newest first, each target with its status, PostProxy id, permalink, error, will_retry, timed_out, reconciled |
POST ?action=delete {id, account_id} | Owner, admin, or a member marked "Their OK publishes" on Team: take a PUBLISHED target down from its network through PostProxy (the cleanup of a duplicate). Without account_id every published target of the post goes. The answer says what PostProxy did and whether it still serves the post; confirm on the network itself |
The action in the body | {"action": "update", ...} in the JSON body is read when the query string has none (it used to fall into create and make a second post) |
| Bluesky and comments | PostProxy takes no comments or reply chains on Bluesky (405): a first comment or a thread for a Bluesky account is reported as followups_unsupported on the result and never attempted. Put the link in the body for that network |
| An interrupted send | A post whose error_message says the send was interrupted (no network id was recorded) may be live on the networks: run sync, read get, check the accounts, and only then decide. Never recreate it blindly (post 1097 went out twice that way) |
GET ?action=review_stats&days=7|14|30 | Flags 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.php | The 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 |
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"}
]
}
}
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= ..."
}
}
A project is a brand. Its keywords (product names, ingredients, the problems its buyers search for, #hashtags) are searched every few hours on X, Quora, Pinterest, Instagram, TikTok and Facebook by the always-on extension, and on Reddit by the server. The posts that match feed the radar's Brand filter and the alerts: a discussion whose comments climb right now, reported once per brand to the owners and admins (email, push) and to the brand's own agent through this endpoint.
| Call | What it does |
|---|---|
GET ?action=status[&project_id=N] | Every brand: keywords, category, webhook, alerts_7d, radar_url, and per network last_run, never_run, runs, found (is anybody searching?). Plus the alert rules in force and machine: the heartbeat of the always-on Chrome (extension_alive = a machine with the poll on reported in the last 15 minutes; poll_on false = a Chrome is open but attends nothing; machines[] every machine seen). Owners and admins also get a daily digest email of the most commented threads per brand (brand.digest_hour, 8:00 in the project's timezone) |
GET ?action=keywords&project_id=N | The list with its networks, the category and the sweep state |
POST {action: "keywords", project_id, keywords: [...], networks?: [...], category?} | Owner or admin: replaces the list whole (up to 40; a leading # makes a hashtag). The queues learn about it at once |
GET ?action=hot&project_id=N[&hours=8][&window=24][&min_delta=10][&networks=reddit,x][&all=1] | The discussions heating up right now, read live: title, url, author, comments, comments_from, comments_to, delta, hours, born_hot, rising, alerted_at. Where a comment today reaches people. all=1 returns every matching post of the window, the rising ones first and then the most commented (delta null = one reading only), which is what the app's dashboard shows; limit up to 100. The answer carries machine: whether a Chrome with the poll on reported in the last 15 minutes (extension_alive, poll_on, heartbeat_minutes_ago) |
GET ?action=alerts&project_id=N[&since=ISO][&unread=1][&limit=50] | What the brand was alerted about, newest first, each thread once. POST {action: "alerts_read", ids: [...]} (or project_id for all) marks them taken |
GET ?action=webhook&project_id=N / POST {action: "webhook", project_id, url, format?} | Owner or admin sets an https URL (null clears it). format: json (our signed body, for an agent or n8n/Make/Zapier), discord or slack (a message for that channel's incoming webhook, no code in between), auto (default) reads the URL. POST {action: "webhook_test", project_id} sends one sample alert (event brand.test) and answers ok, code, hint. The guide, for people and agents: viralhunt.io/webhooks. New alerts are POSTed there the minute the cron writes them: JSON {event, project, alerts[], radar_url, sent_at}, headers X-VH-Event, X-VH-Project, X-VH-Delivery, X-VH-Signature: sha256=<hex HMAC-SHA256 of the raw body with the secret>. Answer 2xx within 8 s; no retry |
curl -H "Authorization: Bearer YOUR_KEY" \
"https://viralhunt.io/tool/api/v1/brand.php?action=hot&project_id=3&hours=8"
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": [...]
}
}
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."]
}
}
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" } ] } }
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": "…" } ] } }
The calendar base: international days (UN, UNESCO, WHO) and anniversaries of science, technology, space, health, the environment, history and civilization (Wikidata's day-precise statements: births, deaths, discoveries, launches, foundations, treaties and publications of everything the world has written about; Wikipedia's on-this-day feed; 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 or Commons picture when there is one (check the article's licence before using it under a brand). On Wikidata rows importance comes from how many Wikipedias cover the subject (150+ = 3, 70+ = 2); verified is 1 when a person vouched for the row or two independent sources agree on the day and the title. Nothing in the base is written from memory.
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.
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.
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.
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 |
favorites | int | 1 = only the templates the brand starred in its gallery. Every row carries favorite, and favourites come first |
q | string | Search name / description |
full | int | 1 = include html/css in the list (heavy) |
preview | int | With slug or id: 1 = how the template looks before anything is published. preview.document is a complete HTML page (the manifest's example values filled, the organization's design, fixed slots and layers baked in) to open in a browser or render; preview.html / css / format are the parts. variables={"text":"…"} (JSON) previews your own values, format=feed|story|square another size |
runtime | int | 1 = the script the platform appends to a template that uses data-highlight, data-fit or data-text, and what each attribute does (no slug needed) |
The list is paginated (20 by default, per_page up to 100; meta.total and meta.has_more say if there is more). Your organization's own templates come first, newest first; scope=org lists only them and scope=global only the library. Every template carries scope, organization_id, created_by and is_active.
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. Owners and admins, or a member or agent the owner ticked "Creates templates" on the Team page (with a token that can write: they create, clone a library template, and edit only the templates they created; assigning templates to projects stays owner/admin). A template is html, css and images, never code: scripts, iframes, forms, event handlers, javascript: URLs and stylesheets other than Google Fonts are removed before it is stored and listed in removed in the answer; fonts and pictures embedded as data:font/ / data:image/ are accepted. 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. action goes in the JSON body or as ?action=; PUT and PATCH are taken as update (with id or slug_ref). {"action": "archive", "id": N} retires one of your organization's templates: it leaves the gallery, the agents' lists and every brand, and posts already made with it keep their picture.
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.**bold**, *italic*, __underline__, {c:#e11d48}coloured{/c} or {c:accent}…{/c} (the template's accent). Plain text in the value, converted at render time by the converter every template's html carries (vh-rich-runtime; GET ?runtime=1 → inline_marks). Marks count toward max_chars; never in caption.data-highlight="{{highlight}}" on the headline (the listed words get <span class="hl">; your css styles .hl), data-fit="28" on a block whose text must shrink to fit its box (or data-fit-lines="4"), and the platform appends its runtime when it stores the template (runtime_added: true; GET ?runtime=1 shows it). It also sets data-vh-ready="1" on .vh-card for your renderer to wait on. Your own <script> is removed.Returns 201 with the stored template (and cloned_from when a global was cloned), 200 on a plain update. Then GET ?slug=…&preview=1 shows how it came out.
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)