Go client for the FreeStuff API v2.
Freego is a Go library for the FreeStuff API, the API behind the FreeStuff Discord bot. It gives your services the same data and events the bot uses to announce free games:
- Webhooks (package
webhook): receive new announcements and product updates as they happen. Deliveries are verified (Ed25519 signature, timestamp, replay protection) and handed to typed callbacks. - REST API (package
freego): list and look up products, and read the API's schemas, problem types and event types.
It has no dependencies outside the Go standard library.
| Free tier | Full tier | |
|---|---|---|
| Webhooks | ✓ | ✓ |
Ping, Schemas, Schema, Problems, Events |
✓ | ✓ |
Products, ProductsIter, Product |
✗ (ErrUnavailableForFreeTier) |
✓ |
- Go
1.22or later. - A FreeStuff application. Create one on the FreeStuff dashboard, sign up for a plan under Plans & Billing, and copy your REST API key and public key from my application.
go get github.com/eoussama/freegoSet your webhook URL on the dashboard, then mount a handler on it:
package main
import (
"context"
"log"
"os"
"os/signal"
"github.com/eoussama/freego"
"github.com/eoussama/freego/webhook"
)
func main() {
verifier, err := webhook.NewVerifier(os.Getenv("FREEGO_FREESTUFF_PUBLIC_KEY"))
if err != nil {
log.Fatal(err)
}
handler := webhook.NewHandler(verifier, webhook.Config{
OnAnnouncementCreated: func(ctx context.Context, e *webhook.Event, a *freego.ResolvedAnnouncement) error {
for _, p := range a.ResolvedProducts {
if p.ShouldIgnore() {
continue
}
url, _ := p.BestURL(freego.URLFlagOpensInBrowser)
log.Printf("%s is free on %s: %s", p.Title, p.Store, url.URL)
}
return nil
},
OnProductUpdated: func(ctx context.Context, e *webhook.Event, p *freego.Product) error {
log.Printf("%s was updated", p.Title)
return nil
},
OnError: func(ctx context.Context, e *webhook.Event, err error) {
log.Print(err)
},
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
if err := webhook.ListenAndServe(ctx, ":8080", "/webhook", handler); err != nil {
log.Fatal(err)
}
}webhook.Handler is a regular http.Handler, so you can also mount it on your own server or router (mux.Handle("/webhook", handler)). The handler:
- verifies the
Webhook-Signatureagainst your public key and rejects timestamps more than 5 minutes off (webhook.WithTolerancechanges this); - remembers message ids for 24 hours (
Config.ReplayWindow), so FreeStuff's retries and replayed requests are acknowledged without running your callbacks twice. Ids are kept in memory by default; implementwebhook.ReplayCacheto share them across instances or keep them across restarts; - runs callbacks synchronously, recovers panics, reports errors to
OnErrorand answers204regardless, as FreeStuff requires; - pins the payload format to the library's compatibility date (see Compatibility dates).
On another framework, use verifier.Verify(id, timestamp, signature, rawBody) directly. To test your own handlers, webhook.Sign produces deliveries signed like FreeStuff's.
client, err := freego.New(os.Getenv("FREEGO_FREESTUFF_API_KEY"))
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
// Check the API and the key. Works on every plan.
if err := client.Ping(ctx); err != nil {
log.Fatal(err)
}
// List products (full tier). Without Resolve, only partial products are
// returned: ID, Kind, Until, Type, Flags and Store.
list, err := client.Products(ctx, &freego.ProductsQuery{
Type: freego.ChannelKeep,
Resolve: true,
})
if errors.Is(err, freego.ErrUnavailableForFreeTier) {
log.Fatal("listing products requires the full tier")
}
// Walk every page.
it := client.ProductsIter(&freego.ProductsQuery{Resolve: true})
for it.Next(ctx) {
fmt.Println(it.Product().Title)
}
if err := it.Err(); err != nil {
log.Fatal(err)
}
// Look up one product.
product, err := client.Product(ctx, 123456)To poll cheaply, send back the ETag of the previous result; unchanged results return freego.ErrNotModified:
list, err := client.Products(ctx, &freego.ProductsQuery{IfNoneMatch: previous.ETag})
if errors.Is(err, freego.ErrNotModified) {
// nothing changed
}Options: freego.WithHTTPClient (default: 30s timeout), freego.WithUserAgent, freego.WithBaseURL, freego.WithCompatibilityDate. All methods take a context.Context.
p.Description.Get("de") // localized description, falls back to English
p.Price("USD") // prices are in minor units: 499 = $4.99
p.BestImage(freego.ImageFlagWide) // highest priority image with the given flags
p.BestURL(freego.URLFlagOpensInClient)
p.MetaValue(freego.MetaSteamSubIDs) // metadata (full tier)
p.Flags.Has(freego.ProductFlagTrash) // low quality offer
p.Until.IsZero() // no known end date
p.ShouldIgnore() // FreeStuff asks not to show this productEnumerations (Channel, ProductKind, Store, Platform) and bitfields are open-ended: values unknown to the library are kept as-is.
API errors are *freego.Problem values decoded from FreeStuff's RFC 7807 problem documents:
var problem *freego.Problem
if errors.As(err, &problem) {
log.Println(problem.Status, problem.Type, problem.Detail)
}
errors.Is(err, freego.ErrUnauthorized) // missing, invalid or reset API key
errors.Is(err, freego.ErrUnavailableForFreeTier) // content endpoint on the free tier
errors.Is(err, freego.ErrRateLimited) // see problem.RetryAfter
errors.Is(err, freego.ErrNotFound) // any 404client.Problems(ctx) lists every problem type the API can return.
FreeStuff versions its data model with compatibility dates. This version of freego is built for 2026-06-08 (freego.CompatibilityDate), and pins it everywhere:
- REST: every request sends
X-Compatibility-Date. - Webhooks: each response carries
X-Set-Compatibility-Date. A delivery in another format is answered with400, which makes FreeStuff switch your app to the library's date and redeliver the message in that format, so no event is lost. This changes the compatibility date configured on your dashboard. An app is created with the date of its creation day, so a newly created app will usually differ from the library's date. Behind a proxy that hides the response from FreeStuff (such as smee), the switch cannot happen. To keep managing the date yourself, setwebhook.Config.AcceptAnyCompatibilityDate; deliveries in other formats are then accepted as they are, and those in the older2025-03-01format still decode.
Runnable programs live in examples/. They read the variables from .env.example:
cp .env.example .env # then fill in your keys
set -a && . ./.env && set +a
go run ./examples/products # REST: ping, schemas, current free games
go run ./examples/webhook # webhook server logging every eventexamples/webhook also reads FREEGO_WEBHOOK_PORT, FREEGO_WEBHOOK_ROUTE and FREEGO_WEBHOOK_ACCEPT_ANY_DATE. See Receiving webhooks locally for a step-by-step test with the dashboard's ping button.
Remove FREEGO_FREESTUFF_API_URL from your .env unless you want to override the base URL (it must point at …/v2).
go test -race ./...The tests run offline against local servers and locally generated keys. Live tests against the real API are opt-in:
FREEGO_FREESTUFF_API_KEY=... FREEGO_FREESTUFF_PUBLIC_KEY=... go test -tags live -run Live ./...They also check that the live Product schema still matches the one the Go types are tested against (testdata/).
FreeStuff delivers webhooks from the internet, so your local server needs a public URL. There are two ways to get one. Either way, the receiver is examples/webhook and the test is the ping button on your app's page on the FreeStuff dashboard.
You need your app's public key in .env (FREEGO_FREESTUFF_PUBLIC_KEY, see .env.example).
smee is a free relay: it gives you a public URL and forwards what it receives to your machine. The project ships a Docker image with smee preinstalled, so nothing needs installing besides Docker.
-
Create a channel. Open https://smee.io/new. It shows a URL like
https://smee.io/AbC123xYz. -
Start the dev container from the project root. The prompt changes to the container's:
scripts/start.sh
-
Start smee in the container, with your channel URL (not a placeholder:
<channel>makes the shell fail withNo such file or directory). It runs in the background:scripts/smee.sh https://smee.io/AbC123xYz 8080
It prints
Connected to https://smee.io/…andForwarding … to http://127.0.0.1:8080/webhook. -
Open a second terminal on your machine and join the same container:
docker exec -it freego-dev bash -
Start the receiver there.
docker execdoes not load.env, so do that first:cd /go/src/github.com/eoussama/freego set -a && . ./.env && set +a FREEGO_WEBHOOK_ACCEPT_ANY_DATE=true go run ./examples/webhook
Wait for
listening on :8080/webhook. The receiver must run inside the container: smee forwards to127.0.0.1:8080in there, and a server on your own machine is not reachable that way (smee reportsECONNREFUSED). -
Point FreeStuff at smee. On the dashboard, set your app's webhook URL to your smee channel URL.
-
Press ping on the dashboard. The receiver prints:
ping msg_… (manual: true)That confirms the whole chain: delivery, signature verification against your public key, and your callback. The channel page (
https://smee.io/AbC123xYz) lists every event smee received and lets you redeliver them.
Why FREEGO_WEBHOOK_ACCEPT_ANY_DATE=true? By default the handler answers a delivery in another data format with 400 plus an X-Set-Compatibility-Date header, which makes FreeStuff switch your app to the library's compatibility date and redeliver. smee does not pass the response back to FreeStuff, so that never happens, and an app whose dashboard date differs from the library's (apps get the date they were created on, for example 2026-10-01) would see every ping rejected with compatibility date mismatch. This switch accepts the dashboard's date as it is, which is safe for testing a ping.
Tunnels such as cloudflared or ngrok forward requests with their bodies untouched and relay your responses, so the default behaviour works and no Docker is needed:
set -a && . ./.env && set +a
go run ./examples/webhook # listens on :8080/webhook
cloudflared tunnel --url http://localhost:8080 # in another terminal, prints a public URLSet the dashboard's webhook URL to the printed address followed by /webhook, then press ping. If port 8080 is busy (for example because the dev container publishes it), choose another one with FREEGO_WEBHOOK_PORT=9090 for the receiver and the tunnel.
| Symptom | Cause and fix |
|---|---|
No such file or directory from scripts/smee.sh |
The channel URL was a placeholder. Use the real URL from https://smee.io/new. |
smee prints ECONNREFUSED 127.0.0.1:8080 |
Nothing is listening in the container. Start the receiver in a shell inside it (step 5). |
bind: address already in use |
Another process holds the port. ss -ltnp | grep 8080 shows which. The dev container publishes its port on your machine, so run the receiver inside it or pick another port. |
rejected delivery … compatibility date mismatch |
The dashboard's date differs from the library's. Set FREEGO_WEBHOOK_ACCEPT_ANY_DATE=true (smee) or use a tunnel (option B). This line also proves the signature was valid, as signatures are checked first. |
rejected delivery … invalid signature |
The body was altered on the way (a relay that re-encodes JSON), or the public key does not belong to this app. Check the key on the dashboard; try a tunnel. |
rejected delivery … 400 … invalid timestamp |
The clock of your machine (or container) is more than 5 minutes off. |
| Nothing arrives | Check that the webhook URL on the dashboard is your channel (or tunnel) URL, and look at the smee channel page to see whether FreeStuff delivered. |
v0.0.x was written for FreeStuff API v1, which FreeStuff has deprecated. v0.1.0 is a rewrite for API v2, and nothing carries over unchanged:
| v0.0.x | v0.1.0 |
|---|---|
freego.Config(&models.Options{...}) + freego.Init(config) |
freego.New(apiKey, opts...) |
src/enums, src/models, src/types packages |
everything in freego and freego/webhook |
client.Ping() |
client.Ping(ctx) |
client.GetGames(filter) (ids) |
client.Products(ctx, query) / client.ProductsIter(query) |
client.GetGameInfo(ids, languages) |
client.Product(ctx, id), Products with Resolve: true; localized descriptions in Product.Description |
client.GetGameAnalytics(...) |
removed: FreeStuff dropped partner endpoints |
client.On(enums.EventFreeGames, cb) (blocking, shared secret) |
webhook.NewHandler(verifier, webhook.Config{OnAnnouncementCreated: ...}) (signature verified) |
models.GameInfo |
freego.Product |
FREEGO_WEBHOOK_SECRET |
FREEGO_FREESTUFF_PUBLIC_KEY (Ed25519 public key) |
| configuration read from the environment | explicit options; only the examples read the environment |
See the changelog for details.
