Skip to content

Use an HTTP proxy in Go with net/http

Set Transport.Proxy to http.ProxyURL with a URL that carries your netdash username and password. Go sends them to the gateway in a Proxy-Authorization header, for plain HTTP requests and for the CONNECT tunnel that HTTPS uses.

Updated
Reading time
6 min
Checked against
3 sources

net/http handles a Go HTTP proxy connection on its own: give an http.Transport a proxy URL with your credentials in it, and every request made through that client goes through the netdash gateway. There is no SDK. The examples are complete package main programs that use only the standard library and were checked with Go 1.24. They use the Scale plan on the Americas gateway, scale-us.netdash.io:2000; other plans differ only in host and port, listed in the proxy format reference.

Quick start

Replace USERNAME and PASSWORD with the credentials from the dashboard (Residential, then Generate), save the file as main.go and run go run main.go:

Checked against a local test proxy
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/url"
	"time"
)

func main() {
	proxyURL, err := url.Parse("http://USERNAME-cc-US:PASSWORD@scale-us.netdash.io:2000")
	if err != nil {
		panic(err)
	}
	client := &http.Client{
		Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
		Timeout:   30 * time.Second,
	}

	resp, err := client.Get("https://api.ipify.org?format=json")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(body))
}
Go

Example output

{"ip":"203.0.113.10"}

Your output shows a different address in the country you chose.

It prints the exit IP the website saw, for example {"ip":"203.0.113.10"}. The proxy URL starts with http:// even for HTTPS websites: netdash is an HTTP proxy, and Go opens a CONNECT tunnel through it, so TLS still runs end to end between your program and the website.

Go HTTP proxy authentication

net/http documents that when the proxy URL contains a userinfo part, the request to the proxy carries the username and password in a Proxy-Authorization header. In our checks against a local test gateway that requires authentication, Go sent the header both on plain HTTP requests and on the CONNECT request for HTTPS websites.

Build the URL with url.UserPassword rather than string formatting, so the userinfo is escaped correctly whatever your password contains. Your netdash username carries the options: -cc-US sets the exit country, and -session-{ID}-ttl-{MINUTES} makes the session sticky. Save this helper as netdash.go:

Checked against a local test proxy
netdash.go
package main

import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"
)

// netdashClient returns a client that sends every request through a netdash gateway.
// Leave sessionID empty for a rotating session; set it for a sticky one.
func netdashClient(country, sessionID string, ttl int, gateway string) *http.Client {
	cc := strings.ToUpper(country)
	if cc == "UK" {
		cc = "GB"
	}
	username := os.Getenv("NETDASH_USERNAME") + "-cc-" + cc
	if sessionID != "" {
		username += fmt.Sprintf("-session-%s-ttl-%d", sessionID, ttl)
	}
	proxyURL := &url.URL{
		Scheme: "http",
		User:   url.UserPassword(username, os.Getenv("NETDASH_PASSWORD")),
		Host:   gateway, // e.g. "scale-us.netdash.io:2000"
	}
	return &http.Client{
		Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
		Timeout:   30 * time.Second,
	}
}

// newSessionID returns 8 random characters from 0-9 and a-f for a sticky session.
func newSessionID() (string, error) {
	b := make([]byte, 4)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return hex.EncodeToString(b), nil
}

Then use it from a second file in the same directory and run both with go run netdash.go main.go:

Checked against a local test proxy
main.go
package main

import (
	"fmt"
	"io"
	"log"
)

func main() {
	client := netdashClient("US", "", 0, "scale-us.netdash.io:2000")

	resp, err := client.Get("https://api.ipify.org?format=json")
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(resp.Status, string(body))
}

Credentials

Keep NETDASH_USERNAME and NETDASH_PASSWORD in a .env file or your secret manager, never in source control or in a chat with an assistant. If you log a proxy URL, log proxyURL.Redacted(), which replaces the password with xxxxx.

Sticky sessions: one client per session

A sticky session keeps the same exit IP for the TTL you set while that IP stays available. Because the session lives in the username, and the username lives in the client's transport, use one *http.Client per session ID: reuse it for every request of the task, and call CloseIdleConnections() when the task is done. A new session ID starts a new session. The dashboard's sticky presets are 15 minutes, 30 minutes, 12 hours and 7 days; rotating and sticky sessions explains how to choose.

Checked against a local test proxy
sticky.go
package main

import (
	"fmt"
	"io"
	"log"
)

