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:
- From the dashboard β Go to Dashboard β Projects, open a project, and paste your HTTPS webhook URL.
- From the API β Pass
webhookUrlwhen you submit a job (e.g.POST /v1/replit/test). If the project doesn't exist yet, we auto-create it with that 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:
- Bring your own (recommended for code-driven integrations). Pass
webhookSecretalongsidewebhookUrlon your firstPOST /v1/replit/testcall. Use a long random string (32β256 ASCII characters) that you store as an env var on your side. We'll use that exact value to sign every callback to your URL β no dashboard round-trip needed. You can keep sending the same value on every call; matching values are a no-op. Sending a different value to a project that already has a secret returns409 WEBHOOK_SECRET_CONFLICTrather than silently desynchronizing signatures. - Let us generate one. If you omit
webhookSecret, we generate a secret server-side the first time a webhook URL is attached. Open the project's Webhook page in your dashboard to copy it. You can rotate it any time from the same page; the new value is shown once on rotation, so copy it then.
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"'"
}'
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.
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-Signature | HMAC-SHA256 hex digest of the body, signed with your webhook secret |
X-TMV-Event | Event type (e.g. job.completed) |
Content-Type | application/json |
User-Agent | TestMyVibes-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:
- 1st retry: after 1 second
- 2nd retry: after 10 seconds
- 3rd retry: after 60 seconds
Each delivery attempt is logged. We have a 10-second timeout per request.
Events
| Event | When |
|---|---|
job.completed | A test has been completed and report is ready |
job.claimed | A checker has picked up your test |
job.cancelled | A test was cancelled |
loop_session.ended | An 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