Developers · content report inbox
Code snippets for the report inbox
No SDK to install — each snippet is a few lines with no dependencies. Create a source on your organization’s Reports → Sources & API keys tab first; it shows the source id, the API key (tlr_…) and the signing secret (whsec_…) once. Keep both secrets on your server. What the inbox does is on the overview page.
1. Signed report link (Node.js)
Set the source’s report link to Signed links only. Your server signs kind, id, url, title, author joined by newlines — empty values keep their place — and the app opens the URL. Unsigned or altered links show an error instead of the form.
// server — never put the signing secret in your app
import { createHmac } from "node:crypto";
const SOURCE_ID = process.env.TELLOO_SOURCE_ID; // shown on the Sources tab
const SECRET = process.env.TELLOO_SIGNING_SECRET; // whsec_…
export function reportUrl({ kind, id, url = "", title = "", author = "" }) {
const t = { kind, id, url, title, author };
// Telloo trims each value and caps its length before checking
for (const [k, max] of [["kind", 40], ["id", 200], ["url", 2000], ["title", 200], ["author", 200]]) {
t[k] = String(t[k] ?? "").trim().slice(0, max);
}
const sig = createHmac("sha256", SECRET)
.update([t.kind, t.id, t.url, t.title, t.author].join("\n"))
.digest("hex");
const q = new URLSearchParams({ ...t, sig });
return `https://telloo.io/report/${SOURCE_ID}?${q}`;
}
// e.g. GET /report-url?post=8842 → { url } — the app opens it in a web view2. Send a report from your server (Node.js)
For apps with their own report screen. reason is one of offensive, sexual, violence, harassment, spam, rights, illegal, other. Pass a stable, non-personal reporter id (a hash is fine) so the same person is counted once. Limit: 1,000 reports per source per hour.
// server — forward a report from your own report screen
export async function sendReport({ kind, id, url, title, author, reason, note, reporter }) {
const res = await fetch("https://telloo.io/api/v1/reports", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TELLOO_API_KEY}`, // tlr_…
"Content-Type": "application/json",
},
body: JSON.stringify({ target: { kind, id, url, title, author }, reason, note, reporter }),
});
if (res.status === 429) throw new Error("Too many reports from this source");
if (!res.ok) throw new Error((await res.json()).error);
return res.json(); // { id, status, reportCount, duplicate }
}3. Verify the resolve callback (Node.js)
Add a callback URL to the source. When someone marks a report Removed or Kept, Telloo posts the result with a Telloo-Signature: t=…,v1=… header — HMAC-SHA256 of `${t}.${body}`. Verify against the raw body, before parsing JSON. Failed deliveries can be resent from the inbox.
// server — the callback Telloo sends when you mark a report Removed or Kept
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyTelloo(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const got = String(parts.v1 ?? "");
return got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}
// Express: app.post("/telloo-callback", express.text({ type: "*/*" }), (req, res) => {
// if (!verifyTelloo(req.body, req.get("Telloo-Signature") ?? "", SECRET)) return res.sendStatus(401);
// const { event, report } = JSON.parse(req.body);
// if (event === "report.resolved" && report.status === "removed") hide(report.target);
// res.sendStatus(200);
// });4. The same on Cloudflare Workers, Deno or Bun
// Cloudflare Workers / Deno / Bun — Web Crypto, no Node APIs
const enc = new TextEncoder();
async function hmacHex(secret, message) {
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const mac = await crypto.subtle.sign("HMAC", key, enc.encode(message));
return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
export async function reportUrl(env, { kind, id, url = "", title = "", author = "" }) {
const t = { kind, id, url, title, author };
for (const [k, max] of [["kind", 40], ["id", 200], ["url", 2000], ["title", 200], ["author", 200]]) {
t[k] = String(t[k] ?? "").trim().slice(0, max);
}
const sig = await hmacHex(env.TELLOO_SIGNING_SECRET, [t.kind, t.id, t.url, t.title, t.author].join("\n"));
return `https://telloo.io/report/${env.TELLOO_SOURCE_ID}?${new URLSearchParams({ ...t, sig })}`;
}
export async function verifyTelloo(request, secret, toleranceSec = 300) {
const body = await request.text();
const parts = Object.fromEntries((request.headers.get("Telloo-Signature") ?? "").split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return null;
const expected = await hmacHex(secret, `${t}.${body}`);
if (parts.v1 !== expected) return null;
return JSON.parse(body); // { event: "report.resolved", report: { … } }
}5. In the app
The app never signs anything — it asks your server for the URL and opens it. The page is built for web views: no header, seven languages, a bot check.
// iOS (SwiftUI) — ask your server for the signed URL, then open it
let url = try await api.reportURL(kind: "post", id: post.id)
present(SFSafariViewController(url: url), animated: true)
// Android (Kotlin)
val url = api.reportUrl(kind = "post", id = post.id)
CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))