Countdown Timer API with Go
A client using nothing outside the standard library, with context on every call, typed errors that distinguish the two kinds of 429, and a net/http webhook handler that verifies signatures in constant time. Nothing here needs a dependency, so there is nothing to add to go.mod.
The client and its error type
The Retryable() method on the error is the important part. Both rate_limited and quota_exhausted arrive as HTTP 429, and only the first one clears by waiting — the second means the monthly allowance is spent until the billing cycle resets.
countdownshare/client.go
// countdownshare/client.go
package countdownshare
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
)
const base = "https://countdownshare.com/api/v1"
type APIError struct {
Code string
Message string
Status int
RequestID string
RetryAfter time.Duration
}
func (e *APIError) Error() string {
return fmt.Sprintf("%s: %s (%s)", e.Code, e.Message, e.RequestID)
}
// Retryable reports whether waiting could plausibly help.
// quota_exhausted is a 429 too, but the monthly allowance does not
// refill until the billing cycle resets.
func (e *APIError) Retryable() bool {
return e.Code == "rate_limited" || e.Code == "internal_error"
}
type Client struct {
APIKey string
HTTP *http.Client
}
func New(apiKey string) *Client {
return &Client{
APIKey: apiKey,
HTTP: &http.Client{Timeout: 10 * time.Second},
}
}The request path
One method handles the envelope, the error mapping, and decoding. Everything else is a thin wrapper over it.
do()
type envelope struct {
Data json.RawMessage `json:"data"`
RequestID string `json:"request_id"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func (c *Client) do(ctx context.Context, method, path string,
body any, headers map[string]string, out any) error {
var payload []byte
if body != nil {
var err error
if payload, err = json.Marshal(body); err != nil {
return err
}
}
req, err := http.NewRequestWithContext(ctx, method, base+path, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return err
}
if res.StatusCode >= 400 {
apiErr := &APIError{
Status: res.StatusCode,
RequestID: env.RequestID,
}
if env.Error != nil {
apiErr.Code, apiErr.Message = env.Error.Code, env.Error.Message
}
if s := res.Header.Get("Retry-After"); s != "" {
if n, convErr := strconv.Atoi(s); convErr == nil {
apiErr.RetryAfter = time.Duration(n) * time.Second
}
}
return apiErr
}
if out == nil {
return nil
}
return json.Unmarshal(env.Data, out)
}RequestID on the error is worth the extra field. It identifies the exact call in our logs, and quoting it turns a support conversation into a single lookup.Typed methods
Timers and status
type Timer struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Revision int `json:"revision"`
}
type Status struct {
Status string `json:"status"`
Ended bool `json:"ended"`
ServerTime string `json:"server_time"`
Remaining struct {
TotalSeconds int `json:"total_seconds"`
} `json:"remaining"`
}
func (c *Client) CreateTimer(ctx context.Context, body any, idempotencyKey string) (*Timer, error) {
var timer Timer
err := c.do(ctx, http.MethodPost, "/timers", body,
map[string]string{"Idempotency-Key": idempotencyKey}, &timer)
return &timer, err
}
func (c *Client) Status(ctx context.Context, timerID string) (*Status, error) {
var status Status
err := c.do(ctx, http.MethodGet, "/timers/"+timerID+"/status", nil, nil, &status)
return &status, err
}Creation requires an Idempotency-Key. Derive it from the record the timer belongs to — "hold_" + order.ID — so a retry after a network failure returns the original timer instead of creating a second one. See idempotency.
Retrying
WithRetry
// WithRetry retries only the errors that can clear on their own.
func WithRetry(ctx context.Context, attempts int, fn func() error) error {
var err error
for attempt := 0; attempt < attempts; attempt++ {
if err = fn(); err == nil {
return nil
}
var apiErr *APIError
if !errors.As(err, &apiErr) || !apiErr.Retryable() {
return err
}
if attempt == attempts-1 {
return err
}
wait := apiErr.RetryAfter
if wait == 0 {
wait = time.Duration(1<<attempt) * 500 * time.Millisecond
}
select {
case <-time.After(wait):
case <-ctx.Done():
return ctx.Err()
}
}
return err
}The select on ctx.Done() matters: without it a backoff sleep ignores cancellation, so a request that has already been abandoned still holds a goroutine for another eight seconds.
The webhook handler
webhook.go
// webhook.go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"time"
)
func webhookHandler(secret string, queue chan<- Event) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Read the RAW body. The signature covers these exact bytes,
// so decoding first makes verification impossible.
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "unreadable", http.StatusBadRequest)
return
}
if !verify(raw, r.Header, secret) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var event Event
if err := json.Unmarshal(raw, &event); err != nil {
// Malformed but authentic: a 400 stops pointless retries.
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
// Acknowledge inside ten seconds, then do the work.
w.WriteHeader(http.StatusOK)
queue <- event
}
}
func verify(raw []byte, headers http.Header, secret string) bool {
timestamp := headers.Get("X-CountdownShare-Timestamp")
signature := headers.Get("X-CountdownShare-Signature")
if timestamp == "" || signature == "" {
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
// Reject anything older than five minutes.
if delta := time.Now().Unix() - ts; delta > 300 || delta < -300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(raw)
expected := mac.Sum(nil)
received, err := hex.DecodeString(strings.TrimPrefix(signature, "v1="))
if err != nil {
return false
}
// hmac.Equal is constant time. bytes.Equal is not.
return hmac.Equal(expected, received)
}Four details worth keeping
io.ReadAll over the raw body before anything else, because the signature covers those exact bytes. LimitReader around it, because the endpoint is public and reading an unbounded body from an unauthenticated caller is an easy way to be knocked over. hmac.Equal rather than bytes.Equal, for constant-time comparison. And the 200 written before the work is handed to the queue, because the delivery is treated as a timeout after roughly ten seconds.
Common questions
Is there a Go module on pkg.go.dev?
Not currently. There are no SDKs in any language — every documentation example is plain HTTP. The client here is standard library only, so there is nothing to add to go.mod. The OpenAPI 3.1 spec is public if you would rather generate one with oapi-codegen.
Why hmac.Equal instead of bytes.Equal?
bytes.Equal short-circuits on the first differing byte, so how long the comparison takes reveals how many leading bytes matched. hmac.Equal always compares the full length. It is the same reasoning behind crypto/subtle.ConstantTimeCompare, which hmac.Equal wraps.
Should I use context timeouts?
Yes, and the client takes a context on every call for that reason. The http.Client timeout is a backstop; a per-call context lets a request inherit the deadline of whatever is calling it, so a slow timer call cannot outlive the HTTP request that triggered it.
Why LimitReader on the webhook body?
It bounds how much memory an unauthenticated request can make you allocate. The endpoint is public, so anyone can POST to it — reading an unbounded body before you have verified anything is an easy denial-of-service. One megabyte is far more than any delivery needs.
Next steps
No dependencies to add
Sandbox is free with any account. The client compiles against the standard library alone — set the key and run it.