Skip to main content

Signature verification

This page applies to HMAC subscriptions (the default). If your subscription uses Basic auth, you validate the Authorization header instead — see Authentication.

Before doing anything with an incoming webhook body, verify the signature. Unverified webhooks are untrusted input — anyone on the internet can POST JSON at your endpoint.

What you're checking

RCM signs each delivery as follows:

signing_input = X-RCM-Timestamp + <raw request body>
signature = Base64( HMAC-SHA256(secret, signing_input) )
header = X-RCM-Signature

The header that arrives looks like:

X-RCM-Signature: k3m9wF2...== (Base64, standard encoding)

Your job:

  1. Read X-RCM-Timestamp (an ISO-8601 timestamp) and reject if it's more than 5 minutes off from your server's clock (replay protection).
  2. Read the raw request body — bytes as received, before any JSON parsing.
  3. Concatenate timestamp + raw_body directly, with no separator.
  4. Compute HMAC-SHA256 using your shared secret and Base64-encode the result.
  5. Compare to X-RCM-Signature using a constant-time comparison (not ==).

Only after all checks pass should you parse the JSON and act on it.

Critical gotchas

  • No separator between timestamp and body — they are concatenated directly (timestamp + body), not timestamp + "." + body.
  • Base64, not hex, and no sha256= prefix — the header is the raw Base64 of the digest.
  • X-RCM-Timestamp is ISO-8601 (e.g. 2026-06-25T15:34:00.123-06:00), not Unix seconds.
  • Use the raw body, not a re-serialised one. Frameworks that auto-parse JSON change the bytes — the HMAC will not match.
  • Constant-time compare, not == / ===.
  • Don't log the secret.

Code samples

Node.js (Express)

import express from 'express';
import crypto from 'crypto';

const app = express();
const WEBHOOK_SECRET = process.env.RCM_WEBHOOK_SECRET;

// CRITICAL: capture the raw body BEFORE express.json() parses it.
app.use('/webhooks/osigu-rcm', express.raw({type: 'application/json'}));

app.post('/webhooks/osigu-rcm', (req, res) => {
const signature = req.header('X-RCM-Signature');
const timestamp = req.header('X-RCM-Timestamp');

if (!signature || !timestamp) {
return res.status(400).send('Missing signature headers');
}

// Replay protection: 5-minute skew window (timestamp is ISO-8601)
const skewMs = Math.abs(Date.now() - Date.parse(timestamp));
if (Number.isNaN(skewMs) || skewMs > 5 * 60 * 1000) {
return res.status(401).send('Timestamp too old');
}

const signingInput = timestamp + req.body.toString('utf8');
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signingInput, 'utf8')
.digest('base64');

// Constant-time compare
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('Invalid signature');
}

// OK — parse and process
const event = JSON.parse(req.body.toString('utf8'));
// ... dedupe on event.event_id, persist, ack ...
res.status(200).send('ok');
});

Python (FastAPI)

import os
import hmac
import hashlib
import base64
from datetime import datetime, timezone
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
WEBHOOK_SECRET = os.environ['RCM_WEBHOOK_SECRET']

@app.post("/webhooks/osigu-rcm")
async def osigu_rcm_webhook(request: Request):
signature = request.headers.get("X-RCM-Signature")
timestamp = request.headers.get("X-RCM-Timestamp")
if not signature or not timestamp:
raise HTTPException(400, "Missing signature headers")

# Replay protection (timestamp is ISO-8601)
ts = datetime.fromisoformat(timestamp)
if abs((datetime.now(timezone.utc) - ts).total_seconds()) > 5 * 60:
raise HTTPException(401, "Timestamp too old")

raw_body = await request.body()
signing_input = timestamp.encode("utf-8") + raw_body
expected = base64.b64encode(
hmac.new(WEBHOOK_SECRET.encode("utf-8"), signing_input, hashlib.sha256).digest()
).decode("utf-8")

if not hmac.compare_digest(signature, expected):
raise HTTPException(401, "Invalid signature")

import json
event = json.loads(raw_body)
# ... dedupe on event["event_id"], persist, ack ...
return {"ok": True}

Java (Spring Boot)

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.OffsetDateTime;
import java.time.Duration;
import java.util.Base64;

