Webhooks
Set up and handle webhook events from Discord, Telegram, and custom integrations. Includes signature verification and payload examples.
Webhooks
> Spray and Play receives webhook events from external services. Set up secure webhook
> endpoints to handle Discord bot interactions, Telegram messages, and custom integrations.
Overview
Our platform uses webhooks to receive events from:
| Service | Events | Security |
|---|---|---|
| Discord | Bot commands, interactions | Ed25519 signature |
| Telegram | Messages, callbacks | Secret token |
These are incoming webhooks (we receive them), not outgoing webhooks.
---
Discord Webhooks
Endpoint
POST /api/webhooks/discordReceives events from the Discord bot.
Events Received
| Event | Type | Description |
|---|---|---|
interaction_create | 2 | Slash command used |
message_create | 1 | Direct message received |
guild_member_add | - | New member joined server |
Payload Example
{
"type": 2,
"id": "1234567890123456789",
"token": "interaction_token_xyz",
"application_id": "987654321098765432",
"guild_id": "111111111111111111",
"channel_id": "222222222222222222",
"member": {
"user": {
"id": "555555555555555555",
"username": "sprayer",
"discriminator": "0001",
"avatar": "abc123"
},
"roles": [],
"joined_at": "2026-01-01T00:00:00.000000+00:00"
},
"data": {
"id": "333333333333333333",
"name": "balance",
"type": 1,
"options": []
},
"timestamp": "2026-02-16T15:30:00.000000+00:00"
}Signature Verification
Discord webhooks include signature headers for verification:
import crypto from 'crypto';
const DISCORD_PUBLIC_KEY = process.env.DISCORD_PUBLIC_KEY!;
function verifyDiscordWebhook(
payload: string,
signature: string | null,
timestamp: string | null
): boolean {
if (!signature || !timestamp) {
return false;
}
const message = timestamp + payload;
const signatureBuffer = Buffer.from(signature, 'hex');
const publicKeyBuffer = Buffer.from(DISCORD_PUBLIC_KEY, 'hex');
try {
return crypto.verify(
'ed25519',
Buffer.from(message),
publicKeyBuffer,
signatureBuffer
);
} catch {
return false;
}
}
// Next.js API Route Example
export async function POST(request: Request) {
const signature = request.headers.get('x-signature-ed25519');
const timestamp = request.headers.get('x-signature-timestamp');
const payload = await request.text();
// Verify signature
if (!verifyDiscordWebhook(payload, signature, timestamp)) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(payload);
// Handle interaction
switch (event.type) {
case 1: // Ping
return new Response(JSON.stringify({ type: 1 }), {
headers: { 'Content-Type': 'application/json' }
});
case 2: // Application Command
return handleSlashCommand(event);
default:
return new Response('Unknown event type', { status: 400 });
}
}
async function handleSlashCommand(event: any) {
const { name, options } = event.data;
switch (name) {
case 'balance':
return new Response(JSON.stringify({
type: 4, // Channel message with source
data: {
content: 'Your balance: $1,000.50'
}
}), {
headers: { 'Content-Type': 'application/json' }
});
default:
return new Response('Unknown command', { status: 400 });
}
}Discord Bot Setup
1. Create a Discord application at discord.com/developers
2. Enable "Message Content Intent" in the Bot section
3. Set Interactions Endpoint URL to:
```
https://app.playtrenches.xyz/api/webhooks/discord
```
4. Copy credentials to environment variables:
```bash
DISCORD_BOT_TOKEN="your-bot-token"
DISCORD_PUBLIC_KEY="your-public-key"
DISCORD_APPLICATION_ID="your-app-id"
```
---
Telegram Webhooks
Endpoint
POST /api/webhooks/telegramReceives updates from the Telegram bot.
Events Received
| Event | Description |
|---|---|
message | Text message received |
callback_query | Inline button clicked |
chat_member | Member status changed |
edited_message | Message edited |
Payload Example
{
"update_id": 123456789,
"message": {
"message_id": 1,
"from": {
"id": 123456789,
"is_bot": false,
"first_name": "John",
"username": "sprayer",
"language_code": "en"
},
"chat": {
"id": 123456789,
"first_name": "John",
"username": "sprayer",
"type": "private"
},
"date": 1708102200,
"text": "/balance"
}
}Secret Token Verification
Telegram supports secret tokens in webhook URLs:
https://app.playtrenches.xyz/api/webhooks/telegram?secret=YOUR_SECRET_TOKEN// Verify secret token
export async function POST(request: Request) {
const url = new URL(request.url);
const secret = url.searchParams.get('secret');
if (secret !== process.env.TELEGRAM_WEBHOOK_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
const update = await request.json();
// Handle message
if (update.message) {
await handleMessage(update.message);
}
return new Response('OK', { status: 200 });
}
async function handleMessage(message: any) {
const { text, chat, from } = message;
switch (text) {
case '/start':
await sendMessage(chat.id, 'Welcome to Spray and Play Bot! 🎮');
break;
case '/balance':
const balance = await getUserBalance(from.id);
await sendMessage(chat.id, `Your balance: $${balance}`);
break;
default:
await sendMessage(chat.id, 'Unknown command. Try /help');
}
}
async function sendMessage(chatId: number, text: string) {
await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: 'HTML'
})
});
}Telegram Bot Setup
1. Create a bot via @BotFather
2. Get your bot token
3. Set webhook:
```bash
curl -X POST "https://api.telegram.org/bot
-H "Content-Type: application/json" \
-d '{
"url": "https://app.playtrenches.xyz/api/webhooks/telegram",
"secret_token": "YOUR_SECRET_TOKEN"
}'
```
4. Configure environment:
```bash
TELEGRAM_BOT_TOKEN="your-bot-token"
TELEGRAM_WEBHOOK_SECRET="your-secret-token"
```
---
Custom Webhooks
Creating Custom Webhook Endpoints
You can create custom webhook endpoints for third-party integrations:
// /api/webhooks/custom/route.ts
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
// Verify custom auth
const apiKey = request.headers.get('x-api-key');
if (!verifyApiKey(apiKey)) {
return NextResponse.json(
{ error: 'Invalid API key' },
{ status: 401 }
);
}
const payload = await request.json();
// Process webhook event
await processWebhookEvent(payload);
return NextResponse.json({ received: true });
}
async function processWebhookEvent(payload: any) {
// Handle the event
switch (payload.event) {
case 'payment.received':
await handlePaymentReceived(payload);
break;
case 'user.registered':
await handleUserRegistered(payload);
break;
default:
console.warn('Unknown event:', payload.event);
}
}Webhook Event Schema
interface WebhookEvent {
id: string;
event: string;
timestamp: string;
data: Record<string, unknown>;
}
interface PaymentReceivedEvent extends WebhookEvent {
event: 'payment.received';
data: {
userId: string;
amount: number;
currency: string;
txHash: string;
status: 'confirmed' | 'pending';
};
}---
Testing Webhooks
Local Development with ngrok
Use ngrok to expose your local server:
# Start ngrok
ngrok http 3000
# Update webhook URL with ngrok URL
# Discord: Update in Developer Portal → Interactions URL
# Telegram: POST to setWebhook with ngrok URLTesting Tools
| Platform | Tool | URL |
|---|---|---|
| Discord | Developer Portal | discord.com/developers |
| Telegram | Bot API | https://api.telegram.org/bot<TOKEN>/getUpdates |
| General | Webhook.site | webhook.site |
Discord Test Command
# Test Discord interaction
curl -X POST https://app.playtrenches.xyz/api/webhooks/discord \
-H "Content-Type: application/json" \
-d '{
"type": 1,
"id": "test_id",
"token": "test_token"
}'Telegram Test Command
# Test Telegram webhook
curl -X POST "https://app.playtrenches.xyz/api/webhooks/telegram?secret=YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{
"update_id": 123,
"message": {
"message_id": 1,
"from": {"id": 123, "username": "test"},
"chat": {"id": 123, "type": "private"},
"date": 1708102200,
"text": "/start"
}
}'---
Real-Time User Updates (SSE)
For real-time updates to clients, we use Server-Sent Events (SSE), not webhooks:
GET /api/sse?userId=USER_IDSee API Endpoints for details.
// Client-side SSE connection
const eventSource = new EventSource('/api/sse?userId=123');
eventSource.addEventListener('PAYOUT_COMPLETED', (event) => {
const data = JSON.parse(event.data);
console.log('Payout received:', data);
});---
Configuration
Required Environment Variables
# Discord
DISCORD_BOT_TOKEN="your-bot-token"
DISCORD_PUBLIC_KEY="your-public-key"
DISCORD_APPLICATION_ID="your-app-id"
# Telegram
TELEGRAM_BOT_TOKEN="your-bot-token"
TELEGRAM_WEBHOOK_SECRET="optional-secret"
# Custom Webhooks (if using)
WEBHOOK_SECRET="your-webhook-secret"Webhook Endpoint Configuration
| Platform | Endpoint | Method | Security |
|---|---|---|---|
| Discord | /api/webhooks/discord | POST | Ed25519 signature |
| Telegram | /api/webhooks/telegram | POST | Secret token |
| Custom | /api/webhooks/custom | POST | API key |
---
Error Handling
Discord
| Response | Meaning | Action |
|---|---|---|
200 | Success | Event processed |
401 | Invalid signature | Check public key |
500 | Server error | Discord will retry |
Discord automatically retries failed webhooks with exponential backoff.
Telegram
| Response | Meaning | Action |
|---|---|---|
200 OK | Success | Update processed |
401 | Invalid secret | Check secret token |
| Any other | Error | Telegram retries for 24h |
Retry Logic
// Implement idempotency for webhook handlers
const processedEvents = new Set<string>();
export async function POST(request: Request) {
const event = await request.json();
// Check for duplicate
if (processedEvents.has(event.id)) {
return new Response('Already processed', { status: 200 });
}
try {
await processEvent(event);
processedEvents.add(event.id);
return new Response('OK', { status: 200 });
} catch (error) {
console.error('Webhook processing failed:', error);
return new Response('Processing failed', { status: 500 });
}
}---
Security Best Practices
> ### 🔐 Security Checklist
>
> - ✅ Always verify webhook signatures
> - ✅ Use HTTPS in production
> - ✅ Store secrets in environment variables
> - ✅ Implement idempotency (prevent duplicate processing)
> - ✅ Return 200 quickly, process async
> - ✅ Log all webhook events for debugging
> - ✅ Rate limit webhook endpoints
>
> ### ⚠️ Common Pitfalls
>
> - ❌ Don't trust webhook payloads without verification
> - ❌ Don't block the response while processing
> - ❌ Don't expose sensitive data in error messages
> - ❌ Don't forget to handle retries
---
Complete Implementation Example
// /app/api/webhooks/discord/route.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';
const PUBLIC_KEY = process.env.DISCORD_PUBLIC_KEY!;
function verifySignature(
payload: string,
signature: string,
timestamp: string
): boolean {
const message = timestamp + payload;
const sig = Buffer.from(signature, 'hex');
const key = Buffer.from(PUBLIC_KEY, 'hex');
try {
return crypto.verify('ed25519', Buffer.from(message), key, sig);
} catch {
return false;
}
}
export async function POST(request: Request) {
const signature = request.headers.get('x-signature-ed25519');
const timestamp = request.headers.get('x-signature-timestamp');
const body = await request.text();
// Security: Verify signature
if (!signature || !timestamp ||
!verifySignature(body, signature, timestamp)) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
const interaction = JSON.parse(body);
// Handle ping
if (interaction.type === 1) {
return NextResponse.json({ type: 1 });
}
// Process command asynchronously
// Return immediate acknowledgment
processCommand(interaction).catch(console.error);
return NextResponse.json({
type: 5, // Deferred response
data: { flags: 64 } // Ephemeral
});
}
async function processCommand(interaction: any) {
const { name, options } = interaction.data;
// Update with actual response
await fetch(
`https://discord.com/api/v10/webhooks/${interaction.application_id}/${interaction.token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: `Command "${name}" processed!`
})
}
);
}---