Webhooks

Receive conversion results asynchronously via webhook delivery.

When you provide a webhook_url parameter to the POST /convert endpoint, Markdown Anything delivers the conversion result to your URL as an HTTP POST request. This is the recommended approach for production integrations — it eliminates polling and delivers results as soon as they are ready.

Setup

Create an HTTPS endpoint

Set up a publicly accessible HTTPS endpoint that accepts POST requests and returns a 2xx status code on success.

Webhook URLs must use HTTPS. HTTP URLs are rejected during validation.

Include the webhook URL in your request

Pass webhook_url (and optionally webhook_secret) when converting a file:

curl -X POST https://markdownanything.com/api/v1/convert \
  -H "Authorization: Bearer mda_your_token_here" \
  -F "[email protected]" \
  -F "webhook_url=https://example.com/webhooks/conversions" \
  -F "webhook_secret=whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"

If you provided a webhook_secret, verify the X-Webhook-Signature header to ensure the payload is authentic. See Signature Verification below.

Return a 2xx response

Your endpoint must respond with a 2xx status code within 30 seconds. Any other status code or a timeout triggers a retry.

Payload Format

The webhook payload is a JSON POST request containing the conversion result.

Completed conversion

{
    "id": "9e5fcd8a-1b2c-3d4e-5f6a-7b8c9d0e1f2a",
    "status": "completed",
    "created_at": "2025-01-15T10:30:00+00:00",
    "markdown": "# My Document\n\nConverted content here...",
    "completed_at": "2025-01-15T10:30:05+00:00",
    "metadata": {
        "title": "My Document",
        "author": "Jane Doe",
        "page_count": 3
    }
}

Failed conversion

{
    "id": "9e5fcd8a-1b2c-3d4e-5f6a-7b8c9d0e1f2a",
    "status": "failed",
    "created_at": "2025-01-15T10:30:00+00:00",
    "error": "Unable to extract text from the provided file.",
    "completed_at": "2025-01-15T10:30:03+00:00"
}

The metadata field is only included when include_metadata was set to true in the original conversion request.

Headers

Every webhook request includes the following headers:

Prop

Type

Signature Verification

When you provide a webhook_secret in your conversion request, every webhook delivery includes an X-Webhook-Signature header. This is an HMAC-SHA256 hex digest of the raw JSON payload, signed with your secret.

import crypto from "crypto";

function verifyWebhookSignature(payload, signature, secret) {
    const expected = crypto
        .createHmac("sha256", secret)
        .update(JSON.stringify(payload))
        .digest("hex");

    return crypto.timingSafeEqual(
        Buffer.from(signature),
        Buffer.from(expected)
    );
}

// Express.js example
app.post("/webhooks/conversions", express.json(), (req, res) => {
    const signature = req.headers["x-webhook-signature"];
    const secret = process.env.WEBHOOK_SECRET;

    if (!verifyWebhookSignature(req.body, signature, secret)) {
        return res.status(401).json({ error: "Invalid signature" });
    }

    // Process the conversion result
    console.log(req.body.markdown);
    res.status(200).json({ received: true });
});
import hmac
import hashlib
import json
from flask import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"

def verify_signature(payload, signature, secret):
    expected = hmac.new(
        secret.encode(),
        json.dumps(payload, separators=(",", ":")).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):
        return jsonify({"error": "Invalid signature"}), 401

    # Process the conversion result
    print(request.json["markdown"])
    return jsonify({"received": True}), 200
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = env('WEBHOOK_SECRET');

$expected = hash_hmac('sha256', $payload, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

$data = json_decode($payload, true);

// Process the conversion result
if ($data['status'] === 'completed') {
    // Save the markdown
    file_put_contents('output.md', $data['markdown']);
}

http_response_code(200);
echo json_encode(['received' => true]);

Always use a constant-time comparison function (e.g. crypto.timingSafeEqual, hmac.compare_digest, hash_equals) to prevent timing attacks.

Retry Policy

If your endpoint returns a non-2xx status code or fails to respond within the timeout, the delivery is retried automatically.

Prop

Type

AttemptDelay After Failure
1st (initial)
2nd (retry 1)60 seconds
3rd (retry 2)300 seconds (5 min)

After all 3 attempts fail, the delivery is marked as permanently failed. You can check delivery status via the GET /conversions/{id} endpoint — the webhook_delivered and can_retry_webhook fields indicate the current state.

Best Practices

  • Return 200 immediately. Process the payload asynchronously in your application to avoid hitting the 30-second timeout.
  • Use a webhook secret. Always provide a webhook_secret and verify the X-Webhook-Signature header to ensure payloads are authentic.
  • Make your handler idempotent. Use the id field to deduplicate, in case a delivery is retried after a timeout.
  • Log the X-Webhook-Conversion-Id header. This makes it easy to correlate webhook deliveries with conversions during debugging.