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/httppatterns with methods and wildcards).
Both values stay on the server, in environment variables. Never put them in code that runs in a browser.
Install#
go get github.com/standard-webhooks/standard-webhooks/librariesCopy failed. The text is selected; copy it with your keyboard.
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:
// 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)
}
Copy failed. The text is selected; copy it with your keyboard.
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:
// 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)
}
Copy failed. The text is selected; copy it with your keyboard.
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:
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)
}
Copy failed. The text is selected; copy it with your keyboard.
fail passes Videxo's problem details to the page as they came, so the page can show what was
wrong:
// 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)
}
Copy failed. The text is selected; copy it with your keyboard.
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:
// 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)
}
Copy failed. The text is selected; copy it with your keyboard.
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)
}
Copy failed. The text is selected; copy it with your keyboard.
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:
// 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, "")
}
Copy failed. The text is selected; copy it with your keyboard.
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})
}
Copy failed. The text is selected; copy it with your keyboard.
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.
// 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
}
Copy failed. The text is selected; copy it with your keyboard.
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)
}
Copy failed. The text is selected; copy it with your keyboard.
A job's outputs[].download_url is also a signed link, valid until download_url_expires_at.
Run it#
The routes:
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))
}
Copy failed. The text is selected; copy it with your keyboard.
export VIDEXO_API_KEY=... VIDEXO_WEBHOOK_SECRET=...
go run . # http://localhost:3000Copy failed. The text is selected; copy it with your keyboard.
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#
- Operations and Presets: what else a JSON job can make.
- Webhooks: events, retries and the deliveries log.
- Error codes: every
codea problem can carry.