Why verify signatures?
Webhook endpoints are publicly accessible URLs. Without signature verification, anyone could send fake payloads to your endpoint. ModelRiver signs every webhook delivery with HMAC-SHA256 using your webhook secret, so you can verify that a request is authentic and unmodified.
What a delivery looks like
Every webhook delivery is a POST with a JSON body and three headers:
| Header | Value |
|---|---|
x-modelriver-signature | Lowercase hex HMAC-SHA256 signature |
x-modelriver-timestamp | Unix time in seconds when this delivery attempt was signed |
x-modelriver-webhook-id | ID of the webhook endpoint this delivery was sent to |
The body is an envelope. The event itself is inside data:
1{2 "callback_url": "https://api.modelriver.com/v1/callback/6f1c2a9e-...",3 "channel_id": "6f1c2a9e-...",4 "data": { "...": "the event payload" },5 "timestamp": 17600000006}See Standard webhooks for the contents of data.
How the signature is computed
ModelRiver signs the timestamp and the data value, joined by a dot:
1signed_string = <x-modelriver-timestamp> + "." + <JSON text of the "data" value>2signature = hex(HMAC-SHA256(key = your webhook secret, message = signed_string))The key is the webhook secret exactly as it was shown when you created the webhook.
Use the raw request body
The JSON text of data must be byte-for-byte what ModelRiver sent. Take it from the raw request body, before any JSON parsing: it is everything after the first "data": and before the last ,"timestamp":. The envelope keys always arrive in the order shown above, and timestamp is always the last key, so this is exact.
Do not parse the body and re-encode data with JSON.stringify, json.dumps, json_encode, or similar. Re-encoding changes the text in small ways. For example, a model output of "score": 4.0 becomes 4, and some characters are escaped differently. The signature then fails even though the request is genuine.
Do not compute the HMAC over the whole raw body either. Only the timestamp and the data value are signed.
Node.js implementation
1const crypto = require('crypto');2const express = require('express');3 4const app = express();5 6function verifyModelRiverSignature(rawBody, timestamp, signature, secret) {7 const start = rawBody.indexOf('"data":');8 const end = rawBody.lastIndexOf(',"timestamp":');9 if (start === -1 || end === -1 || !timestamp || !signature) return false;10 11 const data = rawBody.slice(start + '"data":'.length, end);12 const expected = crypto13 .createHmac('sha256', secret)14 .update(`${timestamp}.${data}`)15 .digest('hex');16 17 return (18 signature.length === expected.length &&19 crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))20 );21}22 23// express.raw keeps the body as a Buffer so the exact bytes can be verified24app.post('/webhooks/modelriver', express.raw({ type: 'application/json' }), (req, res) => {25 const rawBody = req.body.toString('utf8');26 const timestamp = req.get('x-modelriver-timestamp');27 const signature = req.get('x-modelriver-signature');28 29 if (!verifyModelRiverSignature(rawBody, timestamp, signature, process.env.MODELRIVER_WEBHOOK_SECRET)) {30 return res.status(401).json({ error: 'Invalid signature' });31 }32 33 // Signature verified: process the webhook34 const { channel_id, data } = JSON.parse(rawBody);35 console.log('Verified webhook:', channel_id, data.status ?? data.type);36 37 res.status(200).json({ received: true });38});Python implementation
1import hashlib2import hmac3import json4import os5 6from flask import Flask, jsonify, request7 8app = Flask(__name__)9 10 11def verify_modelriver_signature(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:12 start = raw_body.find(b'"data":')13 end = raw_body.rfind(b',"timestamp":')14 if start == -1 or end == -1 or not timestamp or not signature:15 return False16 17 data = raw_body[start + len(b'"data":'):end]18 expected = hmac.new(19 secret.encode("utf-8"),20 timestamp.encode("utf-8") + b"." + data,21 hashlib.sha256,22 ).hexdigest()23 24 return hmac.compare_digest(expected, signature)25 26 27@app.route("/webhooks/modelriver", methods=["POST"])28def handle_webhook():29 raw_body = request.get_data() # raw bytes, before JSON parsing30 timestamp = request.headers.get("x-modelriver-timestamp", "")31 signature = request.headers.get("x-modelriver-signature", "")32 33 if not verify_modelriver_signature(raw_body, timestamp, signature, os.environ["MODELRIVER_WEBHOOK_SECRET"]):34 return jsonify({"error": "Invalid signature"}), 40135 36 # Signature verified: process the webhook37 body = json.loads(raw_body)38 event = body["data"]39 print(f"Verified webhook: {body['channel_id']}")40 41 return jsonify({"received": True}), 200PHP implementation
1<?php2 3function verify_modelriver_signature(string $rawBody, string $timestamp, string $signature, string $secret): bool4{5 $start = strpos($rawBody, '"data":');6 $end = strrpos($rawBody, ',"timestamp":');7 if ($start === false || $end === false || $timestamp === '' || $signature === '') {8 return false;9 }10 11 $dataStart = $start + strlen('"data":');12 $data = substr($rawBody, $dataStart, $end - $dataStart);13 $expected = hash_hmac('sha256', $timestamp . '.' . $data, $secret);14 15 return hash_equals($expected, $signature);16}17 18$rawBody = file_get_contents('php://input');19$timestamp = $_SERVER['HTTP_X_MODELRIVER_TIMESTAMP'] ?? '';20$signature = $_SERVER['HTTP_X_MODELRIVER_SIGNATURE'] ?? '';21 22if (!verify_modelriver_signature($rawBody, $timestamp, $signature, getenv('MODELRIVER_WEBHOOK_SECRET'))) {23 http_response_code(401);24 exit;25}26 27$body = json_decode($rawBody, true);28$event = $body['data'];29http_response_code(200);Ruby implementation
1require "json"2require "openssl"3 4def verify_modelriver_signature(raw_body, timestamp, signature, secret)5 start = raw_body.index('"data":')6 finish = raw_body.rindex(',"timestamp":')7 return false if start.nil? || finish.nil? || timestamp.to_s.empty? || signature.to_s.empty?8 9 data = raw_body[(start + '"data":'.length)...finish]10 expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{data}")11 12 OpenSSL.secure_compare(expected, signature)13end14 15# Rails controller action16def modelriver17 raw_body = request.raw_post18 timestamp = request.headers["x-modelriver-timestamp"]19 signature = request.headers["x-modelriver-signature"]20 21 unless verify_modelriver_signature(raw_body, timestamp, signature, ENV.fetch("MODELRIVER_WEBHOOK_SECRET"))22 return head :unauthorized23 end24 25 event = JSON.parse(raw_body)["data"]26 head :ok27endRejecting old deliveries
ModelRiver does not reject replays for you. To limit replay attacks, reject deliveries whose x-modelriver-timestamp is too old, for example more than five minutes:
1const ageSeconds = Math.floor(Date.now() / 1000) - Number(timestamp);2if (!Number.isFinite(ageSeconds) || Math.abs(ageSeconds) > 300) {3 return res.status(401).json({ error: 'Stale webhook' });4}Every retry is signed again with a fresh timestamp, so a retried delivery passes this check.
Troubleshooting failed verification
- You re-encoded the JSON. Most frameworks parse JSON automatically. Make sure you verify against the raw body (
express.raw,request.get_data(),php://input,request.raw_post), not a re-serialized object. - You signed the whole body. Only
<timestamp>.<data JSON text>is signed. - Wrong header names. The headers are
x-modelriver-signatureandx-modelriver-timestamp. - Wrong secret. Each webhook endpoint has its own secret. Use the secret of the endpoint named in
x-modelriver-webhook-id.
Security best practices
- Always verify signatures: Never process webhooks without validating the
x-modelriver-signatureheader - Use timing-safe comparison: Use
crypto.timingSafeEqual(Node.js),hmac.compare_digest(Python),hash_equals(PHP), orOpenSSL.secure_compare(Ruby) to prevent timing attacks - Use HTTPS endpoints: ModelRiver accepts both
http://andhttps://webhook URLs. Usehttps://in production so payloads are encrypted in transit - Implement idempotency: Use
channel_id(plusstep_indexfor multi-step pipelines) to deduplicate webhook deliveries - Set reasonable timeouts: Respond quickly, ideally within a few seconds, and use background jobs for long-running work. ModelRiver waits up to 30 seconds for a response before treating the attempt as failed and retrying
- Store secrets securely: Keep webhook signature secrets in environment variables, never in code
Next steps
- Delivery & retries: Understand retry policies and monitoring
- Standard webhooks: Payload structure reference
- CLI tool: Test webhook signatures locally