Build a bot
in 5 minutes.

Send messages, react, pin, respond to events. One HTTP endpoint, any language.

On this page
Step 1

Create your bot

Open any server you own in Uproar, then click the gear icon to open your server's settings. In the sidebar, click Bots.

  1. Click + Create Bot in the top right
  2. Give it a name (whatever you want, you can change it later)
  3. Pick a channel from the Default Channel dropdown. This is the fallback channel - your bot can send to any channel in the server by including channel_id in the request body.
  4. Hit Create Bot

Once it's created, you'll land on your bot's settings page. At the top, there's a Bot URL with a Copy button next to it. Copy it now.

Your Bot URL is the only thing your code needs to talk to Uproar. It already has your bot's credentials built in, so treat it like a password. If it ever leaks, come back here and click Regenerate.
Getting IDs: Right-click any server icon, channel, or username in Uproar to see Copy Server ID, Copy Channel ID, or Copy User ID. Use these IDs in your bot code when targeting specific channels or replying to users.
Permissions: Your bot starts with the server's default role. If it needs access to restricted channels or actions like pinning or deleting messages, an admin can assign additional roles or set channel permission overrides in server settings - same as any other member.
Delivery failures: If your bot's webhook endpoint goes down, Uproar retries each event 4 times (at 0s, 1s, 5s, and 25s). After 15 consecutive failed deliveries, event delivery is automatically disabled. You can re-enable it from the bot's settings page.
Step 2

Send your first message

Paste your Bot URL into the code below and run it. That's it. Your bot will say hello in the default channel you picked.

To send to a different channel, add "channel_id": "CHANNEL_ID" to the JSON body. You can get any channel's ID by right-clicking it in the sidebar and choosing Copy Channel ID.
You need Go 1.21+
Save as bot.go then run: go run bot.go
package main

import (
    "fmt"
    "net/http"
    "strings"
)

const botURL = "YOUR_BOT_URL_HERE"

func main() {
    body := strings.NewReader(`{"content": "Hello from my bot!"}`)
    resp, err := http.Post(botURL, "application/json", body)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer resp.Body.Close()
    fmt.Println("Status:", resp.Status)
}
You need Node 18+
Save as bot.mjs then run: node bot.mjs
const botURL = "YOUR_BOT_URL_HERE";

const res = await fetch(botURL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ content: "Hello from my bot!" }),
});

console.log("Status:", res.status);
const data = await res.json();
console.log(data);
You need Node 18+ and npx tsx
Save as bot.ts then run: npx tsx bot.ts
const botURL: string = "YOUR_BOT_URL_HERE";

interface BotResponse {
    id: string;
    content: string;
    channel_id: string;
}

const res = await fetch(botURL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ content: "Hello from my bot!" }),
});

const data: BotResponse = await res.json();
console.log(`Sent message ${data.id}`);
You need Python 3.11+ and requests - run: pip install requests
Save as bot.py then run: python3 bot.py
import requests

bot_url = "YOUR_BOT_URL_HERE"

res = requests.post(
    bot_url,
    json={"content": "Hello from my bot!"},
    timeout=10,
)

print("Status:", res.status_code)
print(res.json())
You need Rust 1.70+ - run: cargo init bot && cd bot && cargo add reqwest -F json,blocking && cargo add serde_json
Replace src/main.rs then run: cargo run
use reqwest::blocking::Client;
use serde_json::json;

fn main() {
    let bot_url = "YOUR_BOT_URL_HERE";
    let client = Client::new();

    let res = client
        .post(bot_url)
        .json(&json!({ "content": "Hello from my bot!" }))
        .send()
        .expect("request failed");

    println!("Status: {}", res.status());
}
Step 3

Price check bot

Now something people will actually use. When someone types !price or $price in your channel, this bot fetches live crypto prices and replies with a styled card showing BTC, ETH, and SOL.

This bot listens for messages, so it needs event delivery set up. You do not need to self-host Uproar or buy a domain for this. Run the bot code locally, then expose http://localhost:8080/webhook with a tunnel (for example ngrok or Cloudflare Tunnel) and paste that tunnel URL into Delivery URL.

  1. Set Delivery URL to your tunnel URL, like https://abc123.ngrok-free.app/webhook (or your Cloudflare Tunnel URL)
  2. Under Subscribed Events, turn on message_create
  3. Copy your Delivery Secret
  4. Turn on Delivery enabled and click Save Events
You need Go 1.21+
Save as prices.go then run: go run prices.go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "strings"
)

const (
    botURL   = "YOUR_BOT_URL_HERE"
    secret   = "YOUR_DELIVERY_SECRET_HERE"
    listenOn = ":8080"
)

func main() {
    http.HandleFunc("/webhook", handleWebhook)
    fmt.Println("Listening on", listenOn)
    http.ListenAndServe(listenOn, nil)
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))
    if r.Header.Get("X-Uproar-Signature") != expected {
        http.Error(w, "bad signature", 401)
        return
    }

    var event struct {
        Type string `json:"type"`
        Data struct {
            Content   string `json:"content"`
            ChannelID string `json:"channel_id"`
        } `json:"data"`
    }
    json.Unmarshal(body, &event)

    content := strings.TrimSpace(event.Data.Content)
    if event.Type != "message_create" || (content != "!price" && content != "$price") {
        w.WriteHeader(200)
        return
    }

    go postPrices(event.Data.ChannelID)
    w.WriteHeader(200)
}

