Custom Webhooks
Build a custom webhook receiver for async document-to-Markdown conversion results. Examples in Node.js, Python, PHP, and Ruby.
When you send a conversion request with a webhook_url, Markdown Anything delivers the result to your endpoint when processing completes. This guide shows how to build a webhook receiver.
Webhook Payload
Completed conversions deliver this payload:
{
"id": "9e5fcd8a-1b2c-3d4e-5f6a-7b8c9d0e1f2a",
"status": "completed",
"created_at": "2025-01-15T10:30:00+00:00",
"markdown": "# Converted Document\n\nContent here...",
"completed_at": "2025-01-15T10:30:05+00:00",
"metadata": {
"title": "My Document",
"author": "Jane Doe",
"page_count": 3
}
}Failed conversions include an error field instead of markdown.
Headers
Every webhook request includes:
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | MarkdownAnything-Webhook/1.0 |
X-Webhook-Conversion-Id | UUID of the conversion |
X-Webhook-Signature | HMAC-SHA256 signature (only if you provided a webhook_secret) |
Setup
Create an HTTPS Endpoint
Your webhook URL must be publicly accessible over HTTPS. Set up a POST endpoint that accepts JSON.
Webhook URLs must use HTTPS. HTTP URLs are rejected during validation.
Verify the Signature
If you provided a webhook_secret when creating the conversion, verify the X-Webhook-Signature header to ensure the request is authentic.
The signature is an HMAC-SHA256 hash of the raw JSON request body using your secret as the key.
import crypto from 'crypto';
function verifySignature(body, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(body))
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// In your Express handler:
app.post('/webhooks/conversions', express.json(), (req, res) => {
const signature = req.headers['x-webhook-signature'];
if (!verifySignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const { id, status, markdown } = req.body;
// Process the conversion result...
res.status(200).send('OK');
});import hmac
import hashlib
import json
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_secret"
def verify_signature(payload, signature, secret):
expected = hmac.new(
secret.encode(),
json.dumps(payload).encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
@app.route('/webhooks/conversions', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Webhook-Signature', '')
if not verify_signature(request.json, signature, WEBHOOK_SECRET):
abort(401)
data = request.json
# Process the conversion result...
return 'OK', 200$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET');
$expected = hash_hmac('sha256', $payload, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
$data = json_decode($payload, true);
// Process the conversion result...
// $data['id'], $data['status'], $data['markdown']
http_response_code(200);
echo 'OK';require 'openssl'
require 'json'
post '/webhooks/conversions' do
payload = request.body.read
signature = request.env['HTTP_X_WEBHOOK_SIGNATURE'] || ''
secret = ENV['WEBHOOK_SECRET']
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
unless Rack::Utils.secure_compare(expected, signature)
halt 401, 'Invalid signature'
end
data = JSON.parse(payload)
# Process the conversion result...
status 200
'OK'
endRespond Quickly
Return a 2xx status code within 30 seconds. If your endpoint takes longer or returns a non-2xx status, the webhook will be retried.
For heavy processing, acknowledge the webhook immediately and handle the work asynchronously.
Retry Policy
Failed deliveries are retried up to 3 times with increasing backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 60 seconds |
| 2nd retry | 5 minutes |
| 3rd retry | 15 minutes |
After 3 failed attempts, the delivery is marked as permanently failed. You can still retrieve the result by polling the conversion status endpoint.
A delivery is considered failed if your endpoint returns a non-2xx status code, times out (30s), or is unreachable.