> ## Documentation Index
> Fetch the complete documentation index at: https://docs.markpdf.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Express

> Router in Express.js with multer, from-url, error and production middleware.

# Express

## Setup

```bash theme={null}
npm install express multer
```

```
# .env
FLASH_MD_API_URL=https://api.markpdf.tech
FLASH_MD_API_KEY=tu_key
```

<Warning>
  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.
</Warning>

## Basic endpoint

```js theme={null}
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 the `mimetype` with `multer.fileFilter`, and centralize the error mapping in a middleware instead of repeating it in each route.

```js theme={null}
// 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" });
}
```

```js theme={null}
// 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"));
```

<Note>
  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.
</Note>

## Production

### Timeouts with `AbortSignal.timeout`

```js theme={null}
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 only `429` and `5xx`. A different `4xx` (400, 401, 403, 413, 415, 422) indicates that the request must be corrected, not repeated.

```js theme={null}
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.

```js theme={null}
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();
}
```

<Tip>
  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.
</Tip>