func postPrices(channelID string) {
    resp, err := http.Get("https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum,solana&vs_currencies=usd")
    if err != nil {
        return
    }
    defer resp.Body.Close()
    raw, _ := io.ReadAll(resp.Body)

    var prices map[string]map[string]float64
    json.Unmarshal(raw, &prices)

    payload, _ := json.Marshal(map[string]interface{}{
        "channel_id": channelID,
        "embeds": []map[string]interface{}{
            {
                "title":       "Crypto Prices",
                "description": "Live prices via CoinGecko",
                "color":       5242879, // #4FC3F7 as integer
                "fields": []map[string]interface{}{
                    {"name": "BTC", "value": fmt.Sprintf("$%.2f", prices["bitcoin"]["usd"]), "inline": true},
                    {"name": "ETH", "value": fmt.Sprintf("$%.2f", prices["ethereum"]["usd"]), "inline": true},
                    {"name": "SOL", "value": fmt.Sprintf("$%.2f", prices["solana"]["usd"]), "inline": true},
                },
                "footer": map[string]string{"text": "Type !price or $price to refresh"},
            },
        },
    })
    http.Post(botURL, "application/json", strings.NewReader(string(payload)))
}
You need Node 18+ - run: npm init -y && npm install express
Save as prices.mjs then run: node prices.mjs
import express from "express";
import crypto from "node:crypto";

const botURL = "YOUR_BOT_URL_HERE";
const secret = "YOUR_DELIVERY_SECRET_HERE";

const app = express();
app.use(express.text({ type: "application/json" }));

app.post("/webhook", async (req, res) => {
    const body = req.body;
    const expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
    if (req.headers["x-uproar-signature"] !== expected) return res.sendStatus(401);

    const event = JSON.parse(body);
    const content = (event.data?.content || "").trim();
    const channelId = event.data?.channel_id;

    if (event.type !== "message_create" || (content !== "!price" && content !== "$price")) {
        return res.sendStatus(200);
    }
    res.sendStatus(200);

    const r = await fetch(
        "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum,solana&vs_currencies=usd"
    );
    const prices = await r.json();

    await fetch(botURL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            channel_id: channelId,
            embeds: [{
                title: "Crypto Prices",
                description: "Live prices via CoinGecko",
                color: 5242879,
                fields: [
                    { name: "BTC", value: `$${prices.bitcoin.usd.toLocaleString()}`, inline: true },
                    { name: "ETH", value: `$${prices.ethereum.usd.toLocaleString()}`, inline: true },
                    { name: "SOL", value: `$${prices.solana.usd.toLocaleString()}`, inline: true },
                ],
                footer: { text: "Type !price or $price to refresh" },
            }],
        }),
    });
});

app.listen(8080, () => console.log("Listening on :8080"));
You need Node 18+ - run: npm init -y && npm install express && npm install -D @types/express
Save as prices.ts then run: npx tsx prices.ts
import express from "express";
import crypto from "node:crypto";

const botURL: string = "YOUR_BOT_URL_HERE";
const secret: string = "YOUR_DELIVERY_SECRET_HERE";

interface PriceData { [coin: string]: { usd: number } }
interface UproarEvent { type: string; data: { content: string; channel_id: string } }

const app = express();
app.use(express.text({ type: "application/json" }));

app.post("/webhook", async (req, res) => {
    const body = req.body as string;
    const expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
    if (req.headers["x-uproar-signature"] !== expected) return res.sendStatus(401);

    const event: UproarEvent = JSON.parse(body);
    const content = event.data.content.trim();
    const channelId = event.data.channel_id;

    if (event.type !== "message_create" || (content !== "!price" && content !== "$price")) {
        return res.sendStatus(200);
    }
    res.sendStatus(200);

    const r = await fetch(
        "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum,solana&vs_currencies=usd"
    );
    const prices: PriceData = await r.json();

    await fetch(botURL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            channel_id: channelId,
            embeds: [{
                title: "Crypto Prices",
                description: "Live prices via CoinGecko",
                color: 5242879,
                fields: [
                    { name: "BTC", value: `$${prices.bitcoin.usd.toLocaleString()}`, inline: true },
                    { name: "ETH", value: `$${prices.ethereum.usd.toLocaleString()}`, inline: true },
                    { name: "SOL", value: `$${prices.solana.usd.toLocaleString()}`, inline: true },
                ],
                footer: { text: "Type !price or $price to refresh" },
            }],
        }),
    });
});

app.listen(8080, () => console.log("Listening on :8080"));
You need Python 3.11+ and Flask + requests - run: pip install flask requests
Save as prices.py then run: python3 prices.py
import hmac
import hashlib
import requests
from flask import Flask, request, abort

bot_url = "YOUR_BOT_URL_HERE"
secret = "YOUR_DELIVERY_SECRET_HERE"
app = Flask(__name__)

@app.post("/webhook")
def webhook():
    body = request.get_data()
    expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    if request.headers.get("X-Uproar-Signature") != expected:
        return abort(401)

    event = request.get_json(force=True) or {}
    content = (event.get("data", {}).get("content", "") or "").strip()
    channel_id = event.get("data", {}).get("channel_id")

    if event.get("type") != "message_create" or content not in {"!price", "$price"}:
        return ("", 200)

    prices = requests.get(
        "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum,solana&vs_currencies=usd",
        timeout=10,
    ).json()

    requests.post(bot_url, json={
        "channel_id": channel_id,
        "embeds": [{
            "title": "Crypto Prices",
            "description": "Live prices via CoinGecko",
            "color": 5242879,
            "fields": [
                {"name": "BTC", "value": f"${prices['bitcoin']['usd']:.2f}", "inline": True},
                {"name": "ETH", "value": f"${prices['ethereum']['usd']:.2f}", "inline": True},
                {"name": "SOL", "value": f"${prices['solana']['usd']:.2f}", "inline": True},
            ],
            "footer": {"text": "Type !price or $price to refresh"}
        }]
    }, timeout=10)

    return ("", 200)

app.run(host="0.0.0.0", port=8080)
You need Rust 1.70+ - cargo add actix-web reqwest serde serde_json hmac sha2 hex tokio -F tokio/full,serde/derive,hmac/std,sha2/std,reqwest/json
Replace src/main.rs then run: cargo run
use actix_web::{web, App, HttpServer, HttpRequest, HttpResponse};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use serde::Deserialize;
use serde_json::json;

