Deno 100% free

Webhook API for bot developers

Let your Yapin’ bot work with Deno: warn members, add notes, post to the mod log, read records, and receive signed moderation events.

#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

  1. 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.
  2. Check it works. Send ping:
curl
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"}'
Node.js
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());
Python
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())
  1. Use it. You should get { "ok": true, "server": "…" }. Now try warn or modlog from your bot.

#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 POST with a JSON object to https://deno.win/hook/{server_id}. The action is the action field. (Only the member lookup is a GET.)
  • 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.
LimitValue
Requests60 per minute for each server. Every key of that server shares the count.
Body size16 KB.
Wrong keys20 in 10 minutes from one address. After that, even the right key is refused from that address for a while.
sourceUp to 40 characters.
reasonUp to 150 characters (warn and note).
Mod log postUp 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

FieldTypeRequiredNotes
actionstringyes"ping"

Example

curl
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"}'
Node.js
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());
Python
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

200The key works. 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

FieldTypeRequiredNotes
actionstringyes"warn"
user_idstring (user ID)yesThe member to warn. Moderators and admins are refused, and so is Deno itself.
reasonstringnoUp to 150 characters. Defaults to “No reason given”. Moderators see it as via <source>: <reason>.
sourcestringrecommendedYour bot’s name, up to 40 characters. It is shown next to the action so moderators know where it came from.

Example

curl
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"}'
Node.js
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());
Python
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

200case_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
}
400The member is staff, or 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

FieldTypeRequiredNotes
actionstringyes"note"
user_idstring (user ID)yesThe member the note is about.
reasonstringnoThe note itself, up to 150 characters.
sourcestringrecommendedYour bot’s name, up to 40 characters.

Example

curl
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"}'
Node.js
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());
Python
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

200case_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

FieldTypeRequiredNotes
actionstringyes"modlog"
contentstringone ofPlain text, up to 1500 characters. Use this or embed.
embedobjectone oftitle (up to 100 characters), description (up to 1500) and color (like #ff7a12). At least a title or a description is needed.
sourcestringrecommendedShown in the card’s footer as via <source>.

Example

curl
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"}'
Node.js
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());
Python
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

202Accepted. Deno has checked the request and queued the post, so you get the answer fast. A failure while posting is not reported back.
{
  "ok": true,
  "queued": true
}
409The server has not chosen a mod log channel yet.
{
  "error": "This server has no mod log channel yet."
}
400Neither 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
curl https://deno.win/hook/0b8e5c1a-6d2f-4c37-9a15-7e4d3b2a1f90/user/3f2a9c1e-5b7d-4e21-9a40-8c6d1e0f7b52 \
  -H "Authorization: Bearer dh_YOUR_KEY"
Node.js
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());
Python
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

200warnings 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.

400Bad request. Not JSON, not an object, an unknown action, or a field is missing or wrong. The error text says which.
{
  "error": "Unknown action. Use ping, warn, note or modlog."
}
401Unknown server or wrong key. Deno never says which, on purpose.
{
  "error": "Unknown webhook or wrong token."
}
404No such path.
{
  "error": "Not found. See https://deno.win/developers/"
}
405A method other than POST (or GET for the lookup).
{
  "error": "Use POST, or GET for the user lookup."
}
409The server has no mod log channel (modlog only).
{
  "error": "This server has no mod log channel yet."
}
413The body is over 16 KB.
{
  "error": "The body is too large (16 KB at most)."
}
429Too many requests, or too many wrong keys. A retry_after (seconds) is included when waiting helps.
{
  "error": "Slow down: 60 requests a minute per server.",
  "retry_after": 10
}
502Something failed on Deno’s side. Try again in a moment.
{
  "error": "Something went wrong on our side. Try again."
}

When to retry

  • 429: wait retry_after seconds (or a few seconds if it is missing), then try again.
  • 502 and network errors: try again after a short, growing pause. A warn is 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

HeaderValue
Content-Typeapplication/json
User-AgentDeno-Webhooks/1
X-Deno-Eventcase or test
X-Deno-ServerThe server ID
X-Deno-Signaturesha256=<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"
  }
}
FieldTypeNotes
idstringUnique for every delivery.
eventstringcase when a moderation action is recorded, test for the dashboard’s “Send a test” button.
server_idstringThe server it happened in.
created_atstring (ISO time)When Deno sent it.
data.idnumberThe case number. Unique inside that server, so (server_id, data.id) is a good key for removing duplicates.
data.typestringOne of warn, timeout, untimeout, kick, ban, tempban, unban, note.
data.user_idstringThe member it was done to.
data.moderator_idstringWho did it. For automod actions this is Deno’s own bot ID.
data.reasonstringThe reason that was given. Can be empty.
data.minutesnumber or nullThe length, for timeouts and temporary bans.
data.created_atstring (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 https on 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.

Node.js
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);
}
Python
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

Node.js
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);
Python
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: true mean?
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.
modlog answers 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 ping and 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_id of every message and the user_id of 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 warn calls 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

DateChange
2026-10-23First release: ping, warn, note, modlog, the member lookup, and signed case events.