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 200 immediately — 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 with POST /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.