Skip to content

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: 1788702062
json
{
  "event": "message",
  "client_id": "wc_01M1VD1M49ZNB3TCYZ8B0T6Q4A",
  "contact_id": "user-abc-123",
  "message_id": "6a9d6d6eb48ad989230354b8",
  "text": "Hello — how can I help you today?",
  "timestamp": "1788702062"
}
FieldDescription
eventAlways "message"
client_idYour channel's client id
contact_idThe exact contact_id you sent on the inbound message — use it to route the reply
message_idMongo id of the reply. Unrelated to the id Send message returned.
textThe reply body
timestampUnix seconds, identical to the header value

Verifying the signature ​

signed_content = timestamp + "." + raw_request_body
signature      = HMAC-SHA256(signed_content, client_secret)
  1. Read X-Genudo-Timestamp and X-Genudo-Signature.
  2. Strip the sha256= prefix.
  3. Reject anything older than ~5 minutes to block replays.
  4. Concatenate timestamp + "." + raw_body.
  5. HMAC-SHA256 it with your client_secret.
  6. 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 ​

MistakeWhy it breaks
Parsing JSON then re-encoding itKey order or spacing changes, so the digest differs
Using === instead of a constant-time compareLeaks timing information usable to forge signatures
Ignoring the timestampLeaves the endpoint open to replay
Using the body's timestamp instead of the headerBoth are equal, but the header is the authoritative input

If no callback arrives ​

  • Confirm callback_url is set on the channel in the Genudo Console. With no callback_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_id and client_secret and breaks your integration.