Skip to content

DocsIntegrations

Go

A Go server on net/http that creates jobs from JSON, takes uploads straight from the browser, verifies webhooks and hands out downloads.

What you build#

A Go server, on net/http and the standard library, that turns an uploaded file into an MP4, or into an MP4, a poster and a GIF preview in one job. The browser sends the file straight to Videxo with a one-time URL, so it never passes through your server. Your server keeps the API key, creates the job, verifies the webhook and sends each download to a fresh signed link. Every call to Videxo sits in one file, videxo.go, with plain HTTP.

The whole project: videxo-go.zip, or browse it in the examples repository (opens in a new tab).

Before you start#

  • An API key from the dashboard's API keys page, in VIDEXO_API_KEY.
  • The webhook signing secret from the dashboard's Webhooks page, in VIDEXO_WEBHOOK_SECRET.
  • Go 1.24 or newer (the routes use net/http patterns with methods and wildcards).

Both values stay on the server, in environment variables. Never put them in code that runs in a browser.

Install#

bash
go get github.com/standard-webhooks/standard-webhooks/libraries

The Standard Webhooks library for Go (opens in a new tab) verifies the webhook's signature; everything else is the standard library.

Create a job#

The JSON bodies sit in requests/, embedded in the binary: one MP4 through POST /v1/transcode, and an MP4, a poster and a GIF preview in one job through POST /v1/jobs. The functions put the uploaded file in as the input and send an Idempotency-Key:

examples/go/videxo.go · plain HTTP
// Transcode makes one MP4 from one input: POST /v1/transcode with the body in requests/transcode.json.
func Transcode(input, idempotencyKey string) (*json.RawMessage, error) {
	body, err := withInput(transcodeBody, func(body map[string]any) { body["input"] = input })
	if err != nil {
		return nil, err
	}
	return decode[json.RawMessage]("POST", "/v1/transcode", body, idempotencyKey)
}

// CreateJob makes several outputs from one input in one job: POST /v1/jobs with the body in requests/jobs.json.
func CreateJob(input, idempotencyKey string) (*json.RawMessage, error) {
	body, err := withInput(jobsBody, func(body map[string]any) { body["inputs"] = map[string]string{"talk": input} })
	if err != nil {
		return nil, err
	}
	return decode[json.RawMessage]("POST", "/v1/jobs", body, idempotencyKey)
}

Every function goes through one request helper. An error response is problem details; it becomes a *Problem error with the Code, the Detail and the field Errors. The client does not follow redirects, so the download can read the link:

examples/go/videxo.go · plain HTTP
// Problem is an error answer of the API: RFC 9457 problem details. Branch on Code (and Reason for
// the finer case); show Detail to people; quote RequestID to support.
type Problem struct {
	Type      string            `json:"type"`
	Title     string            `json:"title"`
	Status    int               `json:"status"`
	Detail    string            `json:"detail,omitempty"`
	Code      string            `json:"code,omitempty"`
	Reason    string            `json:"reason,omitempty"`
	Retryable bool              `json:"retryable"`
	RequestID string            `json:"request_id,omitempty"`
	Errors    []json.RawMessage `json:"errors,omitempty"`
}

func (p *Problem) Error() string { return p.Code + ": " + p.Detail }

// The client does not follow redirects: the download route reads the Location itself.
var client = &http.Client{
	Timeout:       60 * time.Second,
	CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}

func apiURL() string {
	if value := os.Getenv("VIDEXO_API_URL"); value != "" {
		return value
	}
	return "https://api.videxo.io"
}

