spoo

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 19, 2026 License: AGPL-3.0 Imports: 24 Imported by: 0

README

spoo.me Go SDK

The official Go SDK for the spoo.me link management API.

import (
    spoo "github.com/spoo-me/spoo-go"
    "github.com/spoo-me/spoo-go/option"
)

client := spoo.NewClient(option.WithAPIKey(os.Getenv("SPOO_API_KEY")))

link, err := client.Shorten(ctx, spoo.ShortenRequest{
    LongURL: "https://example.com/launch",
})
fmt.Println(link.ShortURL) // https://spoo.me/xyz
  • Zero dependencies, standard library only
  • Typed errors, automatic retries, range-over-func pagination
  • Timestamps in and out as time.Time, whatever the wire format
  • Anonymous, API key, and Sign in with Spoo authentication

Install

go get github.com/spoo-me/spoo-go

Requires Go 1.24 or newer.

Authentication

Create an API key from your spoo.me dashboard and pass it explicitly:

client := spoo.NewClient(option.WithAPIKey(os.Getenv("SPOO_API_KEY")))

Constructing without credentials is valid too: anonymous shortening and the public endpoints (stats, previews, the emoji set) work without an account.

Self-hosting spoo.me? Point the client at your instance:

client := spoo.NewClient(option.WithBaseURL("https://links.example.com"))

Apps built on the SDK should set their own attribution tag:

client := spoo.NewClient(option.WithClientTag("my-app/1.0.0"))
link, err := client.Shorten(ctx, spoo.ShortenRequest{
    LongURL:     "https://example.com/launch",
    Alias:       "launch", // or emoji: "🚀🔥"
    Password:    "optional-password",
    MaxClicks:   10_000,
    ExpireAfter: time.Now().AddDate(0, 1, 0),
})

Anonymous creations return a one-time ClaimToken. Store it and the link can be claimed into an account later:

outcome, err := client.ClaimURLs(ctx, []spoo.Claim{
    {URLID: link.ID, Token: link.ClaimToken},
})
page, err := client.ListURLs(ctx, spoo.ListURLsOptions{PageSize: 50})

// or iterate everything; pages are fetched lazily
for link, err := range client.ListURLsAll(ctx, spoo.ListURLsOptions{}) {
    if err != nil {
        return err
    }
    fmt.Println(link.Alias, link.TotalClicks)
}

Updates distinguish "leave it alone" from "clear it". Omitted fields keep their current setting, spoo.Null clears one, spoo.Set replaces it:

updated, err := client.UpdateURL(ctx, link.ID, spoo.UpdateURLParams{
    LongURL:     "https://example.com/moved",
    Password:    spoo.Null[string](),                   // remove protection
    MaxClicks:   spoo.Set(500),                         // replace the limit
    ExpireAfter: spoo.Set(time.Now().AddDate(1, 0, 0)), // replace the expiry
})

Bulk operations take up to 100 ids and always answer with per-item results, never a batch-level failure:

res, err := client.BulkUpdateStatus(ctx, ids, "INACTIVE")
if err != nil {
    return err // request-level failure only
}
for _, row := range res.Results {
    if !row.OK {
        log.Printf("%s: %s (%s)", row.ID, row.Error, row.ErrorCode)
    }
}

BulkDelete, BulkUpdateExpiry, and BulkMoveDomain follow the same shape.

Stats and exports

stats, err := client.Stats(ctx, spoo.StatsQuery{
    StartDate: time.Now().AddDate(0, 0, -30),
    GroupBy:   []string{"time", "browser", "country"},
    Timezone:  "UTC",
})

// one link, addressed by alias
stats, err = client.StatsByAlias(ctx, "launch", "spoo.me", spoo.StatsQuery{})

// anyone's public stats, no auth; the result pairs link facts with stats
public, err := client.PublicStats(ctx, "launch", spoo.PublicStatsQuery{})
fmt.Println(public.Link.Status, public.Stats.Summary.TotalClicks)

// password-protected stats: the password travels in a POST body, never
// the query string
public, err = client.PublicStats(ctx, "secret", spoo.PublicStatsQuery{
    Password: "the-link-password",
})

Without explicit dates the API returns only the last 7 days; request up to spoo.MaxRangeDays (90) explicitly for more.

Exports stream and carry the server-suggested filename. The csv format arrives as a ZIP archive with one CSV per dimension:

file, err := client.Export(ctx, spoo.StatsQuery{}, "xlsx")
if err != nil {
    return err
}
defer file.Body.Close()

out, err := os.Create(file.Filename)
if err != nil {
    return err
}
defer out.Close()
_, err = io.Copy(out, file.Body)

Errors

Failed calls return *spoo.Error with the parsed envelope and response metadata. Retrieve it with errors.As:

What you get Where
HTTP status err.StatusCode
Machine-readable code err.Code (lowercase snake_case, e.g. conflict, not_found, blocked; the one uppercase outlier is EMAIL_NOT_VERIFIED)
Human-readable message err.Message, plus err.Field on validation errors
Request id for support err.RequestID
Rate-limit state err.RateLimit (limit, remaining, reset, retry-after)

Common branches have predicates and sentinels:

Helper Meaning
spoo.IsNotFound(err) 404: no such resource, or not yours
spoo.IsRateLimited(err) 429: budget exhausted even after retries
errors.Is(err, spoo.ErrSessionExpired) the refresh token no longer works; log in again
errors.Is(err, spoo.ErrLinkPasswordProtected) the link's stats need the link password
spoo.IsBlocked(err) 451: the link was taken down by the safety pipeline

Retries

Idempotent requests (GET, PUT, DELETE) are retried twice by default on connection errors and 408, 429, 500, 502, 503 and 504 responses, with exponential backoff and jitter. Requests that are not idempotent are only retried when the server provably did no work (429 and 503). A Retry-After header is authoritative when the server sends one. Configure with option.WithMaxRetries(n); 0 disables retries.

Pagination

ListURLs is the manual page: check HasNext and bump Page. ListURLsAll is the auto-pager, a lazy iter.Seq2[URLItem, error] that stops cleanly on break and yields any fetch error once.

Sign in with Spoo

Connected apps authenticate users through the device flow. The SDK ships the protocol pieces; your app owns the browser, the callback listener, and token storage:

