Dribbble is where a lot of designers keep their portfolio, so it's a natural data source if you're scouting talent, studying which kinds of shots get attention, or keeping an eye on your own numbers. The first thing most people search for is "Dribbble API". There is one. It just doesn't do what most people expect.

So this post covers both: what the official Dribbble API gives you, and how to build a small Dribbble scraper in Go for the public pages it doesn't cover. The scraper uses the standard library plus golang.org/x/net/html, and it will:

  1. Respect robots.txt with a tiny matcher, and stop cleanly at bot challenges
  2. Read a designer profile: name, headline, bio, location, followers, skills
  3. Page through a designer's shots with likes, views, and publish dates
  4. List a designer's collections, and read the shots inside one
  5. Snapshot a few portfolios into CSV and report what changed

Everything below was run on October 30, 2026, and the output is real.

Before you start: Dribbble's Terms of Service list "copying, distributing, or disclosing any part of the Website ... by any automated or non-automated 'scraping'" as a prohibited activity. robots.txt allows the pages used here, but robots rules and terms are different things. Read the terms and weigh the legal risk for your use case before running anything, and prefer the official API wherever it covers what you need. Treat this as a learning project or small-scale research: keep request rates low, don't republish people's work, don't resell the data, and don't use it to mass-message designers. If you only need your own data, use the official API.

Is there a Dribbble API?