const BOT_URL: &str = "YOUR_BOT_URL_HERE";
const SECRET: &str = "YOUR_DELIVERY_SECRET_HERE";
type HmacSha256 = Hmac<Sha256>;

#[derive(Deserialize)]
struct Event { r#type: String, data: EventData }
#[derive(Deserialize)]
struct EventData { content: Option<String>, channel_id: Option<String> }

async fn webhook(req: HttpRequest, body: String) -> HttpResponse {
    let sig = req.headers().get("X-Uproar-Signature")
        .and_then(|v| v.to_str().ok()).unwrap_or("");
    let mut mac = HmacSha256::new_from_slice(SECRET.as_bytes()).unwrap();
    mac.update(body.as_bytes());
    let expected = hex::encode(mac.finalize().into_bytes());
    if sig != expected { return HttpResponse::Unauthorized().finish(); }

    let event: Event = match serde_json::from_str(&body) {
        Ok(e) => e, Err(_) => return HttpResponse::Ok().finish(),
    };
    let content = event.data.content.unwrap_or_default();
    let trimmed = content.trim();
    let channel_id = event.data.channel_id.clone().unwrap_or_default();
    if event.r#type != "message_create" || (trimmed != "!price" && trimmed != "$price") {
        return HttpResponse::Ok().finish();
    }

    tokio::spawn(async move {
        let client = reqwest::Client::new();
        let prices: serde_json::Value = client
            .get("https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum,solana&vs_currencies=usd")
            .send().await.unwrap().json().await.unwrap();

        let _ = client.post(BOT_URL).json(&json!({
            "channel_id": channel_id,
            "embeds": [{
                "title": "Crypto Prices",
                "description": "Live prices via CoinGecko",
                "color": 5242879,
                "fields": [
                    { "name": "BTC", "value": format!("${:.2}", prices["bitcoin"]["usd"].as_f64().unwrap_or(0.0)), "inline": true },
                    { "name": "ETH", "value": format!("${:.2}", prices["ethereum"]["usd"].as_f64().unwrap_or(0.0)), "inline": true },
                    { "name": "SOL", "value": format!("${:.2}", prices["solana"]["usd"].as_f64().unwrap_or(0.0)), "inline": true },
                ],
                "footer": { "text": "Type !price or $price to refresh" },
            }]
        })).send().await;
    });

    HttpResponse::Ok().finish()
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    println!("Listening on :8080");
    HttpServer::new(|| App::new().route("/webhook", web::post().to(webhook)))
        .bind("0.0.0.0:8080")?.run().await
}
Step 4

Reminder bot (listening for messages)

Same webhook setup as the price bot - if you already have event delivery configured from step 3, you're good. When someone types !remind 5m take out the trash, the bot waits 5 minutes and posts a reminder.

You need Go 1.21+
Save as remind.go then run: go run remind.go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "strconv"
    "strings"
    "time"
)

const (
    botURL   = "YOUR_BOT_URL_HERE"
    secret   = "YOUR_DELIVERY_SECRET_HERE"
    listenOn = ":8080"
)

func main() {
    http.HandleFunc("/webhook", handleWebhook)
    fmt.Println("Listening on", listenOn)
    http.ListenAndServe(listenOn, nil)
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)

    // Verify signature
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))
    if r.Header.Get("X-Uproar-Signature") != expected {
        http.Error(w, "bad signature", 401)
        return
    }

    var event struct {
        Type string `json:"type"`
        Data struct {
            Content   string `json:"content"`
            Username  string `json:"username"`
            ChannelID string `json:"channel_id"`
        } `json:"data"`
    }
    json.Unmarshal(body, &event)

    if event.Type != "message_create" || !strings.HasPrefix(event.Data.Content, "!remind ") {
        w.WriteHeader(200)
        return
    }

    // Parse "!remind 5m take out the trash"
    parts := strings.SplitN(event.Data.Content, " ", 3)
    if len(parts) < 3 {
        w.WriteHeader(200)
        return
    }

    dur := parseDuration(parts[1])
    reminder := parts[2]
    channelID := event.Data.ChannelID
    username := event.Data.Username

    go func() {
        time.Sleep(dur)
        msg := fmt.Sprintf("Hey @%s, you asked me to remind you: %s", username, reminder)
        payload, _ := json.Marshal(map[string]string{"content": msg, "channel_id": channelID})
        http.Post(botURL, "application/json", strings.NewReader(string(payload)))
    }()

    w.WriteHeader(200)
}

func parseDuration(s string) time.Duration {
    if strings.HasSuffix(s, "m") {
        n, _ := strconv.Atoi(s[:len(s)-1])
        return time.Duration(n) * time.Minute
    }
    if strings.HasSuffix(s, "s") {
        n, _ := strconv.Atoi(s[:len(s)-1])
        return time.Duration(n) * time.Second
    }
    if strings.HasSuffix(s, "h") {
        n, _ := strconv.Atoi(s[:len(s)-1])
        return time.Duration(n) * time.Hour
    }
    return time.Minute
}
You need Node 18+ - run: npm init -y && npm install express
Save as remind.mjs then run: node remind.mjs
import express from "express";
import crypto from "node:crypto";

const botURL = "YOUR_BOT_URL_HERE";
const secret = "YOUR_DELIVERY_SECRET_HERE";

const app = express();
app.use(express.text({ type: "application/json" }));

