Exemplos de servidor
Código completo e funcional para as duas formas como o Telybot comunica com o seu servidor, em Node.js, Python, PHP, Go, Java, C# e Ruby: uma ação que responde ao bot durante uma conversa e um webhook que é informado do que aconteceu nela.
Escolha a sua linguagem uma vez: os exemplos aqui e no guia de Ações passam a segui-la.
O que o exemplo faz
POST /telybot/order-statusresponde a uma ação. O bot envia o número da encomenda e o endereço de e-mail que o cliente escreveu. O servidor responde200com o estado da encomenda quando ambos correspondem, e404quando não correspondem, para que o bot peça ao cliente que verifique o que escreveu.POST /telybot/eventsrecebe webhooks. Uma nova tentativa traz o mesmoid, por isso cada evento é tratado uma única vez, por muitas vezes que chegue, e a resposta é204de imediato.- Ambos recusam, com
401, um pedido cuja assinatura não confere: um pedido não assinado com o seu segredo, alterado pelo caminho ou com mais de cinco minutos.
Antes de começar
- Abra O seu bot → Ações → Segurança e, em Assinatura do Telybot, prima Mostrar o segredo e copie-o. Começa por
whsec_. - Forneça-o ao seu servidor como a definição
TELYBOT_SIGNING_SECRET, a partir de uma variável de ambiente ou do seu cofre de segredos. Nunca o coloque numa página web nem numa aplicação. - Em Ações → Nova ação, escolha
POSTe o endereçohttps://your-server.example/telybot/order-status, e peça ao clienteorder_id(um número) eemail(um endereço de e-mail). - Em Ações → Webhooks, adicione
https://your-server.example/telybot/eventscom os eventos que pretende.
O 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
endAs encomendas e os ids de evento são mantidos em memória para que o exemplo funcione sozinho. Guarde-os na sua base de dados: um id de evento que sobrevive a um reinício continua a travar um duplicado.
O que importa no seu próprio código
- Verifique o corpo recebido, byte a byte. Leia o corpo em bruto e verifique a assinatura antes de o analisar. Um JSON que foi lido e escrito novamente fica com um espaçamento diferente e deixa de corresponder. No Express, isso é
express.raw(); no Flask,request.get_data(); no PHP,php://input; no Spring, um corpobyte[]; no ASP.NET Core,Request.Body; e no Rails,request.raw_post. - Cinco minutos para mais ou para menos. Um pedido cujo timestamp esteja mais afastado do relógio do seu servidor é recusado, para que um pedido gravado não possa ser reenviado mais tarde. Mantenha o relógio do seu servidor acertado automaticamente.
- Compare em tempo constante. Cada exemplo compara as assinaturas com a função de tempo constante da respetiva linguagem, para que o tempo que uma verificação demora não revele nada sobre o quão perto uma tentativa esteve.
- Qualquer uma das assinaturas.
webhook-signaturepode conter várias assinaturas separadas por espaços. O pedido é autêntico quando uma delas corresponde. - Responda depressa. O bot espera 8 segundos por uma ação, e um webhook espera 10. Num webhook, responda primeiro e faça o trabalho demorado depois, a partir de uma fila.
- Diga "não encontrado" da mesma forma. Responda
404tanto quando a encomenda não existe como quando o e-mail não corresponde, para que ninguém consiga descobrir que números de encomenda são reais.
Bibliotecas
A assinatura segue o Standard Webhooks, cujas bibliotecas para estas e outras linguagens fazem a verificação por si: passe-lhes o segredo tal como é mostrado, incluindo whsec_, e o corpo exatamente como chegou.
Clientes com sessão iniciada
Para indicar ao bot quem é um cliente com sessão iniciada, o seu servidor assina um token curto para esse cliente com outro segredo, o segredo de identidade. Como o fazer, com o código nas mesmas linguagens, está em Visitantes verificados.
Quando não funciona
- Todos os pedidos recebem
401. Verifique se o servidor tem o segredo de assinatura, que começa porwhsec_, e não o segredo de identidade, que começa portbid_. Criar um novo em Assinatura do Telybot desativa de imediato o segredo antigo, por isso atualize o seu servidor logo a seguir. E verifique o corpo antes de o analisar. - Alguns pedidos recebem
401. O relógio do seu servidor está desfasado mais de cinco minutos. - O bot diz que não conseguiu consultar algo. Ações → Todas as ações mostra porque é que a última chamada falhou. Prima Testar para enviar uma chamada por si e ver exatamente o que foi devolvido.
- Um webhook parou. Após cinquenta entregas falhadas consecutivas, é colocado em pausa e os proprietários recebem um e-mail. Corrija o recetor, prima Enviar um evento de teste e depois Retomar. Entregas lista o que o seu recetor respondeu, durante trinta dias.