Webhook security
Webhook signature verification
All webhook requests include an HMAC-SHA256 signature in the X-Moveo-Signature header. Always verify this signature before processing the request.
Algorithm:
signature = HEX(HMAC-SHA256(webhook_secret, request_body))
- Node.js
- Python
- Go
- PHP
const crypto = require("crypto");
function verifySignature(body, signature, secret) {
const computed = crypto
.createHmac("sha256", secret)
.update(body, "utf8")
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature));
}
// Express.js middleware example
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-moveo-signature"];
const isValid = verifySignature(req.body, signature, WEBHOOK_SECRET);
if (!isValid) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body);
// Process event...
res.status(200).send("OK");
});
import hmac
import hashlib
def verify_signature(body: bytes, signature: str, secret: str) -> bool:
computed = hmac.new(
secret.encode('utf-8'),
body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed, signature)
# Flask example
from flask import Flask, request
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Moveo-Signature')
is_valid = verify_signature(request.data, signature, WEBHOOK_SECRET)
if not is_valid:
return 'Invalid signature', 401
event = request.json
# Process event...
return 'OK', 200
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func verifySignature(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
function verifySignature($body, $signature, $secret) {
$computed = hash_hmac('sha256', $body, $secret);
return hash_equals($computed, $signature);
}
// Usage
$body = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_MOVEO_SIGNATURE'];
$isValid = verifySignature($body, $signature, $webhookSecret);
if (!$isValid) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($body, true);
// Process event...
Every webhook payload includes a root timestamp field (Unix milliseconds). To guard against replay attacks, compare this value to the current time and reject requests older than your tolerance window (e.g., 5 minutes). Always verify the signature first — the timestamp is only trustworthy if the payload has not been tampered with.
Webhook response requirements
Your webhook endpoint must:
- Return HTTP 2xx status (200-299) to acknowledge receipt.
- Respond within 8 seconds. Moveo abandons the delivery after that.
- Answer at the URL you configured. Redirects are not followed.
| Your response | Moveo behavior |
|---|---|
| 2xx | Success — message marked as delivered. |
| 429 | Temporary failure — retried after your Retry-After delay. See retry timing. |
| 408 | Temporary failure — retried with exponential backoff. |
| Other 4xx | Permanent failure — message marked as failed, no retry. |
| 500, 502, 503, 504 | Temporary failure — retried with exponential backoff. |
| Timeout | Permanent failure — not retried. |
| 3xx | Permanent failure — redirects are not followed. |
Because your endpoint has no way to tell Moveo whether a timed-out delivery was processed, a retry could duplicate the message. Moveo does not retry timeouts. Acknowledge the webhook as soon as you have stored the payload and do your processing asynchronously, rather than holding the response open.
Retry timing
A delivery is attempted at most 3 times in total. How long Moveo waits between attempts depends on the response:
| Your response | Delay before the next attempt |
|---|---|
429 with a Retry-After header | The delay you asked for, plus up to 20%, capped at 60 seconds. |
| Any other retryable response | Exponential backoff — 250 ms, then 500 ms, capped at 2 seconds. |
Retry-After is accepted either as a number of seconds or as an RFC 1123 timestamp. A value Moveo cannot parse, or one already in the past, falls back to exponential backoff.
Use 429 with Retry-After when you need Moveo to back off further than two seconds — for example, while your own rate limiter is shedding load. Note that the delay can reach a full minute, so the AI Agent's reply reaches your system that much later.