Webhooks

Receive real-time notifications when tests complete. No polling required — we POST results directly to your server.

Setup

Webhooks are configured per project. There are two ways to set the URL:

The signing secret (recommended path)

Every callback is signed with HMAC-SHA256 using a per-project secret. You have two ways to set it:

Generating a strong secret in your shell:

# Node:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

# OpenSSL:
openssl rand -hex 32

# Python:
python -c "import secrets; print(secrets.token_hex(32))"

Then, on every job submission:

curl -X POST https://testmyvibes.com/v1/replit/test \
  -H "Authorization: Bearer $TMV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://my-app.replit.app",
    "webhookUrl": "https://my-server.com/webhooks/testmyvibes",
    "webhookSecret": "'"$TMV_WEBHOOK_SECRET"'"
  }'
HTTPS required. Webhook URLs must use HTTPS. We also reject private/internal IP addresses for security.

Payload Format

When a test completes, we send a POST request to your webhook URL with this JSON body:

{
  "event": "job.completed",
  "type": "job.completed",
  "timestamp": "2025-01-15T10:30:00.000Z",
  "projectId": "proj_abc",
  "jobId": "abc123",
  "dashboardReportUrl": "https://testmyvibes.com/projects/proj_abc/jobs/abc123",
  "data": {
    "projectId": "proj_abc",
    "jobId": "abc123",
    "dashboardReportUrl": "https://testmyvibes.com/projects/proj_abc/jobs/abc123",
    "title": "Post-deploy QA check",
    "url": "https://your-app.com",
    "status": "completed",
    "jobType": "General QA",
    "overallScore": 85,
    "criticalIssues": 0,
    "majorIssues": 2,
    "minorIssues": 1,
    "completedAt": "2025-01-15T10:28:00.000Z",
    "checklist": [
      {
        "description": "Homepage loads correctly",
        "status": "pass",
        "severity": "Critical"
      },
      {
        "description": "Navigation links work",
        "status": "fail",
        "severity": "Major",
        "notes": "About page returns 404"
      }
    ]
  }
}

The event name is available as both event (the field we shipped with) and type (a Stripe-style alias added so most webhook frameworks read it without configuration). The fields jobId, projectId, and dashboardReportUrl are emitted at the top level for routing convenience, and also inside data for parsers that look there. dashboardReportUrl is the operator-facing report page — perfect for deep-linking humans into the report directly from your app.

Verifying Signatures

Every webhook request includes an X-TMV-Signature header containing an HMAC-SHA256 signature of the request body, signed with your webhook secret.

Verify against the raw request body, not JSON.stringify(req.body). We sign the exact JSON bytes we send. Re-serializing a parsed body can change key order, whitespace, or Unicode escaping and break the signature check. Capture the raw body before any JSON middleware parses it.

Node.js / Express

const crypto = require("crypto");
const express = require("express");

// Use express.raw() ONLY on the webhook route so the body stays as a Buffer.
// Don't put express.json() before this route, or req.body will be parsed
// and the raw bytes lost.
app.post(
  "/webhook/testmyvibes",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["x-tmv-signature"] || "";
    const expected = crypto
      .createHmac("sha256", process.env.TMV_WEBHOOK_SECRET)
      .update(req.body) // Buffer of the exact bytes we signed
      .digest("hex");

    const ok =
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
    if (!ok) return res.status(401).json({ error: "Invalid signature" });

    const { event, data } = JSON.parse(req.body.toString("utf8"));
    console.log(`Test ${data.jobId} scored ${data.overallScore}/100`);

    if (data.overallScore < 70) {
      // Alert team, create issue, etc.
      console.log("QA score below threshold!");
    }

    res.json({ received: true });
  },
);

Python / Flask

import hmac, hashlib, json
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/webhook/testmyvibes", methods=["POST"])
def handle_webhook():
    signature = request.headers.get("X-TMV-Signature", "")
    expected = hmac.new(
        os.environ["TMV_WEBHOOK_SECRET"].encode(),
        request.data,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(signature, expected):
        return jsonify(error="Invalid signature"), 401

    data = request.json
    print(f"Test {data['data']['jobId']} scored {data['data']['overallScore']}/100")
    return jsonify(received=True)

Headers

Header Description
X-TMV-SignatureHMAC-SHA256 hex digest of the body, signed with your webhook secret
X-TMV-EventEvent type (e.g. job.completed)
Content-Typeapplication/json
User-AgentTestMyVibes-Webhook/1.0

Retry Policy

If your endpoint doesn't respond with a 2xx status code, we retry up to 3 times with exponential backoff:

Each delivery attempt is logged. We have a 10-second timeout per request.

Events

Event When
job.completedA test has been completed and report is ready
job.claimedA checker has picked up your test
job.cancelledA test was cancelled
loop_session.endedAn auto-test agent loop ended badly (stuck on the same bug, ran out of credits, or hit the max iteration cap). Payload includes finalStatus, iterations, creditsSpent, platform, projectName, and finalReportId. Suppressed when loop alerts are snoozed for the project.

Testing Locally

Use a service like ngrok or webhook.site to test webhooks locally:

# Start a tunnel to your local server
ngrok http 3000

# Then use the ngrok URL as your webhook URL:
# https://abc123.ngrok.io/webhook/testmyvibes