app.post("/webhook", (req, res) => {
    const body = req.body;

    // Verify signature
    const expected = crypto
        .createHmac("sha256", secret)
        .update(body)
        .digest("hex");
    if (req.headers["x-uproar-signature"] !== expected) {
        return res.status(401).send("bad signature");
    }

    const event = JSON.parse(body);
    const content = event.data?.content || "";
    const channelId = event.data?.channel_id;

    if (event.type !== "message_create" || !content.startsWith("!remind ")) {
        return res.sendStatus(200);
    }

    // Parse "!remind 5m take out the trash"
    const [, time, ...rest] = content.split(" ");
    const reminder = rest.join(" ");
    const ms = parseTime(time);

    setTimeout(async () => {
        await fetch(botURL, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
                channel_id: channelId,
                content: `Hey, you asked me to remind you: ${reminder}`,
            }),
        });
    }, ms);

    res.sendStatus(200);
});

function parseTime(s) {
    const n = parseInt(s);
    if (s.endsWith("h")) return n * 3600000;
    if (s.endsWith("m")) return n * 60000;
    return n * 1000;
}

app.listen(8080, () => console.log("Listening on :8080"));
You need Node 18+ - run: npm init -y && npm install express && npm install -D @types/express
Save as remind.ts then run: npx tsx remind.ts
import express, { Request, Response } from "express";
import crypto from "node:crypto";

const botURL: string = "YOUR_BOT_URL_HERE";
const secret: string = "YOUR_DELIVERY_SECRET_HERE";

interface UproarEvent {
    type: string;
    data: { content: string; channel_id: string; username: string };
}

const app = express();
app.use(express.text({ type: "application/json" }));

app.post("/webhook", (req: Request, res: Response) => {
    const body = req.body as string;

    const expected = crypto
        .createHmac("sha256", secret)
        .update(body)
        .digest("hex");
    if (req.headers["x-uproar-signature"] !== expected) {
        return res.status(401).send("bad signature");
    }

    const event: UproarEvent = JSON.parse(body);
    if (event.type !== "message_create" || !event.data.content.startsWith("!remind ")) {
        return res.sendStatus(200);
    }

    const [, time, ...rest] = event.data.content.split(" ");
    const reminder = rest.join(" ");
    const ms = parseTime(time);
    const channelId = event.data.channel_id;

    setTimeout(async () => {
        await fetch(botURL, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ channel_id: channelId, content: `Reminder: ${reminder}` }),
        });
    }, ms);

    res.sendStatus(200);
});

function parseTime(s: string): number {
    const n = parseInt(s);
    if (s.endsWith("h")) return n * 3600000;
    if (s.endsWith("m")) return n * 60000;
    return n * 1000;
}

app.listen(8080, () => console.log("Listening on :8080"));
You need Python 3.11+ and Flask + requests - run: pip install flask requests
Save as remind.py then run: python3 remind.py
import hmac
import hashlib
import threading
import time
import requests
from flask import Flask, request, abort

bot_url = "YOUR_BOT_URL_HERE"
secret = "YOUR_DELIVERY_SECRET_HERE"
app = Flask(__name__)

def parse_time(s):
    n = int(s[:-1]) if s[:-1].isdigit() else 1
    if s.endswith("h"): return n * 3600
    if s.endswith("m"): return n * 60
    return n

def send_later(seconds, reminder, channel_id):
    time.sleep(seconds)
    requests.post(bot_url, json={"channel_id": channel_id, "content": f"Reminder: {reminder}"}, timeout=10)

@app.post("/webhook")
def webhook():
    body = request.get_data()
    expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    if request.headers.get("X-Uproar-Signature") != expected:
        return abort(401)

    event = request.get_json(force=True) or {}
    content = (event.get("data", {}).get("content", "") or "")
    channel_id = event.get("data", {}).get("channel_id")
    if event.get("type") != "message_create" or not content.startswith("!remind "):
        return ("", 200)

    parts = content.split(" ", 2)
    if len(parts) < 3:
        return ("", 200)

    delay = parse_time(parts[1])
    reminder = parts[2]
    threading.Thread(target=send_later, args=(delay, reminder, channel_id), daemon=True).start()
    return ("", 200)

app.run(host="0.0.0.0", port=8080)
You need Rust 1.70+ - cargo add actix-web serde serde_json reqwest hmac sha2 hex tokio -F tokio/full,serde/derive,hmac/std,sha2/std
Replace src/main.rs then run: cargo run
use actix_web::{web, App, HttpServer, HttpRequest, HttpResponse};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use serde::Deserialize;
use serde_json::json;

const BOT_URL: &str = "YOUR_BOT_URL_HERE";
const SECRET: &str = "YOUR_DELIVERY_SECRET_HERE";

type HmacSha256 = Hmac<Sha256>;

#[derive(Deserialize)]
struct Event {
    r#type: String,
    data: EventData,
}

#[derive(Deserialize)]
struct EventData {
    content: Option<String>,
    channel_id: Option<String>,
}

async fn webhook(req: HttpRequest, body: String) -> HttpResponse {
    // Verify signature
    let sig = req.headers().get("X-Uproar-Signature")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");
    let mut mac = HmacSha256::new_from_slice(SECRET.as_bytes()).unwrap();
    mac.update(body.as_bytes());
    let expected = hex::encode(mac.finalize().into_bytes());
    if sig != expected {
        return HttpResponse::Unauthorized().finish();
    }

    let event: Event = match serde_json::from_str(&body) {
        Ok(e) => e,
        Err(_) => return HttpResponse::Ok().finish(),
    };

    let content = event.data.content.unwrap_or_default();
    let channel_id = event.data.channel_id.clone().unwrap_or_default();
    if event.r#type != "message_create" || !content.starts_with("!remind ") {
        return HttpResponse::Ok().finish();
    }

    let parts: Vec<&str> = content.splitn(3, ' ').collect();
    if parts.len() < 3 {
        return HttpResponse::Ok().finish();
    }

    let dur = parse_duration(parts[1]);
    let reminder = parts[2].to_string();

    tokio::spawn(async move {
        tokio::time::sleep(dur).await;
        let _ = reqwest::Client::new()
            .post(BOT_URL)
            .json(&json!({ "channel_id": channel_id, "content": format!("Reminder: {}", reminder) }))
            .send()
            .await;
    });

    HttpResponse::Ok().finish()
}

