Webhooks
Stop polling. Tell us where to call, and we push events to you as they happen — signed, so you can prove they came from us.
1. Subscribe
Create an endpoint with the events you care about. Get the full list first —
GET /webhook-events returns every event name we can send.
curl -X POST "https://vm.viraldashboard.io/api/public/v1/webhooks" \
-H "Authorization: Bearer $VD_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/viraldashboard",
"events": ["post.published", "post.failed"],
"description": "Production listener"
}'
{
"data": {
"id": 59,
"url": "https://hooks.example.com/viraldashboard",
"events": ["post.published", "post.failed"],
"is_active": true,
"secret": "whsec_…"
}
}
The secret appears exactly once, in this create response. Reading the endpoint
back later will never show it again. Store it now — without it you cannot verify a single delivery.
Lost it? POST /webhooks/{id}/rotate-secret issues a new one, and the old one stops
working immediately.
Your URL must be public HTTPS. We refuse to register anything that resolves to a private or reserved address
(422 invalid_webhook_url) — that includes localhost and cloud metadata endpoints.
To develop locally, use a tunnel.
2. What a delivery looks like
POST /viraldashboard HTTP/1.1
Content-Type: application/json
User-Agent: ViralDashboard-Webhook/1.0
X-Webhook-Event: post.published
X-Webhook-Delivery-Id: 1204
X-Webhook-Timestamp: 1783252800
X-Webhook-Signature: t=1783252800,v1=5f8c…
{
"event": "post.published",
"data": {
"post_id": 1000241,
"brand_id": 412,
"platform": "telegram"
}
}
3. Verify the signature — always
Your endpoint is a public URL: anyone can POST to it. The signature is the only thing separating a real event from someone typing curl. Verify before you trust a single field.
X-Webhook-Signature is t=<unix-timestamp>,v1=<hmac>. The HMAC is
SHA-256 over the string "{timestamp}.{raw body}", keyed with your endpoint secret.
The timestamp is signed into the HMAC, which is what makes a captured payload useless to a replayer.
Sign the raw request body, byte for byte, exactly as it arrived. If you parse the JSON and re-serialise it, key order or spacing will shift and the signature will never match.
Node (Express)
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const SECRET = process.env.VD_WEBHOOK_SECRET
const TOLERANCE = 300 // seconds
// express.raw — NOT express.json — so we keep the exact bytes we must sign.
app.post('/viraldashboard', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-Webhook-Signature') ?? ''
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')))
const { t, v1 } = parts
if (!t || !v1) return res.status(400).send('malformed signature')
// Reject anything too old to be a live event.
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > TOLERANCE) {
return res.status(400).send('stale timestamp')
}
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${t}.${req.body.toString('utf8')}`)
.digest('hex')
// Constant-time — a plain === leaks the answer one byte at a time.
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
if (!ok) return res.status(400).send('bad signature')
const event = JSON.parse(req.body.toString('utf8'))
handle(event) // do the work — see "Be idempotent" below
res.sendStatus(200) // ack fast; anything non-2xx is a failed delivery
})
PHP (Laravel)
use Illuminate\Http\Request;
Route::post('/viraldashboard', function (Request $request) {
$secret = config('services.viraldashboard.webhook_secret');
$header = $request->header('X-Webhook-Signature', '');
parse_str(str_replace(',', '&', $header), $parts); // t=…,v1=… → ['t' => …, 'v1' => …]
$t = $parts['t'] ?? null;
$v1 = $parts['v1'] ?? null;
abort_if(! $t || ! $v1, 400, 'malformed signature');
abort_if(abs(time() - (int) $t) > 300, 400, 'stale timestamp');
// getContent() — the raw body, exactly as sent.
$expected = hash_hmac('sha256', $t.'.'.$request->getContent(), $secret);
abort_unless(hash_equals($expected, $v1), 400, 'bad signature');
handle($request->json()->all());
return response()->noContent();
});
curl (checking one by hand)
# Given the raw body in body.json and the values from the headers:
TS=1783252800
printf '%s.%s' "$TS" "$(cat body.json)" \
| openssl dgst -sha256 -hmac "$VD_WEBHOOK_SECRET" -hex
4. Be idempotent on your side too
We retry failed deliveries, and a retry can arrive after your handler actually succeeded but failed to respond in
time. So the same event may reach you more than once. Treat
X-Webhook-Delivery-Id as the deduplication key:
const deliveryId = req.get('X-Webhook-Delivery-Id')
if (await seen(deliveryId)) return res.sendStatus(200) // already handled — ack and move on
await markSeen(deliveryId)
5. Retries and failures
- Any 2xx is success. Anything else — or a timeout — counts as a failed delivery.
- We retry failures with backoff, up to the delivery's
max_attempts. -
Acknowledge fast, work later. We wait about 25 seconds. If your handler is slow, queue the
job and return
200immediately — do not do the work inside the request. -
Inspect what happened with
GET /webhooks/{id}/deliveries: status, response code, attempts, duration. Replay a specific one withPOST /webhooks/{id}/deliveries/{delivery}/retry.
Events
GET /webhook-events is the authoritative list — it is generated from the same registry that
fires them, so it can never drift from reality. It includes post lifecycle events
(post.published, post.failed), account events
(account.connected, account.disconnected) and brand.created.
account.connected is how you complete an API-initiated OAuth connect: you hand someone an
authorization URL, they authorize in a browser, and the webhook is your signal that it worked. See
Connecting accounts.