> ## 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.

# Go

> net/http to convert documents from Go.

# Go

This guide uses only the standard library (`net/http`, `encoding/json`), with no external dependencies. A reusable client is built with methods to upload a local file, convert from a signed URL and poll an asynchronous job, with idiomatic error handling in Go (own error type implemented by `error`, wrapped with `%w`).

## Local file (minimal example)

```go theme={null}
package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
)

const apiURL = "https://api.markpdf.tech"
const apiKey = "YOUR_API_KEY"

func main() {
	f, _ := os.Open("report.pdf")
	defer f.Close()

	req, _ := http.NewRequest("POST", apiURL+"/convert/raw?filename=report.pdf&mode=fast", f)
	req.Header.Set("x-api-key", apiKey)
	req.Header.Set("content-type", "application/pdf")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	body, _ := io.ReadAll(res.Body)
	fmt.Println(string(body))
}
```

## URL signed (minimal example)

```go theme={null}
package main

import (
	"fmt"
	"io"
	"net/http"
	"strings"
)

func main() {
	payload := `{"url":"https://bucket.s3.amazonaws.com/report.pdf?X-Amz-Signature=...","filename":"report.pdf","mode":"fast"}`

	req, _ := http.NewRequest("POST", "https://api.markpdf.tech/convert/from-url", strings.NewReader(payload))
	req.Header.Set("x-api-key", "YOUR_API_KEY")
	req.Header.Set("content-type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	body, _ := io.ReadAll(res.Body)
	fmt.Println(string(body))
}
```

## Cliente reutilizable

For real use, it is convenient to encapsulate the authentication logic, construction of requests, handling of `202` and errors in a `struct` with methods. This client covers `ConvertRaw` (local file), `ConvertFromURL` (URL signed), and `pollJob` (%%0005% polling).

