<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ángel Josué Parra Mendoza</title>
    <description>The latest articles on DEV Community by Ángel Josué Parra Mendoza (@ngel_josuparramendoza).</description>
    <link>https://dev.to/ngel_josuparramendoza</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4170355%2F0c606eeb-f84d-4fb5-86e3-19ff6b9c53b7.jpeg</url>
      <title>DEV Community: Ángel Josué Parra Mendoza</title>
      <link>https://dev.to/ngel_josuparramendoza</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ngel_josuparramendoza"/>
    <language>en</language>
    <item>
      <title>Fetching Instagram profiles concurrently in Go with HikerAPI</title>
      <dc:creator>Ángel Josué Parra Mendoza</dc:creator>
      <pubDate>Thu, 08 Oct 2026 06:07:09 +0000</pubDate>
      <link>https://dev.to/ngel_josuparramendoza/fetching-instagram-profiles-concurrently-in-go-with-hikerapi-13al</link>
      <guid>https://dev.to/ngel_josuparramendoza/fetching-instagram-profiles-concurrently-in-go-with-hikerapi-13al</guid>
      <description>&lt;p&gt;HikerAPI has official clients for Python and Node, but not for Go. I wanted to pull public Instagram profile data (followers, post count, bio) from a Go program, so I wrote a small client using only the standard library.&lt;/p&gt;

&lt;p&gt;By the end of this post you'll have a CLI that:&lt;/p&gt;

&lt;p&gt;takes a list of usernames,&lt;br&gt;
fetches them concurrently with a bounded worker pool,&lt;br&gt;
prints one JSON object per line (ready for jq or a .jsonl file),&lt;br&gt;
and comes with a test that runs against a fake server, so it doesn't spend your API credits.&lt;br&gt;
Setup&lt;/p&gt;

&lt;p&gt;Create an account at hikerapi.com and generate an access key. The free tier gives you 100 requests, which is plenty to follow along.&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
mkdir igprofiles &amp;amp;&amp;amp; cd igprofiles&lt;br&gt;
go mod init igprofiles&lt;br&gt;
export HIKERAPI_KEY="your-access-key"&lt;/p&gt;

&lt;p&gt;The endpoint we'll use is:&lt;/p&gt;

&lt;p&gt;GET &lt;a href="https://api.hikerapi.com/v2/user/by/username?username=" rel="noopener noreferrer"&gt;https://api.hikerapi.com/v2/user/by/username?username=&lt;/a&gt;&lt;br&gt;
Header: x-access-key: &lt;/p&gt;

&lt;p&gt;The response wraps the profile in a user object.&lt;/p&gt;

&lt;p&gt;Modeling only what we need&lt;/p&gt;

&lt;p&gt;Instagram's user object has dozens of fields. I only decode the ones I care about, and encoding/json silently ignores the rest:&lt;/p&gt;

