Documentation

Webhook signature verification

Verify the x-modelriver-signature header to ensure webhook requests came from ModelRiver. Prevent spoofing with HMAC-SHA256 validation.

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:

HeaderValue
x-modelriver-signatureLowercase hex HMAC-SHA256 signature
x-modelriver-timestampUnix time in seconds when this delivery attempt was signed
x-modelriver-webhook-idID of the webhook endpoint this delivery was sent to

The body is an envelope. The event itself is inside data:

JSON
1{
2 "callback_url": "https://api.modelriver.com/v1/callback/6f1c2a9e-...",
3 "channel_id": "6f1c2a9e-...",
4 "data": { "...": "the event payload" },
5 "timestamp": 1760000000
6}

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:

TEXT
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

JAVASCRIPT
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 = crypto
13 .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 verified
24app.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 webhook
34 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

PYTHON
1import hashlib
2import hmac
3import json
4import os
5 
6from flask import Flask, jsonify, request
7 
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 False
16 
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 parsing
30 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"}), 401
35 
36 # Signature verified: process the webhook
37 body = json.loads(raw_body)
38 event = body["data"]
39 print(f"Verified webhook: {body['channel_id']}")
40 
41 return jsonify({"received": True}), 200

PHP implementation

PHP
1<?php
2 
3function verify_modelriver_signature(string $rawBody, string $timestamp, string $signature, string $secret): bool
4{
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

RUBY
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)
13end
14 
15# Rails controller action
16def modelriver
17 raw_body = request.raw_post
18 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 :unauthorized
23 end
24 
25 event = JSON.parse(raw_body)["data"]
26 head :ok
27end

Rejecting 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:

JAVASCRIPT
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-signature and x-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

  1. Always verify signatures: Never process webhooks without validating the x-modelriver-signature header
  2. Use timing-safe comparison: Use crypto.timingSafeEqual (Node.js), hmac.compare_digest (Python), hash_equals (PHP), or OpenSSL.secure_compare (Ruby) to prevent timing attacks
  3. Use HTTPS endpoints: ModelRiver accepts both http:// and https:// webhook URLs. Use https:// in production so payloads are encrypted in transit
  4. Implement idempotency: Use channel_id (plus step_index for multi-step pipelines) to deduplicate webhook deliveries
  5. 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
  6. Store secrets securely: Keep webhook signature secrets in environment variables, never in code

Next steps