```go theme={null}
package flashmd

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"time"
)

// Client is a reusable HTTP client for API Flash PDF to Markdown.
type Client struct {
	BaseURL    string
	APIKey     string
	HTTPClient *http.Client
}

// NewClient creates a client with reasonable timeouts for production.
func NewClient(baseURL, apiKey string) *Client {
	return &Client{
		BaseURL: baseURL,
		APIKey:  apiKey,
		HTTPClient: &http.Client{
			Timeout: 120 * time.Second,
		},
	}
}

// APIError represents a business error returned by API (4xx/5xx).
// Implements the error interface and exposes StatusCode and Detail for
// so the caller can decide whether to retry or not.
type APIError struct {
	StatusCode int
	Detail     string
}

func (e *APIError) Error() string {
	return fmt.Sprintf("flashmd: HTTP %d: %s", e.StatusCode, e.Detail)
}

// Retryable indicates whether this request is worth retrying (429 or 5xx).
func (e *APIError) Retryable() bool {
	return e.StatusCode == http.StatusTooManyRequests || e.StatusCode >= 500
}

type errorBody struct {
	Detail string `json:"detail"`
}

type acceptedJob struct {
	JobID             string `json:"job_id"`
	RetryAfterSeconds int    `json:"retry_after_seconds"`
}

type jobStatus struct {
	Status string `json:"status"`
	Body   string `json:"body"`
	Error  string `json:"error"`
}

// ConvertOptions groups optional conversion parameters.
type ConvertOptions struct {
	Filename string
	Mode     string // fast | ultra_fast | balanced | quality | auto
	Clean    *bool // nil = use default (true)
}

func (o ConvertOptions) toQuery() url.Values {
	q := url.Values{}
	if o.Filename != "" {
		q.Set("filename", o.Filename)
	}
	if o.Mode != "" {
		q.Set("mode", o.Mode)
	}
	if o.Engine != "" {
		q.Set("engine", o.Engine)
	}
	}
	if o.Clean != nil {
		q.Set("clean", fmt.Sprintf("%t", *o.Clean))
	}
	return q
}

// ConvertRaw uploads the binary content of a document to POST /convert/raw
// and returns the resulting markdown, resolving flow 202 -> polling
// transparently for the caller.
func (c *Client) ConvertRaw(ctx context.Context, content io.Reader, contentType string, opts ConvertOptions) (string, error) {
	endpoint := c.BaseURL + "/convert/raw?" + opts.toQuery().Encode()

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, content)
	if err != nil {
		return "", fmt.Errorf("flashmd: building request: %w", err)
	}
	req.Header.Set("x-api-key", c.APIKey)
	req.Header.Set("content-type", contentType)

	return c.doAndResolve(ctx, req)
}

// ConvertFromURL asks API to download the document from URL
// (for example a signed URL from S3) and convert it.
func (c *Client) ConvertFromURL(ctx context.Context, sourceURL string, opts ConvertOptions) (string, error) {
	payload, err := json.Marshal(map[string]any{
		"url":      sourceURL,
		"filename": opts.Filename,
		"mode":     opts.Mode,
		"engine":   opts.Engine,
	})
	if err != nil {
		return "", fmt.Errorf("flashmd: encoding payload: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/convert/from-url", bytes.NewReader(payload))
	if err != nil {
		return "", fmt.Errorf("flashmd: building request: %w", err)
	}
	req.Header.Set("x-api-key", c.APIKey)
	req.Header.Set("content-type", "application/json")

	return c.doAndResolve(ctx, req)
}

// doAndResolve executes the request and, if API responds 202, polls
// of /jobs/{job_id} until the job ends (completed or failed).
func (c *Client) doAndResolve(ctx context.Context, req *http.Request) (string, error) {
	res, err := c.HTTPClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("flashmd: request failed: %w", err)
	}
	defer res.Body.Close()

	body, err := io.ReadAll(res.Body)
	if err != nil {
		return "", fmt.Errorf("flashmd: reading response body: %w", err)
	}

	switch res.StatusCode {
	case http.StatusOK:
		return string(body), nil
	case http.StatusAccepted:
		var job acceptedJob
		if err := json.Unmarshal(body, &job); err != nil {
			return "", fmt.Errorf("flashmd: decoding 202 payload: %w", err)
		}
		return c.PollJob(ctx, job.JobID, job.RetryAfterSeconds)
	default:
		var eb errorBody
		_ = json.Unmarshal(body, &eb)
		if eb.Detail == "" {
			eb.Detail = string(body)
		}
		return "", &APIError{StatusCode: res.StatusCode, Detail: eb.Detail}
	}
}

// PollJob queries GET /jobs/{job_id} every retryAfterSeconds (or 5s per
// default) until the job becomes "completed" or "failed".
func (c *Client) PollJob(ctx context.Context, jobID string, retryAfterSeconds int) (string, error) {
	delay := time.Duration(retryAfterSeconds) * time.Second
	if delay <= 0 {
		delay = 5 * time.Second
	}

	timer := time.NewTimer(delay)
	defer timer.Stop()

	for {
		select {
		case <-ctx.Done():
			return "", fmt.Errorf("flashmd: polling job %s: %w", jobID, ctx.Err())
		case <-timer.C:
		}

		req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/jobs/%s", c.BaseURL, jobID), nil)
		if err != nil {
			return "", fmt.Errorf("flashmd: building poll request: %w", err)
		}
		req.Header.Set("x-api-key", c.APIKey)

		res, err := c.HTTPClient.Do(req)
		if err != nil {
			return "", fmt.Errorf("flashmd: polling job %s: %w", jobID, err)
		}

		var status jobStatus
		decodeErr := json.NewDecoder(res.Body).Decode(&status)
		res.Body.Close()
		if decodeErr != nil {
			return "", fmt.Errorf("flashmd: decoding job status: %w", decodeErr)
		}

		switch status.Status {
		case "completed":
			return status.Body, nil
		case "failed":
			return "", fmt.Errorf("flashmd: job %s failed: %s", jobID, status.Error)
		}

		timer.Reset(delay)
	}
}
```

<Note>
  The code above assumes that you import `context` in addition to the packages already listed; add it to the `import` block of your actual file (`"context"`).
</Note>

### Client usage

