Why webhooks
Some events you'd otherwise have to poll for. The most obvious is the proactive-check — the bot occasionally decides it's time to reach out to your user, but you don't want to hit our API every minute waiting. Subscribe to proactive_ready and we'll POST you the message the moment it's composed.
Same goes for billing state changes, milestone events, and anything else where push beats poll.
Register an endpoint
Use the dashboard Settings → Webhooks tab, or call the API:
curl -X POST https://api.vilow.dev/v1/me/webhooks \ -H "Cookie: vilow_session=…" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/hooks/vilow", "events": ["proactive_ready", "subscription_updated"] }'
Response (the secret is shown only on creation — copy it now):
{
"id": 7,
"url": "https://your-app.example.com/hooks/vilow",
"events": ["proactive_ready", "subscription_updated"],
"secret": "whs_...",
"is_active": true,
"created_at": "2026-04-27T12:00:00+00:00"
}
events array means subscribe to all event types. Useful for development; tighten in production.
Event catalog
| Event | When | Payload data |
|---|---|---|
proactive_ready |
The proactive-check decided the bot should reach out. Fired right after POST .../proactive/check commits. |
{external_id, character_id, message, reason, suggested_channel} |
subscription_updated |
Plan or status changed (Stripe webhook → us → you). | {plan, status, period_end} |
payment_completed |
A top-up or subscription invoice was paid. | {kind, amount_eur, invoice_id} |
quota_warning |
Tenant crossed 80% of monthly message quota. | {used, included, percent_remaining} |
character_milestone |
Trust threshold crossed or relationship stage changed for any character. | {character_id, event, from, to} |
intimate_consent |
A character's intimate consent was granted or revoked. | {character_id, action: "granted" | "revoked"} |
Payload shape
Every event POST has the same envelope:
{
"event": "proactive_ready",
"delivery_id": 12345,
"tenant_id": 7,
"timestamp": "2026-04-27T12:00:00+00:00",
"data": { // event-specific, see catalog above
"external_id": "alice-42",
"character_id": 10,
"message": "Hey, just thinking about you 😊",
"reason": "missed_user",
"suggested_channel": "text"
}
}
Headers we send:
POST /hooks/vilow HTTP/1.1 Host: your-app.example.com User-Agent: Vilow-Webhook/1.0 Content-Type: application/json X-Vilow-Event: proactive_ready X-Vilow-Signature: sha256=<64-char hex> X-Vilow-Delivery: 12345
Verify the signature
Node / Express
import crypto from "crypto"; const SECRET = process.env.VILOW_WEBHOOK_SECRET; app.post("/hooks/vilow", express.raw({type: "application/json"}), // raw body! (req, res) => { const got = req.headers["x-vilow-signature"] || ""; const want = "sha256=" + crypto.createHmac("sha256", SECRET) .update(req.body) .digest("hex"); if (!crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))) { return res.status(401).send("bad signature"); } const body = JSON.parse(req.body); if (body.event === "proactive_ready") { // queue your push notification... } res.status(204).end(); });
Python / FastAPI
import hashlib, hmac from fastapi import FastAPI, Request, HTTPException SECRET = os.environ["VILOW_WEBHOOK_SECRET"].encode() @app.post("/hooks/vilow") async def hook(request: Request): body = await request.body() got = request.headers.get("x-vilow-signature", "") want = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest() if not hmac.compare_digest(got, want): raise HTTPException(401, "bad signature") payload = json.loads(body) # ... handle payload return {"ok": True}
Acknowledgement
Return any 2xx status code within 10 seconds. Anything else (incl. timeout) counts as failure and triggers retry.
Retry policy
Failed deliveries retry with exponential backoff, up to 8 attempts total:
| Attempt | Delay before retry |
|---|---|
| 1 → 2 | 30 sec |
| 2 → 3 | 1 min |
| 3 → 4 | 2 min |
| 4 → 5 | 5 min |
| 5 → 6 | 15 min |
| 6 → 7 | 30 min |
| 7 → 8 | 1 hour |
| 8 → fail | marked failed, no more attempts |
The dispatcher runs every 30 seconds and processes due retries. delivery_id stays the same across retries — use it for idempotency on your side.
Manage / inspect deliveries
# List all webhooks (secret hidden after creation) GET /v1/me/webhooks # Pause without deleting PATCH /v1/me/webhooks/{id} { "is_active": false } # Filter what events fire PATCH /v1/me/webhooks/{id} { "events": ["proactive_ready"] } # Recent deliveries with status + error reasons GET /v1/me/webhooks/{id}/deliveries?limit=50 # Delete DELETE /v1/me/webhooks/{id}
Testing locally
Easiest is ngrok or Cloudflare Tunnel:
ngrok http 3000 # → https://xxxx.ngrok.io POST /v1/me/webhooks { "url": "https://xxxx.ngrok.io/hooks/vilow", "events": [] }
Trigger an event by hitting a proactive-check or by toggling intimate consent in the dashboard. The delivery record will show up in /v1/me/webhooks/{id}/deliveries with status + last response code.