#Overview
Yapin’ bots cannot message each other, and Deno ignores messages written by other bots. This webhook is how your bot works with Deno instead, in a server whose admin has agreed to it.
With it your bot can warn a member, add a note, post to the mod log and look up a member’s record. In the other direction Deno can send your bot a signed message whenever it records a warning, timeout, kick, ban or note.
It is deliberately small. It cannot ban, kick, change settings or warn a moderator. Everything it does is recorded in the server’s Logs, marked “via your bot’s name”.
- Base address
https://deno.win/hook/{server_id}- Format
- JSON in, JSON out
- Authentication
- A key (
dh_…) that belongs to one server - Machine-readable
- OpenAPI 3.1 file
#Quick start
- Get a key. A server admin opens the dashboard, picks the server, goes to Connect other bots and presses Create webhook. They give you the address (it contains the server ID) and the key. The key is shown once.
- Check it works. Send
ping:
curl -X POST https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90 \
-H "Authorization: Bearer dh_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"action":"ping"}'const res = await fetch('https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DENO_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'ping'
}),
});
console.log(res.status, await res.json());import os, requests
res = requests.post(
"https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90",
headers={"Authorization": "Bearer " + os.environ["DENO_KEY"]},
json={
"action": "ping"
},
timeout=10,
)
print(res.status_code, res.json())#Authentication
Send the key in the Authorization header:
Authorization: Bearer dh_YOUR_KEY
The key also works inside the address, https://deno.win/hook/{server_id}/dh_YOUR_KEY, which is handy for tools that cannot set headers. Prefer the header: addresses end up in logs and browser history.
- A key belongs to one server and only works there.
- Deno stores only a hash of the key, so it cannot show it again. If it is lost, an admin presses Make a new key, which cancels the old one at once.
- An admin can turn the webhook off at any time. Your requests then get
401.
#Conventions and limits
- Send
POSTwith a JSON object tohttps://deno.win/hook/{server_id}. The action is theactionfield. (Only the member lookup is aGET.) - IDs are UUIDs, written in the dashed form.
- Answers are JSON. Errors look like
{ "error": "…" }. - Always send
source: your bot’s name. Moderators see it next to the action.
| Limit | Value |
|---|---|
| Requests | 60 per minute for each server. Every key of that server shares the count. |
| Body size | 16 KB. |
| Wrong keys | 20 in 10 minutes from one address. After that, even the right key is refused from that address for a while. |
source | Up to 40 characters. |
reason | Up to 150 characters (warn and note). |
| Mod log post | Up to 1500 characters of text or description, and 100 for a title. |
#Actions
Every action goes to the same address. Pick it with the action field.
#ping
Checks that the address and key work. Use it once when you set the connection up.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
action | string | yes | "ping" |
Example
curl -X POST https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90 \
-H "Authorization: Bearer dh_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"action":"ping"}'const res = await fetch('https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DENO_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'ping'
}),
});
console.log(res.status, await res.json());import os, requests
res = requests.post(
"https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90",
headers={"Authorization": "Bearer " + os.environ["DENO_KEY"]},
json={
"action": "ping"
},
timeout=10,
)
print(res.status_code, res.json())Responses
server is the server’s name.{
"ok": true,
"server": "Pixel Lounge"
}#warn
Adds a warning to a member. It counts toward the server’s warning limit exactly like a moderator’s warning, so reaching the limit triggers the automatic timeout.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
action | string | yes | "warn" |
user_id | string (user ID) | yes | The member to warn. Moderators and admins are refused, and so is Deno itself. |
reason | string | no | Up to 150 characters. Defaults to “No reason given”. Moderators see it as via <source>: <reason>. |
source | string | recommended | Your bot’s name, up to 40 characters. It is shown next to the action so moderators know where it came from. |
Example
curl -X POST https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90 \
-H "Authorization: Bearer dh_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"action":"warn","user_id":"3f2a9c1e-5b7d-4e21-9a40-8c6d1e0f7b52","reason":"Posted a scam link","source":"GuardBot"}'const res = await fetch('https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DENO_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'warn',
user_id: '3f2a9c1e-5b7d-4e21-9a40-8c6d1e0f7b52',
reason: 'Posted a scam link',
source: 'GuardBot'
}),
});
console.log(res.status, await res.json());import os, requests
res = requests.post(
"https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90",
headers={"Authorization": "Bearer " + os.environ["DENO_KEY"]},
json={
"action": "warn",
"user_id": "3f2a9c1e-5b7d-4e21-9a40-8c6d1e0f7b52",
"reason": "Posted a scam link",
"source": "GuardBot"
},
timeout=10,
)
print(res.status_code, res.json())Responses
case_id is the case number, warnings is the member’s active warning count, and escalated is true when this warning reached the server’s limit. The automatic one-hour timeout is then applied a moment later, in the background.{
"ok": true,
"case_id": 42,
"warnings": 2,
"escalated": false
}user_id is not a valid ID.{
"error": "Moderators and admins cannot be warned through the webhook."
}#note
Adds a note to a member’s record. A note is not a warning: it never counts toward the limit and nothing happens to the member.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
action | string | yes | "note" |
user_id | string (user ID) | yes | The member the note is about. |
reason | string | no | The note itself, up to 150 characters. |
source | string | recommended | Your bot’s name, up to 40 characters. |
Example
curl -X POST https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90 \
-H "Authorization: Bearer dh_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"action":"note","user_id":"3f2a9c1e-5b7d-4e21-9a40-8c6d1e0f7b52","reason":"Verified by GuardBot","source":"GuardBot"}'const res = await fetch('https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DENO_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'note',
user_id: '3f2a9c1e-5b7d-4e21-9a40-8c6d1e0f7b52',
reason: 'Verified by GuardBot',
source: 'GuardBot'
}),
});
console.log(res.status, await res.json());import os, requests
res = requests.post(
"https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90",
headers={"Authorization": "Bearer " + os.environ["DENO_KEY"]},
json={
"action": "note",
"user_id": "3f2a9c1e-5b7d-4e21-9a40-8c6d1e0f7b52",
"reason": "Verified by GuardBot",
"source": "GuardBot"
},
timeout=10,
)
print(res.status_code, res.json())Responses
case_id is the case number of the note.{
"ok": true,
"case_id": 43
}#modlog
Posts a card to the server’s mod log channel, so your bot’s own actions show up where the moderators already look.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
action | string | yes | "modlog" |
content | string | one of | Plain text, up to 1500 characters. Use this or embed. |
embed | object | one of | title (up to 100 characters), description (up to 1500) and color (like #ff7a12). At least a title or a description is needed. |
source | string | recommended | Shown in the card’s footer as via <source>. |
Example
curl -X POST https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90 \
-H "Authorization: Bearer dh_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"action":"modlog","embed":{"title":"Raid blocked","description":"Blocked 12 joins in 10 seconds.","color":"#ff7a12"},"source":"GuardBot"}'const res = await fetch('https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DENO_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'modlog',
embed: {
title: 'Raid blocked',
description: 'Blocked 12 joins in 10 seconds.',
color: '#ff7a12'
},
source: 'GuardBot'
}),
});
console.log(res.status, await res.json());import os, requests
res = requests.post(
"https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90",
headers={"Authorization": "Bearer " + os.environ["DENO_KEY"]},
json={
"action": "modlog",
"embed": {
"title": "Raid blocked",
"description": "Blocked 12 joins in 10 seconds.",
"color": "#ff7a12"
},
"source": "GuardBot"
},
timeout=10,
)
print(res.status_code, res.json())Responses
{
"ok": true,
"queued": true
}{
"error": "This server has no mod log channel yet."
}content nor an embed title or description was sent.{
"error": "Send content, or an embed with a title or description."
}#Look up a member
Read-only. Returns how many active warnings a member has and their five most recent cases, so your bot can decide what to do (for example, only act on repeat offenders).
GET https://deno.win/hook/{server_id}/user/{user_id}
Example
curl https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90/user/3f2a9c1e-5b7d-4e21-9a40-8c6d1e0f7b52 \
-H "Authorization: Bearer dh_YOUR_KEY"const res = await fetch('https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90/user/3f2a9c1e-5b7d-4e21-9a40-8c6d1e0f7b52', {
headers: { Authorization: 'Bearer ' + process.env.DENO_KEY },
});
console.log(res.status, await res.json());import os, requests
res = requests.get(
"https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90/user/3f2a9c1e-5b7d-4e21-9a40-8c6d1e0f7b52",
headers={"Authorization": "Bearer " + os.environ["DENO_KEY"]},
timeout=10,
)
print(res.status_code, res.json())Responses
warnings counts active (not removed) warnings, cases is every case on record for the member, and recent is the newest five, newest first. type is one of warn, timeout, untimeout, kick, ban, tempban, unban or note.{
"user_id": "3f2a9c1e-5b7d-4e21-9a40-8c6d1e0f7b52",
"warnings": 2,
"cases": 5,
"recent": [
{
"id": 41,
"type": "warn",
"reason": "via GuardBot: Posted a scam link",
"created_at": "2026-10-23T11:20:00.000Z",
"removed": false
}
]
}#Errors
Failed requests use a normal HTTP status and a JSON body with an error message.
action, or a field is missing or wrong. The error text says which.{
"error": "Unknown action. Use ping, warn, note or modlog."
}{
"error": "Unknown webhook or wrong token."
}{
"error": "Not found. See https://deno.win/developers/"
}POST (or GET for the lookup).{
"error": "Use POST, or GET for the user lookup."
}modlog only).{
"error": "This server has no mod log channel yet."
}{
"error": "The body is too large (16 KB at most)."
}retry_after (seconds) is included when waiting helps.{
"error": "Slow down: 60 requests a minute per server.",
"retry_after": 10
}{
"error": "Something went wrong on our side. Try again."
}When to retry
- 429: wait
retry_afterseconds (or a few seconds if it is missing), then try again. - 502 and network errors: try again after a short, growing pause. A
warnis not safe to repeat blindly, so look up the member first if you are unsure it went through. - 400, 401, 404, 405, 409, 413: retrying will not help. Fix the request or ask the admin.
#Events from Deno
An admin can give Deno an https address, under Connect other bots → Send Deno’s events to another bot. Deno then sends a POST there each time it records a warning, timeout, untimeout, kick, ban, tempban, unban or note. The dashboard shows the signing secret (starts with ds_) once when the address is first saved, and has a Send a test button.
The request
| Header | Value |
|---|---|
| Content-Type | application/json |
| User-Agent | Deno-Webhooks/1 |
| X-Deno-Event | case or test |
| X-Deno-Server | The server ID |
| X-Deno-Signature | sha256=<hex>, an HMAC-SHA256 of the raw body keyed with the signing secret |
The body
{
"id": "6f1c0a52-3b4d-4e8a-9c71-2d5e8f0a1b34",
"event": "case",
"server_id": "0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90",
"created_at": "2026-10-23T11:21:12.000Z",
"data": {
"id": 42,
"type": "ban",
"user_id": "3f2a9c1e-5b7d-4e21-9a40-8c6d1e0f7b52",
"moderator_id": "8d1c4e7a-2b5f-4a93-b6e0-1f7a9c3d5e28",
"reason": "Raid",
"minutes": null,
"created_at": "2026-10-23T11:21:12.000Z"
}
}
| Field | Type | Notes |
|---|---|---|
id | string | Unique for every delivery. |
event | string | case when a moderation action is recorded, test for the dashboard’s “Send a test” button. |
server_id | string | The server it happened in. |
created_at | string (ISO time) | When Deno sent it. |
data.id | number | The case number. Unique inside that server, so (server_id, data.id) is a good key for removing duplicates. |
data.type | string | One of warn, timeout, untimeout, kick, ban, tempban, unban, note. |
data.user_id | string | The member it was done to. |
data.moderator_id | string | Who did it. For automod actions this is Deno’s own bot ID. |
data.reason | string | The reason that was given. Can be empty. |
data.minutes | number or null | The length, for timeouts and temporary bans. |
data.created_at | string (ISO time) | When the case was recorded. |
The test button sends the same shape with "event": "test":
{
"id": "9a2b7c4d-1e3f-4a5b-8c6d-7e8f9a0b1c2d",
"event": "test",
"server_id": "0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90",
"created_at": "2026-10-23T11:25:00.000Z",
"data": {
"hello": "from Deno",
"note": "This is a test event. Nothing happened in your server."
}
}
Delivery rules
- Answer with a 2xx within 3 seconds and do the work afterwards. Deno waits 3 seconds.
- Deno does not retry and does not follow redirects. If you were offline you miss that event; the case is still in the server’s Logs.
- The address must be
httpson a public domain. IP addresses,localhost, private names and ports other than 443 are refused. - Cases created through the webhook are not sent back out, so bots cannot trigger each other in a loop.
- One address per server. Each delivery has its own
id; use (server_id,data.id) to remove duplicates.
#Verifying signatures
Always check the signature before trusting an event: anyone can post to your address. The signature is made over the exact bytes of the body, so verify with the raw body before parsing JSON, and compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody must be the exact bytes Deno sent, before any JSON parsing
export function verify(rawBody, header, secret) {
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(header ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "")A complete receiver
import express from 'express';
import { verify } from './verify.js';
const app = express();
const seen = new Set();
// keep the body raw: the signature is made over the exact bytes
app.post('/deno-events', express.raw({ type: '*/*', limit: '64kb' }), (req, res) => {
if (!verify(req.body, req.get('X-Deno-Signature'), process.env.DENO_SIGNING_SECRET)) {
return res.sendStatus(401);
}
res.sendStatus(200); // answer first, within 3 seconds, then do the work
const event = JSON.parse(req.body.toString('utf8'));
if (event.event !== 'case') return;
const key = event.server_id + ':' + event.data.id;
if (seen.has(key)) return; // already handled
seen.add(key);
console.log(event.data.type, 'for', event.data.user_id, 'in', event.server_id);
});
app.listen(3000);import os, json
from flask import Flask, request, abort
from verify import verify
app = Flask(__name__)
seen = set()
@app.post("/deno-events")
def deno_events():
raw = request.get_data() # the exact bytes, before parsing
if not verify(raw, request.headers.get("X-Deno-Signature"), os.environ["DENO_SIGNING_SECRET"]):
abort(401)
event = json.loads(raw)
if event["event"] == "case":
key = (event["server_id"], event["data"]["id"])
if key not in seen:
seen.add(key)
print(event["data"]["type"], "for", event["data"]["user_id"])
return "", 200 # Flask answers right after this returns#Examples
Warn a spammer, and back off when told to
// Warn a spammer, and wait politely if Deno says slow down
async function report(serverId, userId, reason) {
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch('https://deno.win/hook/' + serverId, {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.DENO_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'warn', user_id: userId, reason, source: 'MyBot' }),
});
if (res.status !== 429) return { status: res.status, body: await res.json() };
const { retry_after = 5 } = await res.json();
await new Promise((r) => setTimeout(r, retry_after * 1000));
}
throw new Error('Deno kept asking us to slow down');
}
Only act on people who already have a record
// Only warn people who already have a record
const res = await fetch('https://deno.win/hook/' + serverId + '/user/' + userId, {
headers: { Authorization: 'Bearer ' + process.env.DENO_KEY },
});
const record = await res.json();
if (record.warnings >= 2) {
// they have been warned before: act, or send a modlog card for the moderators
}
#Security
- Keep keys out of code and logs. Use environment variables, and the header rather than the address form.
- Only ask for what you need. A reason and a note are visible to moderators and anyone who can read the mod log, so leave out private details.
- Verify every event with the signing secret, using the raw body and a constant-time comparison.
- Rotate on suspicion. If a key may have leaked, the admin presses “Make a new key”. The old one stops working immediately.
- Deno limits what a key can do (no bans, no settings, no staff), caps requests at 60 a minute per server, and blocks an address after 20 wrong keys. That limits the damage of a leak, but protecting the key is still your job.
#Troubleshooting
- I get 401 even though the key looks right.
- Check three things: the server ID in the address belongs to the same server the key was made in, the key has not been replaced (“Make a new key” cancels the old one at once), and you send it as
Authorization: Bearer dh_…with no extra spaces. Deno gives the same answer for a wrong server and a wrong key. - Why can’t I ban, kick or change settings?
- On purpose. A leaked key should never be able to remove people or reconfigure a server, so the webhook only warns, notes, posts to the mod log and reads. Anything stronger has to be done by a moderator.
- Why is a moderator refused on
warn? - Staff cannot be warned through the webhook, so a bug or a stolen key cannot be used to punish the people who run the server.
- What does
escalated: truemean? - The warning reached the server’s warning limit (the admin sets it, 3 by default). Deno applies a one-hour timeout a moment later, in the background, and records it as its own case.
modloganswers 202. Did it work?- 202 means the request was valid and the post is queued. If Yapin’ refuses the post later (for example the mod log channel was deleted), you are not told. Use
pingand a test post when you set it up. - How do I get a key and the server ID?
- A server admin opens the dashboard, picks the server, goes to Connect other bots and presses Create webhook. The address already contains the server ID, and the key is shown once. Ask the admin; Deno cannot hand out keys.
- Where do I find a member’s user ID?
- Yapin’ gives your bot the
author_idof every message and theuser_idof joins and reactions. Mentions in message text look like<@user-id>. - My receiver gets events but the signature never matches.
- The signature is made over the exact bytes Deno sent. Verify before parsing, using the raw body, and use the signing secret (starts with
ds_), not the webhook key (dh_). - Will Deno send my own
warncalls back to me? - No. Cases created through the webhook are not sent to the server’s event address, so two bots can never trigger each other in a loop.
#Changelog
| Date | Change |
|---|---|
| 2026-10-23 | First release: ping, warn, note, modlog, the member lookup, and signed case events. |