diff --git a/PARSER_PROMPT.md b/PARSER_PROMPT.md
new file mode 100644
index 0000000..d34e281
--- /dev/null
+++ b/PARSER_PROMPT.md
@@ -0,0 +1,553 @@
+# Webhook Parser Development Guide
+
+This guide explains how to create custom webhook parsers for the Webhook Email Forwarder system.
+
+## ๐ Overview
+
+Parsers allow you to format webhook data from specific services (like GitHub, Stripe, GitLab, etc.) into beautifully formatted HTML emails, rather than receiving raw webhook data.
+
+## ๐ Quick Start
+
+### URL Format
+
+Parsers are triggered by adding the parser name to the URL:
+
+```
+https://your-domain.com/{email}/{parser-name}
+```
+
+**Examples:**
+- `https://webhooks.dixon.cx/test%40example.com/github`
+- `https://webhooks.dixon.cx/user%40company.com/grafana`
+- `https://webhooks.dixon.cx/dev%40startup.io/json`
+
+### How It Works
+
+1. User configures webhook URL with their email and parser name
+2. Service sends webhook to your URL
+3. System looks for `parsers/{parser-name}.php` and a `{parser-name}Parse()` function inside it
+4. Parser function processes the webhook data
+5. Formatted email is sent to the user
+6. The caller gets a JSON acknowledgement that includes the parser name and the email subject
+
+If no parser is specified, the file doesn't exist, or the parser returns `false`, the system falls back to the default raw webhook format.
+
+---
+
+## ๐ ๏ธ Creating a Parser
+
+### File and Function
+
+Create `parsers/{servicename}.php`. The file is loaded on demand with `require_once`, and `parsers/helpers.php` is already loaded before it, so `emailShell()`, `esc()`, and `renderDataAsTable()` are available.
+
+Every parser must follow this exact signature:
+
+```php
+ '2026-01-22 15:30:45', // When webhook was received (Y-m-d H:i:s)
+ 'ip' => '203.0.113.7', // Client IP (the address nginx forwards, not the proxy)
+ 'method' => 'POST', // HTTP method
+ 'contentType' => 'application/json' // Content-Type header
+]
+```
+
+### Return Value
+
+**Success:** Return an array with two keys:
+
+```php
+return [
+ 'html' => $htmlEmailBody, // Complete HTML email (string)
+ 'subject' => $emailSubject // Email subject line (string)
+];
+```
+
+**Failure:** Return `false` to fall back to default formatting:
+
+```php
+if (!$payload || !isset($payload['required_field'])) {
+ return false; // Will use default webhook email format
+}
+```
+
+---
+
+## ๐จ Building the Email
+
+You do not write a full HTML document. Build the **body** out of the shared building blocks, then hand it to `emailShell()`, which adds the stylesheet, the gradient header, and the footer. Every parser looks the same this way and design changes happen in one place.
+
+```php
+$html = emailShell($title, $timestamp, $body, $footerSentence);
+```
+
+| Argument | What it is |
+|----------|------------|
+| `$title` | Header text, already HTML-safe. Start with an emoji. |
+| `$timestamp` | Usually `$metadata['date']`, escaped. Shown as a pill under the title. |
+| `$body` | Your sections (see below). |
+| `$footerSentence` | Optional. One line, e.g. "This Stripe webhook was automatically forwarded to your email address." |
+
+### Building Blocks
+
+**Section** with a title:
+
+```html
+
+
๐ณ Payment Details
+ ...
+
+```
+
+**Metadata table.** Always a real ``; that is what keeps the value column full-width in every mail client:
+
+```html
+
+```
+
+**Other classes you can use:**
+
+| Class | Use for |
+|-------|---------|
+| `.subsection-title` | A bold sub-heading inside a section |
+| `.data-box` | A `` for raw JSON, headers, or code (dark block) |
+| `.message-box` | Quoted free text such as an SMS body |
+| `.error-box` | Error details |
+| `.array-item` / `.array-item-title` | A card for one item of a list, e.g. one alert or one commit |
+| `.path-title` | A breadcrumb pill above a nested table, e.g. `order->items->[0]` |
+| `.commit-item` with `.sha`, `.who`, `.msg` | The GitHub commit card layout, reusable for any "id + author + message" list |
+
+**Dump an arbitrary array as tables** with the helper, which handles nesting for you:
+
+```php
+$body .= renderDataAsTable($payload['details']);
+```
+
+### Escaping
+
+Two helpers, use the right one:
+
+- `htmlspecialchars($s, ENT_QUOTES, 'UTF-8')` for anything that goes into an attribute, a subject line, or a single-line value.
+- `esc($s)` for free text that may contain line breaks (commit messages, descriptions, SMS bodies). It escapes **and** converts newlines to `
`.
+
+---
+
+## ๐ Example: Simple Parser
+
+A minimal `parsers/simplepay.php` for a fictional "SimplePay" service:
+
+```php
+
+ ๐ณ Payment Details
+
+
+HTML;
+
+ if ($noteSafe !== '') {
+ $body .= <<
+ ๐ Note
+ $noteSafe
+
+HTML;
+ }
+
+ $html = emailShell("๐ณ SimplePay: $eventSafe", $dateSafe, $body,
+ 'This SimplePay webhook was automatically forwarded to your email address.');
+
+ $subject = "๐ณ SimplePay $eventType - $amount $currency";
+
+ return [
+ 'html' => $html,
+ 'subject' => $subject
+ ];
+}
+```
+
+---
+
+## ๐จ Style Guidelines
+
+The shell owns colours, fonts, and spacing. Parsers only choose structure and words.
+
+1. **Sections**: one `.section` per logical group, each with a `.section-title`
+2. **Tables**: `table.metadata` for label/value pairs, never bare divs
+3. **Lists**: one `.array-item` card per item (alerts, commits, line items)
+4. **Raw data**: `.data-box` for anything the reader might want to copy
+5. **Free text**: `.message-box`, escaped with `esc()`
+6. **Links**: plain ``; the shell styles them
+
+### Emojis
+
+Use relevant emojis to make emails scannable:
+- ๐ณ Payment events
+- ๐ Deployments/releases
+- ๐ค Push/upload events
+- ๐ Merge/pull request events
+- โ ๏ธ Errors/warnings
+- โ
Success events
+- ๐ Notifications
+
+Put the same emoji at the start of the subject line so inbox filters can key on it.
+
+---
+
+## ๐ Security Best Practices
+
+### 1. Always Sanitise HTML Output
+
+**CRITICAL:** Escape ALL dynamic content:
+
+```php
+// โ DANGEROUS - XSS vulnerability
+$html = "{$payload['user_input']}
";
+
+// โ
SAFE (single line)
+$safe = htmlspecialchars($payload['user_input'], ENT_QUOTES, 'UTF-8');
+
+// โ
SAFE (multi-line free text)
+$safe = esc($payload['user_message']);
+```
+
+### 2. Validate Input
+
+Check that required data exists before using it:
+
+```php
+if (!is_array($payload) || !isset($payload['event_type'])) {
+ return false; // Fail safely
+}
+```
+
+### 3. Handle Errors Gracefully
+
+Return `false` if parsing fails. The system falls back to the default format and the sender still gets a `200`.
+
+### 4. Verify Signatures (Optional)
+
+If the service provides webhook signatures, validate them:
+
+```php
+$headers = array_change_key_case($headers, CASE_LOWER);
+$signature = $headers['x-service-signature'] ?? null;
+if ($signature) {
+ $expectedSignature = hash_hmac('sha256', $rawBody, $secret);
+ if (!hash_equals($expectedSignature, $signature)) {
+ return false; // Invalid signature
+ }
+}
+```
+
+---
+
+## ๐ฆ Parser Examples by Service Type
+
+### JSON API Webhooks (Most Common)
+
+```php
+$payload = json_decode($rawBody, true);
+if (!is_array($payload)) return false;
+```
+
+### XML Webhooks
+
+```php
+$xml = simplexml_load_string($rawBody);
+if (!$xml) return false;
+$event = (string)$xml->event;
+```
+
+### Form Data Webhooks
+
+```php
+parse_str($rawBody, $formData);
+if (empty($formData)) return false;
+$event = $formData['event'] ?? 'Unknown';
+```
+
+---
+
+## ๐งช Testing Your Parser
+
+### 1. Lint it
+
+```bash
+php -l parsers/yourservice.php
+```
+
+### 2. Run locally without sending mail
+
+PHP's built-in server plus a `sendmail_path` override captures every email as a file instead of sending it:
+
+```bash
+mkdir -p /tmp/mail
+php -S 127.0.0.1:8099 -d sendmail_path="sh -c 'cat > /tmp/mail/\$\$.eml'" index.php
+```
+
+Then post to it:
+
+```bash
+curl -X POST http://127.0.0.1:8099/test%40example.com/yourservice \
+ -H "Content-Type: application/json" \
+ -H "X-Service-Event: payment.completed" \
+ -d '{"amount": 99.99, "currency": "USD"}'
+```
+
+The JSON response tells you which parser ran and what subject was used. Open the `.eml` file in `/tmp/mail` (strip the headers above the first blank line) in a browser to check the layout.
+
+### 3. Test the Failure Case
+
+Send invalid data to ensure your parser returns `false` correctly:
+
+```bash
+curl -X POST http://127.0.0.1:8099/test%40example.com/yourservice \
+ -H "Content-Type: application/json" \
+ -d 'invalid json{'
+```
+
+You should get the default webhook format email.
+
+### 4. Test a lowercase header
+
+Real deliveries over HTTP/2 arrive with lowercase header names. Send one that way and make sure your parser still identifies the event:
+
+```bash
+curl -X POST http://127.0.0.1:8099/test%40example.com/yourservice \
+ -H "x-service-event: payment.completed" -d '{}'
+```
+
+### 5. Check the Email
+
+- โ
Subject line is descriptive and includes an emoji
+- โ
Value cells fill the table width
+- โ
Multi-line text keeps its line breaks
+- โ
No raw HTML from the payload is rendered
+- โ
Links (if any) are clickable
+
+---
+
+## ๐ Parser Checklist
+
+Before submitting a parser, verify:
+
+- [ ] File is `parsers/{servicename}.php` and the function is `{servicename}Parse` (lowercase)
+- [ ] Function signature matches exactly: `($rawBody, $headers, $metadata)`
+- [ ] Returns `array` with 'html' and 'subject' keys on success
+- [ ] Returns `false` on failure/invalid data
+- [ ] Header lookups are case-insensitive
+- [ ] All dynamic content is escaped (`htmlspecialchars()` or `esc()`)
+- [ ] Body uses the shared classes and is wrapped with `emailShell()`
+- [ ] Metadata is rendered with `table.metadata`, not divs
+- [ ] Subject line includes an emoji and key information
+- [ ] Tested with valid, invalid, and lowercase-header requests
+- [ ] Documented any special requirements (API keys, signature validation, etc.)
+
+---
+
+## ๐ค Contributing Parsers
+
+To contribute a parser:
+
+1. **Add `parsers/{servicename}.php`**
+2. **Test Thoroughly** with real webhook data
+3. **Document Usage** - Add a section to README.md with:
+ - Service name
+ - URL format
+ - Supported events
+ - Example webhook configuration
+4. **Submit Pull Request** with:
+ - Parser file
+ - Documentation updates
+ - Example webhook payload (in PR description)
+
+---
+
+## ๐ก Tips & Tricks
+
+### Handle Multiple Event Types
+
+Use a `switch` statement for different event types:
+
+```php
+switch ($eventType) {
+ case 'payment.completed':
+ $icon = '๐ณ';
+ break;
+ case 'refund.created':
+ $icon = 'โฉ๏ธ';
+ break;
+ default:
+ $icon = '๐';
+}
+```
+
+### Pretty Print JSON
+
+For a debugging section:
+
+```php
+$jsonSafe = htmlspecialchars(
+ json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
+ ENT_QUOTES,
+ 'UTF-8'
+);
+$body .= '๐ฆ Raw Payload
' . $jsonSafe . '
';
+```
+
+### Truncate Long Content
+
+```php
+$message = mb_substr($payload['message'], 0, 500);
+if (mb_strlen($payload['message']) > 500) {
+ $message .= 'โฆ';
+}
+```
+
+### Include Links
+
+```php
+$url = htmlspecialchars($payload['url'], ENT_QUOTES, 'UTF-8');
+$body .= "$url";
+```
+
+---
+
+## ๐ Reference
+
+### Existing Parsers
+
+Study these in `parsers/`:
+- `github.php` - Multiple event types, commit cards, case-insensitive headers
+- `grafana.php` - Nested alert cards with label and annotation tables
+- `json.php` - Recursive rendering with path breadcrumbs
+- `wxinteract.php` - Many event types split into helper functions
+- `helpers.php` - `emailShell()`, `esc()`, `renderDataAsTable()`
+
+### Useful PHP Functions
+
+- `json_decode($string, true)` - Parse JSON to array
+- `simplexml_load_string($string)` - Parse XML
+- `parse_str($string, $output)` - Parse URL-encoded data
+- `array_change_key_case($headers, CASE_LOWER)` - Normalise header names
+- `htmlspecialchars($string, ENT_QUOTES, 'UTF-8')` - Escape HTML
+- `esc($string)` - Escape HTML and keep line breaks
+- `hash_hmac()` / `hash_equals()` - Verify signatures
+
+---
+
+## โ FAQ
+
+**Q: Can I use external libraries?**
+A: No, parsers should use only PHP built-in functions to avoid dependencies.
+
+**Q: Can I make API calls in a parser?**
+A: Not recommended - parsers should be fast and not depend on external services.
+
+**Q: What if my service sends different content types?**
+A: Check `$metadata['contentType']` and handle accordingly.
+
+**Q: Can I store data in a database?**
+A: No, parsers should only format and return email content.
+
+**Q: How do I handle webhook signatures?**
+A: Validate in your parser and return `false` if invalid.
+
+**Q: Can I create multiple parsers for one service?**
+A: Yes! Use names like `githubissues`, `githubpush`, etc. Each is its own file.
+
+**Q: Can I change the colours or fonts?**
+A: Change them in `emailShell()` in `parsers/helpers.php` and every email picks them up.
+
+---
+
+## ๐ Support
+
+For questions or help developing parsers:
+- GitHub Issues: https://github.com/andydixon/webhook/issues
+- Review existing parsers in `parsers/`
+- Check this guide for examples
+
+---
+
+**Happy Parser Building! ๐**
diff --git a/README.md b/README.md
index a1b1e62..7ea1e18 100644
--- a/README.md
+++ b/README.md
@@ -176,7 +176,7 @@ See also the [Webex Interact Guide](WEBEX_INTERACT_GUIDE.md).
### Creating Your Own Parser
-A parser is one file in `parsers/` exposing `{name}Parse($rawBody, $headers, $metadata)` that returns `['html' => ..., 'subject' => ...]` or `false`. Build the body from `.section`, `table.metadata`, and `.data-box` blocks, escape free text with `esc()`, and wrap it with `emailShell()` from `parsers/helpers.php` so it picks up the shared design. See the [Parser Development Guide](../PARSER_PROMPT.md) for details.
+A parser is one file in `parsers/` exposing `{name}Parse($rawBody, $headers, $metadata)` that returns `['html' => ..., 'subject' => ...]` or `false`. Build the body from `.section`, `table.metadata`, and `.data-box` blocks, escape free text with `esc()`, and wrap it with `emailShell()` from `parsers/helpers.php` so it picks up the shared design. See the [Parser Development Guide](PARSER_PROMPT.md) for details.
---
@@ -370,7 +370,7 @@ The standard (no parser) email contains:
- **Plus Addressing**: Use email plus addressing (e.g., `yourname+github%40gmail.com`) to track which services are sending webhooks
- **Timestamp in Emails**: Check the timestamp in the header to verify webhook timing and debug delivery delays
- **Standard Format**: Use the standard format (no parser) when you need complete raw data for debugging
-- **Create Parsers**: Build custom parsers for your favorite services - see [Parser Guide](../PARSER_PROMPT.md)
+- **Create Parsers**: Build custom parsers for your favorite services - see [Parser Guide](PARSER_PROMPT.md)
## ๐ Error Handling