Webhook
Webhooks allow an external server to get notified when certain events happen.
Prerequisites
- A server reachable from the Internet (through public IP or port forwarding)
- Ability to write (or whip your coding agent to write) server-side programs that work with our schema
Schema
All webhooks are sent as POST requests with a customizableJSON body.
The schema specification uses text/template package from Golang.
The following fields are exposed at root level:
{
Event: 'create' | 'update' | 'delete',
Initiator: { // user whose action triggered this webhook
ID: number,
Username: string,
},
Data: {
ImageID: number,
Text: string,
Rating: 'none' | 'moderate' | 'violet',
Tags: string[],
ImageURL: string,
CreatedAt: string, // ISO 8601
Uploader: { // image owner
ID: number,
Username: string,
},
Changes: {
Text?: {
Old: string,
New: string,
},
Tags?: {
Old: string[],
New: string[]
},
Rating?: {
Old: 'none' | 'moderate' | 'violet',
New: 'none' | 'moderate' | 'violet',
}
}
}
}Each image field also has a root alias, such as .Text or .Changes.
.Data.Uploader identifies the image owner; .Initiator identifies the user whose action triggered the event.
Use json to encode values, including quotes, newlines, arrays, and timestamps.
Do not surround {{json}} expressions with additional quotes:
{
"event": {{json .Event}},
"data": {{json .Data}},
"initiator": {{json .Initiator}}
}For update events, data.changes contains only changed metadata fields. Each entry has old and new values. Text and rating are compared directly; tags are compared as unordered sets of database tags. An update without metadata changes has an empty changes object. Creation and deletion payloads omit changes.
{
"changes": {
"text": { "old": "Original caption", "new": "Updated caption" },
"tags": { "old": ["landscape"], "new": [] }
}
}Guard optional changes when referencing individual entries:
{
"event": {{json .Event}},
"previousText": {{with .Changes}}{{with .text}}{{json .Old}}{{else}}null{{end}}{{else}}null{{end}}
}An example that complies with Discord Webhook:
{
{{if eq .Event "create"}}
"content": "{{.Initiator.Username}} created image #{{.Data.ImageID}}",
{{else if eq .Event "update"}}
"embeds": [
{
"type": "rich",
"title": "{{.Initiator.Username}} updated image #{{.Data.ImageID}}",
"url": "https://longhub.top/image/{{.Data.ImageID}}",
"thumbnail": {
"url": "{{.Data.ImageURL}}"
}
}
],
{{else if eq .Event "delete"}}
"content": "{{.Initiator.Username}} deleted image #{{.Data.ImageID}}",
{{end}}
"username": "LONG Hub Webhook",
"avatar_url": "https://longhub.top/long.jpg"
}The invocation request is as follows:
POST https://example.com/webhook/endpoint
Content-Type: application/json
X-Signature: 0123456890abcdef
{
...
}Verification
In order to prove identity and integrity, webhooks are signed with the secret registered at creation. The signature is a hexadecimal string generated by signing the request body (in raw bytes encoded with UTF-8) with the webhook secret (also encoded with UTF-8) using HMAC-256, and will be sent in the X-Signature HTTP header.
To verify the signature, use whatever cryptography library you like. For example:
import hashlib
import hmac
import os
from flask import Flask, request, jsonify
app = Flask()
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode("utf-8")
@app.post("/hooks/longhub")
def receive_webhook():
raw_body = request.get_data(cache=False)
received_signature = request.headers.get("X-Signature", "")
expected_signature = hmac.new(
WEBHOOK_SECRET,
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(received_signature, expected_signature):
return jsonify(error="invalid signature"), 401
payload = request.get_json()
print("Verified webhook:", payload)
return "", 204const express = require("express");
const crypto = require("crypto");
const app = express();
const WEBHOOK_SECRET = Buffer.from(process.env.WEBHOOK_SECRET, "utf8");
app.post("/hooks/longhub",
express.raw({ type: "*/*" }),
(req, res) => {
const rawBody = req.body;
const receivedSignature = req.get("X-Signature") || "";
const expectedSignature = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
const received = Buffer.from(receivedSignature, "utf8");
const expected = Buffer.from(expectedSignature, "utf8");
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(received, expected);
if (!valid) {
return res.status(401).json({ error: "invalid signature" });
}
const payload = JSON.parse(rawBody.toString("utf8"));
console.log("Verified webhook:", payload);
return res.sendStatus(204);
}
);
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});Restrictions
To protect our server from being affected by malicious invocations, we have applied the following restrictions to webhooks:
- A user can have up to 10 webhooks.
- Requests pointing at private IP ranges are forbidden.
- Webhooks are invoked via a remote Worker. You may see an IP from Cloudflare.
- Webhooks do not follow redirects, and redirect responses (HTTP 3xx) are considered failures.
- Webhooks that fail (HTTP 3xx, 4xx, 5xx) for 3 invocations in a row will be suspended until manual re-activation.
- Webhook invocations have a timeout of 30s.