Files
Andy Dixon 680bfac014 Send as webhook.me.uk, add favicon, share image and SEO tags
Mail now comes from no-reply@webhook.me.uk (DKIM, SPF and DMARC are
set up for it) and the Message-ID uses that domain instead of the
container hostname. The landing page gets a favicon, an Open Graph
image, canonical/OG/Twitter/JSON-LD tags, robots.txt that keeps
crawlers off email URLs, and a sitemap. Footer links to dixon.cx.
2026-09-22 08:50:20 +01:00

301 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Webhook → Email: forward any webhook to your inbox | {{.Canonical}}</title>
<meta name="description" content="Point any webhook at a URL with your email in it. Get the whole request in your inbox. That's it. That's the product.">
<link rel="canonical" href="https://{{.Canonical}}/">
<meta name="robots" content="index, follow">
<meta name="author" content="Andy Dixon">
<meta name="theme-color" content="#0047ab">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta property="og:type" content="website">
<meta property="og:site_name" content="webhook→email">
<meta property="og:title" content="Webhooks. Straight to your inbox.">
<meta property="og:description" content="Point any webhook at a URL with your email in it. Get the whole request in your inbox. No signup, no dashboard.">
<meta property="og:url" content="https://{{.Canonical}}/">
<meta property="og:image" content="https://{{.Canonical}}/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Webhooks. Straight to your inbox. https://{{.Canonical}}/you%40example.com">
<meta property="og:locale" content="en_GB">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Webhooks. Straight to your inbox.">
<meta name="twitter:description" content="Point any webhook at a URL with your email in it. Get the whole request in your inbox. No signup, no dashboard.">
<meta name="twitter:image" content="https://{{.Canonical}}/og.png">
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"WebApplication","name":"webhook→email","url":"https://{{.Canonical}}/","description":"Forward any webhook to your inbox as a formatted email. Parsers for GitHub, Grafana, JSON and Webex Interact.","applicationCategory":"DeveloperApplication","operatingSystem":"Any","isAccessibleForFree":true,"offers":{"@type":"Offer","price":"0","priceCurrency":"GBP"},"author":{"@type":"Person","name":"Andy Dixon","url":"https://dixon.cx/"}}
</script>
<style>
:root{
--bg:#ffffff;--panel:#f6f8fc;--line:#e3e7ef;
--text:#0a0a0a;--muted:#4a4f5c;--dim:#7a8090;
--blue:#0047ab;--blue-dark:#003380;--blue-soft:#eef3fb;
--mono:ui-monospace,SFMono-Regular,"JetBrains Mono",Menlo,Consolas,monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,"Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{margin:0;background:var(--bg);color:var(--text);font-family:var(--sans);line-height:1.6;font-size:17px;-webkit-font-smoothing:antialiased}
a{color:var(--blue)}
.wrap{max-width:900px;margin:0 auto;padding:0 20px}
::selection{background:var(--blue);color:#fff}
/* nav */
nav{display:flex;align-items:center;justify-content:space-between;padding:22px 0;font-size:14px;border-bottom:1px solid var(--line)}
nav .brand{font-family:var(--mono);font-weight:700;letter-spacing:-.02em}
nav .brand span{color:var(--blue)}
nav ul{display:flex;gap:22px;list-style:none;margin:0;padding:0}
nav a{text-decoration:none;color:var(--muted)}nav a:hover{color:var(--blue)}
@media(max-width:600px){nav ul{display:none}}
/* hero */
.hero{padding:64px 0 48px}
.eyebrow{font-family:var(--mono);font-size:13px;color:var(--blue);letter-spacing:.12em;text-transform:uppercase;margin-bottom:18px}
h1{font-size:clamp(2.4rem,6.5vw,4.6rem);line-height:1;letter-spacing:-.04em;margin:0 0 22px;font-weight:800}
h1 em{font-style:normal;color:var(--blue)}
.hero p{font-size:clamp(1.05rem,2vw,1.3rem);color:var(--muted);max-width:640px;margin:0 0 30px}
.hero p b{color:var(--text);font-weight:600}
/* url builder */
.builder{border:1px solid var(--line);border-radius:12px;padding:20px}
.builder label{display:block;font-size:12px;color:var(--dim);font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase;margin-bottom:8px}
.builder input{width:100%;font:inherit;font-size:1.1rem;padding:12px 14px;border-radius:8px;background:#fff;border:1px solid #c9cfdb;color:var(--text);outline:none}
.builder input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,71,171,.15)}
.url{margin-top:14px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.url code{flex:1;min-width:0;font-family:var(--mono);font-size:.95rem;word-break:break-all;padding:12px 14px;border-radius:8px;background:var(--panel);border:1px solid var(--line)}
.url code b{color:var(--blue);font-weight:600}
button.copy{font:inherit;font-size:.9rem;font-weight:600;padding:12px 18px;border-radius:8px;border:0;cursor:pointer;background:var(--blue);color:#fff;white-space:nowrap}
button.copy:hover{background:var(--blue-dark)}
button.copy:active{transform:scale(.97)}
.hint{font-size:.85rem;color:var(--dim);margin:12px 0 0}
/* sections */
section{padding:56px 0;border-top:1px solid var(--line)}
h2{font-size:clamp(1.6rem,4vw,2.2rem);letter-spacing:-.03em;margin:0 0 8px;line-height:1.15}
.sub{color:var(--muted);margin:0 0 28px;max-width:640px}
h3{font-size:1.1rem;margin:28px 0 10px;letter-spacing:-.01em}
.tangent{color:var(--dim);font-size:.92rem;font-style:italic}
p code,li code{font-family:var(--mono);font-size:.88em;background:var(--panel);border:1px solid var(--line);padding:2px 6px;border-radius:5px;color:var(--blue-dark)}
pre{margin:0;background:#0a0a0a;border-radius:10px;padding:16px 18px;font-family:var(--mono);font-size:.9rem;line-height:1.55;overflow-x:auto;color:#e8e8e8;position:relative}
pre::before{content:attr(data-lang);position:absolute;top:8px;right:12px;font-size:11px;color:#8a8a8a;letter-spacing:.1em;text-transform:uppercase}
pre .h{color:#8a8a8a}pre .s{color:#8fb8ff}pre .k{color:#ffffff;font-weight:600}
.stack{display:grid;gap:14px}
/* steps */
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;counter-reset:s}
.step{border:1px solid var(--line);border-radius:12px;padding:22px;counter-increment:s}
.step::before{content:"0" counter(s);font-family:var(--mono);font-size:2rem;font-weight:800;line-height:1;color:var(--blue);display:block;margin-bottom:12px}
.step b{display:block;font-size:1.05rem;margin-bottom:4px}
.step span{color:var(--muted);font-size:.95rem}
/* parsers */
.parsers{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px}
.parser{border:1px solid var(--line);border-radius:12px;padding:20px;transition:border-color .15s}
.parser:hover{border-color:var(--blue)}
.parser .ico{font-size:1.6rem;margin-bottom:8px}
.parser code{display:inline-block;font-family:var(--mono);font-size:.85rem;font-weight:700;color:var(--blue);margin-bottom:8px}
.parser p{margin:0;color:var(--muted);font-size:.95rem}
/* inbox list */
.inbox{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px;list-style:none;padding:0;margin:0}
.inbox li{padding:14px 16px;background:var(--panel);border-radius:10px;font-size:.95rem}
.inbox li b{display:block;color:var(--text)}
.inbox li span{color:var(--muted)}
/* callouts */
.note{border-left:3px solid var(--blue);padding:14px 18px;background:var(--blue-soft);border-radius:0 10px 10px 0;margin:22px 0}
.note.warn{border-color:var(--text);background:var(--panel)}
.note b{color:var(--text)}
.note p{margin:0;color:var(--muted)}
/* chips */
.chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.chip{font-family:var(--mono);font-size:.8rem;padding:5px 11px;border:1px solid var(--line);border-radius:999px;color:var(--muted)}
footer{padding:40px 0 60px;border-top:1px solid var(--line);color:var(--dim);font-size:.9rem;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}
footer a{color:var(--muted)}
</style>
</head>
<body>
<div class="wrap">
<nav>
<div class="brand"><span>webhook</span>→email</div>
<ul>
<li><a href="#start">Start</a></li>
<li><a href="#examples">Examples</a></li>
<li><a href="#parsers">Parsers</a></li>
<li><a href="#inbox">Inbox</a></li>
<li><a href="#faq">Caveats</a></li>
</ul>
</nav>
<div class="hero">
<div class="eyebrow">// no signup. no dashboard. no, seriously.</div>
<h1>Webhooks.<br>Straight to <em>your inbox.</em></h1>
<p>Put your email in the URL. Point a webhook at it. Every header, every byte of body, every superglobal shows up as an email. <b>Done. Go do the next thing.</b></p>
<div class="builder">
<label for="em">Type your email → get your URL</label>
<input id="em" type="email" placeholder="you@example.com" autocomplete="email" spellcheck="false">
<div class="url">
<code>https://{{.Host}}/<b id="out">you%40example.com</b></code>
<button class="copy" id="cp">Copy</button>
</div>
<p class="hint">Yes the <code>@</code> becomes <code>%40</code>. Yes that's the whole trick. Add <code>/github</code>, <code>/json</code>, etc. on the end for the fancy parsers — more on that below, if you scroll that far.</p>
</div>
</div>
<section id="start">
<h2>Three steps. Two, really.</h2>
<p class="sub">The third one is just "wait for an email", which you were going to do anyway.</p>
<div class="steps">
<div class="step"><b>Encode your email</b><span><code>@</code> → <code>%40</code>. The box above already did it for you. Ok moving on.</span></div>
<div class="step"><b>Paste the URL into whatever</b><span>Stripe, GitHub, Grafana, that cron job, a curl you're about to forget you wrote.</span></div>
<div class="step"><b>Check your inbox</b><span>Full request, nicely formatted. Subject starts with ‼️ so it's filterable. You're welcome.</span></div>
</div>
<h3>The fastest possible test</h3>
<pre data-lang="bash">curl -X POST https://{{.Host}}/<span class="s" id="ex1">you%40example.com</span> \
-d '{"test": "hello, is this thing on"}'</pre>
<p class="tangent">(the email will arrive before you've alt-tabbed back. probably. depends on your mail server. anyway.)</p>
</section>
<section id="examples">
<h2>Examples, because reading is hard</h2>
<p class="sub">Any method, any content type. GET, POST, PUT, PATCH, DELETE, something you made up. JSON, form data, multipart, XML, plain text, a string of emoji. It all gets forwarded.</p>
<div class="stack">
<pre data-lang="bash"><span class="h"># JSON</span>
curl -X POST https://{{.Host}}/<span class="s ex">you%40example.com</span> \
-H "Content-Type: application/json" \
-d '{"event": "user.created", "user_id": 12345}'
<span class="h"># GET with query string (yes, this works too)</span>
curl "https://{{.Host}}/<span class="s ex">you%40example.com</span>?event=test&id=123"</pre>
<pre data-lang="javascript"><span class="k">fetch</span>(<span class="s">'https://{{.Host}}/'</span> + <span class="k">encodeURIComponent</span>(<span class="s">'you@example.com'</span>), {
method: <span class="s">'POST'</span>,
headers: { <span class="s">'Content-Type'</span>: <span class="s">'application/json'</span> },
body: JSON.stringify({ event: <span class="s">'order.completed'</span>, order_id: <span class="s">'ORD-789'</span>, total: 99.99 })
});</pre>
<pre data-lang="python"><span class="k">import</span> requests
<span class="k">from</span> urllib.parse <span class="k">import</span> quote
requests.post(
<span class="s">f'https://{{.Host}}/{quote("you@example.com")}'</span>,
json={<span class="s">'event'</span>: <span class="s">'payment.received'</span>, <span class="s">'amount'</span>: 250.00},
headers={<span class="s">'X-Custom-Header'</span>: <span class="s">'MyValue'</span>},
)</pre>
</div>
<div class="note">
<p><b>Pro tip you'll ignore and then come back for:</b> use plus addressing. <code>you+stripe%40example.com</code>, <code>you+github%40example.com</code>. Now your inbox filters do the sorting so you don't have to.</p>
</div>
</section>
<section id="parsers">
<h2>Parsers. Ooh, shiny.</h2>
<p class="sub">Add one word to the end of the URL and the email stops being a wall of raw JSON and starts being something you can actually read at 3am. Parser doesn't understand the payload? It falls back to the raw format. Nothing gets lost.</p>
<div class="parsers">
<div class="parser"><div class="ico">🐙</div><code>/github</code><p>Push, pull request, issue and release events get their own layout with commits, authors, branches and links. Everything else still renders, just less fancy. Subjects like <em>📤 GitHub push - you/repo</em>.</p></div>
<div class="parser"><div class="ico">📈</div><code>/grafana</code><p>Grafana and Prometheus Alertmanager alerts. Firing, resolved, labels, annotations. Finally know <em>which</em> thing is on fire.</p></div>
<div class="parser"><div class="ico">🧩</div><code>/json</code><p>Any JSON at all, rendered as tables. Nested objects get split out into their own tables with path-based titles so you can find <code>data.items[3].sku</code> without squinting.</p></div>
<div class="parser"><div class="ico">💬</div><code>/wxinteract</code><p>Webex Interact SMS events. Inbound messages, delivery receipts, the lot, with the event type in the subject line.</p></div>
</div>
<h3>Setting up GitHub, since that's the one everyone asks about</h3>
<ol>
<li>Repo → Settings → Webhooks → Add webhook</li>
<li>Payload URL: <code>https://{{.Host}}/<span class="ex">you%40example.com</span>/github</code></li>
<li>Content type: <code>application/json</code></li>
<li>Pick events (or "Send me everything", live a little)</li>
<li>Save. Push something. Refresh inbox. Refresh again. There it is.</li>
</ol>
</section>
<section id="inbox">
<h2>What actually lands in your inbox</h2>
<p class="sub">One HTML email per request. Gmail-safe styling. All of the following, every time, whether you asked for it or not:</p>
<ul class="inbox">
<li><b>Timestamp</b><span>when it arrived, to the second</span></li>
<li><b>Source IP + method</b><span>who sent it and how</span></li>
<li><b>Content-Type</b><span>what they claimed it was</span></li>
<li><b>Every header</b><span>auth, signatures, user agents, the weird custom ones</span></li>
<li><b>Raw body</b><span>byte-for-byte, ready to paste into a test</span></li>
<li><b>Parsed superglobals</b><span>$_GET, $_POST, $_REQUEST, $_FILES</span></li>
</ul>
<h3>And the sender gets</h3>
<pre data-lang="http 200 · application/json">{
<span class="k">"ok"</span>: true,
<span class="k">"message"</span>: <span class="s">"Webhook received and forwarded to you@example.com"</span>,
<span class="k">"forwarded_to"</span>: <span class="s">"you@example.com"</span>,
<span class="k">"parser"</span>: <span class="s">"default"</span>,
<span class="k">"subject"</span>: <span class="s">"‼️ Webhook Request Received - 2026-09-11 17:15:42"</span>,
<span class="k">"received_at"</span>: <span class="s">"2026-09-11T17:15:42+00:00"</span>,
<span class="k">"request"</span>: { <span class="k">"method"</span>: <span class="s">"POST"</span>, <span class="k">"content_type"</span>: <span class="s">"application/json"</span>, <span class="k">"ip"</span>: <span class="s">"203.0.113.7"</span>, <span class="k">"headers"</span>: 9, <span class="k">"body_bytes"</span>: 40 },
<span class="k">"data"</span>: {
<span class="k">"query"</span>: { <span class="k">"src"</span>: <span class="s">"curl"</span> },
<span class="k">"form"</span>: {},
<span class="k">"json"</span>: { <span class="k">"event"</span>: <span class="s">"user.created"</span>, <span class="k">"user_id"</span>: 12345 },
<span class="k">"files"</span>: []
}
}</pre>
<p class="tangent">JSON bodies get decoded and echoed back under <code>data.json</code>, so you can eyeball what the server actually understood. Form fields land in <code>form</code>, query string in <code>query</code>. No print_r in sight.</p>
<p class="tangent">Any 200 is enough for most services to mark the delivery as "succeeded" and stop retrying. Which is the point.</p>
</section>
<section id="faq">
<h2>Caveats, small print, that sort of thing</h2>
<div class="note warn">
<p><b>Anyone with the URL can email you.</b> There's no auth. There's no secret. It's your email address in a URL. Use it for debugging and testing. Do not point production payment data at it. Don't make me put a bigger warning here.</p>
</div>
<h3>Stuff that's true</h3>
<ul>
<li><b>Nothing is stored.</b> Request comes in, email goes out, request is forgotten. No logs of your payloads, no database, no "insights".</li>
<li><b>Output is escaped.</b> Everything is run through <code>htmlspecialchars()</code> before it hits the email, so a malicious payload renders as text, not as a thing.</li>
<li><b>Bad email → HTTP 400, as JSON.</b> <code>{"ok": false, "error": "invalid_email", ...}</code> with a hint about <code>%40</code>. Validated with PHP's <code>FILTER_VALIDATE_EMAIL</code>. Boring, correct.</li>
<li><b>Failed sends get logged.</b> On the server side, for the person running it. Not for you. Sorry.</li>
</ul>
<h3>Good for</h3>
<div class="chips">
<span class="chip">debugging a webhook you didn't write</span>
<span class="chip">seeing what Stripe actually sends</span>
<span class="chip">capturing real payloads for docs</span>
<span class="chip">testing before the handler exists</span>
<span class="chip">"is it even firing?"</span>
<span class="chip">alerts in your inbox without a whole thing</span>
</div>
</section>
<footer>
<div>Built with Go and ADHD medication. Not necessarily in that order.</div>
<div>© 2025–2026 Andy Dixon · <a href="https://dixon.cx/">dixon.cx</a></div>
</footer>
</div>
<script>
(function(){
var em=document.getElementById('em'),out=document.getElementById('out'),cp=document.getElementById('cp');
var ex=document.querySelectorAll('.ex, #ex1');
function enc(v){return encodeURIComponent(v.trim()||'you@example.com');}
em.addEventListener('input',function(){
var e=enc(em.value);out.textContent=e;
ex.forEach(function(n){n.textContent=e;});
});
cp.addEventListener('click',function(){
var url=out.parentNode.textContent.replace(/\s+/g,'');
var done=function(){cp.textContent='Copied ✓';setTimeout(function(){cp.textContent='Copy';},1400);};
if(navigator.clipboard){navigator.clipboard.writeText(url).then(done);}
else{var r=document.createRange();r.selectNodeContents(out.parentNode);getSelection().removeAllRanges();getSelection().addRange(r);document.execCommand('copy');done();}
});
})();
</script>
</body>
</html>