// request sends one call and answers the response of a 2xx or 3xx; an error answer becomes a *Problem.
func request(method, path string, body any, idempotencyKey string) (*http.Response, error) {
	apiKey := os.Getenv("VIDEXO_API_KEY")
	if apiKey == "" {
		return nil, errors.New("set VIDEXO_API_KEY in the server environment")
	}
	var reader io.Reader
	if body != nil {
		encoded, err := json.Marshal(body)
		if err != nil {
			return nil, err
		}
		reader = bytes.NewReader(encoded)
	}
	req, err := http.NewRequest(method, apiURL()+path, reader)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+apiKey)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	if idempotencyKey != "" {
		req.Header.Set("Idempotency-Key", idempotencyKey)
	}
	res, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	if res.StatusCode < 400 {
		return res, nil
	}
	defer res.Body.Close()
	problem := &Problem{}
	if json.NewDecoder(res.Body).Decode(problem) != nil || problem.Code == "" {
		// Not problem details: a proxy or the network answered, not the API.
		problem = &Problem{Type: "about:blank", Title: "The API answered with an error.", Status: res.StatusCode,
			Detail: fmt.Sprintf("HTTP %d", res.StatusCode), Code: "http_error"}
	}
	return nil, problem
}

// decode sends one call and reads its JSON answer into a value of type T.
func decode[T any](method, path string, body any, idempotencyKey string) (*T, error) {
	res, err := request(method, path, body, idempotencyKey)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	value := new(T)
	return value, json.NewDecoder(res.Body).Decode(value)
}

The handler takes the upload's input_url from the page and derives the idempotency key from the upload, so a retried request returns the same job:

examples/go/main.go · plain HTTP
var uploadSource = regexp.MustCompile(`^videxo://uploads/(up_[a-f0-9]{24})$`)

func createJob(w http.ResponseWriter, r *http.Request) {
	var body struct {
		Kind     string `json:"kind"`
		InputURL string `json:"input_url"`
	}
	json.NewDecoder(r.Body).Decode(&body)
	// Accept only uploads, so nobody can make this server fetch URLs of their choice.
	match := uploadSource.FindStringSubmatch(body.InputURL)
	if match == nil {
		fail(w, badRequest("input_url must be the input_url of an upload."))
		return
	}
	// The same key for the same upload and kind: a retried request answers the same job, never a second one.
	var job *json.RawMessage
	var err error
	if body.Kind == "jobs" {
		job, err = CreateJob(body.InputURL, "jobs-"+match[1])
	} else {
		job, err = Transcode(body.InputURL, "transcode-"+match[1])
	}
	if err != nil {
		fail(w, err)
		return
	}
	reply(w, http.StatusAccepted, job)
}

fail passes Videxo's problem details to the page as they came, so the page can show what was wrong:

examples/go/main.go · plain HTTP
// fail answers an error: Videxo's problem details go to the page as they came, so it can show
// Detail and the field errors; anything else is a plain 500.
func fail(w http.ResponseWriter, err error) {
	w.Header().Set("Content-Type", "application/problem+json")
	var problem *Problem
	if !errors.As(err, &problem) {
		log.Print(err)
		problem = &Problem{Type: "about:blank", Title: "Something went wrong.", Status: 500}
	}
	w.WriteHeader(problem.Status)
	json.NewEncoder(w).Encode(problem)
}

Receive the webhook#

When the job ends, Videxo sends a signed POST (Webhooks). The handler reads the raw body, exactly as it arrived, and checks it with the request's headers:

examples/go/videxo.go · plain HTTP
// VerifyWebhook answers the event when the request is a webhook Videxo signed, in the last five
// minutes, and an error otherwise. body is the raw body exactly as received: parsed and serialized
// again, it no longer matches the signature.
func VerifyWebhook(body []byte, headers http.Header) (*WebhookEvent, error) {
	webhook, err := standardwebhooks.NewWebhook(os.Getenv("VIDEXO_WEBHOOK_SECRET"))
	if err != nil {
		return nil, err
	}
	if err := webhook.Verify(body, headers); err != nil {
		return nil, err
	}
	event := &WebhookEvent{}
	return event, json.Unmarshal(body, event)
}
examples/go/main.go · plain HTTP
func webhook(w http.ResponseWriter, r *http.Request) {
	// The raw body, exactly as received: parsed and serialized again, it no longer matches the
	// signature. It is the whole job, up to several hundred KB; 2 MB leaves room.
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 2<<20))
	if err != nil {
		w.WriteHeader(http.StatusRequestEntityTooLarge)
		return
	}
	event, err := VerifyWebhook(body, r.Header)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	id := r.Header.Get("webhook-id")
	handledMu.Lock()
	seen := handled[id]
	handled[id] = true
	handledMu.Unlock()
	if !seen {
		onJobEnded(event)
	}
	// Answer 2xx within seconds; do slow work in a queue of your own.
	w.WriteHeader(http.StatusNoContent)
}

