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:

HeaderDescription
Content-Typeapplication/json
User-AgentMarkdownAnything-Webhook/1.0
X-Webhook-Conversion-IdUUID of the conversion
X-Webhook-SignatureHMAC-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'
end

Respond 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:

AttemptDelay
1st retry60 seconds
2nd retry5 minutes
3rd retry15 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.