Understanding Webhook Payload Formats: JSON, XML, and Form Data

Every webhook delivery carries a payload — the data that tells your application what happened and provides the details needed to act on the event. Understanding payload formats is essential for building webhook integrations that parse data correctly, validate inputs safely, and handle the variety of formats you will encounter across different providers. This guide covers every payload format you will work with, from modern JSON to legacy XML and everything in between.
The Role of Content-Type Headers
Before parsing any webhook payload, you must check the Content-Type header. This header tells your application exactly how to interpret the request body.
# JSON payload
Content-Type: application/json
# XML payload
Content-Type: application/xml
# or
Content-Type: text/xml
# Form-encoded payload
Content-Type: application/x-www-form-urlencoded
# Multipart payload
Content-Type: multipart/form-data; boundary=----WebhookBoundary
Never assume a webhook will always be JSON. Even providers that primarily use JSON might send form-encoded data for certain event types, or you might integrate with a legacy system that uses XML. Your webhook endpoint should inspect the Content-Type and route to the appropriate parser.
app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
const contentType = req.headers['content-type'] || '';
let parsedData;
if (contentType.includes('application/json')) {
parsedData = JSON.parse(req.body.toString());
} else if (contentType.includes('application/xml') || contentType.includes('text/xml')) {
parsedData = parseXML(req.body.toString());
} else if (contentType.includes('application/x-www-form-urlencoded')) {
parsedData = Object.fromEntries(new URLSearchParams(req.body.toString()));
} else {
console.warn('Unknown content type:', contentType);
return res.status(415).json({ error: 'Unsupported media type' });
}
processWebhook(parsedData);
res.status(200).json({ received: true });
});
JSON Payloads: The Modern Standard
JSON (JavaScript Object Notation) is the dominant webhook payload format. It is lightweight, human-readable, and natively supported by virtually every programming language.
Typical JSON Webhook Structure
Most webhook providers follow a common pattern with an event envelope wrapping the actual data:
{
"id": "evt_1OqR3x2eZvKYlo2C",
"type": "invoice.payment_succeeded",
"created": 1708523400,
"data": {
"object": {
"id": "in_1OqR3x2eZvKYlo2C",
"customer": "cus_PqR3x2eZvKYlo",
"amount_paid": 4999,
"currency": "usd",
"status": "paid",
"lines": {
"data": [
{
"description": "Pro Plan (Monthly)",
"amount": 4999,
"quantity": 1
}
]
}
}
},
"livemode": true,
"pending_webhooks": 1
}
Parsing JSON in Different Languages
JavaScript / Node.js:
// Express.js with JSON body parser
app.use(express.json());
app.post('/webhook', (req, res) => {
const { type, data } = req.body;
// Access nested data safely
const amount = data?.object?.amount_paid;
const currency = data?.object?.currency;
console.log(`Payment of ${amount} ${currency} received`);
res.status(200).json({ received: true });
});
Python:
import json
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
payload = request.get_json()
event_type = payload.get('type')
data = payload.get('data', {}).get('object', {})
amount = data.get('amount_paid')
currency = data.get('currency')
print(f"Payment of {amount} {currency} received")
return {'received': True}, 200
Go:
type WebhookEvent struct {
ID string `json:"id"`
Type string `json:"type"`
Created int64 `json:"created"`
Data json.RawMessage `json:"data"`
}
type PaymentData struct {
Object struct {
ID string `json:"id"`
AmountPaid int `json:"amount_paid"`
Currency string `json:"currency"`
} `json:"object"`
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
var event WebhookEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if event.Type == "invoice.payment_succeeded" {
var payment PaymentData
json.Unmarshal(event.Data, &payment)
fmt.Printf("Payment of %d %s received\n", payment.Object.AmountPaid, payment.Object.Currency)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
JSON Schema Validation
Validating incoming JSON payloads against a schema catches malformed data before it reaches your business logic:
const Ajv = require('ajv');
const addFormats = require('ajv-formats');
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const paymentEventSchema = {
type: 'object',
required: ['id', 'type', 'data'],
properties: {
id: { type: 'string', pattern: '^evt_' },
type: { type: 'string' },
created: { type: 'integer' },
data: {
type: 'object',
required: ['object'],
properties: {
object: {
type: 'object',
required: ['id', 'amount_paid', 'currency'],
properties: {
id: { type: 'string' },
amount_paid: { type: 'integer', minimum: 0 },
currency: { type: 'string', minLength: 3, maxLength: 3 },
status: { type: 'string', enum: ['paid', 'open', 'void', 'uncollectible'] }
}
}
}
}
}
};
const validatePayment = ajv.compile(paymentEventSchema);
function processWebhook(payload) {
if (!validatePayment(payload)) {
console.error('Validation errors:', validatePayment.errors);
throw new Error('Invalid webhook payload');
}
// Payload is valid and safe to process
return handlePaymentEvent(payload);
}
Use a schema validation library like Zod (TypeScript), Ajv (JavaScript), Pydantic (Python), or jsonschema to validate webhook payloads at the boundary of your system. This catches breaking changes from the provider early and prevents invalid data from propagating through your application.
XML Payloads: Legacy but Still Present
While JSON dominates modern webhooks, you will encounter XML payloads when integrating with legacy systems, enterprise platforms, SOAP-based services, and certain payment processors.
Typical XML Webhook Structure
<?xml version="1.0" encoding="UTF-8"?>
<webhook>
<event-type>payment.completed</event-type>
<timestamp>2026-02-21T10:30:00Z</timestamp>
<data>
<payment>
<id>pay_789</id>
<amount>49.99</amount>
<currency>USD</currency>
<customer>
<email>jane@example.com</email>
<name>Jane Doe</name>
</customer>
<status>completed</status>
</payment>
</data>
</webhook>
Parsing XML in Node.js
const { XMLParser } = require('fast-xml-parser');
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
parseTagValue: true,
trimValues: true
});
app.post('/webhook', express.text({ type: ['application/xml', 'text/xml'] }), (req, res) => {
try {
const parsed = parser.parse(req.body);
const event = parsed.webhook;
const eventType = event['event-type'];
const payment = event.data.payment;
console.log(`XML webhook: ${eventType}, amount: ${payment.amount}`);
res.status(200).send('<response><status>received</status></response>');
} catch (error) {
console.error('XML parsing error:', error);
res.status(400).send('<response><status>error</status></response>');
}
});
Parsing XML in Python
import xml.etree.ElementTree as ET
from flask import Flask, request
@app.route('/webhook', methods=['POST'])
def xml_webhook():
root = ET.fromstring(request.data)
event_type = root.find('event-type').text
payment = root.find('data/payment')
amount = payment.find('amount').text
currency = payment.find('currency').text
print(f"XML webhook: {event_type}, amount: {amount} {currency}")
return '<response><status>received</status></response>', 200
Be cautious when parsing XML from untrusted sources. XML parsers can be vulnerable to XXE (XML External Entity) attacks, which allow attackers to read files from your server or make requests to internal systems. Disable external entity processing and DTD loading in your XML parser configuration.
// Secure XML parser configuration
const parser = new XMLParser({
// Disable features that could be exploited
processEntities: false,
allowBooleanAttributes: false,
// Additional security settings depend on the library
});
Form-Encoded Payloads
Some webhook providers send data as application/x-www-form-urlencoded — the same format used by HTML form submissions. This is common with older platforms, Twilio, and some payment gateways.
Typical Form-Encoded Payload
The raw request body looks like this:
event_type=sms.received&from=%2B15551234567&to=%2B15559876543&body=Hello+World&message_id=msg_abc123×tamp=1708523400
Which represents:
{
"event_type": "sms.received",
"from": "+15551234567",
"to": "+15559876543",
"body": "Hello World",
"message_id": "msg_abc123",
"timestamp": "1708523400"
}
Parsing Form-Encoded Data
Node.js / Express:
app.use(express.urlencoded({ extended: true }));
app.post('/webhook/sms', (req, res) => {
const { event_type, from, to, body, message_id } = req.body;
console.log(`SMS from ${from}: ${body}`);
res.status(200).send('OK');
});
Python / Flask:
@app.route('/webhook/sms', methods=['POST'])
def sms_webhook():
event_type = request.form.get('event_type')
sender = request.form.get('from')
body = request.form.get('body')
print(f"SMS from {sender}: {body}")
return 'OK', 200
Limitations of Form-Encoded Format
Form-encoded data has significant limitations compared to JSON:
- No nested structures — everything is a flat key-value pair
- No data types — all values are strings (numbers, booleans, dates must be parsed manually)
- No arrays — representing lists requires conventions like
item[0],item[1]or repeated keys - Size limits — some servers limit URL-encoded body sizes more aggressively than JSON
If a provider offers both form-encoded and JSON formats, always choose JSON.
Multipart Form Data
Multipart payloads (multipart/form-data) are occasionally used when webhooks include file attachments or binary data alongside text fields.
Structure
POST /webhook HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebhookBoundary
------WebhookBoundary
Content-Disposition: form-data; name="event"
file.uploaded
------WebhookBoundary
Content-Disposition: form-data; name="metadata"
Content-Type: application/json
{"user_id":"usr_123","filename":"report.pdf","size":1048576}
------WebhookBoundary
Content-Disposition: form-data; name="file"; filename="report.pdf"
Content-Type: application/pdf
(binary content here)
------WebhookBoundary--
Parsing Multipart Data
const multer = require('multer');
const upload = multer({ limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit
app.post('/webhook/files', upload.single('file'), (req, res) => {
const event = req.body.event;
const metadata = JSON.parse(req.body.metadata);
const file = req.file;
console.log(`File uploaded: ${metadata.filename}, size: ${file.size}`);
res.status(200).json({ received: true });
});
Building a Universal Webhook Parser
In production, your webhook endpoint might receive payloads from multiple providers in different formats. A universal parser handles this gracefully:
class WebhookParser {
static parse(contentType, body) {
if (!contentType) {
// Default to JSON if no content type specified
return WebhookParser.parseJSON(body);
}
const type = contentType.toLowerCase();
if (type.includes('application/json')) {
return WebhookParser.parseJSON(body);
}
if (type.includes('application/xml') || type.includes('text/xml')) {
return WebhookParser.parseXML(body);
}
if (type.includes('application/x-www-form-urlencoded')) {
return WebhookParser.parseFormEncoded(body);
}
throw new Error(`Unsupported content type: ${contentType}`);
}
static parseJSON(body) {
const str = Buffer.isBuffer(body) ? body.toString('utf-8') : body;
return { format: 'json', data: JSON.parse(str) };
}
static parseXML(body) {
const str = Buffer.isBuffer(body) ? body.toString('utf-8') : body;
const parser = new XMLParser({ processEntities: false });
return { format: 'xml', data: parser.parse(str) };
}
static parseFormEncoded(body) {
const str = Buffer.isBuffer(body) ? body.toString('utf-8') : body;
return { format: 'form', data: Object.fromEntries(new URLSearchParams(str)) };
}
}
// Usage
app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
try {
const { format, data } = WebhookParser.parse(req.headers['content-type'], req.body);
console.log(`Received ${format} webhook:`, data);
processWebhook(data);
res.status(200).json({ received: true });
} catch (error) {
console.error('Parse error:', error.message);
res.status(400).json({ error: 'Failed to parse payload' });
}
});
Provider-Specific Payload Examples
Different providers structure their JSON payloads differently. Here are real-world examples:
Stripe
{
"id": "evt_1OqR3x",
"object": "event",
"api_version": "2026-01-15",
"type": "checkout.session.completed",
"data": {
"object": {
"id": "cs_test_abc",
"amount_total": 2000,
"currency": "usd",
"customer_email": "customer@example.com",
"payment_status": "paid"
}
}
}
GitHub
{
"action": "opened",
"pull_request": {
"id": 123456789,
"number": 42,
"title": "Fix login bug",
"user": { "login": "developer" },
"head": { "ref": "fix/login-bug" },
"base": { "ref": "main" }
},
"repository": {
"full_name": "org/repo"
}
}
Shopify
{
"id": 820982911946154508,
"email": "jon@example.com",
"total_price": "199.00",
"currency": "USD",
"financial_status": "paid",
"line_items": [
{
"title": "Premium Widget",
"quantity": 1,
"price": "199.00"
}
]
}
Notice how each provider uses different conventions for the same concept: Stripe nests data under data.object, GitHub uses a flat structure with an action field, and Shopify sends the object directly without an event wrapper.
Handling Payload Size and Performance
Large webhook payloads require careful handling:
Set Appropriate Body Size Limits
// Set maximum body size to 5MB
app.use(express.json({ limit: '5mb' }));
app.use(express.raw({ type: '*/*', limit: '5mb' }));
Stream Large Payloads
For very large payloads, consider streaming the body instead of buffering it entirely in memory:
app.post('/webhook/large', (req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
if (body.length > 10 * 1024 * 1024) { // 10MB safety limit
req.destroy();
res.status(413).json({ error: 'Payload too large' });
}
});
req.on('end', () => {
const data = JSON.parse(body);
processWebhook(data);
res.status(200).json({ received: true });
});
});
When debugging payload format issues, Webhookify is invaluable. Point any webhook provider at a Webhookify endpoint, and you can inspect the exact headers, content type, and raw body of every delivery. This eliminates guesswork about what format the provider is actually sending — which is especially helpful when documentation is unclear or outdated.
Inspect Webhook Payloads Instantly
Create a Webhookify endpoint in seconds and see the exact headers, body, and format of every webhook delivery. Debug payload issues without writing a single line of code.
Create Free EndpointBest Practices Summary
- Always check the Content-Type header before parsing — never assume JSON.
- Validate payloads with schemas to catch malformed data early.
- Use the raw body for signature verification — parsed and re-serialized data may differ from the original.
- Handle multiple formats if you accept webhooks from different providers.
- Set body size limits to prevent memory issues from oversized payloads.
- Sanitize all webhook data before using it in queries, commands, or templates. See our security guide for details.
- Log raw payloads (or use Webhookify) for debugging — you will need to reference the exact data when troubleshooting.
Further Reading
- The Complete Guide to Webhooks — webhook fundamentals
- How Webhooks Work — the technical architecture
- Webhook Security Best Practices — protect your endpoints
- Webhook Debugging Guide — troubleshoot payload issues
Related Articles
- How Webhooks Work: A Technical Deep Dive
- The Ultimate Webhook Debugging Guide
- How to Set Up Stripe Webhook Notifications
- How to Set Up GitHub Webhook Notifications
- E-Commerce Webhook Monitoring: Never Miss a Sale