Integration documentation for editorial and journal management systems
Scholvet Integration Documentation

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

typeMeaning
invitation.acceptedThe reviewer accepted. data.reviewer holds their identity; assign them in your system.
invitation.declinedThe reviewer declined, or deleted their account while the invitation was pending (declineReason other).
invitation.expiredNo answer before respondBy.

Changes you make yourself, cancelling and confirming completion, are not sent back to you.

The request

HeaderMeaning
X-Scholvet-EventEvent type, the same as type in the body.
X-Scholvet-Event-IdUnique event identifier, the same as id in the body. Use it to ignore repeated deliveries.
X-Scholvet-TimestampUnix time in seconds when the request was signed.
X-Scholvet-SignatureLower-case hex HMAC-SHA256 of timestamp + "." + raw body, keyed with your webhook secret.
Example
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

  1. Read the raw request body as bytes before any JSON parsing. Re-serialised JSON will not match.
  2. Reject the request if X-Scholvet-Timestamp is more than five minutes from your clock.
  3. Compute the HMAC-SHA256 of timestamp + "." + body with your webhook secret and compare it with X-Scholvet-Signature in constant time.
  4. Only then parse the body and act on it.
C# (.NET 8+)
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()));
}
Node.js
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));
}
PHP
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)));
}
Python
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

Next: Errors and versioning →