Webhook adapter (any language)
The webhook adapter is the escape hatch. If your data lives somewhere Spelo doesn’t ship an adapter for — an Elastic / Algolia / Meilisearch index, a GraphQL gateway, an ERP, a proprietary DB, a combination of sources — you write one endpoint in any language and Spelo calls it.
~30 minutes to a working integration.
The contract
Spelo → your endpoint, POST <endpoint>:
{ "collection": "properties", "query": "pool", "filters": [ { "field": "beds", "operator": "eq", "value": 2 }, { "field": "city", "operator": "eq", "value": "West Hollywood" }, { "field": "amenities", "operator": "contains", "value": "pool" } ], "sort_by": "price", "sort_direction": "asc", "limit": 5}Headers sent with every request:
Content-Type: application/jsonUser-Agent: Spelo-Widget/0.1 (WebhookAdapter)X-Spelo-Signature: sha256=<hmac>— only if you configured a shared secretX-Spelo-Timestamp: <ms>— only if you configured a shared secret
Operators you’ll receive: eq, neq, gt, gte, lt, lte, contains, in.
Your endpoint → Spelo, 200 OK:
{ "success": true, "total": 42, "returned": 5, "items": [ { "id": "p1", "address": "123 Main St", "beds": 2, "price": 3200, "amenities": ["pool", "gym"] } ]}Items can have any shape — the AI reads fields dynamically. Keep keys simple (strings, numbers, booleans, arrays).
On error, return { "success": false, "total": 0, "returned": 0, "items": [], "error": "..." }. The AI will see the message and tell the user something went wrong.
Config shape
{ "type": "webhook", "config": { "endpoint": "https://yourcompany.com/voice-search", "secret": "a-shared-hmac-secret", "headers": { "X-Tenant-ID": "acme" }, "timeoutMs": 10000 }, "collections": { "properties": { "source": "properties" } }}endpoint— your HTTPS URL. Must start withhttps://(orhttp://localhostin dev).secret— optional. If set, every request gets a signed header (see below).headers— optional. Passed through on every call.timeoutMs— optional. Default 10,000 (10s).
Signature verification
If you set a secret, Spelo adds:
X-Spelo-Signature: sha256=<hex>— HMAC-SHA256 of the raw request bodyX-Spelo-Timestamp: <ms>— request time in ms since epoch
Verify both to prevent replay and spoofing. We strongly recommend rejecting timestamps more than 5 minutes old.
Examples
import express from 'express'import crypto from 'crypto'
const app = express()app.use(express.json())
const SECRET = process.env.SPELO_SECRET
app.post('/voice-search', (req, res) => { const sig = req.header('X-Spelo-Signature') || '' const expected = 'sha256=' + crypto.createHmac('sha256', SECRET) .update(JSON.stringify(req.body)).digest('hex') if (sig !== expected) return res.status(401).json({ error: 'Bad signature' })
const { collection, filters = [], query, limit = 5 } = req.body const items = yourSearchFunction(collection, filters, query, limit)
res.json({ success: true, total: items.length, returned: Math.min(items.length, limit), items: items.slice(0, limit), })})
app.listen(3000)from flask import Flask, request, jsonifyimport hmac, hashlib, os
app = Flask(__name__)SECRET = os.environ['SPELO_SECRET'].encode()
@app.post('/voice-search')def voice_search(): body = request.get_data() sig = request.headers.get('X-Spelo-Signature', '') expected = 'sha256=' + hmac.new(SECRET, body, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): return jsonify(error='Bad signature'), 401
params = request.json items = your_search_function( collection=params['collection'], filters=params.get('filters', []), query=params.get('query'), limit=params.get('limit', 5), ) return jsonify( success=True, total=len(items), returned=min(len(items), params.get('limit', 5)), items=items[:params.get('limit', 5)], )package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "os")
type Filter struct { Field string `json:"field"` Operator string `json:"operator"` Value interface{} `json:"value"`}
type SearchReq struct { Collection string `json:"collection"` Query string `json:"query,omitempty"` Filters []Filter `json:"filters,omitempty"` Limit int `json:"limit,omitempty"`}
func voiceSearch(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) sig := r.Header.Get("X-Spelo-Signature") mac := hmac.New(sha256.New, []byte(os.Getenv("SPELO_SECRET"))) mac.Write(body) if !hmac.Equal([]byte(sig), []byte("sha256="+hex.EncodeToString(mac.Sum(nil)))) { http.Error(w, "bad signature", 401) return }
var req SearchReq _ = json.Unmarshal(body, &req) items := yourSearch(req)
_ = json.NewEncoder(w).Encode(map[string]any{ "success": true, "total": len(items), "returned": len(items), "items": items, })}post '/voice-search' do body = request.body.read sig = request.env['HTTP_X_SPELO_SIGNATURE'] expected = 'sha256=' + OpenSSL::HMAC.hexdigest('sha256', ENV['SPELO_SECRET'], body) halt 401, { error: 'bad signature' }.to_json unless Rack::Utils.secure_compare(sig, expected)
params = JSON.parse(body) items = your_search_function(params) { success: true, total: items.size, returned: items.size, items: items }.to_jsonendRoute::post('/voice-search', function (Request $r) { $body = $r->getContent(); $sig = $r->header('X-Spelo-Signature'); $expected = 'sha256=' . hash_hmac('sha256', $body, env('SPELO_SECRET')); if (!hash_equals($sig, $expected)) abort(401);
$items = yourSearchFunction($r->json()->all()); return ['success' => true, 'total' => count($items), 'returned' => count($items), 'items' => $items];});What your search function should do
For the request above, a real-estate backend might:
- Filter
listingswherebeds = 2 AND city = 'West Hollywood' AND 'pool' IN amenities - Substring-match
descriptionoraddressagainst"pool" - Sort by
price ASC - Return the top 5 as a list of simple objects
If your backend is Elastic / Algolia / Meilisearch, translate the filters into their DSL. If it’s a custom DB, run a prepared SQL query. The only contract is the request/response shape.
Security checklist
FAQ
Can my endpoint call multiple backends? Yes. Your handler is free to query any number of backends internally and merge results.
Can I return streaming results? Not yet. Return a single JSON payload. The AI speaks after the full result is back.
Do I have to support every operator?
No. Implement what your backend supports. Return an error for unsupported ops — the AI will relay it.
How do I test locally?
Run your server at http://localhost:3000/voice-search, set endpoint: "http://localhost:3000/voice-search" in the config. Localhost is always allowed.
Can I have multiple webhook adapters for one site? Not directly — one adapter per site. But your webhook can fan out to multiple backends.
Troubleshooting
500 Internal Server Errorfrom your endpoint → Spelo returnssuccess: falseto the AI, which tells the user there was a problem. Check your server logs.Timeout— default is 10s. BumptimeoutMsif your backend is slow, but voice users won’t wait that long. Optimize.- Signature mismatch — double-check you’re hashing the raw body bytes, not a parsed/re-serialized representation (JSON ordering matters).
More: Build a custom adapter for a native TypeScript adapter, if you outgrow the webhook.