func main() {
	sessionID, err := newSessionID()
	if err != nil {
		log.Fatal(err)
	}
	// One client per sticky session: German exit through the Europe gateway, 30-minute TTL
	client := netdashClient("DE", sessionID, 30, "scale-eu.netdash.io:2000")
	defer client.CloseIdleConnections()

	for i := 0; i < 2; i++ {
		resp, err := client.Get("https://api.ipify.org?format=json")
		if err != nil {
			log.Fatal(err)
		}
		body, err := io.ReadAll(resp.Body)
		resp.Body.Close()
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println(string(body)) // the same exit IP both times
	}
}

Run it with go run netdash.go sticky.go. For a rotating session, pass an empty session ID and the gateway assigns exit IPs for you. Requests sent through one HTTPS tunnel share that tunnel's exit IP, so when consecutive requests must share an exit IP, use a sticky session rather than relying on connection reuse.

Exit countries

The first argument of netdashClient is the exit country, an ISO 3166-1 alpha-2 code: netdashClient("DE", "", 0, "scale-eu.netdash.io:2000") exits in Germany. Use GB for the United Kingdom, not UK (the helper maps it). Use the -us gateway for exits in the Americas and the -eu gateway for exits in Europe. Targeting is country-level; the country targeting syntax has the details.

Timeouts, contexts and retries

Client.Timeout caps each request, including reading the body; a zero value means no timeout, so always set one. For a deadline across several attempts, pass a context with http.NewRequestWithContext. Retry only what is worth retrying: network errors, 5xx responses and 429 Too Many Requests (after the Retry-After delay), with a cap on attempts. A 403 means stop.

Checked against a local test proxy
retry.go
package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"net/http"
	"strconv"
	"time"
)

// getWithRetry retries network errors, 5xx and 429, three attempts at most, and honors
// Retry-After on 429. Every other status, including 403, goes back to the caller.
func getWithRetry(ctx context.Context, client *http.Client, target string) (*http.Response, error) {
	wait := time.Second
	for attempt := 1; ; attempt++ {
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
		if err != nil {
			return nil, err
		}
		resp, err := client.Do(req)
		retryable := err != nil || resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests
		if !retryable || attempt == 3 {
			return resp, err
		}
		delay := wait
		if resp != nil {
			if s, convErr := strconv.Atoi(resp.Header.Get("Retry-After")); convErr == nil && resp.StatusCode == http.StatusTooManyRequests {
				delay = time.Duration(s) * time.Second
			}
			resp.Body.Close()
		}
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case <-time.After(delay):
		}
		wait *= 2
	}
}

func main() {
	client := netdashClient("US", "", 0, "scale-us.netdash.io:2000")
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()

	resp, err := getWithRetry(ctx, client, "https://api.ipify.org?format=json")
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusForbidden {
		log.Fatal("403: stop here, then check the site's terms or use its official API")
	}
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(resp.Status, string(body))
}

Environment variables and DefaultTransport

http.Get, http.DefaultClient and every client without its own transport use http.DefaultTransport, which picks a proxy with ProxyFromEnvironment: it reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY, and never proxies requests to localhost. Exporting HTTPS_PROXY therefore routes every such client in the process through netdash, including SDKs that call model APIs or your own services, and those requests count against your balance. Give the clients that fetch websites an explicit Transport, as netdashClient does, and leave the environment alone.

Concurrency with a worker pool

netdash sets no per-account concurrency limit; the website sets the pace. This pool runs four workers that share one ticker, so the site sees at most two requests per second however many workers you add, and the pool stops as soon as the site answers 403 or 429:

Checked against a local test proxy
pool.go
package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"sync"
	"time"
)

func main() {
	client := netdashClient("US", "", 0, "scale-us.netdash.io:2000")
	urls := make([]string, 0, 10)
	for i := 1; i <= 10; i++ {
		urls = append(urls, fmt.Sprintf("https://example.com/catalog?page=%d", i))
	}

	ctx, stop := context.WithCancel(context.Background())
	defer stop()
	pace := time.NewTicker(500 * time.Millisecond) // at most two requests per second to this site
	defer pace.Stop()

	jobs := make(chan string, len(urls))
	for _, u := range urls {
		jobs <- u
	}
	close(jobs)

	results := make(chan string)
	var wg sync.WaitGroup
	for w := 0; w < 4; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for u := range jobs {
				select {
				case <-ctx.Done():
					return
				case <-pace.C:
				}
				req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
				if err != nil {
					results <- fmt.Sprintf("%s: %v", u, err)
					continue
				}
				resp, err := client.Do(req)
				if err != nil {
					results <- fmt.Sprintf("%s: %v", u, err)
					continue
				}
				n, _ := io.Copy(io.Discard, resp.Body)
				resp.Body.Close()
				if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
					stop() // the site is pushing back: stop, then check its terms and limits
				}
				results <- fmt.Sprintf("%s: %s, %d bytes", u, resp.Status, n)
			}
		}()
	}
	go func() {
		wg.Wait()
		close(results)
	}()
	for r := range results {
		fmt.Println(r)
	}
}

