Express
Setup
npm install express multer
# .env
FLASH_MD_API_URL=https://api.markpdf.tech
FLASH_MD_API_KEY=tu_key
Do not hardcode
FLASH_MD_API_KEY into the code or send it to the client. Load it from environment variables (process.env) and keep it only in the server process.Basic endpoint
import express from "express";
import multer from "multer";
const app = express();
const upload = multer({ limits: { fileSize: 12 * 1024 * 1024 } });
const API_URL = process.env.FLASH_MD_API_URL || "https://api.markpdf.tech";
const API_KEY = process.env.FLASH_MD_API_KEY;
async function pollJob(jobId) {
while (true) {
await new Promise((r) => setTimeout(r, 5000));
const res = await fetch(`${API_URL}/jobs/${jobId}`, {
headers: { "x-api-key": API_KEY },
});
const data = await res.json();
if (data.status === "completed") return data.body;
if (data.status === "failed") throw new Error(data.error);
}
}
app.post("/convert", upload.single("file"), async (req, res) => {
if (!req.file) return res.status(400).json({ error: "Missing file" });
const upstream = await fetch(
`${API_URL}/convert/raw?filename=${encodeURIComponent(req.file.originalname)}&mode=fast`,
{
method: "POST",
headers: {
"x-api-key": API_KEY,
"content-type": req.file.mimetype,
},
body: req.file.buffer,
}
);
if (upstream.status === 200) {
return res.type("text/markdown").send(await upstream.text());
}
if (upstream.status === 202) {
const job = await upstream.json();
const markdown = await pollJob(job.job_id);
return res.type("text/markdown").send(markdown);
}
res.status(upstream.status).send(await upstream.text());
});
app.listen(3000);
Complete router with vavalidation and error handling
In a real app it is advisable to isolate the integration in its own router, vavalidate themimetype with multer.fileFilter, and centralize the error mapping in a middleware instead of repeating it in each route.
// src/routes/convert.js
import { Router } from "express";
import multer from "multer";
const API_URL = process.env.FLASH_MD_API_URL || "https://api.markpdf.tech";
const API_KEY = process.env.FLASH_MD_API_KEY;
const ALLOWED_MIMETYPES = new Set([
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/csv",
"text/plain",
"application/zip",
]);
const upload = multer({
limits: { fileSize: 12 * 1024 * 1024 }, // same as the /convert/raw limit
fileFilter: (req, file, cb) => {
if (!ALLOWED_MIMETYPES.has(file.mimetype)) {
return cb(new Error("UNSUPPORTED_MIMETYPE"));
}
cb(null, true);
},
});
/** Poll GET /jobs/{job_id} every 5s until completed/failed. */
async function pollJob(jobId, { intervalMs = 5000, maxAttempts = 60 } = {}) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await new Promise((r) => setTimeout(r, intervalMs));
const res = await fetch(`${API_URL}/jobs/${jobId}`, {
headers: { "x-api-key": API_KEY },
});
if (!res.ok) throw new UpstreamError(res.status, "Could not fetch the job");
const data = await res.json();
if (data.status === "completed") return data.body;
if (data.status === "failed") throw new UpstreamError(500, data.error ?? "Conversión falvalida");
}
throw new UpstreamError(504, "El job no completó dentro del tiempo esperado");
}
/** Typed error preserving upstream status for error middleware. */
class UpstreamError extends Error {
constructor(status, detail) {
super(detail);
this.status = status;
this.detail = detail;
}
}
async function convertBuffer(buffer, filename, mimetype, opts = {}) {
const params = new URLSearchParams({
filename,
mode: opts.mode ?? "fast",
});
const res = await fetch(`${API_URL}/convert/raw?${params}`, {
method: "POST",
headers: { "x-api-key": API_KEY, "content-type": mimetype },
body: buffer,
});
if (res.status === 200) return res.text();
if (res.status === 202) {
const job = await res.json();
return pollJob(job.job_id);
}
const payload = await res.json().catch(() => ({ detail: res.statusText }));
throw new UpstreamError(res.status, payload.detail);
}
export const convertRouter = Router();
convertRouter.post("/convert", upload.single("file"), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ error: "Missing file" });
}
const mode = req.body.mode ?? "fast";
const markdown = await convertBuffer(req.file.buffer, req.file.originalname, req.file.mimetype, {
mode,
});
res.type("text/markdown").send(markdown);
} catch (err) {
next(err);
}
});
/** Variant for documents already hosted in a public URL. */
convertRouter.post("/convert/from-url", express.json(), async (req, res, next) => {
try {
const { url, mode = "fast" } = req.body;
if (!url) return res.status(400).json({ error: "Missing url" });
const upstream = await fetch(`${API_URL}/convert/from-url`, {
method: "POST",
headers: { "x-api-key": API_KEY, "content-type": "application/json" },
body: JSON.stringify({ url, mode }),
});
if (upstream.status === 200) {
return res.type("text/markdown").send(await upstream.text());
}
if (upstream.status === 202) {
const job = await upstream.json();
return res.type("text/markdown").send(await pollJob(job.job_id));
}
const payload = await upstream.json().catch(() => ({ detail: upstream.statusText }));
throw new UpstreamError(upstream.status, payload.detail);
} catch (err) {
next(err);
}
});
// Centralized error handling middleware — must be registered at the end of the app.
export function convertErrorHandler(err, req, res, next) {
if (err.message === "UNSUPPORTED_MIMETYPE") {
return res.status(415).json({ error: "Unsupported file format" });
}
if (err instanceof multer.MulterError && err.code === "LIMIT_FILE_SIZE") {
return res.status(413).json({ error: "File too large" });
}
if (err instanceof UpstreamError) {
// Maps 1:1 the known statuses of the API; anything else is treated as 502.
const known = [400, 401, 403, 413, 415, 422, 429];
const status = known.includes(err.status) ? err.status : 502;
return res.status(status).json({ error: err.detail });
}
console.error(err);
res.status(500).json({ error: "Error interno" });
}
// src/app.js
import express from "express";
import { convertRouter, convertErrorHandler } from "./routes/convert.js";
const app = express();
app.use(convertRouter);
app.use(convertErrorHandler); // siempre al final, después de todas las rutas
app.listen(3000, () => console.log("Escuchando en :3000"));
Express’s error middleware is recognized because it declares 4 parameters (
err, req, res, next), not 3. If your handler has only 3 parameters, Express treats it as normal middleware and will never catch errors.Production
Timeouts with AbortSignal.timeout
async function convertWithTimeout(buffer, filename, mimetype, timeoutMs = 30_000) {
const res = await fetch(
`${API_URL}/convert/raw?filename=${encodeURIComponent(filename)}&mode=fast`,
{
method: "POST",
headers: { "x-api-key": API_KEY, "content-type": mimetype },
body: buffer,
signal: AbortSignal.timeout(timeoutMs),
}
);
return res;
}
Retries with exponential backoff
Retry only429 and 5xx. A different 4xx (400, 401, 403, 413, 415, 422) indicates that the request must be corrected, not repeated.
async function fetchWithRetry(url, init, { maxAttempts = 4, baseDelayMs = 1000 } = {}) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await fetch(url, init);
const shouldRetry = res.status === 429 || res.status >= 500;
if (!shouldRetry || attempt === maxAttempts) return res;
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), 15_000) + Math.random() * 250;
await new Promise((r) => setTimeout(r, delay));
}
}
Large files with BYOS (output_url)
When you convert large documents into a background job (queues, workers), avoid loading the entire Markdown into the memory of the Node process: ask API to upload it directly to your bucket with a pre-signed URL PUT.
async function convertToStorage({ sourceUrl, putUrl, headUrl, mode = "balanced" }) {
const res = await fetch(`${API_URL}/convert/from-url`, {
method: "POST",
headers: { "x-api-key": API_KEY, "content-type": "application/json" },
body: JSON.stringify({
url: sourceUrl,
mode,
output_url: putUrl, // pre-signed PUT to your storage
output_head_url: headUrl, // pre-signed HEAD: avoids reprocessing if it already exists
output_encoding: "zstd",
}),
});
// output_url auto-activates response_format=json and slim=true; the answer is small
return res.json();
}
If your app processes many documents in the background (for example with BullMQ), combine
output_url with the retry pattern: the worker should retry only on 429/5xx, and mark the job as failed permanently on any other 4xx without re-queuing it.