fn parse_duration(s: &str) -> std::time::Duration {
    let n: u64 = s.trim_end_matches(|c: char| !c.is_ascii_digit()).parse().unwrap_or(1);
    if s.ends_with('h') { std::time::Duration::from_secs(n * 3600) }
    else if s.ends_with('m') { std::time::Duration::from_secs(n * 60) }
    else { std::time::Duration::from_secs(n) }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    println!("Listening on :8080");
    HttpServer::new(|| App::new().route("/webhook", web::post().to(webhook)))
        .bind("0.0.0.0:8080")?
        .run()
        .await
}
API Reference

Bots API v1.0.0

Comprehensive contract for Uproar bot lifecycle management and bot execution.

Authentication modes:

  • Management endpoints use a user session token (Authorization: Bearer <session_token>) or session cookie.
  • Execute endpoint uses bot credentials in the URL path (/api/bots/{id}/{token}).

Notes:

  • Global rate limit: 300 requests/minute per source IP (applies to both management and execute routes).
  • Bot execute requests are rate-limited to 30 requests/minute per bot.
  • Event delivery can be configured per bot via delivery_url, delivery_events, and delivery_enabled.
  • Bots start with the server default role only. Standard channel/role permission resolution applies.
  • Bots always appear online in member presence state.
  • Bots cannot be friended, DMed, or removed through normal moderation endpoints.
  • If the creating user leaves/kicked/banned, all bots they created are auto-deleted.
  • Deleting a bot removes membership only; historical bot messages are preserved.

Server: https://uproar.chat
GET /api/servers/{id}/bots

List bots for a server. Caller must have manage_bots.

Parameters

NameInRequiredTypeDescription
idpathrequiredstringServer ID.

Responses

200 OK
401 Error response
403 Error response
500 Error response
POST /api/servers/{id}/bots

Create a bot in a server. Caller must be a server member with manage_bots.

Parameters

NameInRequiredTypeDescription
idpathrequiredstringServer ID.

Request Body

application/json
default
{
  "name": "Ops Bot",
  "default_channel_id": "ch_6fb77a2e"
}

Responses

201 Created
application/json
created
{
  "id": "bot_8122f0cb",
  "server_id": "srv_1eea3e72",
  "name": "Ops Bot",
  "description": "",
  "avatar_url": null,
  "token": "9d1d2ff8f4acb720f84d5f7f5c6360e6c0a3db8de27d06c9d273b0f9306f7c22",
  "user_id": "usr_bot_2bcbf5ee",
  "default_channel_id": "ch_6fb77a2e",
  "delivery_url": "",
  "delivery_secret": "33861fe7f0f7bb16c31e2212864f56722dc65c6f4e7f3b84fdc0cc42d581d4f1",
  "delivery_events": "",
  "delivery_enabled": false,
  "consecutive_failures": 0,
  "disabled_reason": null,
  "created_by": "usr_38f40aba",
  "created_at": "2026-04-18 20:11:10",
  "url": "https://uproar.chat/api/bots/bot_8122f0cb/9d1d2ff8f4acb720f84d5f7f5c6360e6c0a3db8de27d06c9d273b0f9306f7c22"
}
400 Validation failure
application/json
nameRequired
{
  "error": "name is required"
}
nameTooLong
{
  "error": "name too long (max 32 characters)"
}
botLimit
{
  "error": "server bot limit reached (max 25)"
}
invalidChannel
{
  "error": "invalid channel"
}
401 Error response
403 Error response
500 Error response
PATCH /api/servers/{id}/bots/{botId}

Partially update bot settings.

Important behavior:

  • default_channel_id: "" clears the default channel.
  • delivery_events is a comma-separated string of event IDs.
  • regenerate_secret: true rotates delivery_secret.
  • Setting delivery_enabled: true resets consecutive_failures and clears disabled_reason.

Parameters

NameInRequiredTypeDescription
idpathrequiredstringServer ID.
botIdpathrequiredstringBot ID.

Request Body

application/json
webhookConfig
{
  "delivery_url": "https://abc123.ngrok-free.app/webhook",
  "delivery_events": "message_create,message_edit,reaction_add,reaction_remove,pin_update",
  "delivery_enabled": true
}
profileUpdate
{
  "name": "Price Bot",
  "description": "Replies to !price and $price.",
  "avatar_url": "https://cdn.example.com/bot-price.png"
}

Responses

200 Updated
400 Validation failure
application/json
nameEmpty
{
  "error": "name cannot be empty"
}
nameTooLong
{
  "error": "name too long (max 32 characters)"
}
descriptionTooLong
{
  "error": "description too long (max 256 characters)"
}
invalidChannel
{
  "error": "invalid channel"
}
invalidDeliveryURL
{
  "error": "delivery URL must start with http:// or https://"
}
invalidEvents
{
  "error": "invalid events"
}
401 Error response
403 Error response
404 Error response
500 Error response
DELETE /api/servers/{id}/bots/{botId}

Delete a bot and remove its bot user from the server membership.

Historical messages are preserved.

Emits a member_leave webhook event for the bot user.

Parameters

NameInRequiredTypeDescription
idpathrequiredstringServer ID.
botIdpathrequiredstringBot ID.

Responses

200 Deleted
application/json
deleted
{
  "status": "deleted"
}
401 Error response
403 Error response
404 Error response
500 Error response
POST /api/servers/{id}/bots/{botId}/regenerate

Rotate bot execute token. Old execute URL immediately stops working.

Parameters

NameInRequiredTypeDescription
idpathrequiredstringServer ID.
botIdpathrequiredstringBot ID.

Responses

