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"Verify the signature (recommended)
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
| Attempt | Delay 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_secretand verify theX-Webhook-Signatureheader to ensure payloads are authentic. - Make your handler idempotent. Use the
idfield to deduplicate, in case a delivery is retried after a timeout. - Log the
X-Webhook-Conversion-Idheader. This makes it easy to correlate webhook deliveries with conversions during debugging.