verifier, _ := spoo.GenerateCodeVerifier()
state, _ := spoo.GenerateState()

authURL := client.DeviceAuthURL(spoo.DeviceAuthParams{
    AppID:         "my-app",
    RedirectURI:   "http://127.0.0.1:53682/callback",
    State:         state,
    CodeChallenge: spoo.CodeChallengeS256(verifier),
})
// open authURL in a browser; the callback delivers ?code=...&state=...

tokens, err := client.ExchangeDeviceCode(ctx, "my-app", code, verifier)

Every app needs its own registration: redirect URIs match exactly against the registered list, and the app id is a required argument everywhere.

Hand the pair to a client and refresh happens automatically, including under concurrency (one refresh at a time, rotation persisted through your TokenSource):

client := spoo.NewClient(
    option.WithTokenSource(spoo.StaticTokens(tokens.AccessToken, tokens.RefreshToken)),
)

StaticTokens keeps rotations in memory only. For anything that outlives the process, implement the two-method TokenSource interface over your keyring, file, or database, and rotated tokens persist through it.

API coverage

Method Endpoint
Shorten, CheckAlias POST /api/v1/shorten, GET /api/v1/shorten/check-alias
ListURLs, ListURLsAll GET /api/v1/urls
GetURL, ResolveAlias GET /api/v1/urls/{id}, GET /api/v1/urls/{domain}/{alias}
UpdateURL, SetURLStatus PATCH /api/v1/urls/{id}, PATCH /api/v1/urls/{id}/status
DeleteURL, DeleteURLsByDomain DELETE /api/v1/urls/{id}, DELETE /api/v1/urls?domain=
ClaimURLs POST /api/v1/urls/claim
BulkDelete, BulkUpdateStatus, BulkUpdateExpiry, BulkMoveDomain POST /api/v1/urls/bulk/*
Stats, LinkStats, StatsByAlias GET /api/v1/stats, GET /api/v1/stats/links/{id}
PublicStats, PublicPreview GET or POST /api/v1/public/stats/{code}, GET /api/v1/public/preview/{code}
Export, ExportLink GET /api/v1/export, GET /api/v1/export/links/{id}
EmojiSet GET /api/v1/emoji-set (ETag-cached)
Me GET /auth/me
ExchangeDeviceCode, RefreshTokens, DeviceAuthURL POST /auth/device/token, POST /auth/device/refresh

License

AGPL-3.0. See LICENSE.

Documentation

Overview

Package spoo is the official Go client for the spoo.me URL shortener API.

The package covers the v1 HTTP API: shortening, link management, claiming, bulk operations, stats, exports, public previews, the emoji alias policy, and the connected-apps device flow. It has no dependencies outside the standard library.

Quickstart

Construction options live in the option subpackage (github.com/spoo-me/spoo-go/option):

client := spoo.NewClient(
	option.WithAPIKey(os.Getenv("SPOO_API_KEY")),
)

link, err := client.Shorten(ctx, spoo.ShortenRequest{
	LongURL: "https://example.com/very/long/url",
})
if err != nil {
	log.Fatal(err)
}
fmt.Println(link.ShortURL)

Authentication

Three modes are supported, all optional. Anonymous calls are legitimate for the public endpoints (shorten, public stats and previews, the emoji set):

  • option.WithAPIKey: a spoo_... API key (CI, servers).
  • option.WithTokenSource(spoo.StaticTokens(access, refresh)): a JWT pair from the connected-apps device flow. Refresh and rotation are handled automatically; implement your own TokenSource to persist rotated tokens (a keyring, a file, a database).
  • Nothing: anonymous.

Errors

Failed API calls return *Error carrying the backend's error envelope, the HTTP status, the request id, and parsed rate-limit headers. Use errors.As, the IsNotFound and IsRateLimited predicates, or the ErrSessionExpired and ErrLinkPasswordProtected sentinels:

if _, err := client.ResolveAlias(ctx, "launch", "spoo.me"); spoo.IsNotFound(err) {
	// no such link, or not yours
}

Timestamps

The wire mixes Unix seconds and ISO 8601 strings per endpoint. Response timestamps normalize to Timestamp (an embedded time.Time), and request fields take plain time.Time.

Retries

Idempotent requests (GET, PUT, DELETE) are retried twice by default on connection errors and 408, 429, 500, 502, 503 and 504, with exponential backoff and jitter, honoring Retry-After. Requests that are not idempotent are only retried when the server provably did no work (429 and 503). Tune with option.WithMaxRetries.

Pagination

Client.ListURLs returns one page with HasNext; Client.ListURLsAll returns an iter.Seq2 that pages lazily:

for link, err := range client.ListURLsAll(ctx, spoo.ListURLsOptions{}) {
	if err != nil {
		return err
	}
	fmt.Println(link.Alias, link.TotalClicks)
}
Example (DeviceFlow)

The device flow (Sign in with Spoo) in an app: the SDK provides the protocol pieces and the app owns the browser, the callback listener, and token storage. Register your app to get an app id; redirect URIs match exactly against that registration.

package main

import (
	"context"
	"fmt"
	"log"

	spoo "github.com/spoo-me/spoo-go"
	"github.com/spoo-me/spoo-go/option"
)

func main() {
	client := spoo.NewClient()

	verifier, err := spoo.GenerateCodeVerifier()
	if err != nil {
		log.Fatal(err)
	}
	state, err := spoo.GenerateState()
	if err != nil {
		log.Fatal(err)
	}
	authURL := client.DeviceAuthURL(spoo.DeviceAuthParams{
		AppID:         "my-app",
		RedirectURI:   "http://127.0.0.1:53682/callback",
		State:         state,
		CodeChallenge: spoo.CodeChallengeS256(verifier),
	})
	fmt.Println("open in a browser:", authURL)

	// The app drives the browser and receives ?code=...&state=... on
	// its callback listener, verifying the state matches.
	code := "one-time-code-from-the-callback"

	tokens, err := client.ExchangeDeviceCode(context.Background(), "my-app", code, verifier)
	if err != nil {
		log.Fatal(err)
	}

	// Hand the pair to a client via a TokenSource; refresh and
	// rotation persistence happen automatically from here.
	authed := spoo.NewClient(
		option.WithTokenSource(spoo.StaticTokens(tokens.AccessToken, tokens.RefreshToken)),
	)
	_ = authed
}
Example (ErrorHandling)

Branch on API errors with errors.As and the typed predicates.

package main

import (
	"context"
	"errors"
	"fmt"
	"os"

	spoo "github.com/spoo-me/spoo-go"
	"github.com/spoo-me/spoo-go/option"
)

func main() {
	client := spoo.NewClient(option.WithAPIKey(os.Getenv("SPOO_API_KEY")))

	_, err := client.ResolveAlias(context.Background(), "launch", "spoo.me")
	switch {
	case err == nil:
		// found
	case spoo.IsNotFound(err):
		fmt.Println("no such link, or not yours")
	case spoo.IsRateLimited(err):
		var apiErr *spoo.Error
		errors.As(err, &apiErr)
		fmt.Println("retry after", apiErr.RateLimit.RetryAfter)
	default:
		var apiErr *spoo.Error
		if errors.As(err, &apiErr) {
			fmt.Println(apiErr.Code, apiErr.Message, apiErr.RequestID)
		}
	}
}

Index

Examples

Constants

View Source
const (
	ClaimStatusClaimed      = "claimed"       // ownership transferred, token burned
	ClaimStatusAlreadyYours = "already_yours" // idempotent repeat
	ClaimStatusInvalid      = "invalid"       // unknown id, wrong token, or not claimable
)

Claim result statuses. The batch never hard-fails per item — check each result's Status.

View Source
const DefaultBaseURL = "https://spoo.me"

DefaultBaseURL is the hosted spoo.me API; see option.WithBaseURL for self-hosted deployments.

View Source
const MaxRangeDays = 90

MaxRangeDays is the widest window the stats endpoint accepts; without explicit dates it defaults to only the LAST 7 DAYS, so clients that want "all recent activity" should request this window explicitly.

Variables

View Source
var (
	// ErrSessionExpired marks a device-flow session whose refresh token
	// no longer works. The only recovery is a fresh login.
	ErrSessionExpired = errors.New("session expired")

	// ErrLinkPasswordProtected marks a 401 that is a property of the
	// link, not of the session: the link's stats require the link
	// password, supplied via PublicStatsQuery.Password.
	ErrLinkPasswordProtected = errors.New("link is password protected")

	// ErrLinkBlocked marks a 451: the link was taken down by the
	// safety pipeline because its destination was flagged. This is a
	// verdict on the link, not a transient failure — see also
	// [IsBlocked].
	ErrLinkBlocked = errors.New("link is blocked")
)

Sentinel conditions the API signals on 401 responses. A 401 means one of three things — dead session, password-protected link, or plain missing auth — and these let callers branch without string matching. Both are surfaced through Error: test with errors.Is.

View Source
var ErrTokenSourceRequired = errors.New("no refresh-capable token source configured")

ErrTokenSourceRequired is returned by Client.ForceRefresh when the client has no refresh-capable TokenSource to rotate.

View Source
var Version = "dev"

Version is the SDK release, updated on each tag.

Functions

func CodeChallengeS256

func CodeChallengeS256(verifier string) string

CodeChallengeS256 derives the S256 challenge for a verifier: BASE64URL(SHA256(verifier)) without padding (RFC 7636 §4.2).

func GenerateCodeVerifier

func GenerateCodeVerifier() (string, error)

GenerateCodeVerifier returns a PKCE code verifier: 32 random bytes encoded as unpadded base64url, always 43 characters (RFC 7636 §4.1).

func GenerateState

func GenerateState() (string, error)

GenerateState returns a random state value binding an authorization callback to the flow that started it (CSRF protection). The app must reject callbacks whose state does not match.

func IsBlocked added in v0.4.0

func IsBlocked(err error) bool

IsBlocked reports whether err is an API 451: the link was taken down by the safety pipeline. Integrators should branch on this to tell "the link was removed" apart from "something broke". errors.Is(err, ErrLinkBlocked) reports the same condition.

func IsNotFound

func IsNotFound(err error) bool

IsNotFound reports whether err is an API 404 — for the resolve-first endpoints that means "no such link, or not yours".

func IsRateLimited

func IsRateLimited(err error) bool

IsRateLimited reports whether err is an API 429. The wait to observe is in the error's RateLimit.RetryAfter (the client has already retried, so a 429 surfacing here means the budget is truly gone).

func ParseExpiry

func ParseExpiry(raw string, now time.Time) (time.Time, error)

ParseExpiry normalizes human expiry input to a time.Time for the request types. Durations ("30m", "72h") are relative to now; bare epoch seconds are converted; anything else must parse as ISO 8601. Empty input yields the zero time (no expiry).

Types

type AliasCheck

type AliasCheck struct {
	Available bool   `json:"available"`
	Reason    string `json:"reason"`
}

AliasCheck reports whether an alias is free to use, with the rejection reason when it is not (length, format, reserved, taken, or emoji_policy).

type BulkResult

type BulkResult struct {
	Summary BulkSummary `json:"summary"`
	Results []BulkRow   `json:"results"`
}

BulkResult is a bulk operation's outcome. Bulk endpoints answer HTTP 200 even when every item fails — per-item outcomes are data, not an error. Check Summary.Failed and the per-row ErrorCode.

type BulkRow

type BulkRow struct {
	ID string `json:"id"`
	// Alias is echoed when the id resolved to a URL the caller owns.
	Alias string `json:"alias"`
	OK    bool   `json:"ok"`
	// ErrorCode is set when OK is false: not_found, forbidden,
	// conflict, validation_error, internal, or not_attempted.
	ErrorCode string `json:"error_code"`
	// Error is the human-readable failure message.
	Error string `json:"error"`
}

BulkRow is one id's outcome, in request order.

type BulkSummary

type BulkSummary struct {
	Total     int `json:"total"`
	Succeeded int `json:"succeeded"`
	Failed    int `json:"failed"`
}

BulkSummary counts a bulk operation's outcomes after id dedupe.

type Claim

type Claim struct {
	URLID string `json:"url_id"`
	Token string `json:"token"`
}

Claim pairs an anonymously created URL with its one-time claim token, the bearer proof of creation returned by the anonymous shorten call.

type ClaimOutcome

type ClaimOutcome struct {
	Results []ClaimResult `json:"results"`
	Claimed int           `json:"claimed"`
}

ClaimOutcome is the whole batch's result: one row per submitted item plus a convenience count of claimed rows.

type ClaimResult

type ClaimResult struct {
	URLID  string `json:"url_id"`
	Status string `json:"status"`
}

ClaimResult is one item's outcome, in request order.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a spoo.me API client. Construct it with NewClient; the zero value is not usable. A Client is safe for concurrent use.

func NewClient

func NewClient(opts ...option.RequestOption) *Client

NewClient returns a Client for the hosted spoo.me API, anonymous unless an auth option is given. See the option package for configuration.

Example

Construct a client for the hosted API with an API key. All options are optional: an empty option list gives an anonymous client for the public endpoints, and WithBaseURL points at a self-hosted instance.

package main

import (
	"os"

	spoo "github.com/spoo-me/spoo-go"
	"github.com/spoo-me/spoo-go/option"
)

func main() {
	client := spoo.NewClient(
		option.WithAPIKey(os.Getenv("SPOO_API_KEY")),
		option.WithMaxRetries(3),
	)
	_ = client
}

func (*Client) BulkDelete

func (c *Client) BulkDelete(ctx context.Context, ids []string) (*BulkResult, error)

BulkDelete deletes up to 100 owned links by url id. Duplicates are deduplicated server-side.

func (*Client) BulkMoveDomain

func (c *Client) BulkMoveDomain(ctx context.Context, ids []string, domain string) (*BulkResult, error)

BulkMoveDomain moves up to 100 owned links by url id to a domain namespace: an owned ACTIVE custom-domain fqdn, or "" to move back to the system default (JSON null on the wire).

func (*Client) BulkUpdateExpiry

func (c *Client) BulkUpdateExpiry(ctx context.Context, ids []string, expireAfter time.Time) (*BulkResult, error)

BulkUpdateExpiry applies an expiration to up to 100 owned links by url id. The time must be in the future; the zero time clears expiry (JSON null on the wire).

func (*Client) BulkUpdateStatus

func (c *Client) BulkUpdateStatus(ctx context.Context, ids []string, status string) (*BulkResult, error)

BulkUpdateStatus applies status (ACTIVE or INACTIVE) to up to 100 owned links by url id.

func (*Client) CheckAlias

func (c *Client) CheckAlias(ctx context.Context, alias, domain string) (*AliasCheck, error)

CheckAlias reports whether alias is available, on the given domain when one is passed.

func (*Client) ClaimURLs

func (c *Client) ClaimURLs(ctx context.Context, claims []Claim) (*ClaimOutcome, error)

ClaimURLs claims anonymously created URLs into the authenticated account, up to 16 per call. Items resolve independently and partial success is normal: the call errors only on request-level failures, per-item outcomes are data.

func (*Client) DeleteURL

func (c *Client) DeleteURL(ctx context.Context, id string) error

DeleteURL permanently deletes one owned link by its url id.

func (*Client) DeleteURLsByDomain added in v0.2.0

func (c *Client) DeleteURLsByDomain(ctx context.Context, domain string) (*DomainDeletion, error)

DeleteURLsByDomain deletes every link the account owns on the given custom domain. The server refuses the system default domain, so one call can never wipe the account's spoo.me inventory; the caller must own the domain.

func (*Client) DeviceAuthURL

func (c *Client) DeviceAuthURL(p DeviceAuthParams) string

DeviceAuthURL builds the consent URL the user's browser must visit. The callback delivers ?code=...&state=...; trade the code with Client.ExchangeDeviceCode.

func (*Client) EmojiSet

func (c *Client) EmojiSet(ctx context.Context) (*EmojiSet, error)

EmojiSet fetches the emoji alias policy and pool. The payload is large and changes rarely, so the client revalidates with If-None-Match and serves its cached copy on 304 responses.

func (*Client) ExchangeDeviceCode

func (c *Client) ExchangeDeviceCode(ctx context.Context, appID, code, verifier string) (*DeviceTokens, error)

ExchangeDeviceCode trades a one-time device-auth code for a JWT pair. The code is the credential — no prior auth is required. The verifier is the PKCE code verifier whose S256 challenge was sent on the login URL, and appID must be the registration the flow started under.

func (*Client) Export

func (c *Client) Export(ctx context.Context, q StatsQuery, format string) (*ExportFile, error)

Export downloads account-wide stats in the given format (json, csv, xlsx, xml). Auth is required — anonymous export no longer exists. Slice to specific links with the short_code / url_id filters on StatsQuery; note the aggregate route reports a generic filename regardless of slicing, so single-link exports belong on ExportLink.

func (c *Client) ExportLink(ctx context.Context, urlID string, q StatsQuery, format string) (*ExportFile, error)

ExportLink downloads one owned link's stats by its url id via the per-link route, whose server-suggested filename carries the link's identity (the aggregate route names every download the same, so saved files would silently overwrite each other). Resolve an alias with ResolveAlias first; unknown and foreign ids both 404. The short_code / url_id slicing filters are aggregate-only here too.

func (*Client) ForceRefresh

func (c *Client) ForceRefresh(ctx context.Context) (Credentials, error)

ForceRefresh invalidates the current access token and refreshes the device-flow pair immediately, persisting the rotation through the TokenSource. It shares the client's single-flight guarantee, so concurrent callers trigger at most one exchange. It fails with ErrTokenSourceRequired when the client has no refresh-capable source.

func (*Client) GetURL

func (c *Client) GetURL(ctx context.Context, id string) (*URLItem, error)

GetURL fetches one owned link by its url id. Unknown and foreign ids both answer 404 (no ownership oracle).

func (*Client) LinkStats

func (c *Client) LinkStats(ctx context.Context, urlID string, q StatsQuery) (*StatsResponse, error)

LinkStats returns stats for one owned link by its url id (resolve an alias with ResolveAlias first, or use StatsByAlias). Unknown and foreign ids both 404. The short_code / url_id slicing filters are rejected client-side: the endpoint 422s on them because the path already picks the link.

func (*Client) ListURLs

func (c *Client) ListURLs(ctx context.Context, opts ListURLsOptions) (*URLPage, error)

ListURLs returns one page of the account's links; ListURLsAll iterates them all.

func (*Client) ListURLsAll

func (c *Client) ListURLsAll(ctx context.Context, opts ListURLsOptions) iter.Seq2[URLItem, error]

ListURLsAll pages through every link matching opts, lazily fetching pages as the caller ranges. opts.Page picks the starting page (1 when zero); on a fetch error the iterator yields it once and stops:

for link, err := range client.ListURLsAll(ctx, spoo.ListURLsOptions{}) {
	if err != nil {
		return err
	}
	fmt.Println(link.Alias)
}
Example

Iterate every link in the account; pages are fetched lazily as the loop advances.

package main

import (
	"context"
	"fmt"
	"log"
	"os"

	spoo "github.com/spoo-me/spoo-go"
	"github.com/spoo-me/spoo-go/option"
)

func main() {
	client := spoo.NewClient(option.WithAPIKey(os.Getenv("SPOO_API_KEY")))

	for link, err := range client.ListURLsAll(context.Background(), spoo.ListURLsOptions{
		SortBy: "total_clicks",
	}) {
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println(link.Alias, link.TotalClicks)
	}
}

func (*Client) Me

func (c *Client) Me(ctx context.Context) (*User, error)

Me returns the authenticated user.

func (*Client) PublicPreview

func (c *Client) PublicPreview(ctx context.Context, shortCode string) (*PublicPreview, error)

PublicPreview returns anyone's link preview without auth.

func (*Client) PublicStats

func (c *Client) PublicStats(ctx context.Context, shortCode string, q PublicStatsQuery) (*PublicStatsResult, error)

PublicStats returns anyone's per-link stats without auth. Private links 404; password-protected ones 401 (ErrLinkPasswordProtected) unless the query carries the link password.

func (*Client) RefreshTokens

func (c *Client) RefreshTokens(ctx context.Context, appID, refreshToken string) (*TokenPair, error)

RefreshTokens exchanges a refresh token for a new pair. The old pair is dead the moment this succeeds (rotation) — persist the result before using it. Clients with a refresh-capable TokenSource get this automatically on 401; call it directly only when driving the protocol yourself.

func (*Client) ResolveAlias

func (c *Client) ResolveAlias(ctx context.Context, alias, domain string) (*URLItem, error)

ResolveAlias looks up an owned link by alias via GET /api/v1/urls/{domain}/{alias}, mainly to obtain its url id for the per-link stats and export endpoints. The domain names the namespace the alias lives in: "spoo.me" for the default namespace, or one of the user's custom domains. Unknown and foreign aliases both answer 404 (no ownership oracle).

func (*Client) SetURLStatus added in v0.2.0

func (c *Client) SetURLStatus(ctx context.Context, id, status string) (*UpdatedURL, error)

SetURLStatus flips one owned link between ACTIVE and INACTIVE via the dedicated status endpoint. BLOCKED and EXPIRED are server-owned states and not caller-settable.

func (*Client) Shorten

func (c *Client) Shorten(ctx context.Context, req ShortenRequest) (*ShortURL, error)

Shorten creates a short link. Anonymous calls work and return a one-time ClaimToken; authenticated calls create owned links.

Example

Shorten a link with an alias, a password, and an expiry.

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	spoo "github.com/spoo-me/spoo-go"
	"github.com/spoo-me/spoo-go/option"
)

func main() {
	client := spoo.NewClient(option.WithAPIKey(os.Getenv("SPOO_API_KEY")))

	link, err := client.Shorten(context.Background(), spoo.ShortenRequest{
		LongURL:     "https://example.com/launch",
		Alias:       "launch",
		MaxClicks:   10000,
		ExpireAfter: time.Now().AddDate(0, 1, 0),
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(link.ShortURL)
}

func (*Client) Stats

func (c *Client) Stats(ctx context.Context, q StatsQuery) (*StatsResponse, error)

Stats aggregates clicks across every link the account owns. Auth is required — anonymous stats live on PublicStats. The short_code / url_id slicing filters apply here (and on Export) only.

func (*Client) StatsByAlias

func (c *Client) StatsByAlias(ctx context.Context, alias, domain string, q StatsQuery) (*StatsResponse, error)

StatsByAlias returns stats for one owned link addressed by alias and domain, folding the resolve-then-fetch dance every caller performs into one call. Pass "spoo.me" as the domain for links on the default namespace.

func (*Client) UpdateURL

func (c *Client) UpdateURL(ctx context.Context, id string, params UpdateURLParams) (*UpdatedURL, error)

UpdateURL patches one owned link by its url id. See UpdateURLParams for the tri-state field semantics.

Example

Patch a link. Omitted fields keep their current setting, spoo.Null clears one, and spoo.Set replaces it.

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	spoo "github.com/spoo-me/spoo-go"
	"github.com/spoo-me/spoo-go/option"
)

func main() {
	client := spoo.NewClient(option.WithAPIKey(os.Getenv("SPOO_API_KEY")))

	updated, err := client.UpdateURL(context.Background(), "507f1f77bcf86cd799439011", spoo.UpdateURLParams{
		LongURL:     "https://example.com/moved",
		Password:    spoo.Null[string](), // remove password protection
		ExpireAfter: spoo.Set(time.Now().AddDate(1, 0, 0)),
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(updated.Status)
}

type Credentials

type Credentials = requestconfig.Credentials

Credentials is one authentication state: an API key, or a JWT pair from the connected-apps device flow. The zero value means anonymous, a legitimate mode for the public endpoints.

type DeviceAuthParams

type DeviceAuthParams struct {
	// AppID is the app's identifier in the backend's connected-apps
	// registry. Required.
	AppID string
	// RedirectURI receives the one-time code. It must EXACTLY match a
	// redirect URI registered for the app; leave empty to use the
	// app's registered default.
	RedirectURI string
	// State from GenerateState, echoed back on the callback. Required.
	State string
	// CodeChallenge from CodeChallengeS256; keep the verifier for
	// ExchangeDeviceCode. Required.
	CodeChallenge string
}

DeviceAuthParams parameterizes the browser leg of the device flow.

type DeviceTokens

type DeviceTokens struct {
	TokenPair
	User User `json:"user"`
}

DeviceTokens is the result of the device-code exchange: a token pair plus the user who granted it.

type DomainDeletion added in v0.2.0

type DomainDeletion struct {
	Message string `json:"message"`
	Count   int    `json:"count"`
	Domain  string `json:"domain"`
}

DomainDeletion reports a delete-by-domain sweep.

type EmojiEntry

type EmojiEntry struct {
	// Char is the raw canonical emoji character (no U+FE0F variation
	// selector), matching how aliases are stored and echoed.
	Char string `json:"c"`
	// Name is the lowercased human-readable name, the primary search
	// key (e.g. "rocket").
	Name string `json:"n"`
	// Group is the Unicode category display name (e.g. "Smileys &
	// Emotion").
	Group string `json:"g"`
	// Generated reports whether the emoji is in the auto-generation
	// pool.
	Generated bool `json:"gen"`
	// Keywords are extra search aliases, when the source lists any.
	Keywords []string `json:"k"`
}

EmojiEntry is one emoji a user may choose for an alias.

type EmojiSet

type EmojiSet struct {
	// AcceptMaxVersion is the newest Unicode emoji version a custom
	// alias may use.
	AcceptMaxVersion float64 `json:"accept_max_version"`
	// GenerateMaxVersion caps auto-generated aliases (lower, for older
	// platform coverage).
	GenerateMaxVersion float64 `json:"generate_max_version"`
	// MaxGraphemes is the most emoji graphemes allowed in one alias.
	MaxGraphemes int          `json:"max_graphemes"`
	Emoji        []EmojiEntry `json:"emoji"`
}

EmojiSet is the alias emoji policy plus every choosable emoji.

type Error

type Error struct {
	// StatusCode is the HTTP status of the response.
	StatusCode int `json:"-"`
	// Code is the backend's machine-readable error code, an open
	// string enum in lowercase snake_case: "conflict",
	// "authentication_error", "not_found", "rate_limit_exceeded",
	// "payload_too_large", "blocked", "gone", and so on. The one
	// uppercase outlier is "EMAIL_NOT_VERIFIED". Read from the body,
	// with the X-Error-Code header as fallback for the edge-composed
	// responses whose bodies carry no envelope.
	Code string `json:"code"`
	// Message is the human-readable error message.
	Message string `json:"error"`
	// Field names the offending request field on validation errors.
	Field string `json:"field"`
	// Details optionally carries structured context for the error.
	Details any `json:"details"`
	// RequestID is the X-Request-ID header, for support correlation.
	RequestID string `json:"-"`
	// RateLimit carries the parsed X-RateLimit-* headers.
	RateLimit RateLimit `json:"-"`
	// contains filtered or unexported fields
}

Error mirrors the backend's error envelope {error, code, field, details} plus the response metadata that matters for handling it programmatically.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap exposes the sentinel condition (ErrSessionExpired, ErrLinkPasswordProtected) attached to this error, if any.

type ExportFile

type ExportFile struct {
	// Filename is the server-suggested name from Content-Disposition
	// (RFC 5987 filename* preferred, plain filename fallback), or a
	// spoo-export.<ext> default when the header is absent.
	Filename string
	// ContentType is the response media type.
	ContentType string
	// Body streams the file contents. csv exports arrive as a ZIP
	// archive (one CSV per dimension).
	Body io.ReadCloser
}

ExportFile is a streamed export download. The caller owns Body and must Close it.

type ListURLsOptions

type ListURLsOptions struct {
	Page      int
	PageSize  int
	SortBy    string // created_at | last_click | total_clicks
	SortOrder string // ascending | descending
	Search    string
	Status    string // ACTIVE | INACTIVE | BLOCKED | EXPIRED
	Domain    string
	// CreatedAfter and CreatedBefore bound the creation time.
	CreatedAfter  time.Time
	CreatedBefore time.Time
	// PasswordSet and MaxClicksSet are tri-state: omitted (the zero
	// Opt) leaves the filter off, Set(true) keeps only links with the
	// property, Set(false) only links without it.
	PasswordSet  Opt[bool]
	MaxClicksSet Opt[bool]
}

ListURLsOptions filters, sorts, and pages the account's links. Zero values defer to the server defaults.

type MetricPoint

type MetricPoint struct {
	Label string
	Value float64
}

MetricPoint is one (label, value) pair extracted from the loosely typed metrics payload by StatsResponse.Points.

type Opt

type Opt[T any] struct {
	// contains filtered or unexported fields
}

Opt is a tri-state optional for the few update fields where the API distinguishes JSON null from an absent key (null clears the setting, absent keeps it). The zero value is omitted; build the other states with Set and Null. Fields tagged omitzero drop omitted values from the request body.

Everywhere the API does not make that distinction, request fields are plain values — Opt never spreads beyond the update endpoints.

func Null

func Null[T any]() Opt[T]

Null returns an Opt that serializes as JSON null.

func Set

func Set[T any](v T) Opt[T]

Set returns an Opt carrying v.

func (Opt[T]) IsNull

func (o Opt[T]) IsNull() bool

IsNull reports whether the Opt is an explicit null.

func (Opt[T]) IsZero

func (o Opt[T]) IsZero() bool

IsZero reports whether the Opt is omitted, wiring it into encoding/json's omitzero handling.

func (Opt[T]) MarshalJSON

func (o Opt[T]) MarshalJSON() ([]byte, error)

MarshalJSON writes the carried value, or null for the other states (omitted values are dropped earlier by omitzero tags).

func (*Opt[T]) UnmarshalJSON

func (o *Opt[T]) UnmarshalJSON(data []byte) error

UnmarshalJSON reads null as Null and any value as Set.

func (Opt[T]) Value

func (o Opt[T]) Value() (T, bool)

Value returns the carried value and whether one is set.

type PreviewDestination

type PreviewDestination struct {
	URL     string `json:"url"`
	Domain  string `json:"domain"`
	Path    string `json:"path"`
	IsHTTPS bool   `json:"is_https"`
}

PreviewDestination describes where a short link points, decomposed for display.

type PreviewGeoDestination

type PreviewGeoDestination struct {
	PreviewDestination
	Countries []string `json:"countries"`
}

PreviewGeoDestination is a per-country destination override.

type PublicLinkFacts added in v0.2.0

type PublicLinkFacts struct {
	Alias    string `json:"alias"`
	ShortURL string `json:"short_url"`
	// LongURL is withheld (empty) when the link is not active.
	LongURL           string    `json:"long_url"`
	CreatedAt         Timestamp `json:"created_at"`
	Status            string    `json:"status"` // active | inactive | expired | blocked (lowercase)
	MaxClicks         *int      `json:"max_clicks"`
	BlockBots         bool      `json:"block_bots"`
	PasswordProtected bool      `json:"password_protected"`
}

PublicLinkFacts is the link half of the public stats envelope: what the link is, alongside how it performs.

type PublicPreview

type PublicPreview struct {
	Generation string    `json:"generation"` // v1 | v2
	Alias      string    `json:"alias"`
	ShortURL   string    `json:"short_url"`
	Status     string    `json:"status"` // active | inactive | expired | blocked (lowercase, unlike the management surface)
	CreatedAt  Timestamp `json:"created_at"`
	// PasswordProtected reports redirect protection; the destination
	// is still previewable.
	PasswordProtected bool `json:"password_protected"`
	// Destination is nil when the link is not active.
	Destination     *PreviewDestination     `json:"destination"`
	GeoDestinations []PreviewGeoDestination `json:"geo_destinations"`
}

PublicPreview is the anonymous link-preview payload: enough to show what a short link is before following it.

type PublicStatsQuery

type PublicStatsQuery struct {
	StartDate time.Time
	EndDate   time.Time
	Timezone  string // IANA name
	// Password unlocks a password-protected link's stats. When set,
	// the request goes as a POST with the password in the JSON body:
	// the body is the only channel the API reads (query-string
	// passwords are ignored so they cannot land in URLs, logs, or
	// referrers). A wrong password answers 401 invalid_password,
	// surfaced as ErrLinkPasswordProtected.
	Password string
}

PublicStatsQuery parameterizes PublicStats. The public endpoint takes only a date range and timezone — no group_by — and answers with every dimension at once.

type PublicStatsResult added in v0.2.0

type PublicStatsResult struct {
	Generation string          `json:"generation"` // v1 | v2
	Link       PublicLinkFacts `json:"link"`
	Stats      StatsResponse   `json:"stats"`
}

PublicStatsResult is the full public stats envelope.

type RateLimit

type RateLimit struct {
	// Limit is the request budget of the reported window.
	Limit int
	// Remaining is how much of the budget is left.
	Remaining int
	// Reset is when the reported window resets.
	Reset time.Time
	// RetryAfter is the server-mandated wait, sent on 429 responses.
	RetryAfter time.Duration
}

RateLimit is the backend's rate-limit state parsed from the X-RateLimit-* and Retry-After response headers (zero when absent). The backend reports the shortest rate-limit window that applies to the endpoint.

type ShortURL

type ShortURL struct {
	// ID is the identifier the management endpoints address the link by.
	ID       string `json:"id"`
	ShortURL string `json:"short_url"`
	Alias    string `json:"alias"`
	LongURL  string `json:"long_url"`
	// OwnerID is empty for anonymous creations.
	OwnerID   string    `json:"owner_id"`
	CreatedAt Timestamp `json:"created_at"`
	Status    string    `json:"status"`
	// ClaimToken is present only on anonymous creations: the one-time
	// bearer proof of creation. Store it and the link can be claimed
	// into an account later with [Client.ClaimURLs].
	ClaimToken string `json:"claim_token"`
}

ShortURL mirrors UrlResponse (POST /api/v1/shorten).

type ShortenRequest

type ShortenRequest struct {
	LongURL      string    `json:"long_url"`
	Alias        string    `json:"alias,omitempty"`
	Password     string    `json:"password,omitempty"`
	BlockBots    bool      `json:"block_bots,omitempty"`
	MaxClicks    int       `json:"max_clicks,omitempty"`
	ExpireAfter  time.Time `json:"expire_after,omitzero"`
	PrivateStats bool      `json:"private_stats,omitempty"`
	Domain       string    `json:"domain,omitempty"`
}

ShortenRequest creates a short link. Only LongURL is required; zero-valued optionals are omitted from the request.

type StatsQuery

type StatsQuery struct {
	StartDate time.Time
	EndDate   time.Time
	GroupBy   []string // time, browser, os, device, country, city, referrer, utm_source, utm_medium, utm_campaign; account-only: short_code
	Metrics   []string // clicks, unique_clicks (the default when empty)
	Timezone  string   // IANA name
	// Filters narrows results server-side; keys are the filterable
	// dimensions (browser, os, device, country, city, referrer, the
	// utm_* trio) plus the slicing filters short_code and url_id,
	// which restrict the account aggregate to specific owned links.
	Filters map[string][]string
}

StatsQuery parameterizes the authed stats and export endpoints.

type StatsResponse

type StatsResponse struct {
	URLID           string                      `json:"url_id"` // per-link responses echo the link
	Alias           string                      `json:"alias"`
	Summary         StatsSummary                `json:"summary"`
	TimeRange       StatsTimeRange              `json:"time_range"`
	Metrics         map[string][]map[string]any `json:"metrics"`
	ComputedMetrics map[string]float64          `json:"computed_metrics"`
	GeneratedAt     Timestamp                   `json:"generated_at"`
}

StatsResponse keeps Metrics loosely typed: keys are dynamic ("clicks_by_browser", "unique_clicks_by_time", ...) and each point carries its dimension label under the dimension's own name. The wire still carries a legacy "scope" key — tolerated, never read.

func (*StatsResponse) Points

func (r *StatsResponse) Points(dimension, metric string) []MetricPoint

Points extracts (label, value) pairs from the loosely typed metrics payload for one dimension/metric pair, e.g. ("browser", "clicks") → the "clicks_by_browser" series with labels from the "browser" key.

type StatsSummary

type StatsSummary struct {
	TotalClicks        int       `json:"total_clicks"`
	UniqueClicks       int       `json:"unique_clicks"`
	FirstClick         Timestamp `json:"first_click"`
	LastClick          Timestamp `json:"last_click"`
	AvgRedirectionTime float64   `json:"avg_redirection_time"`
}

StatsSummary is the headline aggregate for a stats window.

type StatsTimeRange

type StatsTimeRange struct {
	StartDate Timestamp `json:"start_date"`
	EndDate   Timestamp `json:"end_date"`
}

StatsTimeRange echoes the window a stats response covers.

type Timestamp

type Timestamp struct {
	time.Time
}

Timestamp is a time.Time that absorbs the API's mixed wire formats: some endpoints emit Unix seconds, others ISO 8601 strings, and nullable fields emit null. The zero value means "not set" (null on the wire). It embeds time.Time, so all its methods are available.

Request fields use plain time.Time and always serialize as RFC 3339, which every endpoint accepts.

func (Timestamp) MarshalJSON

func (t Timestamp) MarshalJSON() ([]byte, error)

MarshalJSON writes RFC 3339, or null for the zero value.

func (*Timestamp) UnmarshalJSON

func (t *Timestamp) UnmarshalJSON(data []byte) error

UnmarshalJSON accepts Unix seconds, ISO 8601 strings, null, and the empty string (the latter two yield the zero value).

type TokenPair

type TokenPair struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
}

TokenPair is a device-flow JWT pair. The refresh token rotates on every refresh: using a pair twice kills the session.

type TokenSource

type TokenSource = requestconfig.TokenSource

TokenSource supplies credentials for API calls and persists rotations.

Token is called before every request. Update is called after a successful token refresh: the backend rotates refresh tokens, so the old pair is dead the moment Update runs. Implementations that back a long-lived login (keyring, file, database) must persist the new pair.

func StaticAPIKey

func StaticAPIKey(key string) TokenSource

StaticAPIKey returns a TokenSource for a fixed spoo_... API key. Update is a no-op: API keys never rotate client-side.

func StaticTokens

func StaticTokens(access, refresh string) TokenSource

StaticTokens returns a TokenSource seeded with a device-flow JWT pair. Rotations are kept in memory only: when the process exits the rotated pair is lost and the seed pair is already dead, so use this for short-lived processes and implement TokenSource yourself for anything that must survive restarts.

type URLItem

type URLItem struct {
	ID      string `json:"id"`
	Alias   string `json:"alias"`
	LongURL string `json:"long_url"`
	// ShortURL is derived client-side (the management wire carries no
	// short_url): https://<Domain>/<Alias> when the link lives on a
	// custom domain, else the client's base URL plus the alias. Empty
	// when Alias is empty. Emoji aliases appear unencoded, matching
	// the shorten response.
	ShortURL     string    `json:"-"`
	CreatedAt    Timestamp `json:"created_at"`
	LastClick    Timestamp `json:"last_click"`
	TotalClicks  int       `json:"total_clicks"`
	Status       string    `json:"status"`
	PasswordSet  bool      `json:"password_set"`
	MaxClicks    *int      `json:"max_clicks"`
	ExpireAfter  Timestamp `json:"expire_after"` // zero when the link does not expire
	PrivateStats bool      `json:"private_stats"`
	BlockBots    bool      `json:"block_bots"`
	Domain       string    `json:"domain"`
}

URLItem is a row from GET /api/v1/urls and the shape of GET /api/v1/urls/{url_id}. The list envelope is camelCase (pageSize, hasNext) but items are snake_case; expire_after arrives as Unix seconds and created_at/last_click as ISO strings — all normalized to Timestamp.

type URLPage

type URLPage struct {
	Items     []URLItem `json:"items"`
	Page      int       `json:"page"`
	PageSize  int       `json:"pageSize"`
	Total     int       `json:"total"`
	HasNext   bool      `json:"hasNext"`
	SortBy    string    `json:"sortBy"`
	SortOrder string    `json:"sortOrder"`
}

URLPage is one page of the account's links. HasNext reports whether requesting Page+1 yields more.

type UpdateURLParams

type UpdateURLParams struct {
	LongURL      string         `json:"long_url,omitzero"`
	Alias        string         `json:"alias,omitzero"`
	Status       string         `json:"status,omitzero"` // ACTIVE | INACTIVE
	Password     Opt[string]    `json:"password,omitzero"`
	MaxClicks    Opt[int]       `json:"max_clicks,omitzero"`
	ExpireAfter  Opt[time.Time] `json:"expire_after,omitzero"`
	Domain       Opt[string]    `json:"domain,omitzero"`
	BlockBots    Opt[bool]      `json:"block_bots,omitzero"`
	PrivateStats Opt[bool]      `json:"private_stats,omitzero"`
}

UpdateURLParams patches a link. Plain fields are sent only when non-zero. The Opt fields carry the API's tri-state semantics: omitted keeps the current setting, Null clears it (remove password, remove click limit, remove expiry, move back to the default domain), Set replaces it. BlockBots and PrivateStats use Opt so that Set(false) is expressible; null keeps the existing setting there.

type UpdatedURL

type UpdatedURL struct {
	ID           string    `json:"id"`
	Alias        string    `json:"alias"`
	LongURL      string    `json:"long_url"`
	Status       string    `json:"status"`
	PasswordSet  bool      `json:"password_set"`
	MaxClicks    *int      `json:"max_clicks"`
	ExpireAfter  Timestamp `json:"expire_after"`
	BlockBots    bool      `json:"block_bots"`
	PrivateStats bool      `json:"private_stats"`
	Domain       string    `json:"domain"`
	UpdatedAt    Timestamp `json:"updated_at"`
}

UpdatedURL mirrors UpdateUrlResponse — unlike the shorten response it carries no short_url.

type User

type User struct {
	ID            string `json:"id"`
	Email         string `json:"email"`
	EmailVerified bool   `json:"email_verified"`
	Name          string `json:"name"`
	Plan          string `json:"plan"`
}

User is the account behind the current credentials.

Directories

Path Synopsis
internal
requestconfig
Package requestconfig holds the client configuration state that the root spoo package and the option package share.
Package requestconfig holds the client configuration state that the root spoo package and the option package share.
transport
Package transport carries the HTTP machinery behind the spoo client: retry policy, redirect header hygiene, and download filename parsing.
Package transport carries the HTTP machinery behind the spoo client: retry policy, redirect header hygiene, and download filename parsing.
Package option configures a spoo.Client at construction:
Package option configures a spoo.Client at construction:

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL