Webhooks need a plan with API access, like API keys. Admins and owners manage endpoints in the dashboard, in the Webhooks tab next to API Keys. API keys cannot manage them.
Webhooks
Get a signed HTTP request the moment something changes in your Creator Hub, instead of polling the API.
Webhooks tell your own systems when something changes in your organization: a creator replies in chat, an application arrives, a billing window becomes due, a payout reaches a creator. viral.app sends each event as an HTTPS POST to endpoints you add in the dashboard. Your CRM, helpdesk, finance tooling, AI agent or n8n, Zapier and Make workflows can then act on it right away.
Deliveries follow Standard Webhooks, so any Standard Webhooks library verifies them. The API reference lists every event under Webhook Events, with the full payload schema. API and automation covers API keys and access.
Webhooks tab
Add endpoints, choose their events, send a test event and watch every delivery.
Verify signatures
Check every request in a few lines of TypeScript, Python or plain Node.js.
Event types
Chat, creators, campaigns and briefs, assignments, payouts, jobs, applications and tracking.
Add an endpoint
Open Webhooks under Organization → API and choose Add Endpoint. Enter the URL, an optional description, and the events it should receive. Choose All events, including future ones to also receive event types viral.app adds later. The tracking and analytics events are opt-in: all events never includes them, so pick them by name, next to all events or on their own.
viral.app then shows the endpoint's signing secret (whsec_…) once. Store it in your secrets manager. Admins and owners can reveal it again later with Reveal Secret in the endpoint's menu, and every reveal is recorded in the action log.
- URLs:
https://with a public host name. IP addresses, local and internal names, and viral.app itself are refused. Treat a URL as a credential: anyone who knows it can send you requests, which is why every request is signed. - Limits: 20 endpoints per organization, URLs up to 2,048 characters.
- Automations: to start an n8n, Zapier or Make workflow, add the URL of its webhook trigger as an endpoint.
Endpoints, their secrets and their deliveries are managed only in the dashboard. The API has no routes for them: the API reference documents what viral.app sends, under Webhook Events.
What a delivery looks like
Every event is one POST with a JSON body:
POST /webhooks/viral-app HTTP/1.1
content-type: application/json
user-agent: viral.app-Webhooks/1.0
webhook-id: whevt_A1b2C3d4E5f6
webhook-timestamp: 1790332496
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "whevt_A1b2C3d4E5f6",
"type": "application.submitted",
"timestamp": "2026-09-25T12:34:56.789Z",
"organizationId": "org_A1b2C3d4E5f6",
"externalId": null,
"actor": { "type": "creator", "surface": "ui", "userId": "user_A1b2C3d4E5f6", "apiKeyId": null, "impersonated": false },
"data": {
"application": {
"id": "orgjapp_A1b2C3d4E5f6",
"jobId": "orgjob_A1b2C3d4E5f6",
"status": "open",
"source": "applied",
…
}
}
}
idis the event id, the same as thewebhook-idheader. It stays the same on every retry, resend and recovery.timestampis when the change happened. Thewebhook-timestampheader is the time of this delivery attempt.organizationIdis the organization the event happened in.externalIdisnullunless that organization is a sub-organization run by a viral.app partner.actorsays who caused the event: a member of your team (user), an API key (api_key, withapiKeyId), a creator, or viral.app itself (system).surfacesays where the change came in: the dashboard (ui), the API (api), the Copilot (copilot) or viral.app (system).datauses the same field names and money units as the matchingGETendpoint. Issued payouts read likeGET /payouts/paidrows (amountin major units withprecision).payout.duereads like aGET /payouts/duerow (payoutAmountin minor units withpayoutPrecision). Every payout event writes billing-period bounds (billingPeriodStart,billingPeriodEnd) with a+00:00offset, like the Due list (2026-09-01T00:00:00.000+00:00): parse them as timestamps rather than comparing them as strings with bounds from other API routes, which may useZ. Those bounds are the period's first and last day; every billing period in a payload also carries the campaign'stimeZoneand the exact instants it starts and ends at there,startsAtandendsAt(midnight in that zone, with its offset;endsAtis exclusive). Manual and video payouts pay no period, so theirs arenull. Payloads never contain creator email addresses or bank details.
Verify signatures
webhook-signature holds one or more space-separated v1,<signature> entries. Each is a base64 HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<raw body>, keyed with your secret. A request is genuine when any entry matches. During a secret rotation, both the old and the new secret sign it.
Verify the raw request body, before any JSON parsing: re-serialized JSON no longer matches the signature. Reject requests whose webhook-timestamp is more than five minutes away from your clock; every retry is signed again with a fresh timestamp, so a genuine request is never stale.
With the official standardwebhooks package in TypeScript:
import { Webhook } from "standardwebhooks"
const webhook = new Webhook(process.env.VIRAL_APP_WEBHOOK_SECRET!) // "whsec_…"
export async function POST(request: Request) {
const body = await request.text()
let event: { id: string; type: string; data: unknown }
try {
event = webhook.verify(body, Object.fromEntries(request.headers)) as typeof event
} catch {
return new Response("Invalid signature", { status: 401 })
}
await queue.add(event) // Process it after answering.
return new Response(null, { status: 204 })
}
In Python with standardwebhooks:
from standardwebhooks.webhooks import Webhook
webhook = Webhook(os.environ["VIRAL_APP_WEBHOOK_SECRET"]) # "whsec_…"
@app.post("/webhooks/viral-app")
async def receive(request: Request):
body = await request.body()
try:
event = webhook.verify(body, dict(request.headers))
except Exception:
return Response(status_code=401)
queue.enqueue(event)
return Response(status_code=204)
Without a library, in Node.js:
import { createHmac, timingSafeEqual } from "node:crypto"
export function isGenuineWebhook(
rawBody: string,
headers: Record<string, string | undefined>,
secret: string,
): boolean {
const id = headers["webhook-id"]
const timestamp = headers["webhook-timestamp"]
const signatures = headers["webhook-signature"]
if (!id || !timestamp || !signatures) return false
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64")
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest()
return signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",")
if (version !== "v1" || !signature) return false
const received = Buffer.from(signature, "base64")
return received.length === expected.length && timingSafeEqual(received, expected)
})
}
viral.app has no fixed outbound IP addresses, so an IP allowlist cannot tell our requests apart. The signature can.
Respond, retries and ordering
Answer with any 2xx status within 15 seconds. Everything else counts as a failure and is retried: other statuses, timeouts, connection errors, and redirects, which are never followed. Do the work after answering, from a queue of your own: a slow receiver collects timeouts and retries.
A failed delivery is tried again 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 14 hours, 20 hours and 24 hours after the previous attempt, each step with up to 20% of random spread. That is ten attempts over about three days. A Retry-After header on a failed response (seconds or an HTTP date) postpones the next attempt, up to 24 hours. A 410 Gone answer disables the endpoint right away.
Deliveries are at least once and unordered:
- Duplicates. A delivery can arrive twice, for example when your answer got lost. Store the
webhook-idof what you processed and skip repeats. - Order. Events can arrive in any order. Order them by
timestamp, or by the resource's own version: a campaign's revision, a payout window'sgeneration, a record'supdatedAt.
When deliveries fail
While an endpoint keeps failing, viral.app holds back its other deliveries and tries one of them every few minutes. The rest follow as soon as one succeeds, so a receiver that comes back isn't flooded at once.
- Emails. Owners and admins get one email a day at most when an endpoint's retries gave up on a delivery, and one when viral.app disables an endpoint.
- Automatic disable. An endpoint that failed for 5 days without a single success, and failed in the last 24 hours, is disabled. So is one that answered
410. - Delivery log. The endpoint's detail page lists every delivery of the last 30 days with each attempt's status, duration and the first 4 KB of your response.
Pause, resume and recover
Pause Endpoint in the endpoint's menu stops its deliveries: pending ones are canceled, and events that happen while it is paused are not recorded for it. A disabled endpoint behaves the same. Choose Resume Endpoint once your receiver works again.
To send what the endpoint missed, choose Recover above its delivery log (or Recover Deliveries in its menu) and pick how far back. This sends every delivery of that endpoint created since then again: the ones that failed, and the ones a pause or disable canceled. The limit is 30 days back, and one recovery re-sends at most 5,000 deliveries, the oldest first. Recovered deliveries keep their original webhook-id, so your duplicate check still works. To send a single delivery again, open it in the delivery log and choose Resend.
Events that happened while no active endpoint subscribed to them were never recorded and cannot be recovered. To catch up after a gap, read the current state from the API, for example GET /payouts/due.
Rotate a secret
Rotate Secret in the endpoint's menu issues a new secret and shows it once:
- Keep the old secret for 24 hours (the default). Every delivery is signed with both secrets meanwhile, so you can switch your receiver without losing a request.
- Retire the old secret now, for a leaked secret. Deliveries fail verification until your receiver uses the new one.
Test an endpoint
Send Test Event in the endpoint's menu sends one webhook.test event to that endpoint only. Use it to check that your receiver verifies the signature and answers with a 2xx. Test events appear in the delivery log but never count toward the endpoint's health.
Event types
Subscribe to a list of types, or to all of them, including types added later (except the opt-in tracking events). The event picker describes each type, and the API reference documents every payload under Webhook Events. Changes to a payload only ever add fields; a breaking change would be a new event type.
Chat
An AI agent that answers creators subscribes to chat.message.received. An integration that mirrors the chat and also sends messages through the API should skip chat.message.sent events whose apiKeyId is its own key, or it answers itself.
Creators
An agent that runs your creator ops subscribes to creator.account_request.created and answers with POST /creators/account-requests/{id}/answer (or the MCP tool answer_account_request), instead of asking creators for their profile links in chat. An add the creator verified, with room in your plan, is applied at once and sends no request.
Campaigns and briefs
Assignments and billing periods
"Today" and "midnight" are the campaign's, in its timezone (timeZone; UTC unless the campaign picked another one).
An assignment created with a start in the past reports the billing period it is in and the periods that began or completed today or in the three days before. Earlier periods are not replayed; their unpaid windows arrive as payout.due.
Payouts
Windows that were already due when you subscribed to payout.due are not reported; GET /payouts/due lists them.
Jobs and applications
Tracking and analytics
Tracking events follow your synced data, so they arrive shortly after a sync lands rather than the moment a video goes up. History is never reported: adding an account, or raising how many of its videos you track, sends no video.published for videos it posted before, and a video or account first seen above a milestone does not report it. Each payload names the tracked account (account.id, the orgacc_… id the tracking routes take), the platform ids, a link to the video and the counts. These types are opt-in: an endpoint subscribed to all events does not receive them until you also pick them by name (one event per posted video or milestone adds up quickly for accounts you track at scale).
sync.completed arrives within about two minutes of the last item going live, so an integration that adds or refreshes through the API can read the new numbers right away instead of polling. Match it to your call by sync.id. Treat it as a fast path rather than a guarantee: when the call returns syncRequestId: null, or no event arrives within your own timeout (a few hours covers the slowest syncs), read the account or video instead. A call that returned an error usually sends no event, but one that failed after its syncs went out still does, under a sync.id you never received: ignore a sync.id you do not know, or match it by its items. platforms lists every platform in the sync, also when items stops at 500. A failed item says why (error) and whether sending it again can help (retryable). An item that stalled or has not reached your dashboard after an hour can still finish, so a sync with one is reported two hours after its last progress. trigger tells an add in the dashboard (user_add) from one through the API (api); refreshes and retries are user_refresh wherever they come from, and actor names the API key or teammate. Try again in the dashboard continues a sync that was already reported and sends no second event, while syncing failed items again through the API starts a new sync with its own event. Scheduled syncs are reported by organization.synced.
organization.synced tells you when your scheduled data is fresh, so a report or an export can run right after each slot instead of on a guess. It usually arrives within a few minutes of the last item going live. sync.scheduledFor is the slot, counts, accounts and videos say how many items synced and failed, and result.videosSynced counts the video rows the slot brought in. A slot that is still not finished 2 hours after its last progress, or 6 hours after its syncs were sent, is reported as it stands: timedOut is true and unfinished counts what did not finish. Slots start being followed once an endpoint listens to organization.synced, from the next slot on; a slot with nothing due sends no event. Like sync.completed, treat it as a fast path: when a slot sends nothing, read your data on your usual schedule. When your slots run is set in Sync schedule.
Plans, holds and privacy
- Plan. Webhooks need a plan with API access. If your plan loses it, viral.app disables your active endpoints, and they resume on their own once API access is back. Events in between are not recorded.
- During a payout hold. Deliveries continue while creator payouts are overdue. During a hard hold you can still pause and delete endpoints, rotate and reveal secrets, and see every endpoint and delivery. Creating, changing, resuming, testing, recovering and resending wait until the hold is lifted.
- Deleted creators. When a creator deletes their viral.app account, stored events that name them are replaced with a redacted placeholder, and their deliveries that have not succeeded end as
skipped: neither retries, Recover nor Resend send the placeholder. Events your endpoints already received cannot be recalled.