Events
Webhooks
Scholvet calls your endpoint when a reviewer answers or an invitation expires, so you do not have to poll.
Setting up
Enter an HTTPS address that accepts POST requests on the journal page in the console. The console shows a webhook secret that signs every request. Without an address, no events are sent; read the invitations instead. The host must resolve to public IP addresses only; Scholvet checks this when you save and again on every delivery.
Events
| type | Meaning |
|---|---|
invitation.accepted | The reviewer accepted. data.reviewer holds their identity; assign them in your system. |
invitation.declined | The reviewer declined, or deleted their account while the invitation was pending (declineReason other). |
invitation.expired | No answer before respondBy. |
Changes you make yourself, cancelling and confirming completion, are not sent back to you.
The request
| Header | Meaning |
|---|---|
X-Scholvet-Event | Event type, the same as type in the body. |
X-Scholvet-Event-Id | Unique event identifier, the same as id in the body. Use it to ignore repeated deliveries. |
X-Scholvet-Timestamp | Unix time in seconds when the request was signed. |
X-Scholvet-Signature | Lower-case hex HMAC-SHA256 of timestamp + "." + raw body, keyed with your webhook secret. |
POST /scholvet/webhook HTTP/1.1
Host: journal.example.org
Content-Type: application/json; charset=utf-8
User-Agent: Scholvet/1.0
X-Scholvet-Event: invitation.accepted
X-Scholvet-Event-Id: 3f2b8d0c-71a4-4c55-9a2e-0d6f1b7c9e41
X-Scholvet-Timestamp: 1793189653
X-Scholvet-Signature: <hex hmac_sha256(secret, timestamp + "." + body)>
{
"id": "3f2b8d0c-71a4-4c55-9a2e-0d6f1b7c9e41",
"type": "invitation.accepted",
"createdAt": "2026-10-29T07:02:46Z",
"data": { "invitationId": "6d8da67a-...", "status": "accepted", "reviewer": { "...": "..." } }
}
The body is { id, type, createdAt, data }, where data is the invitation object.
Verifying the signature
- Read the raw request body as bytes before any JSON parsing. Re-serialised JSON will not match.
- Reject the request if X-Scholvet-Timestamp is more than five minutes from your clock.
- Compute the HMAC-SHA256 of timestamp + "." + body with your webhook secret and compare it with X-Scholvet-Signature in constant time.
- Only then parse the body and act on it.
using System.Security.Cryptography;
using System.Text;
static bool IsValidScholvetSignature(string secret, string timestamp, string rawBody, string signature)
{
if (!long.TryParse(timestamp, out var seconds) ||
Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > 300) return false;
var expected = Convert.ToHexStringLower(HMACSHA256.HashData(
Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(timestamp + "." + rawBody)));
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature.Trim().ToLowerInvariant()));
}
const crypto = require("crypto");
function isValidScholvetSignature(secret, timestamp, rawBody, signature) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`, "utf8")
.digest("hex");
const given = String(signature).trim().toLowerCase();
return given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given));
}
function isValidScholvetSignature(string $secret, string $timestamp, string $rawBody, string $signature): bool
{
if (abs(time() - (int)$timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, strtolower(trim($signature)));
}
import hashlib, hmac, time
def is_valid_scholvet_signature(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature.strip().lower())
Test vector
Your verifier must accept exactly these values. The body is the exact byte sequence shown, without a trailing newline.
- secret
whsec_test_example- X-Scholvet-Timestamp
1793189653- body
{"id":"3f2b8d0c-71a4-4c55-9a2e-0d6f1b7c9e41","type":"invitation.accepted"}- X-Scholvet-Signature
01a26b6c6c8d0377814a5cf39dc828caaedffb08bb47cc6050df73bb280d2dae
Responding and retries
- Answer with any 2xx status within 10 seconds. Do the work after answering if it takes longer.
- Any other answer, or a timeout, is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours. After that the event is dropped. Redirects (3xx) are not followed and count as failures.
- Delivery is at least once. The same event may arrive twice; make your handling repeatable using the event id.
- Events can arrive out of order. When in doubt, read the invitation for its current state.