```go theme={null}
package main

import (
	"context"
	"errors"
	"fmt"
	"os"

	"tu-modulo/flashmd"
)

func main() {
	client := flashmd.NewClient("https://api.markpdf.tech", os.Getenv("FLASH_MD_API_KEY"))

	f, err := os.Open("report.pdf")
	if err != nil {
		panic(err)
	}
	defer f.Close()

	ctx := context.Background()
	markdown, err := client.ConvertRaw(ctx, f, "application/pdf", flashmd.ConvertOptions{
		Filename: "report.pdf",
		Mode:     "fast",
	})
	if err != nil {
		var apiErr *flashmd.APIError
		if errors.As(err, &apiErr) {
			switch apiErr.StatusCode {
			case 401:
				fmt.Println("API key invávalida o ausente")
			case 413:
				fmt.Println("The document exceeds the size/page limit")
			case 415:
				fmt.Println("Formato no soportado")
			default:
				fmt.Printf("Error de la API: %s\n", apiErr.Detail)
			}
			return
		}
		panic(err)
	}

	fmt.Println(markdown)
}
```

## Language error handling

Type `APIError` implements Go's `error` interface (method `Error() string`) and exposes `StatusCode` so that calling code can inspect it with `errors.As`. All internal errors (network, (de)serialization failures) are wrapped with `%w` to preserve the chain of causes and allow `errors.Is`/`errors.As` at any point:

```go theme={null}
markdown, err := client.ConvertRaw(ctx, f, "application/pdf", opts)
if err != nil {
	var apiErr *flashmd.APIError
	switch {
	case errors.As(err, &apiErr) && apiErr.StatusCode == 401:
		// invavalid credentials: do not retry, warn user
	case errors.As(err, &apiErr) && apiErr.Retryable():
		// 429 or 5xx: retry with backoff (see Production section)
	case errors.Is(err, context.DeadlineExceeded):
		// timeout del http.Client
	default:
		// network error or other unexpected error
	}
}
```

## Handling of 202 (auto-async)

When all backends are saturated, API returns `202` with a `job_id`. The client above already resolves this transparently via `PollJob`, but if you prefer not to use the struct, here is the equivalent flow independently:

```go theme={null}
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

const apiURL = "https://api.markpdf.tech"
const apiKey = "YOUR_API_KEY"

type AcceptedJob struct {
	JobID             string `json:"job_id"`
	RetryAfterSeconds int    `json:"retry_after_seconds"`
}

type JobStatus struct {
	Status string `json:"status"`
	Body   string `json:"body"`
	Error  string `json:"error"`
}

func convert(pdfPath string) (string, error) {
	f, err := os.Open(pdfPath)
	if err != nil {
		return "", err
	}
	defer f.Close()

	req, _ := http.NewRequest("POST", apiURL+"/convert/raw?filename="+pdfPath+"&mode=fast", f)
	req.Header.Set("x-api-key", apiKey)
	req.Header.Set("content-type", "application/pdf")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", err
	}
	defer res.Body.Close()

	if res.StatusCode == 200 {
		body, _ := io.ReadAll(res.Body)
		return string(body), nil
	}

	if res.StatusCode == 202 {
		var job AcceptedJob
		json.NewDecoder(res.Body).Decode(&job)
		delay := time.Duration(job.RetryAfterSeconds) * time.Second
		if delay == 0 {
			delay = 5 * time.Second
		}

		for {
			time.Sleep(delay)

			pollReq, _ := http.NewRequest("GET", fmt.Sprintf("%s/jobs/%s", apiURL, job.JobID), nil)
			pollReq.Header.Set("x-api-key", apiKey)
			pollRes, err := http.DefaultClient.Do(pollReq)
			if err != nil {
				return "", err
			}

			var status JobStatus
			json.NewDecoder(pollRes.Body).Decode(&status)
			pollRes.Body.Close()

			switch status.Status {
			case "completed":
				return status.Body, nil
			case "failed":
				return "", fmt.Errorf("job %s failed: %s", job.JobID, status.Error)
			}
		}
	}

	body, _ := io.ReadAll(res.Body)
	return "", fmt.Errorf("HTTP %d: %s", res.StatusCode, body)
}

func main() {
	md, err := convert("report.pdf")
	if err != nil {
		panic(err)
	}
	fmt.Println(md)
}
```

## Production