Verify rejects a wrong signature and a timestamp more than five minutes away. A delivery can arrive more than once with the same webhook-id; keep the ids you have handled in a unique column and skip one you have seen.

Upload a file#

The server creates the upload with the API key and gives the browser only its one-time URL:

examples/go/videxo.go · plain HTTP
// CreateUpload makes a one-time upload URL for a file the browser sends straight to Videxo; the API key stays here.
func CreateUpload(filename string, sizeBytes int64, contentType string) (*Upload, error) {
	body := map[string]any{"filename": filename, "size_bytes": sizeBytes, "content_type": contentType}
	return decode[Upload]("POST", "/v1/uploads", body, "")
}
examples/go/main.go · plain HTTP
func createUpload(w http.ResponseWriter, r *http.Request) {
	var body struct {
		Filename    string `json:"filename"`
		SizeBytes   int64  `json:"size_bytes"`
		ContentType string `json:"content_type"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		fail(w, badRequest("The body is not JSON."))
		return
	}
	upload, err := CreateUpload(body.Filename, body.SizeBytes, body.ContentType)
	if err != nil {
		fail(w, err)
		return
	}
	// Only the one-time URL goes to the browser; it holds no API key.
	reply(w, http.StatusCreated, map[string]string{"input_url": upload.InputURL, "upload_url": upload.UploadURL})
}

The page then sends the file with PUT to upload_url (no API key; the URL takes one PUT) and asks the server for the job with the upload's input_url, videxo://uploads/<id>. See Files for the size limit and how long an upload lasts.

Download the outputs#

Signed links expire within an hour, so the download handler asks for a fresh one each time: GET /v1/jobs/{id}/outputs/{alias} returns a redirect to it, and the handler sends the browser on.

examples/go/videxo.go · plain HTTP
// OutputURL is a fresh signed link to one output, from the redirect of GET /v1/jobs/{id}/outputs/{alias}.
func OutputURL(jobID, alias string) (string, error) {
	res, err := request("GET", "/v1/jobs/"+url.PathEscape(jobID)+"/outputs/"+url.PathEscape(alias), nil, "")
	if err != nil {
		return "", err
	}
	res.Body.Close()
	return res.Header.Get("Location"), nil
}
examples/go/main.go · plain HTTP
func download(w http.ResponseWriter, r *http.Request) {
	// A fresh link each time: signed links expire within an hour.
	link, err := OutputURL(r.PathValue("id"), r.PathValue("alias"))
	if err != nil {
		fail(w, err)
		return
	}
	http.Redirect(w, r, link, http.StatusFound)
}

A job's outputs[].download_url is also a signed link, valid until download_url_expires_at.

Run it#

The routes:

examples/go/main.go · plain HTTP
func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		w.Write(page)
	})
	mux.HandleFunc("POST /api/uploads", createUpload)
	mux.HandleFunc("POST /api/jobs", createJob)
	mux.HandleFunc("GET /api/jobs/{id}", getJob)
	mux.HandleFunc("GET /api/jobs/{id}/outputs/{alias}", download)
	mux.HandleFunc("POST /webhooks/videxo", webhook)
	port := os.Getenv("PORT")
	if port == "" {
		port = "3000"
	}
	log.Printf("http://localhost:%s", port)
	log.Fatal(http.ListenAndServe(":"+port, mux))
}
bash
export VIDEXO_API_KEY=... VIDEXO_WEBHOOK_SECRET=...
go run .                # http://localhost:3000

Open the page, pick a file and choose the job. The page follows the job by reading it every 2 seconds, then lists the outputs as download links.

Webhooks need a public https URL. In development run a tunnel (for example cloudflared tunnel --url http://localhost:3000) and set VIDEXO_WEBHOOK_URL to its address plus /webhooks/videxo; each job then carries it as its webhook_url. Or set the address once as your account's endpoint on the Webhooks page.

Next steps#