spoo

package module
v0.6.0 Latest Latest
Warning

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

Go to latest
Published: Sep 4, 2026 License: MIT Imports: 25 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, BulkMoveDomain, and BulkUpdateTags follow the same shape.

Tags

Tags are account-wide labels with a color and an icon. A link carries up to ten of them, by id:

tag, err := client.CreateTag(ctx, spoo.CreateTagParams{Name: "launch", Color: "violet"})

link, err := client.Shorten(ctx, spoo.ShortenRequest{
    LongURL: "https://example.com/launch",
    TagIDs:  []string{tag.ID},
})

// on update the list replaces the stored one; spoo.Null clears it
_, err = client.UpdateURL(ctx, link.ID, spoo.UpdateURLParams{
    TagIDs: spoo.Set([]string{tag.ID}),
})

Filter the link list by tag id or name, and pick whether a link needs any or all of them:

page, err := client.ListURLs(ctx, spoo.ListURLsOptions{
    TagNames:  []string{"launch", "q3"},
    TagsMatch: "all",
})

Tag or untag up to 100 links at once; the result has the same per-item shape as the other bulk operations:

res, err := client.BulkUpdateTags(ctx, ids, spoo.BulkTagChange{Add: []string{tag.ID}})

Stats and exports take tag (names) and tag_id filters on the aggregate routes, covering the whole click history of the tagged links:

stats, err := client.Stats(ctx, spoo.StatsQuery{
    Filters: map[string][]string{"tag": {"launch"}},
})

ListTags, UpdateTag, and DeleteTag round out the set. Deleting a tag removes it from every link and reports how many were touched.

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, reduced to a bare name so it is safe to pass to os.Create even against a self-hosted or untrusted deployment. 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.ErrMissingLongURL) Shorten was called with an empty LongURL; nothing was sent
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.

Scope

