Webhooks
Receive a POST from us when your submission finishes, instead of polling.
Go to the live page Manage your webhooksA webhook is a URL on your server that Vilvik calls with a JSON body when an event happens. You can register webhooks from your API page.
Using the Python SDK?
client.webhooks.list() lists the webhooks on your account. Webhooks are created and tested from the dashboard in the current SDK release. See Webhooks.
Registering a webhook¶
The form asks for:
- URL. Where we should send the POST. Must be HTTPS.
- Events. Tick which events to receive (see the list below).
- Description. Optional label so you can tell webhooks apart.
We give you a signing secret when you create the webhook. Save it. You will use it to verify that requests really came from us.
Event types¶
Tick one or more of these when you register a webhook:
submission.succeededis sent when a run finishes successfully.submission.failedis sent when a run ends in an error.submission.cancelledis sent when a run is cancelled before it finishes.result.completedis sent alongsidesubmission.succeededwhen the run produced a result. Subscribe to it if you care about the result artifact specifically rather than the run outcome.
Only API and SDK runs fire these events
Webhooks are delivered for submissions you create through the REST API or the Python SDK. Runs you start from the website do not trigger a webhook, so if you submit a job from the dashboard and nothing arrives at your endpoint, that is why. To exercise your handler without an API run, fire the webhook.test event described next.
There is also a webhook.test event you can fire by hand from the dashboard (see Test, replay & inspect) to exercise your handler without running a real job.
What you receive¶
Every event is a POST with Content-Type: application/json:
POST /your/path
Content-Type: application/json
X-Signature: sha256=abc123...
X-Signature-Timestamp: 1715200000
X-Event-Id: 5f2b7c1e-9a3d-4e8b-bf60-228588a1c663
X-Event-Type: submission.succeeded
User-Agent: Vilvik-Webhook/1.0
{
"event_id": "5f2b7c1e-9a3d-4e8b-bf60-228588a1c663",
"event_type": "submission.succeeded",
"occurred_at": "2026-05-01T12:35:21.123456+00:00",
"data": {
"submission_id": "abcDEF123456",
"result_id": "resXYZ789012",
"status": "succeeded",
"execution_duration_ms": 2500,
"cpu_seconds_used": 2.0,
"peak_memory_mb": 32,
"hit_memory_cap": false,
"hit_time_cap": false,
"best_solution_fitness": "0.987654"
}
}
The X-Event-Id is unique per event and stable across retries, so use it to deduplicate. A result.completed event carries submission_id, result_id, best_solution_fitness, and generations_completed in its data.
Verifying the signature¶
Two headers carry the signature:
X-Signatureissha256=<hex>, an HMAC-SHA256 of<timestamp>.<raw body>keyed by your signing secret.X-Signature-Timestampis the Unix timestamp (seconds) used in that signed string.
import hmac, hashlib
def verify(payload_bytes, signature_header, timestamp_header, secret):
sent = signature_header.split("=", 1)[1] # strip the "sha256=" prefix
expected = hmac.new(
secret.encode(),
timestamp_header.encode() + b"." + payload_bytes,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(sent, expected)
Reject any request where verify returns False. Reject requests where the timestamp is more than five minutes old to defeat replay attacks.
Building a webhook handler¶
If you have not built a webhook receiver before, here is the shape of the work:
- Expose an HTTPS URL. Webhooks reach you over the public internet, so the URL has to be reachable from outside your network and protected by TLS. A path on the same web server that already serves your app is the simplest option.
- Accept POST with a JSON body. Read the raw body bytes (not a parsed-and-re-serialized version) before doing anything else. Signature verification has to happen on the bytes we actually sent.
- Verify the signature. Use the snippet above. Compare the timestamp against a window (we suggest five minutes) and the HMAC against the header. If either check fails, return 400 and stop.
- Acknowledge quickly. Return 2xx as soon as the request is valid. Do the real work (database write, sending a notification, updating a dashboard) in the background or after responding. We treat anything other than 2xx as a failure and will retry, so a slow handler that succeeds eventually still costs you retries.
- Be idempotent. If we retry, you will see the same event twice. Use
X-Event-Id(orevent_idin the body) to skip work you have already done.
A common shape for the handler:
1. Read raw body bytes and the X-Signature / X-Signature-Timestamp headers.
2. Reject if the timestamp is older than five minutes.
3. Reject if the HMAC does not match.
4. Parse the JSON.
5. Hand the event off to a background task or job.
6. Return 200.
Worked examples¶
Each example uses the same verify(payload_bytes, signature_header, timestamp_header, secret) function from the previous section, with the same five-minute replay window. Adapt the framing code to your stack.
Python with Flask¶
from flask import Flask, request, abort
import time
app = Flask(__name__)
SECRET = "your_signing_secret"
@app.post("/vilvik-webhook")
def receive():
raw = request.get_data()
sig = request.headers.get("X-Signature", "")
ts = request.headers.get("X-Signature-Timestamp", "0")
if abs(time.time() - int(ts)) > 300:
abort(400, "stale")
if not verify(raw, sig, ts, SECRET):
abort(400, "bad signature")
event = request.get_json()
# TODO: enqueue background work using event["data"]["submission_id"]
return "", 200
if __name__ == "__main__":
app.run(port=4000)
Python with Django¶
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import json, time
SECRET = "your_signing_secret"
@csrf_exempt
@require_POST
def vilvik_webhook(request):
raw = request.body
sig = request.headers.get("X-Signature", "")
ts = request.headers.get("X-Signature-Timestamp", "0")
if abs(time.time() - int(ts)) > 300:
return HttpResponseBadRequest("stale")
if not verify(raw, sig, ts, SECRET):
return HttpResponseBadRequest("bad signature")
event = json.loads(raw)
# TODO: enqueue background work using event["data"]["submission_id"]
return HttpResponse(status=200)
JavaScript on Node.js¶
JavaScript can receive webhooks when it runs on a server. That means Node.js. JavaScript that runs in a browser cannot, because a browser tab has no public URL we can reach. Both examples below verify the signature using the same HMAC formula as the Python snippet.
With Express:
import express from "express";
import crypto from "crypto";
const app = express();
const SECRET = "your_signing_secret";
function verify(rawBody, signatureHeader, timestampHeader, secret) {
const sent = signatureHeader.split("=")[1]; // strip "sha256="
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestampHeader}.`)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected));
}
app.post("/vilvik-webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.get("X-Signature") || "";
const ts = req.get("X-Signature-Timestamp") || "0";
if (Math.abs(Date.now() / 1000 - parseInt(ts, 10)) > 300) return res.status(400).end();
if (!verify(req.body, sig, ts, SECRET)) return res.status(400).end();
const event = JSON.parse(req.body.toString("utf8"));
// TODO: enqueue background work using event.data.submission_id
res.status(200).end();
}
);
app.listen(4000);
Without a framework, using only node:http:
import { createServer } from "node:http";
import crypto from "node:crypto";
const SECRET = "your_signing_secret";
createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/vilvik-webhook") {
res.writeHead(404).end();
return;
}
const chunks = [];
req.on("data", c => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks);
const sig = req.headers["x-signature"] || "";
const ts = req.headers["x-signature-timestamp"] || "0";
// verify() identical to the Express version
if (!verify(raw, sig, ts, SECRET)) { res.writeHead(400).end(); return; }
res.writeHead(200).end();
});
}).listen(4000);
Go¶
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const secret = "your_signing_secret"
func verify(body []byte, signatureHeader, timestampHeader string) bool {
sent := strings.TrimPrefix(signatureHeader, "sha256=")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestampHeader + "."))
mac.Write(body)
return hmac.Equal([]byte(sent), []byte(hex.EncodeToString(mac.Sum(nil))))
}
func main() {
http.HandleFunc("/vilvik-webhook", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sig := r.Header.Get("X-Signature")
tsHeader := r.Header.Get("X-Signature-Timestamp")
ts, _ := strconv.ParseInt(tsHeader, 10, 64)
if time.Now().Unix()-ts > 300 { http.Error(w, "stale", 400); return }
if !verify(body, sig, tsHeader) { http.Error(w, "bad signature", 400); return }
// TODO: enqueue background work
w.WriteHeader(200)
})
http.ListenAndServe(":4000", nil)
}
Testing your endpoint¶
You do not need to wait for a real run to test your handler.
- Use the Replay button on the deliveries page. It re-fires a past delivery's payload at your URL, with a fresh signature. Useful when you fix a bug and want to retry the event that caught it.
- Develop locally with a public tunnel. If your handler is running on your laptop, a tunnel tool gives it a temporary public HTTPS URL you can register as the webhook target. Switch back to the real URL when you deploy.
- Check the response body under the failed delivery in the dashboard. The actual response your server returned is captured there, which makes debugging signature checks a lot easier than reading logs.
Local testing with ngrok¶
A localhost address only works on your own machine, so it cannot be a webhook target. Our delivery servers reach your endpoint over the public internet, and to them localhost means their own machine, not yours. A tunnel solves this by giving your local server a temporary public HTTPS URL. ngrok is one common choice; cloudflared and localtunnel work the same way.
-
Install it. On macOS,
brew install ngrok. On Linux, download it fromhttps://ngrok.com/downloador use your package manager. Windows users can usechoco install ngrokor the same download page. -
Connect your account once. Create a free account, copy the authtoken from your ngrok dashboard, and run it once on your machine:
ngrok config add-authtoken <your-token>
- Start your handler, then point ngrok at the same port. If your Flask example is listening on 4000:
ngrok http 4000
ngrok prints a forwarding line that looks like this:
Forwarding https://a1b2-93-184-216-34.ngrok-free.app -> http://localhost:4000
- Register the HTTPS URL (the
https://...ngrok-free.apppart) plus your route as the webhook target:
https://a1b2-93-184-216-34.ngrok-free.app/vilvik-webhook
- Fire a test with the Replay button, or trigger a real run. Each request now arrives at your local handler with its signature headers intact. ngrok also serves a local inspector at
http://localhost:4040where you can see every request and replay it without leaving your machine.
A few things to know:
- The URL changes each time you restart ngrok on the free plan, so re-register the new one. A reserved domain on a paid plan keeps it stable.
- The signature still verifies through the tunnel. ngrok forwards the request body byte-for-byte, so the HMAC matches as long as you hash the raw bytes (which all the examples above do). If you see signature failures, check that your framework is not re-parsing and re-serializing the body before you verify it.
- Switch back to your deployed URL when you go to production. The tunnel is for local development only.
Retries¶
If your endpoint returns a non-2xx status (or times out after 10 seconds), we retry with exponential backoff: 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours. We stop retrying after 24 hours, mark the delivery failed, and you can re-send it from the deliveries page.
Listing past deliveries¶
The webhooks tab shows every delivery for each webhook, with the HTTP status, the response body, and Inspect and Replay buttons. Use this to debug a broken endpoint without having to wait for a new event.
Required scopes¶
To create or update webhooks via the API: webhooks:write. To list deliveries: webhooks:read.
Test, replay & inspect¶
Vilvik gives you three tools in the webhook table for developing and debugging your receiver, without needing to run a real GA job.
Send a test event¶
Click the Test dropdown next to any webhook. You'll see one item per event type your webhook is subscribed to, plus a generic webhook.test. Picking one fires a synthetic event shaped like the production envelope (sample IDs, fixed timestamps) so your receiver can exercise each handler branch on demand. The dispatched event runs through the full retry pipeline, so it'll show up in the deliveries log alongside real events. Test events are rate-limited to 10 per minute per account.
Replay a past delivery¶
Open the Log modal for a webhook, find the row you want to retry, and click Replay. Vilvik creates a new delivery with the same event_type and data as the original, but a fresh event_id, signature, and occurred_at. Use this after you've fixed a bug in your handler and want to re-process the same event without waiting for it to fire again. Replay is rate-limited to 10 per minute per account.
Inspect a delivery¶
In the same Log modal, click Inspect on any row. You'll see:
- The request URL and full request headers (including the
X-Signaturevalue Vilvik sent, useful for debugging signature verification on your side). - The original payload, pretty-printed.
- One card per attempt with the response status, response headers, response body (truncated at 8 KB), duration, and any network error message.