Appearance
Reply webhook
When the AI produces a reply, Genudo POSTs it to the callback_url configured on your channel, signed with HMAC-SHA256 so you can prove it came from Genudo.
This is a contract, not an endpoint you call.
Request
http
POST https://your-server.com/ai-reply
Content-Type: application/json
X-Genudo-Signature: sha256=4c1fbd744f1c254d5100e4cd67bdf2aa4d9fb336e54012a2c4444b3a91d2def2
X-Genudo-Timestamp: 1788702062json
{
"event": "message",
"client_id": "wc_01M1VD1M49ZNB3TCYZ8B0T6Q4A",
"contact_id": "user-abc-123",
"message_id": "6a9d6d6eb48ad989230354b8",
"text": "Hello — how can I help you today?",
"timestamp": "1788702062"
}| Field | Description |
|---|---|
event | Always "message" |
client_id | Your channel's client id |
contact_id | The exact contact_id you sent on the inbound message — use it to route the reply |
message_id | Mongo id of the reply. Unrelated to the id Send message returned. |
text | The reply body |
timestamp | Unix seconds, identical to the header value |
Verifying the signature
signed_content = timestamp + "." + raw_request_body
signature = HMAC-SHA256(signed_content, client_secret)- Read
X-Genudo-TimestampandX-Genudo-Signature. - Strip the
sha256=prefix. - Reject anything older than ~5 minutes to block replays.
- Concatenate
timestamp + "." + raw_body. - HMAC-SHA256 it with your
client_secret. - Compare constant-time.
Use the raw bytes
Parsing the JSON and re-serialising it changes key order and spacing, producing a different digest. Read the raw body before any JSON middleware touches it.
Node.js / Express
js
import crypto from 'node:crypto'
// express.raw({ type: 'application/json' }) so req.body is a Buffer
app.post('/ai-reply', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.headers['x-genudo-timestamp']
const received = (req.headers['x-genudo-signature'] ?? '').replace(/^sha256=/, '')
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(403)
const expected = crypto
.createHmac('sha256', CLIENT_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex')
if (!crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'))) {
return res.sendStatus(403)
}
const payload = JSON.parse(req.body.toString())
// route by payload.contact_id …
res.json({ message_id: 'your-own-id' })
})PHP
php
function verifyGenudoSignature(
string $rawBody,
string $timestamp,
string $sigHeader,
string $clientSecret,
int $toleranceSeconds = 300,
): bool {
if (abs(time() - (int) $timestamp) > $toleranceSeconds) {
return false;
}
$received = str_starts_with($sigHeader, 'sha256=') ? substr($sigHeader, 7) : $sigHeader;
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $clientSecret);
return hash_equals($expected, $received);
}Python / Flask
python
import hmac, hashlib, time
def verify(raw_body: bytes, timestamp: str, sig_header: str, secret: bytes) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
received = sig_header.removeprefix("sha256=")
expected = hmac.new(secret, f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)Your response
Reply 2xx. If the JSON body contains a top-level message_id, Genudo stores it on the message as external_message_id so you can correlate later:
json
{ "message_id": "your-own-id" }Non-2xx is logged and not retried beyond the transport-level retry (2 attempts, 10s timeout). Treat delivery as at-least-once and make your handler idempotent on message_id.
Common mistakes
| Mistake | Why it breaks |
|---|---|
| Parsing JSON then re-encoding it | Key order or spacing changes, so the digest differs |
Using === instead of a constant-time compare | Leaks timing information usable to forge signatures |
| Ignoring the timestamp | Leaves the endpoint open to replay |
Using the body's timestamp instead of the header | Both are equal, but the header is the authoritative input |
If no callback arrives
- Confirm
callback_urlis set on the channel in the Genudo Console. With nocallback_url, replies are delivered over socket.io only. - Change it by editing the channel in the Console. Do not reconnect the channel to change a URL — reconnecting regenerates
client_idandclient_secretand breaks your integration.