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
}
Output:
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)
}
}
}
Output:
Index ¶
- Constants
- Variables
- func CodeChallengeS256(verifier string) string
- func GenerateCodeVerifier() (string, error)
- func GenerateState() (string, error)
- func IsBlocked(err error) bool
- func IsNotFound(err error) bool
- func IsRateLimited(err error) bool
- func ParseExpiry(raw string, now time.Time) (time.Time, error)
- type AliasCheck
- type BulkResult
- type BulkRow
- type BulkSummary
- type BulkTagChange
- type Claim
- type ClaimOutcome
- type ClaimResult
- type Client
- func (c *Client) BulkDelete(ctx context.Context, ids []string) (*BulkResult, error)
- func (c *Client) BulkMoveDomain(ctx context.Context, ids []string, domain string) (*BulkResult, error)
- func (c *Client) BulkUpdateExpiry(ctx context.Context, ids []string, expireAfter time.Time) (*BulkResult, error)
- func (c *Client) BulkUpdateStatus(ctx context.Context, ids []string, status string) (*BulkResult, error)
- func (c *Client) BulkUpdateTags(ctx context.Context, ids []string, change BulkTagChange) (*BulkResult, error)
- func (c *Client) CheckAlias(ctx context.Context, alias, domain string) (*AliasCheck, error)
- func (c *Client) ClaimURLs(ctx context.Context, claims []Claim) (*ClaimOutcome, error)
- func (c *Client) CreateTag(ctx context.Context, params CreateTagParams) (*Tag, error)
- func (c *Client) Delete(ctx context.Context, path string, out any) error
- func (c *Client) DeleteTag(ctx context.Context, id string) (*TagDeletion, error)
- func (c *Client) DeleteURL(ctx context.Context, id string) error
- func (c *Client) DeleteURLsByDomain(ctx context.Context, domain string) (*DomainDeletion, error)
- func (c *Client) DeviceAuthURL(p DeviceAuthParams) string
- func (c *Client) EmojiSet(ctx context.Context) (*EmojiSet, error)
- func (c *Client) ExchangeDeviceCode(ctx context.Context, appID, code, verifier string) (*DeviceTokens, error)
- func (c *Client) Export(ctx context.Context, q StatsQuery, format string) (*ExportFile, error)
- func (c *Client) ExportLink(ctx context.Context, urlID string, q StatsQuery, format string) (*ExportFile, error)
- func (c *Client) ForceRefresh(ctx context.Context) (Credentials, error)
- func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error
- func (c *Client) GetURL(ctx context.Context, id string) (*URLItem, error)
- func (c *Client) LinkStats(ctx context.Context, urlID string, q StatsQuery) (*StatsResponse, error)
- func (c *Client) ListTags(ctx context.Context) ([]Tag, error)
- func (c *Client) ListURLs(ctx context.Context, opts ListURLsOptions) (*URLPage, error)
- func (c *Client) ListURLsAll(ctx context.Context, opts ListURLsOptions) iter.Seq2[URLItem, error]
- func (c *Client) Me(ctx context.Context) (*User, error)
- func (c *Client) Patch(ctx context.Context, path string, body, out any) error
- func (c *Client) Post(ctx context.Context, path string, body, out any) error
- func (c *Client) PublicPreview(ctx context.Context, shortCode string) (*PublicPreview, error)
- func (c *Client) PublicStats(ctx context.Context, shortCode string, q PublicStatsQuery) (*PublicStatsResult, error)
- func (c *Client) Put(ctx context.Context, path string, body, out any) error
- func (c *Client) RefreshTokens(ctx context.Context, appID, refreshToken string) (*TokenPair, error)
- func (c *Client) ResolveAlias(ctx context.Context, alias, domain string) (*URLItem, error)
- func (c *Client) SetURLStatus(ctx context.Context, id, status string) (*UpdatedURL, error)
- func (c *Client) Shorten(ctx context.Context, req ShortenRequest) (*ShortURL, error)
- func (c *Client) Stats(ctx context.Context, q StatsQuery) (*StatsResponse, error)
- func (c *Client) StatsByAlias(ctx context.Context, alias, domain string, q StatsQuery) (*StatsResponse, error)
- func (c *Client) UpdateTag(ctx context.Context, id string, params UpdateTagParams) (*Tag, error)
- func (c *Client) UpdateURL(ctx context.Context, id string, params UpdateURLParams) (*UpdatedURL, error)
- type CreateTagParams
- type Credentials
- type DeviceAuthParams
- type DeviceTokens
- type DomainDeletion
- type EmojiEntry
- type EmojiSet
- type Error
- type ExportFile
- type ListURLsOptions
- type MetricPoint
- type Opt
- type PreviewDestination
- type PreviewGeoDestination
- type PublicLinkFacts
- type PublicPreview
- type PublicStatsQuery
- type PublicStatsResult
- type RateLimit
- type ShortURL
- type ShortenRequest
- type StatsQuery
- type StatsResponse
- type StatsSummary
- type StatsTimeRange
- type Tag
- type TagDeletion
- type TagRef
- type Timestamp
- type TokenPair
- type TokenSource
- type URLItem
- type URLPage
- type UpdateTagParams
- type UpdateURLParams
- type UpdatedURL
- type User
Examples ¶
Constants ¶
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.
const DefaultBaseURL = "https://spoo.me"
DefaultBaseURL is the hosted spoo.me API; see option.WithBaseURL for self-hosted deployments.
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 ¶
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.
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.
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.
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 ¶
CodeChallengeS256 derives the S256 challenge for a verifier: BASE64URL(SHA256(verifier)) without padding (RFC 7636 §4.2).
func GenerateCodeVerifier ¶
GenerateCodeVerifier returns a PKCE code verifier: 32 random bytes encoded as unpadded base64url, always 43 characters (RFC 7636 §4.1).
func GenerateState ¶
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
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 ¶
IsNotFound reports whether err is an API 404 — for the resolve-first endpoints that means "no such link, or not yours".
func IsRateLimited ¶
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 ¶
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 ¶
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 ¶
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 ¶
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
}
Output:
func (*Client) BulkDelete ¶
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 ¶
CheckAlias reports whether alias is available, on the given domain when one is passed.
func (*Client) ClaimURLs ¶
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
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
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
DeleteTag deletes one tag by id and removes it from every link that carried it; the links are otherwise untouched.
func (*Client) DeleteURLsByDomain ¶ added in v0.2.0
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 ¶
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 (*Client) 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
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 ¶
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
ListTags returns every tag in the account, oldest first, with link counts.
func (*Client) ListURLs ¶
ListURLs returns one page of the account's links; ListURLsAll iterates them all.
func (*Client) ListURLsAll ¶
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)
}
}
Output:
func (*Client) Patch ¶ added in v0.5.0
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
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 ¶
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
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 ¶
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 ¶
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
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 ¶
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)
}
Output:
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
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)
}
Output:
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 ¶
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.
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 ¶
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 (Opt[T]) IsZero ¶
IsZero reports whether the Opt is omitted, wiring it into encoding/json's omitzero handling.
func (Opt[T]) MarshalJSON ¶
MarshalJSON writes the carried value, or null for the other states (omitted values are dropped earlier by omitzero tags).
func (*Opt[T]) UnmarshalJSON ¶
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
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 ¶
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 ¶
MarshalJSON writes RFC 3339, or null for the zero value.
func (*Timestamp) UnmarshalJSON ¶
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.
Source Files
¶
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: |