200 Token regenerated
application/json
default
{
  "token": "7e9aa9bbec06890f65c6581d2e2c6b13a74f5e0e414db469ac719f76a4f4f286",
  "url": "https://uproar.chat/api/bots/bot_8122f0cb/7e9aa9bbec06890f65c6581d2e2c6b13a74f5e0e414db469ac719f76a4f4f286"
}
401 Error response
403 Error response
404 Error response
500 Error response
POST /api/servers/{id}/bots/{botId}/reenable

Re-enable webhook delivery after failure-based auto-disable.

Parameters

NameInRequiredTypeDescription
idpathrequiredstringServer ID.
botIdpathrequiredstringBot ID.

Responses

200 Re-enabled
application/json
reenabled
{
  "status": "re-enabled"
}
401 Error response
403 Error response
404 Error response
500 Error response
POST /api/servers/{id}/bots/{botId}/test

Sends a signed test_ping event to delivery_url.

Notes:

  • Returns HTTP 200 with success: true|false for connection/result outcomes.
  • test_ping does not depend on delivery_events subscription list.

Parameters

NameInRequiredTypeDescription
idpathrequiredstringServer ID.
botIdpathrequiredstringBot ID.

Responses

200 Test attempted
application/json
success
{
  "success": true,
  "status_code": 200
}
upstreamFailure
{
  "success": false,
  "status_code": 500
}
connectionFailure
{
  "success": false,
  "error": "connection failed"
}
invalidDeliveryURL
{
  "success": false,
  "error": "invalid delivery URL"
}
privateInternalURL
{
  "success": false,
  "error": "delivery URL points to a private/internal address"
}
dnsFailure
{
  "success": false,
  "error": "DNS resolution failed for delivery URL"
}
400 Missing delivery URL
application/json
noDeliveryURL
{
  "error": "no delivery URL configured"
}
401 Error response
403 Error response
404 Bot not found
application/json
botNotFound
{
  "error": "bot not found"
}
500 Error response
POST /api/bots/{id}/{token}

Execute one bot action.

Supported actions:

  • send (default when action omitted)
  • edit
  • delete
  • react
  • unreact
  • pin
  • unpin

When action is omitted, it defaults to send.

Action-specific requirements:

  • send: channel_id can target any channel in the bot's server (not just the default). When omitted, falls back to default_channel_id. Requires at least one of content or embeds. Optional display_name and avatar_url override the bot's identity for this message (useful for bridge bots proxying other users).
  • edit: requires message_id; requires at least one of content or embeds; can only edit the bot's own messages.
  • delete: requires message_id. A bot with manage_messages permission can delete other users' messages.
  • react: requires message_id and emoji. Duplicate reactions are idempotent (always returns {"status":"ok"}).
  • unreact: requires message_id and emoji. No reaction permission check, but requireChannelWritable still applies (fails on archived channels).
  • pin/unpin: requires message_id.

Embed validation:

  • up to 10 embeds
  • total text chars across embeds <= 6000
  • color must be integer 0..16777215
  • thumbnail.url and image.url must use https:// when non-empty
  • unknown embed keys pass through to storage as-is
  • embeds are silently dropped if the bot lacks the embed_links permission

Additional send behavior:

  • reply_to must reference a valid message ID in the target channel. Returns 400 invalid reply_to message if not found or in a different channel.
  • @username mentions in content are resolved against server members with access to the target channel. Unresolvable mentions are left as plain text.
  • @everyone and @here in content set mentions_everyone on the message only if the bot has mention_everyone permission. Without the permission, the text is sent but no notification is triggered.
  • All actions fail with 403 if the target channel is archived.

Parameters

NameInRequiredTypeDescription
idpathrequiredstringBot ID for execute endpoint.
tokenpathrequiredstringBot execute token.

Request Body

application/json
sendText
{
  "action": "send",
  "channel_id": "ch_6fb77a2e",
  "content": "Hello from my bot."
}
sendProxy
{
  "action": "send",
  "channel_id": "ch_6fb77a2e",
  "content": "Hello from the other side!",
  "display_name": "BridgedUser",
  "avatar_url": "https://cdn.discordapp.com/avatars/123/abc.png"
}
sendReply
{
  "action": "send",
  "channel_id": "ch_6fb77a2e",
  "content": "Replying to your message.",
  "reply_to": "msg_aabb1122"
}
sendEmbed
{
  "action": "send",
  "channel_id": "ch_6fb77a2e",
  "embeds": [
    {
      "title": "Crypto Prices",
      "description": "Live prices via CoinGecko",
      "color": 5242879,
      "fields": [
        {
          "name": "BTC",
          "value": "$68432.12",
          "inline": true
        },
        {
          "name": "ETH",
          "value": "$3442.51",
          "inline": true
        },
        {
          "name": "SOL",
          "value": "$171.24",
          "inline": true
        }
      ],
      "footer": {
        "text": "Type !price or $price to refresh"
      }
    }
  ]
}
edit
{
  "action": "edit",
  "message_id": "msg_92bb6c1f",
  "content": "Updated text"
}
delete
{
  "action": "delete",
  "message_id": "msg_92bb6c1f"
}
react
{
  "action": "react",
  "message_id": "msg_92bb6c1f",
  "emoji": "🔥"
}
unreact
{
  "action": "unreact",
  "message_id": "msg_92bb6c1f",
  "emoji": "🔥"
}
pin
{
  "action": "pin",
  "message_id": "msg_92bb6c1f"
}
unpin
{
  "action": "unpin",
  "message_id": "msg_92bb6c1f"
}

Responses