Yes: the Dribbble API v2 (https://api.dribbble.com/v2/, docs at developer.dribbble.com). You register an application, do the OAuth flow, and send Authorization: Bearer <token>. The limit is 60 requests per minute and 1,440 per day per authenticated user, with X-RateLimit-* headers and Link header pagination.

The catch is in the endpoint list. v2 has GET /user, GET /user/shots, GET /shots/:id, create/update/delete shot, projects, and jobs. All of them are about the authenticated user. The docs for "Get a shot" say it plainly: "This endpoint returns only shots owned by the currently authenticated user." The old v1 endpoints for any user's shots, followers, and popular lists are gone. Without a token, GET https://api.dribbble.com/v2/user returns 401, and /v2/users/simplebits/shots is a 404.

You want Use
Your own shots, profile, projects (e.g. to show your Dribbble work on your site) Dribbble API v2
Other designers' public profiles, shots, likes/views, collections The public website, which is what this scraper reads

How dribbble.com looks from a scraper's point of view

I checked the site with curl and with Go's HTTP client first. Here is what came back:

path status notes /robots.txt 200 /simplebits 200 ~300 KB, shots embedded as JSON /simplebits?page=2 200 next 24 shots /simplebits/about 200 bio, location, skills, counts /simplebits/collections 200 6 collections per page /simplebits/collections/4582-Data-Viz 200 24 shots per page /shots/27755136-Measure-Twice-Print 202 AWS WAF challenge, empty page /shots/popular, /search/logo 202 AWS WAF challenge /shots/popular.rss 202 AWS WAF challenge /simplebits.rss, /shots.rss 404 the old RSS feeds are gone 

The status codes were the same with a Chrome User-Agent, curl's default, and Go's Go-http-client/1.1. So three findings shaped the code.

1. robots.txt is clear about what's off-limits. Profiles, collections, and shot pages are allowed. These are disallowed for every crawler:

Disallow: /*/followers$ Disallow: /*/following$ Disallow: /*/tags$ Disallow: /*/tags/* Disallow: /shots/*/comments Disallow: /shots/*/fans Disallow: /shots/*/rebounds Disallow: /shots/*/buckets Disallow: /shots/week/* (and /day/*, /month/*, /year/*) 

So no follower lists, no who-liked-this lists, no comments. The scraper below checks every path against these rules before it sends a request.

2. Profile and collection pages are server-rendered, and the shots come as JSON. The profile page includes a <script> with var newestShots = [...], one object per shot in the grid:

{"id":27755136,"title":"Measure Twice Print","path":"/shots/27755136-Measure-Twice-Print", "published_at":"September 23, 2026","is_rebound":null,"rebounds_count":0, "attachments_count":0,"view_count":"23.3k","likes_count":"108","liked":null,"ga":[]} 

That's easier and sturdier than scraping the grid's CSS. Logged out, the page shows a "Sign up to continue" button where infinite scroll would be, but the classic ?page=2 link is still in the HTML and works.

3. Individual shot pages, popular, and search sit behind an AWS WAF challenge. They return HTTP 202 with x-amzn-waf-action: challenge and a small JavaScript page that a real browser solves. I didn't try to get around it, and you shouldn't either. Everything in this post comes from profile and collection pages, which already show titles, likes, views, and dates for every shot. The scraper treats a 202 as "stop", not "retry".

One more detail: the page's own config included "isRobot": true for my requests. Dribbble knows exactly what you are, so be polite.

Step 1: Project setup and a polite fetch helper

mkdir dribbblescraper && cd dribbblescraper go mod init dribbblescraper go get golang.org/x/net/html 

getHTML checks robots.txt, keeps at least two seconds between requests, backs off on 429/5xx, and returns ErrChallenged on a WAF challenge. The User-Agent is honest. Put your own contact URL in it.

package main import ( "bufio" "errors" "fmt" "io" "net/http" "os" "regexp" "strings" "time" ) const ( base = "https://dribbble.com" userAgent = "dribbblescraper/0.1 (+https://example.com/contact)" ) var ( client = &http.Client{Timeout: 25 * time.Second} // ErrChallenged means AWS WAF answered with a JavaScript challenge // (HTTP 202, empty-ish body, x-amzn-waf-action: challenge). We stop // instead of trying to solve it. ErrChallenged = errors.New("blocked by AWS WAF challenge") ErrDisallowed = errors.New("path disallowed by robots.txt") lastRequest time.Time minGap = 2 * time.Second ) // getHTML fetches a dribbble.com path after checking robots.txt, waits at // least minGap between requests, and backs off on 429/5xx. func getHTML(path string) (string, error) { if !robots.Allowed(path) { return "", fmt.Errorf("%w: %s", ErrDisallowed, path) } for attempt := 0; attempt < 4; attempt++ { if wait := minGap - time.Since(lastRequest); wait > 0 { time.Sleep(wait) } lastRequest = time.Now() req, _ := http.NewRequest("GET", base+path, nil) req.Header.Set("User-Agent", userAgent) req.Header.Set("Accept", "text/html") req.Header.Set("Accept-Language", "en-US,en;q=0.9") res, err := client.Do(req) if err != nil { time.Sleep(time.Duration(attempt+1) * 3 * time.Second) continue } body, err := io.ReadAll(io.LimitReader(res.Body, 8<<20)) res.Body.Close() if err != nil { return "", err } switch { case res.Header.Get("x-amzn-waf-action") != "" || res.StatusCode == 202: return "", fmt.Errorf("%w: %s", ErrChallenged, path) case res.StatusCode == 429 || res.StatusCode >= 500: fmt.Fprintf(os.Stderr, "HTTP %d on %s, backing off\n", res.StatusCode, path) time.Sleep(time.Duration(attempt+1) * 15 * time.Second) continue case res.StatusCode == 404: return "", fmt.Errorf("not found: %s", path) case res.StatusCode != 200: return "", fmt.Errorf("GET %s: HTTP %d", path, res.StatusCode) } return string(body), nil } return "", errors.New("gave up after retries: " + path) } 

The robots matcher is about 50 lines. It reads the User-agent: * group and turns each rule into a regexp, so * matches anything and a trailing $ anchors the end:

// --- a tiny robots.txt matcher (User-agent: * group, * and $ wildcards) --- type robotsRules struct{ allow, disallow []*regexp.Regexp } var robots = &robotsRules{} func loadRobots() error { req, _ := http.NewRequest("GET", base+"/robots.txt", nil) req.Header.Set("User-Agent", userAgent) res, err := client.Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode != 200 { return fmt.Errorf("robots.txt: HTTP %d", res.StatusCode) } inStar := false sc := bufio.NewScanner(res.Body) for sc.Scan() { line := strings.TrimSpace(strings.SplitN(sc.Text(), "#", 2)[0]) k, v, ok := strings.Cut(line, ":") if !ok { continue } k, v = strings.ToLower(strings.TrimSpace(k)), strings.TrimSpace(v) switch { case k == "user-agent": inStar = v == "*" case inStar && k == "disallow" && v != "": robots.disallow = append(robots.disallow, robotsPattern(v)) case inStar && k == "allow" && v != "": robots.allow = append(robots.allow, robotsPattern(v)) } } return sc.Err() } // robotsPattern turns "/*/followers$" into ^/[^?]*?/followers$ style regexps. func robotsPattern(p string) *regexp.Regexp { anchored := strings.HasSuffix(p, "$") p = strings.TrimSuffix(p, "$") parts := strings.Split(p, "*") for i := range parts { parts[i] = regexp.QuoteMeta(parts[i]) } re := "^" + strings.Join(parts, ".*") if anchored { re += "$" } return regexp.MustCompile(re) } func (r *robotsRules) Allowed(path string) bool { // Match on the path without the query string for $-anchored rules. p, _, _ := strings.Cut(path, "?") for _, a := range r.allow { if a.MatchString(p) { return true } } for _, d := range r.disallow { if d.MatchString(p) { return false } } return true } 

A test pins down the rules we care about. It runs against the live robots.txt:

package main import ( "errors" "testing" ) func TestRobots(t *testing.T) { if err := loadRobots(); err != nil { t.Skip("no network:", err) } cases := map[string]bool{ "/simplebits": true, "/simplebits?page=2": true, "/simplebits/about": true, "/simplebits/collections": true, "/simplebits/followers": false, "/simplebits/following": false, "/simplebits/tags": false, "/shots/123-Foo/comments": false, "/shots/123-Foo/fans": false, "/shots/week/2026-10-01": false, "/shots/27755136-Measure-Twice-P": true, } for path, want := range cases { if got := robots.Allowed(path); got != want { t.Errorf("Allowed(%q) = %v, want %v", path, got, want) } } } func TestShotPageIsChallenged(t *testing.T) { if err := loadRobots(); err != nil { t.Skip("no network:", err) } _, err := getHTML("/shots/27755136-Measure-Twice-Print") t.Logf("shot page: %v", err) if err != nil && !errors.Is(err, ErrChallenged) { t.Errorf("unexpected error: %v", err) } } 
$ go test -v ./... === RUN TestRobots --- PASS: TestRobots (0.05s) === RUN TestShotPageIsChallenged robots_test.go:37: shot page: blocked by AWS WAF challenge: /shots/27755136-Measure-Twice-Print --- PASS: TestShotPageIsChallenged (0.07s) PASS 

Step 2: Small HTML helpers

golang.org/x/net/html gives you a parse tree but no selectors, so a few helpers go a long way. parseCount matters more than it looks: Dribbble shows views as 23.3k, and counts on the about page as 117,603.

package main import ( "strconv" "strings" "golang.org/x/net/html" ) func parseDoc(s string) (*html.Node, error) { return html.Parse(strings.NewReader(s)) } func attr(n *html.Node, key string) string { for _, a := range n.Attr { if a.Key == key { return a.Val } } return "" } func hasAttr(n *html.Node, key string) bool { for _, a := range n.Attr { if a.Key == key { return true } } return false } func hasClass(n *html.Node, class string) bool { if n.Type != html.ElementNode { return false } for _, c := range strings.Fields(attr(n, "class")) { if c == class { return true } } return false } // findAll walks the tree and returns every node matching pred. func findAll(n *html.Node, pred func(*html.Node) bool) []*html.Node { var out []*html.Node var walk func(*html.Node) walk = func(n *html.Node) { if pred(n) { out = append(out, n) } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(n) return out } func first(n *html.Node, pred func(*html.Node) bool) *html.Node { if all := findAll(n, pred); len(all) > 0 { return all[0] } return nil } func byClass(c string) func(*html.Node) bool { return func(n *html.Node) bool { return hasClass(n, c) } } func byTag(tag string) func(*html.Node) bool { return func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == tag } } // text returns the whitespace-collapsed text content of a node (nil-safe). func text(n *html.Node) string { if n == nil { return "" } var b strings.Builder var walk func(*html.Node) walk = func(n *html.Node) { if n.Type == html.TextNode { b.WriteString(n.Data + " ") } if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style" || nSource:https://dev.to/viktorhendelmann/how-to-scrape-dribbble-with-go-designer-profiles-shots-collections-and-a-portfolio-tracker-3e79

Related posts