Ejemplos de servidor
Código completo y funcional para las dos formas en que Telybot se comunica con tu servidor, en Node.js, Python, PHP, Go, Java, C# y Ruby: una acción que responde al bot durante un chat, y un webhook que se entera de lo que ocurrió en él.
Elige tu lenguaje una vez: los ejemplos de aquí y de la guía de Acciones lo siguen.
Qué hace el ejemplo
POST /telybot/order-statusresponde a una acción. El bot envía el número de pedido y la dirección de correo que escribió el cliente. El servidor responde200con el estado del pedido cuando ambos coinciden, y404cuando no, para que el bot pida al cliente que revise lo que escribió.POST /telybot/eventsrecibe webhooks. Un reintento lleva el mismoid, de modo que cada evento se procesa una sola vez, por muchas veces que llegue, y la respuesta es204de inmediato.- Ambos rechazan, con
401, una solicitud cuya firma no es válida: una que no se firmó con tu secreto, que se modificó por el camino o que tiene más de cinco minutos.
Antes de empezar
- Abre Tu bot → Acciones → Seguridad y, en Firma de Telybot, pulsa Mostrar el secreto y cópialo. Empieza por
whsec_. - Pásaselo a tu servidor como el ajuste
TELYBOT_SIGNING_SECRET, desde una variable de entorno o tu almacén de secretos. Nunca lo pongas en una página web ni en una app. - En Acciones → Nueva acción, elige
POSTy la direcciónhttps://your-server.example/telybot/order-status, y pide al clienteorder_id(un número) yemail(una dirección de correo). - En Acciones → Webhooks, añade
https://your-server.example/telybot/eventscon los eventos que quieras.
El código
// 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);# 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
// Plain PHP 8. In Laravel, $request->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);
}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))
}// 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<String, Map<String, String>> ORDERS =
Map.of("48213", Map.of("email", "asha@example.com", "status", "shipped", "eta", "2 October"));
final Set<String> 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<Map<String, String>> 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<String, String> 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<Void> 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();
}
}// 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<string, Order> { ["48213"] = new("asha@example.com", "shipped", "2 October") };
var seen = new ConcurrentDictionary<string, bool>(); // 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<Dictionary<string, string>>(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<byte[]> 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);# 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
endLos pedidos y los ids de evento se guardan en memoria para que el ejemplo funcione por sí solo. Guárdalos en tu base de datos: un id de evento que sobrevive a un reinicio sigue evitando un duplicado.
Lo que importa en tu propio código
- Comprueba el cuerpo recibido, byte a byte. Lee el cuerpo sin procesar y comprueba la firma antes de analizarlo. Un JSON que se ha leído y vuelto a escribir tiene otros espacios y ya no coincide. En Express es
express.raw(), en Flaskrequest.get_data(), en PHPphp://input, en Spring un cuerpobyte[], en ASP.NET CoreRequest.Bodyy en Railsrequest.raw_post. - Cinco minutos en cualquier sentido. Una solicitud cuya marca de tiempo se aleje más del reloj de tu servidor se rechaza, de modo que una solicitud grabada no puede volver a enviarse más tarde. Mantén el reloj de tu servidor en hora automáticamente.
- Compara en tiempo constante. Cada ejemplo compara las firmas con la función de tiempo constante de su lenguaje, para que lo que tarda una comprobación no revele lo cerca que estuvo un intento.
- Cualquiera de las firmas.
webhook-signaturepuede contener varias firmas separadas por espacios. La solicitud es auténtica si una de ellas coincide. - Responde rápido. El bot espera 8 segundos a una acción, y un webhook espera 10. En un webhook, responde primero y haz el trabajo lento después, desde una cola.
- Di «no encontrado» siempre igual. Responde
404tanto si el pedido no existe como si el correo no coincide, para que nadie pueda averiguar qué números de pedido son reales.
Bibliotecas
La firma sigue Standard Webhooks, cuyas bibliotecas para estos lenguajes y otros la comprueban por ti: dales el secreto tal como se muestra, con whsec_ incluido, y el cuerpo exactamente como llegó.
Clientes con sesión iniciada
Para decirle al bot quién es un cliente que ha iniciado sesión, tu servidor firma un token corto para él con otro secreto, el secreto de identidad. Cómo hacerlo, con el código en los mismos lenguajes, está en Visitantes verificados.
Cuando no funciona
- Todas las solicitudes reciben
401. Comprueba que el servidor tiene el secreto de firma, que empieza porwhsec_, y no el secreto de identidad, que empieza portbid_. Crear uno nuevo en Firma de Telybot desactiva el secreto antiguo al instante, así que actualiza tu servidor justo después. Y comprueba el cuerpo antes de analizarlo. - Algunas solicitudes reciben
401. El reloj de tu servidor está desfasado más de cinco minutos. - El bot dice que no pudo consultar algo. Acciones → Todas las acciones muestra por qué falló la última llamada. Pulsa Probar para enviar una tú mismo y ver exactamente qué se devolvió.
- Un webhook se detuvo. Tras cincuenta entregas fallidas seguidas, se pausa y se envía un correo a los propietarios. Corrige el receptor, pulsa Enviar un evento de prueba y luego Reanudar. Entregas muestra lo que respondió tu receptor, durante treinta días.