The SDK covers the v1 data plane end to end: shortening, link management, claiming, tags, bulk operations, stats, exports, public stats and previews, the emoji alias policy, identity (Me), and the Sign in with Spoo device flow. Deliberately out of scope: API key management, health checks, the contact endpoint, profile management, and all legacy v0 routes. Anything the API grows before the SDK does is reachable through the raw request methods below.

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, BulkUpdateTags POST /api/v1/urls/bulk/*
ListTags, CreateTag GET /api/v1/tags, POST /api/v1/tags
UpdateTag, DeleteTag PATCH /api/v1/tags/{id}, DELETE /api/v1/tags/{id}
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

Raw requests

For endpoints the SDK has no typed method for yet, Get, Post, Put, Patch, and Delete on the client take an API path directly and reuse the configured auth, token refresh, retries, client tag, and error mapping:

var out map[string]any
err := client.Get(ctx, "/api/v1/new-endpoint", url.Values{"page": {"2"}}, &out)

These are supported, not a workaround. Needing one means the SDK surface has a gap, and that gap is worth an issue.

License

MIT. 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, tags, bulk operations, stats, exports, public previews, the emoji alias policy, and the connected-apps device flow. It has no dependencies outside the standard library. Endpoints without a typed method yet are reachable through the raw Client.Get, Client.Post, Client.Put, Client.Patch and Client.Delete passthroughs, which reuse the client's auth, retries and error mapping.

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 ErrMissingLongURL = errors.New("spoo: ShortenRequest.LongURL is required")

ErrMissingLongURL is returned by Client.Shorten before any request goes out when ShortenRequest.LongURL is empty. An empty required field is a programming error at the call site, so it fails fast instead of spending a round trip on a guaranteed 422.

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 overrides the version reported in the default X-Spoo-Client tag. It is normally left at its zero state ("dev"): a Go library has no build step, so instead of hand-bumping a constant on every tag, the SDK resolves its release version at runtime from the consuming binary's build info (see resolveVersion). Set Version to any other value to force what the default tag reports.

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 BulkTagChange added in v0.6.0

type BulkTagChange struct {
	Add    []string `json:"add,omitempty"`
	Remove []string `json:"remove,omitempty"`
}

BulkTagChange names the tag ids to add to and remove from every link in a Client.BulkUpdateTags call. At least one side must name a tag, and no tag may appear on both.

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) BulkUpdateTags added in v0.6.0

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

BulkUpdateTags adds and removes tags, by tag id, on up to 100 owned links by url id. Each link ends up with its current tags minus Remove plus Add (kept once, order preserved). An unknown id in Add rejects the whole request; a link that would exceed 10 tags fails per-item with validation_error.

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) CreateTag added in v0.6.0

func (c *Client) CreateTag(ctx context.Context, params CreateTagParams) (*Tag, error)

CreateTag creates a tag. A name the account already has answers 409 conflict; accounts hold at most 500 tags.

func (*Client) Delete added in v0.5.0

func (c *Client) Delete(ctx context.Context, path string, out any) error

Delete performs a raw DELETE request against an API path and decodes the JSON response into out (skipped when out is nil). It is the supported pressure valve for endpoints the SDK does not cover yet; needing it is worth an issue.

func (*Client) DeleteTag added in v0.6.0

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

DeleteTag deletes one tag by id and removes it from every link that carried it; the links are otherwise untouched.

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 / tag / tag_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 / tag / tag_id slicing filters are not accepted on the per-link routes and are rejected client-side 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) Get added in v0.5.0

func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error

Get performs a raw GET request against an API path and decodes the JSON response into out (skipped when out is nil). It is the supported pressure valve for endpoints the SDK does not cover yet; needing it is worth an issue.

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 / tag / tag_id slicing filters are not accepted on the per-link routes (the path already picks the link) and are rejected client-side.

func (*Client) ListTags added in v0.6.0

func (c *Client) ListTags(ctx context.Context) ([]Tag, error)

ListTags returns every tag in the account, oldest first, with link counts.

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) Patch added in v0.5.0

func (c *Client) Patch(ctx context.Context, path string, body, out any) error

Patch performs a raw PATCH request against an API path, marshaling body to JSON (no body when nil) and decoding the JSON response into out (skipped when out is nil). It is the supported pressure valve for endpoints the SDK does not cover yet; needing it is worth an issue.

func (*Client) Post added in v0.5.0

func (c *Client) Post(ctx context.Context, path string, body, out any) error

Post performs a raw POST request against an API path, marshaling body to JSON (no body when nil) and decoding the JSON response into out (skipped when out is nil). It is the supported pressure valve for endpoints the SDK does not cover yet; needing it is worth an issue.

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) Put added in v0.5.0

func (c *Client) Put(ctx context.Context, path string, body, out any) error

Put performs a raw PUT request against an API path, marshaling body to JSON (no body when nil) and decoding the JSON response into out (skipped when out is nil). It is the supported pressure valve for endpoints the SDK does not cover yet; needing it is worth an issue.

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. An empty LongURL fails with ErrMissingLongURL before any request goes out.

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 / tag / tag_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) UpdateTag added in v0.6.0

func (c *Client) UpdateTag(ctx context.Context, id string, params UpdateTagParams) (*Tag, error)

UpdateTag patches one tag by id; see UpdateTagParams. Renaming onto a name the account already has answers 409 conflict.

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 CreateTagParams added in v0.6.0

type CreateTagParams struct {
	Name  string `json:"name"`
	Color string `json:"color,omitempty"`
	Icon  string `json:"icon,omitempty"`
}

CreateTagParams creates a tag. Only Name is required; it is lowercased and trimmed server-side and must be unique per account. An empty Color gets the least-used palette color in the account and an empty Icon the generic tag glyph.

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), reduced
	// to a bare filename so it is safe to hand to os.Create, or a
	// spoo-export.<ext> default when the header is absent or its name
	// is path-shaped.
	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]
	// TagIDs and TagNames keep only links carrying the listed tags
	// (unknown names match nothing). TagsMatch decides how several
	// tags combine: "any" (the server default) or "all".
	TagIDs    []string
	TagNames  []string
	TagsMatch string
}

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"`
	Tags      []TagRef  `json:"tags"`
	// 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"`
	// TagIDs are ids from [Client.ListTags], at most 10; every one must
	// be the caller's own tag (400 otherwise).
	TagIDs []string `json:"tag_ids,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, url_id, tag
	// (tag names) and tag_id, which restrict the account aggregate to
	// specific owned links. A tag filter covers the whole click history
	// of the links carrying it, not just clicks since it was applied.
	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 Tag added in v0.6.0

type Tag struct {
	ID        string    `json:"id"`
	Name      string    `json:"name"`
	Color     string    `json:"color"`
	Icon      string    `json:"icon"`
	LinkCount int       `json:"link_count"`
	CreatedAt Timestamp `json:"created_at"`
	UpdatedAt Timestamp `json:"updated_at"` // zero until the tag is first edited
}

Tag is an account tag as the tag endpoints return it, with the number of links carrying it. Color is a palette key such as "violet" or "teal" and Icon a lucide icon key such as "rocket" ("tag" by default); the API docs list the current sets.

type TagDeletion added in v0.6.0

type TagDeletion struct {
	Deleted      bool `json:"deleted"`
	LinksUpdated int  `json:"links_updated"`
}

TagDeletion reports a tag delete: the tag is gone and LinksUpdated links had it removed.

type TagRef added in v0.6.0

type TagRef struct {
	ID    string `json:"id"`
	Name  string `json:"name"`
	Color string `json:"color"`
	Icon  string `json:"icon"`
}

TagRef is a tag as it appears on a link: enough to render, no counts.

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"`
	Tags         []TagRef  `json:"tags"`
}

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 UpdateTagParams added in v0.6.0

type UpdateTagParams struct {
	Name  string `json:"name,omitempty"`
	Color string `json:"color,omitempty"`
	Icon  string `json:"icon,omitempty"`
}

UpdateTagParams patches a tag. Empty fields are omitted and keep their current value; links reference tags by id, so a rename shows up on every link at once.

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"`
	TagIDs       Opt[[]string]  `json:"tag_ids,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, remove every tag), Set replaces it. BlockBots and PrivateStats use Opt so that Set(false) is expressible; null keeps the existing setting there. TagIDs replaces the whole list: Set([]string{}), Set of a nil slice and Null all clear it.

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"`
	Tags         []TagRef  `json:"tags"`
	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