<ParamField path="http.Client.Timeout" type="time.Duration" default="120s">
  Always set an explicit timeout to `http.Client`. The `NewClient` client above already does this, but if you use `http.DefaultClient` directly, it will run out of timeout and a hung connection will block your goroutine indefinitely.
</ParamField>

```go theme={null}
client := &http.Client{
	Timeout: 120 * time.Second,
}
```

For finer control (connection timeout vs. body read timeout), use a `http.Transport` with `DialContext` and `ResponseHeaderTimeout`:

```go theme={null}
transport := &http.Transport{
	DialContext: (&net.Dialer{
		Timeout: 10 * time.Second,
	}).DialContext,
	ResponseHeaderTimeout: 30 * time.Second,
}

client := &http.Client{
	Transport: transport,
	Timeout:   120 * time.Second, // lítotal limit, including body read
}
```

### Retries with exponential backoff

For errors `429` (rate limit) and `5xx` (transient server error), retry with exponential backoff and jitter. For the rest of the `4xx` (`400`, `401`, `403`, `413`, `415`, `422`) do not retry without correcting the request.

```go theme={null}
func withRetry(ctx context.Context, maxAttempts int, fn func() (string, error)) (string, error) {
	var lastErr error
	base := 500 * time.Millisecond

	for attempt := 0; attempt < maxAttempts; attempt++ {
		result, err := fn()
		if err == nil {
			return result, nil
		}
		lastErr = err

		var apiErr *flashmd.APIError
		if !errors.As(err, &apiErr) || !apiErr.Retryable() {
			return "", err // error no reintentable: devolver de inmediato
		}

		// exponential backoff with jitter: 0.5s, 1s, 2s, 4s...
		wait := base * time.Duration(1<<attempt)
		jitter := time.Duration(rand.Int63n(int64(wait) / 2))
		select {
		case <-ctx.Done():
			return "", ctx.Err()
		case <-time.After(wait + jitter):
		}
	}

	return "", fmt.Errorf("flashmd: agotados %d intentos: %w", maxAttempts, lastErr)
}
```

Uso:

```go theme={null}
markdown, err := withRetry(ctx, 5, func() (string, error) {
	return client.ConvertRaw(ctx, f, "application/pdf", opts)
})
```

<Tip>
  If you prefer not to reimplement the backoff by hand, libraries like [`cenkalti/backoff`](https://github.com/cenkalti/backoff) offer configurable retry policies (exponential, maximum attempts, jitter) ready to use with any `func() error`.
</Tip>

### BYOS (Bring Your Own Storage) for large files

For large documents, instead of waiting for the full markdown in the HTTP response, you can ask the API to upload it directly to your own storage (S3, GCS, R2, etc.) using a signed URL of type `PUT`. This avoids keeping the connection open and downloading the entire file into memory.

```go theme={null}
func convertToStorage(ctx context.Context, client *flashmd.Client, pdfPath, presignedPutURL, presignedHeadURL string) error {
	f, err := os.Open(pdfPath)
	if err != nil {
		return err
	}
	defer f.Close()

	payload, _ := json.Marshal(map[string]any{
		"filename":         filepath.Base(pdfPath),
		"mode":             "fast",
		"output_url":       presignedPutURL,
		"output_encoding":  "zstd", // enabled automatically when using output_url
		"output_head_url":  presignedHeadURL, // optional: detect cache hits antes de reprocesar
	})

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.markpdf.tech/convert/from-url", bytes.NewReader(payload))
	if err != nil {
		return err
	}
	req.Header.Set("x-api-key", os.Getenv("FLASH_MD_API_KEY"))
	req.Header.Set("content-type", "application/json")

	res, err := client.HTTPClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()

	// The response already comes in response_format=json (activated automatically
	// by output_url) confirming the upload, not the markdown itself.
	body, _ := io.ReadAll(res.Body)
	fmt.Println(string(body))
	return nil
}
```

<Note>
  Using `output_url` automatically activates `response_format=json`, `output_encoding=zstd`, and `slim=true`. Pass `output_head_url` (a signed URL of type `HEAD`) if you want API to detect that the result already exists in your storage and avoid reprocessing the document.
</Note>
