developers/Webhooks

Webhooks

Webhooks deliver real-time HTTP POST notifications when events occur in your Savanto account — products indexed, chat messages sent, handoff requests, and more.

Available Events

EventDescription
product.createdA new product was indexed
product.updatedA product was updated
product.deletedA product was deleted
post.createdA new post was indexed
post.updatedA post was updated
post.deletedA post was deleted
chat.message_sentA chat message was sent
chat.conversation_startedA new conversation began
feedback.submittedUser feedback was submitted
handoff.requestedCustomer requested live agent handoff
handoff.acceptedAgent accepted the handoff
handoff.resolvedHandoff conversation was resolved
contact.submittedCustomer submitted contact info

Creating a Webhook

curl -X POST https://api.savanto.ai/webhooks \
  -H "Authorization: Bearer if_sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Webhook",
    "url": "https://your-app.com/webhooks/savanto",
    "events": ["product.created", "product.updated", "chat.message_sent"],
    "secret": "your-webhook-secret"
  }'
import { createClient, createWebhook } from '@savantoai/ai-sdk';

const client = createClient({
  baseUrl: 'https://api.savanto.ai',
  auth: process.env.SAVANTO_SECRET_KEY!,
});

const { data } = await createWebhook({
  client,
  body: {
    name: 'My Webhook',
    url: 'https://your-app.com/webhooks/savanto',
    events: ['product.created', 'product.updated', 'chat.message_sent'],
    secret: 'your-webhook-secret',
  },
});

Payload Format

All webhook payloads follow this structure:

{
  "id": "evt_abc123",
  "event_type": "product.created",
  "created_at": "2026-01-15T10:30:00Z",
  "data": {
    "id": "product-123",
    "name": "Wireless Headphones",
    "price": 199.99,
    "categories": ["Electronics", "Audio"]
  }
}

chat.message_sent

Fires once per assistant turn, after the response has been delivered. The payload reflects the unified per-turn shape — for both single-domain answers and multi-domain composer answers, you get a single responseMessage describing what the user saw.

{
  "id": "evt_chat_xyz",
  "event_type": "chat.message_sent",
  "created_at": "2026-01-15T10:30:00Z",
  "data": {
    "userId": "user-42",
    "responseType": "structured",
    "streaming": true,
    "hasProducts": true,
    "hasPosts": true,
    "hasPrompts": true,
    "productCount": 3,
    "postCount": 1,
    "promptCount": 4,
    "turnCount": 2,
    "finished": true,
    "degraded": false,
    "workspaceId": "ws_abc",
    "promptId": null,
    "responseMessage": {
      "intro": "Here are some jackets that match your size and style, plus our return policy...",
      "products": [{ "id": "p-1", "name": "Wool Overcoat" }],
      "posts": [{ "id": "post-9", "title": "Returns & Exchanges" }],
      "respondedBy": ["product", "post"],
      "composedBlocks": [
        { "type": "text" },
        { "type": "products", "domain": "product", "ids": ["p-1", "p-2", "p-3"] },
        { "type": "text" },
        { "type": "posts", "domain": "post", "ids": ["post-9"] }
      ]
    },
    "timestamp": "2026-01-15T10:30:00Z"
  }
}

Notes:

  • responseMessage is the same shape used to round-trip thread context, minus heavy fields (full text bodies, embeddings, item-level expanded payloads).
  • respondedBy tells you which domain(s) answered. Single-domain turns set it to a string with the domain id ("product", "post", or any custom domain id). Multi-domain composer turns set it to an array of contributing domain ids in primary-intent order (e.g. ["product", "post"]). Use this to attribute the turn or to drive follow-up routing on your side.
  • composedBlocks is present on composer-driven answers and lists the structured blocks the composer wove together (small — only type / domain / ids). It accompanies both single-contributor and multi-contributor composed turns.
  • responseType describes the shape of responseMessage: "structured" when the cloud returned the rich { intro, products, posts, composedBlocks, … } envelope, "text" when it was a plain text completion (e.g. canned response or pure conversation turn).
  • streaming is true when the client requested NDJSON streaming for this turn (the user saw incremental updates), false for one-shot non-streaming turns. Independent of responseType.
  • degraded is true when the turn was served via a fallback path — composer timeout/failure with deterministic fallback blocks, or a custom-domain handler that streamed an apology after a tool error. The user still got an answer, but the typical pipeline did not run end-to-end. Useful for quality dashboards, alerting on composer-fallback rate, and follow-up routing decisions.
  • finished is false only when the turn was interrupted (e.g. the client closed the stream before the cloud finished writing). Combine with degraded to distinguish "interrupted by user" from "served via fallback".

contact.submitted

Triggered when a customer submits contact information (typically when live agents are unavailable):

{
  "id": "evt_ghi789",
  "event_type": "contact.submitted",
  "created_at": "2026-01-15T18:45:00Z",
  "data": {
    "thread_id": "web_1234567890_abc",
    "name": "Jane Doe",
    "email": "jane@example.com",
    "phone": "+1-555-123-4567",
    "message": "I need help with my recent order #12345"
  }
}

Use this event to add leads to your CRM, create support tickets, or trigger email/SMS alerts.

Signature Verification

Webhooks include a signature header for verification:

X-Webhook-Signature: sha256=abc123...

Node.js

import crypto from 'crypto';

function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const expected =
    'sha256=' +
    crypto.createHmac('sha256', secret).update(payload).digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

PHP

function verifyWebhookSignature(
    string $payload,
    string $signature,
    string $secret
): bool {
    $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
    return hash_equals($expected, $signature);
}

$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

if (!verifyWebhookSignature($payload, $signature, $secret)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($payload, true);

Retry Policy

If your endpoint returns a non-2xx response or times out, Savanto retries with increasing delays:

AttemptDelay
1Immediate
21 minute
35 minutes
430 minutes
52 hours

After 5 failed attempts the webhook is marked as failed and you receive an email notification.

Testing

Send a Test Event

curl -X POST https://api.savanto.ai/webhooks/{webhook-id}/test \
  -H "Authorization: Bearer if_sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "test_payload": {
      "event_type": "product.created",
      "data": {"id": "test-123", "name": "Test Product"}
    }
  }'

Local Development

Use a tunneling service like ngrok for local testing:

ngrok http 3000

Then register the ngrok URL (https://abc123.ngrok.io/webhooks/savanto) as your webhook endpoint.

Best Practices

  1. Always verify signatures to ensure requests originate from Savanto
  2. Respond quickly (under 30 seconds) to avoid timeouts
  3. Process asynchronously — queue webhook payloads for background processing
  4. Handle duplicates — use the event id for idempotency
  5. Monitor failures — set up alerts for repeated webhook failures

Next Steps