# 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! ๐Ÿš€**