MeetOne signs every webhook delivery so your integration can confirm it really came from MeetOne before acting on it.
How signing works
Every delivery includes these headers:
| Header | Description |
|---|---|
X-Webhook-Timestamp |
Unix timestamp (seconds) at which MeetOne dispatched this attempt. |
X-Webhook-Signature-256 |
HMAC-SHA256 of <timestamp>.<raw-body>, keyed with the endpoint's signing secret. Format: sha256=<hex-digest>. |
X-Request-ID |
Stable UUID for this delivery. Unchanged across retries — see Retries and idempotency. |
The signature is computed over the string <timestamp>.<raw-body> — the value of X-Webhook-Timestamp, a literal dot, then the exact bytes of the request body — using the endpoint's signing secret as the HMAC key:
X-Webhook-Timestamp: 1744813872
X-Webhook-Signature-256: sha256=fc31613f0b8b313ac063eb4cad460ceb8eae3f4c45f424ac4b706274217ec45f
Signature verification is required. Without it, anyone who knows your endpoint URL can send you arbitrary JSON claiming to be MeetOne.
Replay protection
Reject any delivery whose X-Webhook-Timestamp is older than a few minutes — we recommend a tolerance of 5 minutes. The timestamp is covered by the HMAC, so it can't be forged independently of the signature; checking it stops a captured, validly-signed delivery from being replayed indefinitely.
Setting your tolerance too tight (under ~30 seconds) can reject legitimate deliveries that took longer to arrive, or retries sent after a brief outage.
Always hash the raw request body bytes — never a re-serialized version of the parsed JSON. Re-encoding the payload (different key order, whitespace, or number formatting) produces different bytes and will not match MeetOne's signature. Read the body before your framework parses it, or use whatever raw-body hook it provides.
Example: Ruby
require "openssl"
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(raw_body, timestamp, signature_header, signing_secret)
return false if timestamp.nil? || signature_header.nil?
return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE_SECONDS
signed_payload = "#{timestamp}.#{raw_body}"
expected = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", signing_secret, signed_payload)}"
ActiveSupport::SecurityUtils.secure_compare(expected, signature_header.to_s)
end
Example: Node.js
const crypto = require("crypto")
const TOLERANCE_SECONDS = 5 * 60
function verifyWebhook(rawBody, timestamp, signatureHeader, signingSecret) {
if (!timestamp || !signatureHeader) return false
if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)) > TOLERANCE_SECONDS) {
return false
}
const signedPayload = `${timestamp}.${rawBody}`
const expected = "sha256=" + crypto
.createHmac("sha256", signingSecret)
.update(signedPayload)
.digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(signatureHeader)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
rawBody must be the unparsed request body as received (e.g. a Buffer/string captured before express.json() parses it), not JSON.stringify(req.body).
Example: Python
import hmac, hashlib, time
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(raw_body: bytes, timestamp: str, signature_header: str, signing_secret: str) -> bool:
if not timestamp or not signature_header:
return False
if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed_payload = f"{timestamp}.".encode("utf-8") + raw_body
expected = "sha256=" + hmac.new(
signing_secret.encode("utf-8"),
signed_payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature_header)
Use a constant-time comparison
All three examples above compare signatures with a constant-time function (ActiveSupport::SecurityUtils.secure_compare, crypto.timingSafeEqual, hmac.compare_digest) instead of ==. A naive string comparison leaks timing information that can, in theory, help an attacker guess a valid signature byte by byte. Always use your language's constant-time comparison for this check.
Troubleshooting signature failures
- Confirm you're hashing
<timestamp>.<raw-body>, not the body alone — the timestamp prefix (with the literal dot) is required. - Confirm you're hashing the raw bytes, not a re-serialized JSON object.
- Confirm you're using the current signing secret — if it was regenerated, old integrations fail until updated.
- Compare the full header value, including the
sha256=prefix, not just the hex digest. - Make sure your replay tolerance isn't so tight that it rejects valid, slightly-delayed deliveries.
Questions? Contact us at support@meetone.io.