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
| Event | Description |
|---|---|
product.created | A new product was indexed |
product.updated | A product was updated |
product.deleted | A product was deleted |
post.created | A new post was indexed |
post.updated | A post was updated |
post.deleted | A post was deleted |
chat.message_sent | A chat message was sent |
chat.conversation_started | A new conversation began |
feedback.submitted | User feedback was submitted |
handoff.requested | Customer requested live agent handoff |
handoff.accepted | Agent accepted the handoff |
handoff.resolved | Handoff conversation was resolved |
contact.submitted | Customer 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:
responseMessageis the same shape used to round-trip thread context, minus heavy fields (full text bodies, embeddings, item-level expanded payloads).respondedBytells 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.composedBlocksis present on composer-driven answers and lists the structured blocks the composer wove together (small — onlytype/domain/ids). It accompanies both single-contributor and multi-contributor composed turns.responseTypedescribes the shape ofresponseMessage:"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).streamingistruewhen the client requested NDJSON streaming for this turn (the user saw incremental updates),falsefor one-shot non-streaming turns. Independent ofresponseType.degradedistruewhen 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.finishedisfalseonly when the turn was interrupted (e.g. the client closed the stream before the cloud finished writing). Combine withdegradedto 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:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 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
- Always verify signatures to ensure requests originate from Savanto
- Respond quickly (under 30 seconds) to avoid timeouts
- Process asynchronously — queue webhook payloads for background processing
- Handle duplicates — use the event
idfor idempotency - Monitor failures — set up alerts for repeated webhook failures
Next Steps
- API Reference — Full webhook endpoint documentation
- Authentication — API key setup