The landing page and the email shell drop the dark gradient look for a white background, black text and a single Bristol blue accent. Copy is unchanged. Email footers and docs now point at webhook.me.uk; the dixon.cx hostnames keep working.
277 lines
16 KiB
HTML
277 lines
16 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Webhook → Email | {{.Host}}</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.">
|
||
<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://{{.Host}}/">{{.Host}}</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>
|