200 Success (non-create actions)
application/json
edit
{
  "id": "msg_92bb6c1f",
  "channel_id": "ch_6fb77a2e",
  "user_id": "usr_bot_2bcbf5ee",
  "content": "Updated text",
  "is_pinned": false,
  "suppress_embeds": false,
  "mentions_everyone": false,
  "created_at": "2026-04-18 20:16:31",
  "edited_at": "2026-04-18 20:18:00",
  "username": "bot.2bcbf5ee",
  "display_name": "Ops Bot",
  "avatar_url": null,
  "is_bot": true,
  "reactions": []
}
delete
{
  "status": "deleted"
}
react
{
  "status": "ok"
}
201 Created (`send` action)
application/json
send
{
  "id": "msg_92bb6c1f",
  "channel_id": "ch_6fb77a2e",
  "user_id": "usr_bot_2bcbf5ee",
  "content": "Hello from my bot.",
  "is_pinned": false,
  "suppress_embeds": false,
  "mentions_everyone": false,
  "created_at": "2026-04-18 20:16:31",
  "edited_at": null,
  "username": "bot.2bcbf5ee",
  "display_name": "Ops Bot",
  "avatar_url": null,
  "is_bot": true,
  "reactions": []
}
400 Validation failure
application/json
invalidAction
{
  "error": "invalid action; valid: send, edit, delete, react, unreact, pin, unpin"
}
noContent
{
  "error": "content or embeds required"
}
tooLong
{
  "error": "message too long (max 2000 characters)"
}
noChannel
{
  "error": "channel_id is required (no default channel set)"
}
invalidChannel
{
  "error": "invalid channel"
}
invalidEmbeds
{
  "error": "invalid embeds format"
}
tooManyEmbeds
{
  "error": "max 10 embeds per message"
}
embedCharLimit
{
  "error": "embeds exceed 6000 character limit"
}
badEmbedColor
{
  "error": "color must be an integer 0-16777215"
}
badThumbnailUrl
{
  "error": "thumbnail URL must use https://"
}
badImageUrl
{
  "error": "image URL must use https://"
}
missingMessageId
{
  "error": "message_id is required"
}
missingEmoji
{
  "error": "message_id and emoji are required"
}
invalidReplyTo
{
  "error": "invalid reply_to message"
}
403 Permission denied, bot timed out, or channel archived
application/json
timedOut
{
  "error": "bot is timed out",
  "retry_after": 123
}
noSendPerm
{
  "error": "bot lacks permission to send in this channel"
}
archivedChannel
{
  "error": "cannot send messages in an archived channel"
}
noDeletePerm
{
  "error": "bot lacks permission to delete this message"
}
noReactPerm
{
  "error": "bot lacks reaction permission"
}
noPinPerm
{
  "error": "bot lacks pin permission"
}
editOwnOnly
{
  "error": "can only edit the bot's own messages"
}
404 Bot or message not found
application/json
botNotFound
{
  "error": "bot not found"
}
messageNotFound
{
  "error": "message not found"
}
429 Rate limit or slowmode
application/json
botRateLimit
{
  "error": "rate limit exceeded",
  "retry_after": 60
}
channelSlowmode
{
  "error": "slowmode active",
  "retry_after": 4
}
500 Error response
Events

Webhook events

Subscribe to any of these by adding their names to your bot's delivery_events list. Each fires a signed POST to your delivery URL.

Envelope format

POST <your delivery_url>
X-Uproar-Event: <event name>
X-Uproar-Signature: <HMAC-SHA256 hex digest>
X-Uproar-Delivery-ID: <unique delivery UUID>

{"type":"<event name>","data":{...}}

Retries: 4 attempts (0s, 1s, 5s, 25s) · Timeout: 10s per attempt · Auto-disabled after 15 consecutive failures

EventDescriptiondata fields
message_createA message was sent in a channel the bot can seeFull Message object
message_editA message was editedFull Message object
message_deleteA message was deletedmessage_id, channel_id, server_id
reaction_addA reaction was added to a messagemessage_id, channel_id, user_id, emoji, message (full Message)
reaction_removeA reaction was removed from a messagemessage_id, channel_id, user_id, emoji, message (full Message)
pin_updateA message was pinned, unpinned, or a pin expiredFull Message object
member_joinA user joined the server (via invite, directory, or bot creation)server_id, user_id, username, display_name, is_bot, source
member_leaveA user left the serverserver_id, user_id
member_kickA member was kickedserver_id, user_id, kicked_by, reason
member_banA member was bannedserver_id, user_id, banned_by, reason
member_unbanA member was unbannedserver_id, user_id, unbanned_by
member_updateMember profile or roles changedserver_id, user_id, updated_by, plus changed fields (display_name, avatar_url, nickname, or roles)
member_timeoutA member was timed outserver_id, user_id, duration, expires_at, timed_out_by
member_timeout_removedA member's timeout was removed earlyserver_id, user_id, removed_by
poll_createA poll was createdserver_id, action, poll (object), actor_id
poll_updateSomeone voted on a pollserver_id, action, poll (object), actor_id, option_id
poll_closeA poll was closed manually or expiredserver_id, action, poll (object), actor_id (if manual)
invite_createA server invite was createdserver_id, code, actor_id, max_uses, expires_at
invite_revokeA server invite was revokedserver_id, code, actor_id
category_createA channel category was createdserver_id, category (object), actor_id
category_updateA channel category was renamed or movedserver_id, category (object), actor_id
category_deleteA channel category was deletedserver_id, category_id, name, orphaned_channels, actor_id
channel_createA channel was createdFull Channel object
channel_updateA channel's settings changed (name, topic, slowmode, etc.)Full Channel object
channel_deleteA channel was deletedchannel_id, server_id, name
channel_structure_updateChannel order, category assignment, or permission overrides changedserver_id, action, actor_id
role_updateServer roles were created, updated, or deletedserver_id, roles (full roles array)
server_updateServer settings or theme changedFull Server object
emoji_createA custom emoji was uploadedserver_id, emoji_id, name, url, animated, uploaded_by
emoji_deleteA custom emoji was deletedserver_id, emoji_id, name, deleted_by
space_startA Space startedserver_id, space_id, space (object), participants
space_endA Space endedserver_id, space_id, space (object)
space_joinA participant joined a Spaceserver_id, space_id, user_id, participants
space_leaveA participant left a Spaceserver_id, space_id, user_id
space_updateA participant was promoted, demoted, co-hosted, or kicked in a Spaceserver_id, space_id, user_id, action, participants
space_handA participant raised or lowered their handserver_id, space_id, user_id, raised
space_muteA participant was muted or unmutedserver_id, space_id, user_id, muted, source
space_reactionAn emoji reaction was sent in a Spaceserver_id, space_id, user_id, emoji
space_chatA text message was sent in a Space chatserver_id, space_id, message (object)
Schemas