Run it with go run netdash.go pool.go.

Collect public data only, respect robots.txt and each site's terms, keep request rates reasonable and back off when a site slows you down. Don't collect personal data without a lawful basis, and don't access content behind logins or paywalls without authorization. Test only systems you own or are authorized to test. netdash does not solve CAPTCHAs: if a site rate-limits or challenges your requests, slow down, check its terms, or use its official API. Read how we expect netdash to be used.

Errors and fixes

SymptomCauseFix
panic: runtime error: invalid memory address or nil pointer dereference at resp.Body.Close()The error from Get or Do was ignored, so resp is nilCheck err before using resp
Get "https://...": Proxy Authentication RequiredThe gateway rejected the credentials during CONNECTCheck the password and the username parameters (-cc-, -session-...-ttl-...)
407 Proxy Authentication Required as the response status for an http:// URLSame cause; for plain HTTP, Go returns the gateway's 407 as a response, not an errorSame fix
proxyconnect tcp: dial tcp ...: connect: connection refusedWrong host and port pairUse the host and port from the endpoint table
context deadline exceeded (Client.Timeout exceeded while awaiting headers) or proxyconnect tcp: ... i/o timeoutYour network blocks the outbound port, or the host is wrongAllow outbound TCP to the gateway port; check the endpoint table
Connections pile up and memory growsResponse bodies are not closeddefer resp.Body.Close() right after the error check
Requests to a local service go through the proxy, or skip it, unexpectedlyProxyFromEnvironment rules: NO_PROXY, and localhost is never proxiedUse an explicit Transport for each purpose
io/ioutil deprecation warningsCode from an older exampleUse io.ReadAll and io.Discard

netdash forwards your traffic unchanged. It doesn't solve CAPTCHAs, render JavaScript or retry requests. Targeting is country-level.

Next steps

The same clients run scheduled rank checks for SEO monitoring. If you also write Python, the Python requests proxy guide uses the same username conventions, and the other proxy integration guides cover Node.js, Scrapy and browser tools. If you don't have credentials yet, the quickstart takes you from a new account to the first request.

Stuck? See troubleshooting or email support with your username (without the password) and the error message.

Email support

Sources

Third-party behaviour described on this page was checked against these documents.

  1. 01Go net/http: Transport.Proxy (userinfo sent as Proxy-Authorization), ProxyURL, ProxyFromEnvironment, DefaultTransport, Client.TimeoutAccessed 2026-10-09
  2. 02Go net/http: ProxyFromEnvironment (HTTP_PROXY, HTTPS_PROXY, NO_PROXY, localhost)Accessed 2026-10-09
  3. 03Go net/url: UserPassword and URL.RedactedAccessed 2026-10-09

Spotted something out of date? Tell us.

Privacy choices

We use cookies and similar technologies that this site needs to work and to remember your choice. We also use Google Analytics to understand which pages and features people use: in the EEA, the UK and Switzerland only with your consent. Live chat loads only if you open it. You can change your choice at any time under “Privacy choices” in the footer. Privacy Policy

Privacy choices

Choose what netdash.io may use. You can change this at any time under “Privacy choices” in the footer.

Necessary (always on)

Keeps the site working and secure, and remembers your privacy choices (nd_consent, 12 months).

In the EEA, the UK and Switzerland it stays off until you switch it on; elsewhere it is on unless you switch it off here. Google Analytics 4 measures page views, clicks on links and buttons, use of the price calculator, connection builder and code examples, scroll depth, file downloads, and page-performance metrics. It sets the cookies _ga and _ga_96MPNM6GHH, which last up to 12 months. Google processes this data, which may involve transfer to the United States. We do not send your name, email address or account details to Google Analytics. With this switched on we also remember a referral code or campaign you arrived with (nd_attr, 30 days), so a partner can be credited if you sign up.

Support chat (loads only when you open it)

Our live chat is provided by LiveChat. It loads only when you open the chat window. LiveChat then sets its own cookies and processes the messages you send and technical data about your browser.