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.
/channels/* and /guilds/* do.
| Option | Cost | Account needed | Reliability |
|---|---|---|---|
| Cloudflare Worker | Free tier | Cloudflare | Good (socket fallback) |
| Google Apps Script | Full free | Best | |
| Any fetch() host | Varies | Deno/Fly/Val/VPS | Good |
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.
cloudflare-worker.js (or copy it below) into the editor, then click Save and Deploy.*.workers.dev URL from the deploy page.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;
}
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.
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.
Code.gs with the code from apps-script.js (or copy it below), then click Save.
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);
}
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}.
Your relay is a single-endpoint proxy with strict rules:
| Incoming request | POST <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 allowlist | Only 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. |
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,...}.
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.
| Symptom | Cause |
|---|---|
403 code 40333 on sends | Writing without a relay (or relay not set). Deploy one and save its URL. |
401 on all spellings | Bad token. Bot Father → Reset Token → paste the new one. |
| Relay returns 400 | Body wasn't valid JSON or missing the url field. |
| Relay returns 500 | Google Apps Script: "Execute as" must be Me, not "User accessing". |
| Socket fallback but still 403 | Cloudflare Workers still originate on Cloudflare's network. Use the Apps Script option instead. |
| "User Install" missing | Slash commands need Developer Portal → Installation → User Install enabled. |
50035 on slash sync | App not configured for user-install. See the Developer Portal. |