Data schemas

Bot

FieldTypeRequiredDescription
idstringrequired
server_idstringrequired
namestringrequiredmax 32 chars
descriptionstringrequiredmax 256 chars
avatar_urlstringoptional, nullable
tokenstringrequiredSecret. Treat as credential.
user_idstringrequired
default_channel_idstringoptional, nullable
delivery_urlstringrequired
delivery_secretstringrequiredSecret used to verify webhook signatures.
delivery_eventsstringrequiredComma-separated event IDs.
delivery_enabledbooleanrequired
consecutive_failuresintegerrequiredmin 0
disabled_reasonstringoptional, nullable
created_bystringrequired
created_atstringrequired
urlstringoptional, nullableFull execute URL containing token.

CreateBotRequest

FieldTypeRequiredDescription
namestringrequiredmax 32 chars; min 1 chars
default_channel_idstringoptional, nullable

UpdateBotRequest

FieldTypeRequiredDescription
namestringoptionalmax 32 chars; min 1 chars
descriptionstringoptionalmax 256 chars
avatar_urlstringoptional, nullableURL string for the bot's avatar image. There is no upload endpoint - provide a hosted URL directly.
default_channel_idstringoptionalSet empty string to clear default channel.
delivery_urlstringoptionalMust start with http:// or https:// when non-empty.
delivery_eventsstringoptionalComma-separated event IDs. Valid IDs: message_create,message_edit,message_delete,reaction_add,reaction_remove,pin_update, member_join,member_leave,member_kick,member_ban,member_unban,member_update,member_timeout,member_timeout_removed, invite_create,invite_revoke,poll_create,poll_update,poll_close, category_create,category_update,category_delete,channel_structure_update, channel_create,channel_update,channel_delete,role_update,server_update, space_start,space_end,space_join,space_leave,space_update,space_hand,space_mute,space_reaction,space_chat, emoji_create,emoji_delete
regenerate_secretbooleanoptional
delivery_enabledbooleanoptional

RegenerateTokenResponse

FieldTypeRequiredDescription
tokenstringrequired
urlstringrequired

DeliveryTestResult

FieldTypeRequiredDescription
successbooleanrequired
status_codeintegeroptional
errorstringoptional

BotExecuteRequest

FieldTypeRequiredDescription
actionstringoptionalDefaults to `send` when omitted.
enum: send, edit, delete, react, unreact, pin, unpin
contentstringoptionalmax 2000 chars
embedsarray<Embed>optionalmax 10 items
channel_idstringoptional
message_idstringoptional
emojistringoptional
reply_tostringoptional, nullable
display_namestringoptionalmax 32 chars. Override display name for this message (e.g. bridge bots proxying another user).
avatar_urlstringoptionalOverride avatar URL for this message (e.g. bridge bots proxying another user).

Embed

Total chars across title, description, footer.text, and fields name/value must be <= 6000.

FieldTypeRequiredDescription
titlestringoptional
descriptionstringoptional
colorintegeroptionalmin 0; max 16777215
fieldsarray<EmbedField>optional
thumbnailobjectoptional
imageobjectoptional
footerobjectoptional

EmbedField

FieldTypeRequiredDescription
namestringrequired
valuestringrequired
inlinebooleanoptional

EmbedMedia

FieldTypeRequiredDescription
urlstringoptionalMust use https://

EmbedFooter

FieldTypeRequiredDescription
textstringoptional

Message

FieldTypeRequiredDescription
idstringrequired
channel_idstringrequired
user_idstringrequired
contentstringrequired
reply_tostringoptional, nullable
is_pinnedbooleanrequired
suppress_embedsbooleanrequired
mentions_everyonebooleanrequired
embedsarray<Embed>optional, nullable
attachmentsarray<object>optional, nullable
created_atstringrequired
edited_atstringoptional, nullable
typestringoptional
pinned_bystringoptional, nullable
pinned_atstringoptional, nullable
pin_categorystringoptional
pin_positionintegeroptional
pin_expires_atstringoptional, nullable
pinned_by_namestringoptional
usernamestringrequired
display_namestringrequired
nicknamestringoptional, nullable
avatar_urlstringoptional, nullable
is_botbooleanoptional
reactionsarray<ReactionSummary>required
reply_msgobjectoptional
posted_anonymousbooleanoptional
anon_color_1stringoptional
anon_color_2stringoptional
is_own_messagebooleanoptional
encryptedbooleanoptional
expires_atstringoptional, nullable
scheduledbooleanoptional
scheduled_atstringoptional, nullable

ReactionSummary

FieldTypeRequiredDescription
emojistringrequired
countintegerrequired
usersarray<string>required

ReplyPreview

FieldTypeRequiredDescription
idstringrequired
user_idstringrequired
contentstringrequired
usernamestringrequired
display_namestringrequired
nicknamestringoptional, nullable
encryptedbooleanoptional

StatusResponse

FieldTypeRequiredDescription
statusstringrequired

ErrorResponse

FieldTypeRequiredDescription
errorstringrequired

ErrorWithRetryAfter

FieldTypeRequiredDescription
errorstringoptional
retry_afterintegeroptional

WebhookEnvelope

Outbound payload sent to your configured `delivery_url`.

FieldTypeRequiredDescription
typestringrequiredEvent name.
dataobjectrequired

TestPingWebhook

FieldTypeRequiredDescription
typestringoptionalenum: test_ping
dataobjectoptional