Webhooks

Server-pushed events instead of poll-loops. Register a URL, choose which events you want, verify the HMAC signature on receive. Failed deliveries retry automatically.

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"
}
Empty events array means subscribe to all event types. Useful for development; tighten in production.

Event catalog

EventWhenPayload 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

Always verify. Otherwise anyone who guesses your endpoint URL can forge events. The signature is HMAC-SHA256 over the raw request body using the secret returned at creation.

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:

AttemptDelay before retry
1 → 230 sec
2 → 31 min
3 → 42 min
4 → 55 min
5 → 615 min
6 → 730 min
7 → 81 hour
8 → failmarked 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.

If your secret leaks — delete the webhook and create a new one. Existing in-flight deliveries with the old secret will keep retrying until they hit max attempts.