@RestController
public class OsiguWebhookController {

private static final String WEBHOOK_SECRET = System.getenv("RCM_WEBHOOK_SECRET");

// IMPORTANT: read the body as bytes — Spring's default Jackson binding
// would re-serialise it and break the HMAC.
@PostMapping(value = "/webhooks/osigu-rcm", consumes = "application/json")
public ResponseEntity<String> receive(
@RequestHeader("X-RCM-Signature") String signature,
@RequestHeader("X-RCM-Timestamp") String timestamp,
@RequestBody byte[] rawBody) throws Exception {

// Replay protection (timestamp is ISO-8601)
Duration skew = Duration.between(OffsetDateTime.parse(timestamp), OffsetDateTime.now());
if (Math.abs(skew.getSeconds()) > 300) {
return ResponseEntity.status(401).body("Timestamp too old");
}

String signingInput = timestamp + new String(rawBody, StandardCharsets.UTF_8);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(WEBHOOK_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = Base64.getEncoder()
.encodeToString(mac.doFinal(signingInput.getBytes(StandardCharsets.UTF_8)));

if (!MessageDigest.isEqual(
signature.getBytes(StandardCharsets.UTF_8),
expected.getBytes(StandardCharsets.UTF_8))) {
return ResponseEntity.status(401).body("Invalid signature");
}

// OK to parse rawBody
return ResponseEntity.ok("ok");
}
}

Go (net/http)

package webhooks

import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"io"
"net/http"
"os"
"time"
)

var webhookSecret = []byte(os.Getenv("RCM_WEBHOOK_SECRET"))

func HandleOsiguRCM(w http.ResponseWriter, r *http.Request) {
sig := r.Header.Get("X-RCM-Signature")
ts := r.Header.Get("X-RCM-Timestamp")
if sig == "" || ts == "" {
http.Error(w, "missing signature headers", http.StatusBadRequest)
return
}

// Replay protection (timestamp is ISO-8601)
parsed, err := time.Parse(time.RFC3339Nano, ts)
if err != nil || abs(time.Since(parsed)) > 5*time.Minute {
http.Error(w, "timestamp too old", http.StatusUnauthorized)
return
}

raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "could not read body", http.StatusBadRequest)
return
}

mac := hmac.New(sha256.New, webhookSecret)
mac.Write([]byte(ts))
mac.Write(raw)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))

if !hmac.Equal([]byte(sig), []byte(expected)) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}

// OK to parse(raw)
w.WriteHeader(http.StatusOK)
}

func abs(d time.Duration) time.Duration { if d < 0 { return -d }; return d }

PHP

<?php
$secret = getenv('RCM_WEBHOOK_SECRET');

$signature = $_SERVER['HTTP_X_RCM_SIGNATURE'] ?? null;
$timestamp = $_SERVER['HTTP_X_RCM_TIMESTAMP'] ?? null;
$rawBody = file_get_contents('php://input');

if (!$signature || !$timestamp) {
http_response_code(400);
exit('missing signature headers');
}

// Replay protection (timestamp is ISO-8601)
if (abs(time() - strtotime($timestamp)) > 300) {
http_response_code(401);
exit('timestamp too old');
}

$expected = base64_encode(hash_hmac('sha256', $timestamp . $rawBody, $secret, true));

if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('invalid signature');
}

$event = json_decode($rawBody, true);
// ... dedupe on $event['event_id'], persist, ack ...
http_response_code(200);

Ruby (Rails)

require 'base64'

class OsiguWebhooksController < ApplicationController
skip_forgery_protection only: :rcm

WEBHOOK_SECRET = ENV.fetch('RCM_WEBHOOK_SECRET')

def rcm
signature = request.headers['X-RCM-Signature']
timestamp = request.headers['X-RCM-Timestamp']

return head :bad_request unless signature && timestamp
# Replay protection (timestamp is ISO-8601)
return head :unauthorized if (Time.now - Time.parse(timestamp)).abs > 300

raw_body = request.raw_post # NOT request.body.read after JSON parsing
expected = Base64.strict_encode64(
OpenSSL::HMAC.digest('sha256', WEBHOOK_SECRET, "#{timestamp}#{raw_body}")
)

return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)

event = JSON.parse(raw_body)
# ... dedupe on event['event_id'], persist, ack ...
head :ok
end
end

Testing locally

Send a known-good payload to your local receiver and verify the signature using curl:

SECRET='your-webhook-secret-from-onboarding'
TS=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
BODY='{"event_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","event_type":"DOCUMENT_VALIDATION_COMPLETED","entity_type":"support_file","entity_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","payload":{},"metadata":{},"created_at":"2026-06-25T15:00:00.000Z"}'
SIG=$(printf '%s%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64)

curl -X POST http://localhost:3000/webhooks/osigu-rcm \
-H "Content-Type: application/json" \
-H "X-RCM-Event-Type: DOCUMENT_VALIDATION_COMPLETED" \
-H "X-RCM-Event-ID: 3fa85f64-5717-4562-b3fc-2c963f66afa6" \
-H "X-RCM-Timestamp: $TS" \
-H "X-RCM-Signature: $SIG" \
--data-raw "$BODY"

If your receiver returns 200, the signature validation works. Tamper with the body and confirm it returns 401.

For end-to-end testing in sandbox, see the implement-webhook-receiver guide.