# Server examples — Telybot docs - URL: https://telybot.com/docs/reference-server - Last updated: 2026-10-25 ## Server examples Complete, working code for the two ways Telybot talks to your server, in Node.js, Python, PHP, Go, Java, C# and Ruby: an action that answers the bot during a chat, and a webhook that hears about what happened in it. Pick your language once: the examples here and in the [Actions](https://telybot.com/docs/guide-actions) guide follow it. ### What the example does - **`POST /telybot/order-status`** answers an action. The bot sends the order number and the email address the customer typed. The server answers `200` with the order's status when both match, and `404` when they do not, so the bot asks the customer to check what they typed. - **`POST /telybot/events`** receives webhooks. A retry carries the same `id`, so each event is handled once however many times it arrives, and the answer is `204` straight away. - Both refuse, with `401`, a request whose signature does not check out: one not signed with your secret, changed on the way, or more than five minutes old. ### Before you start 1. Open **Your bot → Actions → Security** and, under **Telybot’s signature**, press **Show the secret** and copy it. It starts with `whsec_`. 2. Give it to your server as the setting `TELYBOT_SIGNING_SECRET`, from an environment variable or your secrets store. Never put it in a web page or an app. 3. Under **Actions → New action**, choose `POST` and the address `https://your-server.example/telybot/order-status`, and ask the customer for `order_id` (a number) and `email` (an email address). 4. Under **Actions → Webhooks**, add `https://your-server.example/telybot/events` with the events you want. ### The code ```js // npm install express const crypto = require('crypto'); const express = require('express'); const SECRET = process.env.TELYBOT_SIGNING_SECRET; // whsec_…, from Actions → Security // True when your bot signed this request in the last five minutes. function isFromTelyBot(headers, rawBody, secret) { const id = headers['webhook-id']; const timestamp = Number(headers['webhook-timestamp']); const now = Math.floor(Date.now() / 1000); if (!id || !(Math.abs(now - timestamp) <= 300)) return false; const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = crypto.createHmac('sha256', key) .update(`${id}.${headers['webhook-timestamp']}.`).update(rawBody).digest(); return String(headers['webhook-signature'] || '').split(' ').some((part) => { const given = Buffer.from(part.replace(/^v1,/, ''), 'base64'); return part.startsWith('v1,') && given.length === expected.length && crypto.timingSafeEqual(given, expected); }); } const app = express(); // The exact bytes Telybot signed: never parse and re-serialise before checking. app.use('/telybot', express.raw({ type: () => true })); const bodyOf = (req) => (Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)); const orders = new Map([['48213', { email: 'asha@example.com', status: 'shipped', eta: '2 October' }]]); const seen = new Set(); // ids of events already handled: keep these in your database // An action: during a chat, the bot asks where an order is. app.post('/telybot/order-status', (req, res) => { if (!isFromTelyBot(req.headers, bodyOf(req), SECRET)) return res.sendStatus(401); const asked = JSON.parse(bodyOf(req).toString('utf8')); const order = orders.get(asked.order_id); if (!order || order.email !== String(asked.email || '').trim().toLowerCase()) { return res.status(404).json({ error: 'not_found' }); // the bot asks the customer to check } res.json({ status: order.status, eta: order.eta }); }); // A webhook: something happened in a chat. app.post('/telybot/events', (req, res) => { if (!isFromTelyBot(req.headers, bodyOf(req), SECRET)) return res.sendStatus(401); const event = JSON.parse(bodyOf(req).toString('utf8')); if (!seen.has(event.id)) { // a retry carries the same id seen.add(event.id); console.log(event.type, event.data); // hand it to your CRM or a queue } res.sendStatus(204); }); app.listen(3000); ``` ```python # pip install flask import base64 import hashlib import hmac import json import os import time from flask import Flask, request SECRET = os.environ["TELYBOT_SIGNING_SECRET"] # whsec_…, from Actions → Security def is_from_telybot(headers, raw_body: bytes, secret: str) -> bool: """True when your bot signed this request in the last five minutes.""" msg_id = headers.get("webhook-id", "") timestamp = headers.get("webhook-timestamp", "") try: if not msg_id or abs(time.time() - int(timestamp)) > 300: return False except ValueError: return False key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()) return any( part.startswith("v1,") and hmac.compare_digest(part[3:].encode(), expected) for part in headers.get("webhook-signature", "").split() ) app = Flask(__name__) ORDERS = {"48213": {"email": "asha@example.com", "status": "shipped", "eta": "2 October"}} seen = set() # ids of events already handled: keep these in your database # An action: during a chat, the bot asks where an order is. @app.post("/telybot/order-status") def order_status(): raw = request.get_data() # the exact bytes Telybot signed if not is_from_telybot(request.headers, raw, SECRET): return "", 401 asked = json.loads(raw) order = ORDERS.get(asked.get("order_id", "")) if not order or order["email"] != asked.get("email", "").strip().lower(): return {"error": "not_found"}, 404 # the bot asks the customer to check return {"status": order["status"], "eta": order["eta"]} # A webhook: something happened in a chat. @app.post("/telybot/events") def events(): raw = request.get_data() if not is_from_telybot(request.headers, raw, SECRET): return "", 401 event = json.loads(raw) if event["id"] not in seen: # a retry carries the same id seen.add(event["id"]) print(event["type"], event["data"]) # hand it to your CRM or a queue return "", 204 if __name__ == "__main__": app.run(port=3000) ``` ```php getContent() is the raw body and // $request->header('webhook-id') a header. const ORDERS = ['48213' => ['email' => 'asha@example.com', 'status' => 'shipped', 'eta' => '2 October']]; /** True when your bot signed this request in the last five minutes. */ function is_from_telybot(string $id, string $timestamp, string $signatures, string $rawBody, string $secret): bool { if ($id === '' || !preg_match('/^\d{1,12}$/', $timestamp) || abs(time() - (int) $timestamp) > 300) { return false; } $key = base64_decode(preg_replace('/^whsec_/', '', $secret)); $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$rawBody", $key, true)); foreach (explode(' ', $signatures) as $part) { if (str_starts_with($part, 'v1,') && hash_equals($expected, substr($part, 3))) { return true; } } return false; } /** False when this event id was handled before: keep these in your database. */ function first_time(string $id): bool { $marker = sys_get_temp_dir() . '/telybot-event-' . hash('sha256', $id); return ($handle = @fopen($marker, 'x')) !== false && fclose($handle); } $raw = file_get_contents('php://input'); // the exact bytes Telybot signed $signed = is_from_telybot( $_SERVER['HTTP_WEBHOOK_ID'] ?? '', $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '', $raw, getenv('TELYBOT_SIGNING_SECRET'), // whsec_…, from Actions → Security ); if (!$signed) { http_response_code(401); exit; } switch (parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)) { // An action: during a chat, the bot asks where an order is. case '/telybot/order-status': $asked = json_decode($raw, true); $order = ORDERS[$asked['order_id'] ?? ''] ?? null; header('Content-Type: application/json'); if (!$order || $order['email'] !== strtolower(trim($asked['email'] ?? ''))) { http_response_code(404); // the bot asks the customer to check echo json_encode(['error' => 'not_found']); break; } echo json_encode(['status' => $order['status'], 'eta' => $order['eta']]); break; // A webhook: something happened in a chat. case '/telybot/events': $event = json_decode($raw, true); if (first_time($event['id'])) { // a retry carries the same id error_log($event['type'] . ' ' . json_encode($event['data'])); // hand it to your CRM or a queue } http_response_code(204); break; default: http_response_code(404); } ``` ```go package main import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "io" "log" "net/http" "os" "strconv" "strings" "sync" "time" ) // isFromTelyBot reports whether your bot signed this request in the last five minutes. func isFromTelyBot(h http.Header, body []byte, secret string) bool { id, timestamp := h.Get("webhook-id"), h.Get("webhook-timestamp") sent, err := strconv.ParseInt(timestamp, 10, 64) now := time.Now().Unix() if id == "" || err != nil || sent < now-300 || sent > now+300 { return false } key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return false } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + timestamp + ".")) mac.Write(body) expected := mac.Sum(nil) for _, part := range strings.Fields(h.Get("webhook-signature")) { given, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(part, "v1,")) if strings.HasPrefix(part, "v1,") && err == nil && hmac.Equal(given, expected) { return true } } return false } type order struct{ Email, Status, ETA string } var orders = map[string]order{"48213": {"asha@example.com", "shipped", "2 October"}} var ( mu sync.Mutex seen = map[string]bool{} // ids of events already handled: keep these in your database ) func main() { secret := os.Getenv("TELYBOT_SIGNING_SECRET") // whsec_…, from Actions → Security // signed reads the exact bytes Telybot signed and refuses anything else. signed := func(next func(http.ResponseWriter, []byte)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil || !isFromTelyBot(r.Header, body, secret) { w.WriteHeader(http.StatusUnauthorized) return } next(w, body) } } // An action: during a chat, the bot asks where an order is. http.HandleFunc("POST /telybot/order-status", signed(func(w http.ResponseWriter, body []byte) { var asked struct { OrderID string `json:"order_id"` Email string `json:"email"` } json.Unmarshal(body, &asked) w.Header().Set("Content-Type", "application/json") o, ok := orders[asked.OrderID] if !ok || o.Email != strings.ToLower(strings.TrimSpace(asked.Email)) { w.WriteHeader(http.StatusNotFound) // the bot asks the customer to check json.NewEncoder(w).Encode(map[string]string{"error": "not_found"}) return } json.NewEncoder(w).Encode(map[string]string{"status": o.Status, "eta": o.ETA}) })) // A webhook: something happened in a chat. http.HandleFunc("POST /telybot/events", signed(func(w http.ResponseWriter, body []byte) { var event struct { ID string `json:"id"` Type string `json:"type"` Data json.RawMessage `json:"data"` } json.Unmarshal(body, &event) mu.Lock() first := !seen[event.ID] // a retry carries the same id seen[event.ID] = true mu.Unlock() if first { log.Printf("%s %s", event.Type, event.Data) // hand it to your CRM or a queue } w.WriteHeader(http.StatusNoContent) })) log.Fatal(http.ListenAndServe(":3000", nil)) } ``` ```java // Spring Boot 4 (spring-boot-starter-webmvc) package com.example.telybot; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Instant; import java.util.Base64; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; @SpringBootApplication @RestController public class TelyBotReceiver { static final String SECRET = System.getenv("TELYBOT_SIGNING_SECRET"); // whsec_…, from Actions → Security static final Map> ORDERS = Map.of("48213", Map.of("email", "asha@example.com", "status", "shipped", "eta", "2 October")); final Set seen = ConcurrentHashMap.newKeySet(); // ids of events already handled: keep these in your database final JsonMapper json = JsonMapper.builder().build(); public static void main(String[] args) { SpringApplication.run(TelyBotReceiver.class, args); } /** True when your bot signed this request in the last five minutes. */ static boolean isFromTelyBot(String id, String timestamp, String signatures, byte[] body, String secret) throws Exception { long now = Instant.now().getEpochSecond(); long sent; try { sent = Long.parseLong(timestamp); } catch (NumberFormatException notANumber) { return false; } if (id == null || signatures == null || sent < now - 300 || sent > now + 300) return false; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(Base64.getDecoder().decode(secret.replaceFirst("^whsec_", "")), "HmacSHA256")); mac.update((id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8)); byte[] expected = mac.doFinal(body); for (String part : signatures.split(" ")) { try { if (part.startsWith("v1,") && MessageDigest.isEqual(Base64.getDecoder().decode(part.substring(3)), expected)) { return true; } } catch (IllegalArgumentException notBase64) { // not a signature of ours } } return false; } static boolean signed(HttpHeaders headers, byte[] body) throws Exception { return isFromTelyBot(headers.getFirst("webhook-id"), headers.getFirst("webhook-timestamp"), headers.getFirst("webhook-signature"), body, SECRET); } // An action: during a chat, the bot asks where an order is. @PostMapping("/telybot/order-status") ResponseEntity> orderStatus(@RequestHeader HttpHeaders headers, @RequestBody byte[] body) throws Exception { // the exact bytes Telybot signed if (!signed(headers, body)) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); JsonNode asked = json.readTree(body); Map order = ORDERS.get(asked.path("order_id").asString()); if (order == null || !order.get("email").equals(asked.path("email").asString().trim().toLowerCase(Locale.ROOT))) { // the bot asks the customer to check return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "not_found")); } return ResponseEntity.ok(Map.of("status", order.get("status"), "eta", order.get("eta"))); } // A webhook: something happened in a chat. @PostMapping("/telybot/events") ResponseEntity events(@RequestHeader HttpHeaders headers, @RequestBody byte[] body) throws Exception { if (!signed(headers, body)) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); JsonNode event = json.readTree(body); if (seen.add(event.path("id").asString())) { // a retry carries the same id System.out.println(event.path("type").asString() + " " + event.path("data")); // hand it to your CRM or a queue } return ResponseEntity.noContent().build(); } } ``` ```csharp // ASP.NET Core minimal API (.NET 8 or later) using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using System.Text.Json; var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); var secret = builder.Configuration["TELYBOT_SIGNING_SECRET"]!; // whsec_…, from Actions → Security var orders = new Dictionary { ["48213"] = new("asha@example.com", "shipped", "2 October") }; var seen = new ConcurrentDictionary(); // ids of events already handled: keep these in your database // An action: during a chat, the bot asks where an order is. app.MapPost("/telybot/order-status", async (HttpRequest request) => { var body = await ReadBody(request); // the exact bytes Telybot signed if (!IsFromTelyBot(request.Headers, body, secret)) return Results.Unauthorized(); var asked = JsonSerializer.Deserialize>(body) ?? new(); if (!orders.TryGetValue(asked.GetValueOrDefault("order_id", ""), out var order) || order.Email != asked.GetValueOrDefault("email", "").Trim().ToLowerInvariant()) { return Results.NotFound(new { error = "not_found" }); // the bot asks the customer to check } return Results.Ok(new { status = order.Status, eta = order.Eta }); }); // A webhook: something happened in a chat. app.MapPost("/telybot/events", async (HttpRequest request) => { var body = await ReadBody(request); if (!IsFromTelyBot(request.Headers, body, secret)) return Results.Unauthorized(); using var json = JsonDocument.Parse(body); var e = json.RootElement; if (seen.TryAdd(e.GetProperty("id").GetString()!, true)) // a retry carries the same id { Console.WriteLine($"{e.GetProperty("type").GetString()} {e.GetProperty("data")}"); // hand it to your CRM or a queue } return Results.NoContent(); }); app.Run("http://localhost:3000"); static async Task ReadBody(HttpRequest request) { using var buffer = new MemoryStream(); await request.Body.CopyToAsync(buffer); return buffer.ToArray(); } // True when your bot signed this request in the last five minutes. static bool IsFromTelyBot(IHeaderDictionary headers, byte[] body, string secret) { string id = headers["webhook-id"].ToString(), timestamp = headers["webhook-timestamp"].ToString(); long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (id.Length == 0 || !long.TryParse(timestamp, out var sent) || sent < now - 300 || sent > now + 300) return false; var key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret[6..] : secret); var signed = Encoding.UTF8.GetBytes($"{id}.{timestamp}.").Concat(body).ToArray(); var expected = HMACSHA256.HashData(key, signed); foreach (var part in headers["webhook-signature"].ToString().Split(' ')) { var given = new byte[32]; if (part.StartsWith("v1,") && Convert.TryFromBase64String(part[3..], given, out var length) && length == given.Length && CryptographicOperations.FixedTimeEquals(given, expected)) { return true; } } return false; } record Order(string Email, string Status, string Eta); ``` ```ruby # gem install sinatra rackup puma require 'json' require 'openssl' require 'sinatra' set :port, 3000 SECRET = ENV.fetch('TELYBOT_SIGNING_SECRET') # whsec_…, from Actions → Security ORDERS = { '48213' => { email: 'asha@example.com', status: 'shipped', eta: '2 October' } }.freeze SEEN = Set.new # ids of events already handled: keep these in your database # True when your bot signed this request in the last five minutes. def from_telybot?(id, timestamp, signatures, raw_body, secret) return false if id.to_s.empty? || !timestamp.to_s.match?(/\A\d+\z/) || (Time.now.to_i - timestamp.to_i).abs > 300 key = secret.delete_prefix('whsec_').unpack1('m') expected = [OpenSSL::HMAC.digest('SHA256', key, "#{id}.#{timestamp}.".b + raw_body.b)].pack('m0') signatures.to_s.split(' ').any? { |part| part.start_with?('v1,') && OpenSSL.secure_compare(part[3..], expected) } end before '/telybot/*' do @raw = request.body.read # the exact bytes Telybot signed halt 401 unless from_telybot?(request.env['HTTP_WEBHOOK_ID'], request.env['HTTP_WEBHOOK_TIMESTAMP'], request.env['HTTP_WEBHOOK_SIGNATURE'], @raw, SECRET) end # An action: during a chat, the bot asks where an order is. post '/telybot/order-status' do asked = JSON.parse(@raw) order = ORDERS[asked['order_id']] content_type :json unless order && order[:email] == asked['email'].to_s.strip.downcase halt 404, { error: 'not_found' }.to_json # the bot asks the customer to check end { status: order[:status], eta: order[:eta] }.to_json end # A webhook: something happened in a chat. post '/telybot/events' do event = JSON.parse(@raw) if SEEN.add?(event['id']) # a retry carries the same id puts "#{event['type']} #{event['data']}" # hand it to your CRM or a queue end 204 end ``` The orders and the event ids are kept in memory so that the example runs on its own. Keep them in your database: an event id that survives a restart still stops a duplicate. ### What matters in your own code - **Check the body you received, byte for byte.** Read the raw body and check the signature before you parse it. JSON that has been read and written out again is spaced differently, and no longer matches. In Express that is `express.raw()`, in Flask `request.get_data()`, in PHP `php://input`, in Spring a `byte[]` body, in ASP.NET Core `Request.Body`, and in Rails `request.raw_post`. - **Five minutes either way.** A request whose timestamp is further from your server's clock is refused, so a recorded request cannot be sent again later. Keep your server's clock set automatically. - **Compare in constant time.** Each example compares signatures with its language's constant-time function, so how long a check takes says nothing about how close a guess was. - **Any of the signatures.** `webhook-signature` may hold several signatures separated by spaces. The request is genuine when one of them matches. - **Answer quickly.** The bot waits 8 seconds for an action, and a webhook waits 10. For a webhook, answer first and do the slow work afterwards, from a queue. - **Say "not found" the same way.** Answer `404` both when the order does not exist and when the email does not match, so nobody can learn which order numbers are real. ### Libraries The signature follows [Standard Webhooks](https://www.standardwebhooks.com/), whose libraries for these languages and others check it for you: give them the secret as shown, `whsec_` included, and the body exactly as it arrived. ### Signed-in customers To tell the bot who a signed-in customer is, your server signs a short token for them with a different secret, the identity secret. How, with the code in the same languages, is under [Verified visitors](https://telybot.com/docs/guide-actions#verified-visitors). ### When it does not work - **Every request gets `401`.** Check that the server has the signing secret, which starts with `whsec_`, and not the identity secret, which starts with `tbid_`. **Make a new one** under **Telybot’s signature** stops the old secret at once, so update your server straight after. And check the body before parsing it. - **Some requests get `401`.** Your server's clock is more than five minutes out. - **The bot says it could not look something up.** **Actions → All actions** shows why the last call failed. Press **Test** to send one yourself and see exactly what came back. - **A webhook stopped.** After fifty failed deliveries in a row it is paused and the owners are emailed. Fix the receiver, press **Send a test event**, then **Resume**. **Deliveries** lists what your receiver answered, for thirty days.