&lt;p&gt;go&lt;br&gt;
type Profile struct {&lt;br&gt;
    PK             FlexID &lt;code&gt;json:"pk"&lt;/code&gt;&lt;br&gt;
    Username       string &lt;code&gt;json:"username"&lt;/code&gt;&lt;br&gt;
    FullName       string &lt;code&gt;json:"full_name"&lt;/code&gt;&lt;br&gt;
    Biography      string &lt;code&gt;json:"biography"&lt;/code&gt;&lt;br&gt;
    IsPrivate      bool   &lt;code&gt;json:"is_private"&lt;/code&gt;&lt;br&gt;
    IsVerified     bool   &lt;code&gt;json:"is_verified"&lt;/code&gt;&lt;br&gt;
    FollowerCount  int    &lt;code&gt;json:"follower_count"&lt;/code&gt;&lt;br&gt;
    FollowingCount int    &lt;code&gt;json:"following_count"&lt;/code&gt;&lt;br&gt;
    MediaCount     int    &lt;code&gt;json:"media_count"&lt;/code&gt;&lt;br&gt;
    ExternalURL    string &lt;code&gt;json:"external_url"&lt;/code&gt;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;type userResponse struct {&lt;br&gt;
    User Profile &lt;code&gt;json:"user"&lt;/code&gt;&lt;br&gt;
}&lt;br&gt;
The pk gotcha&lt;/p&gt;

&lt;p&gt;The user ID (pk) can show up as a number or as a string depending on the endpoint. If you declare it as string and get a number, decoding fails. If you declare it as int64 and get a string, same thing.&lt;/p&gt;

&lt;p&gt;A tiny custom type handles both and always stores a string:&lt;/p&gt;

&lt;p&gt;go&lt;br&gt;
type FlexID string&lt;/p&gt;

&lt;p&gt;func (f *FlexID) UnmarshalJSON(b []byte) error {&lt;br&gt;
    var s string&lt;br&gt;
    if err := json.Unmarshal(b, &amp;amp;s); err == nil {&lt;br&gt;
        *f = FlexID(s)&lt;br&gt;
        return nil&lt;br&gt;
    }&lt;br&gt;
    var n json.Number&lt;br&gt;
    if err := json.Unmarshal(b, &amp;amp;n); err != nil {&lt;br&gt;
        return err&lt;br&gt;
    }&lt;br&gt;
    *f = FlexID(n.String())&lt;br&gt;
    return nil&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Using json.Number instead of float64 matters here: Instagram IDs are big enough that a float can lose digits.&lt;/p&gt;

&lt;p&gt;The client&lt;br&gt;
go&lt;br&gt;
const baseURL = "&lt;a href="https://api.hikerapi.com" rel="noopener noreferrer"&gt;https://api.hikerapi.com&lt;/a&gt;"&lt;/p&gt;

&lt;p&gt;type Client struct {&lt;br&gt;
    key  string&lt;br&gt;
    base string&lt;br&gt;
    http *http.Client&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;func NewClient(key string) *Client {&lt;br&gt;
    return &amp;amp;Client{key: key, base: baseURL, http: &amp;amp;http.Client{Timeout: 20 * time.Second}}&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;func (c *Client) UserByUsername(ctx context.Context, username string) (*Profile, error) {&lt;br&gt;
    u := c.base + "/v2/user/by/username?username=" + url.QueryEscape(username)&lt;br&gt;
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)&lt;br&gt;
    if err != nil {&lt;br&gt;
        return nil, err&lt;br&gt;
    }&lt;br&gt;
    req.Header.Set("x-access-key", c.key)&lt;br&gt;
    req.Header.Set("accept", "application/json")&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resp, err := c.http.Do(req)
if err != nil {
    return nil, err
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
    return nil, err
}
if resp.StatusCode != http.StatusOK {
    return nil, fmt.Errorf("status %d: %s", resp.StatusCode, truncate(string(body), 200))
}

var ur userResponse
if err := json.Unmarshal(body, &amp;amp;ur); err != nil {
    return nil, fmt.Errorf("decode: %w", err)
}
if ur.User.Username == "" {
    return nil, errors.New("empty profile (user not found?)")
}
return &amp;amp;ur.User, nil
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;Two small decisions worth pointing out:&lt;/p&gt;

&lt;p&gt;base is a field, not just a constant. That's what lets the test point the client at a fake server.&lt;br&gt;
The empty-username check. Some error responses still come back as valid JSON, so "it decoded" doesn't mean "we got a profile".&lt;br&gt;
Fetching many profiles concurrently&lt;/p&gt;

&lt;p&gt;Firing one goroutine per username is fine for 5 names and a bad idea for 500. A fixed pool of workers reading from a channel keeps concurrency bounded:&lt;/p&gt;

&lt;p&gt;go&lt;br&gt;
const workers = 5&lt;br&gt;
jobs := make(chan string)&lt;br&gt;
results := make(chan result)&lt;/p&gt;

&lt;p&gt;var wg sync.WaitGroup&lt;br&gt;
for i := 0; i &amp;lt; workers; i++ {&lt;br&gt;
    wg.Add(1)&lt;br&gt;
    go func() {&lt;br&gt;
        defer wg.Done()&lt;br&gt;
        for name := range jobs {&lt;br&gt;
            p, err := client.UserByUsername(ctx, name)&lt;br&gt;
            results &amp;lt;- result{username: name, profile: p, err: err}&lt;br&gt;
        }&lt;br&gt;
    }()&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;go func() {&lt;br&gt;
    for _, u := range usernames {&lt;br&gt;
        jobs &amp;lt;- u&lt;br&gt;
    }&lt;br&gt;
    close(jobs)&lt;br&gt;
}()&lt;/p&gt;

&lt;p&gt;go func() {&lt;br&gt;
    wg.Wait()&lt;br&gt;
    close(results)&lt;br&gt;
}()&lt;/p&gt;

&lt;p&gt;enc := json.NewEncoder(os.Stdout)&lt;br&gt;
for r := range results {&lt;br&gt;
    if r.err != nil {&lt;br&gt;
        fmt.Fprintf(os.Stderr, "✗ %s: %v\n", r.username, r.err)&lt;br&gt;
        continue&lt;br&gt;
    }&lt;br&gt;
    _ = enc.Encode(r.profile)&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Profiles go to stdout and errors go to stderr, so you can redirect the data to a file and still see failures in the terminal:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
go run . nasa natgeo instagram &amp;gt; profiles.jsonl&lt;br&gt;
jq '{username, follower_count}' profiles.jsonl&lt;/p&gt;

&lt;p&gt;Testing without spending requests&lt;/p&gt;

&lt;p&gt;net/http/httptest spins up a local server, so the test covers the real parsing and error handling without touching the network:&lt;/p&gt;

&lt;p&gt;go&lt;br&gt;
func TestUserByUsername(t *testing.T) {&lt;br&gt;
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {&lt;br&gt;
        if r.Header.Get("x-access-key") != "k" {&lt;br&gt;
            w.WriteHeader(http.StatusUnauthorized)&lt;br&gt;
            return&lt;br&gt;
        }&lt;br&gt;
        switch r.URL.Query().Get("username") {&lt;br&gt;
        case "num":&lt;br&gt;
            w.Write([]byte(&lt;code&gt;{"user":{"pk":528817151,"username":"num","follower_count":10}}&lt;/code&gt;))&lt;br&gt;
        case "str":&lt;br&gt;
            w.Write([]byte(&lt;code&gt;{"user":{"pk":"123","username":"str"}}&lt;/code&gt;))&lt;br&gt;
        default:&lt;br&gt;
            w.Write([]byte(&lt;code&gt;{"detail":"not found"}&lt;/code&gt;))&lt;br&gt;
        }&lt;br&gt;
    }))&lt;br&gt;
    defer srv.Close()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;c := NewClient("k")
c.base = srv.URL

p, err := c.UserByUsername(context.Background(), "num")
if err != nil || p.PK != "528817151" || p.FollowerCount != 10 {
    t.Fatalf("numeric pk: %v %+v", err, p)
}
p, err = c.UserByUsername(context.Background(), "str")
if err != nil || p.PK != "123" {
    t.Fatalf("string pk: %v %+v", err, p)
}
if _, err := c.UserByUsername(context.Background(), "nope"); err == nil {
    t.Fatal("expected error for missing user")
}

bad := NewClient("wrong")
bad.base = srv.URL
if _, err := bad.UserByUsername(context.Background(), "num"); err == nil {
    t.Fatal("expected error for bad key")
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
bash&lt;br&gt;
go test ./... -v&lt;br&gt;
What I'd add next&lt;br&gt;
Retries with backoff for 429 and 5xx responses.&lt;br&gt;
A -workers flag instead of a constant.&lt;br&gt;
Reading usernames from stdin, so it composes with other tools.&lt;/p&gt;

</description>
      <category>api</category>
      <category>cli</category>
      <category>go</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
