Discord • Self-hosted relay

Deploy a send relay

Discord's edge returns 403 {"message":"internal network error","code":40333} for write routes (/channels/*, /guilds/*) when the request carries the canonical Authorization: Bot <token> header and arrives through Perchance's CORS proxy. No client-side header spelling can satisfy both the edge and Discord's auth parser at the same time, so outbound sends need a relay you host. This page walks you through it.

Only needed for Discord sends. Telegram bots, Discord reads, identity calls, slash-command registration, and the gateway socket all work without a relay. Reads never hit the edge block — only writes to /channels/* and /guilds/* do.

Quick deploy options

OptionCostAccount neededReliability
Cloudflare WorkerFree tierCloudflareGood (socket fallback)
Google Apps ScriptFull freeGoogleBest
Any fetch() hostVariesDeno/Fly/Val/VPSGood

Option 1 — Cloudflare Worker

The deployed-and-working path. It tries a plain fetch() first; if Discord's edge refuses that with 403, it retries the same request over a raw TLS socket (cloudflare:sockets) which carries no proxy fingerprint at all.

1
Go to the Cloudflare dashboardWorkers & PagesCreateWorker → delete the template.
2
Paste the code from cloudflare-worker.js (or copy it below) into the editor, then click Save and Deploy.
3
Copy the *.workers.dev URL from the deploy page.
4
In Caligo: open your Discord bot → Gateway tabSend path → paste the URL into Send relay (optional) → click Save relay.
import { connect } from "cloudflare:sockets";

const CORS = {
  "access-control-allow-origin": "*",
  "access-control-allow-headers": "content-type",
  "access-control-allow-methods": "POST, OPTIONS",
};

const reply = (o, s) => new Response(JSON.stringify(o), {
  status: s || 200,
  headers: { "content-type": "application/json", "access-control-allow-origin": "*" },
});

export default {
  async fetch(req) {
    if (req.method === "OPTIONS") return new Response(null, { headers: CORS });
    let p;
    try { p = await req.json(); } catch (e) { return reply({ error: "bad json" }, 400); }
    const url = p.url, method = p.method || "GET", headers = p.headers || {};
    if (!String(url).startsWith("https://discord.com/api/")) return reply({ error: "url not allowed" }, 400);
    const bodyText = p.body == null ? "" : (typeof p.body === "string" ? p.body : JSON.stringify(p.body));
    try {
      const r = await fetch(url, { method: method, headers: headers, body: bodyText || undefined });
      if (r.status !== 403) return reply({ status: r.status, body: await r.text(), via: "fetch" });
    } catch (e) {}
    return reply(await viaSocket(url, method, headers, bodyText));
  },
};

async function viaSocket(url, method, headers, bodyText) {
  const u = new URL(url);
  const lines = [method + " " + u.pathname + u.search + " HTTP/1.1", "Host: " + u.host, "Connection: close"];
  for (const k of Object.keys(headers)) {
    if (k.toLowerCase() !== "host") lines.push(k + ": " + headers[k]);
  }
  if (bodyText) lines.push("Content-Length: " + new TextEncoder().encode(bodyText).length);
  const socket = connect({ hostname: u.hostname, port: 443 }, { secureTransport: "on", allowHalfOpen: false });
  const w = socket.writable.getWriter();
  await w.write(new TextEncoder().encode(lines.join("\r\n") + "\r\n\r\n" + bodyText));
  w.releaseLock();
  const raw = await new Response(socket.readable).text();
  const cut = raw.indexOf("\r\n\r\n");
  const head = raw.slice(0, cut);
  let body = raw.slice(cut + 4);
  if (head.toLowerCase().indexOf("transfer-encoding: chunked") >= 0) body = dechunk(body);
  return { status: parseInt(head.split(" ")[1], 10), body: body, via: "socket" };
}

function dechunk(body) {
  let out = "";
  for (;;) {
    const nl = body.indexOf("\r\n");
    if (nl < 0) break;
    const n = parseInt(body.slice(0, nl), 16);
    if (!n) break;
    out += body.slice(nl + 2, nl + 2 + n);
    body = body.slice(nl + 2 + n + 2);
  }
  return out;
}

Download source file

The via field tells you which path worked. The app displays this under the relay field. "fetch" means Discord accepted a normal Worker fetch(). "socket" means the edge refused fetch and the raw TLS socket fallback did the work.

Option 2 — Google Apps Script

The most reliable free option. It runs on Google's own infrastructure, so the request carries no trace of Perchance's proxy chain and clears the edge block.

1
Open script.google.comNew project.
2
Replace the default Code.gs with the code from apps-script.js (or copy it below), then click Save.
3
Deploy → New deployment → Web app. Set Execute as: Me, Who has access: Anyone, then click Deploy and authorize.
4
Copy the Web app URL, paste it into Caligo's Send relay field on the Discord Gateway tab, and click Save relay.
function doPost(e) {
  var p = JSON.parse(e.postData.contents);
  if (!String(p.url).startsWith("https://discord.com/api/")) return out({ error: "url not allowed" });
  var opts = { method: p.method || "GET", headers: p.headers || {}, muteHttpExceptions: true };
  if (p.body != null) opts.payload = typeof p.body === "string" ? p.body : JSON.stringify(p.body);
  var r = UrlFetchApp.fetch(p.url, opts);
  return out({ status: r.getResponseCode(), body: r.getContentText(), via: "appsscript" });
}

function out(o) {
  return ContentService.createTextOutput(JSON.stringify(o)).setMimeType(ContentService.MimeType.JSON);
}

Download source file

Option 3 — Any host with fetch()

Val Town, Deno Deploy, Fly, or a VPS all work in the same shape: accept a POST, forward one call to Discord, return {status, body}.

The relay contract

Your relay is a single-endpoint proxy with strict rules:

Incoming requestPOST <relay-url> with a JSON body
Body fields{url, method, headers, body} — exactly what the browser was already sending Discord
Response{status, body} (optionally via: "fetch"|"socket"|"appsscript" — the app displays it)
URL allowlistOnly https://discord.com/api/ URLs are forwarded — everything else is refused. This prevents the relay from being abused as a general open proxy.
Secrets stored?No. The relay sees the same request your browser was already sending and stores nothing.

Quick test

Verify your relay works with curl:

curl -X POST <your-relay-url> \
  -H 'content-type: application/json' \
  -d '{"url":"https://discord.com/api/v10/gateway","method":"GET"}'

A healthy relay responds {"status":200,...}.

After deploying, run Test send path in the app. The Gateway tab → Send path card runs dcCalibrate (probes Discord's auth layer) and then does a real send to your test channel. The result shows whether it succeeded, which header spelling it used, and whether the relay responded fetch or socket.

Troubleshooting

SymptomCause
403 code 40333 on sendsWriting without a relay (or relay not set). Deploy one and save its URL.
401 on all spellingsBad token. Bot Father → Reset Token → paste the new one.
Relay returns 400Body wasn't valid JSON or missing the url field.
Relay returns 500Google Apps Script: "Execute as" must be Me, not "User accessing".
Socket fallback but still 403Cloudflare Workers still originate on Cloudflare's network. Use the Apps Script option instead.
"User Install" missingSlash commands need Developer Portal → Installation → User Install enabled.
50035 on slash syncApp not configured for user-install. See the Developer Portal.