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)
}
Capturing 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.

Returning 400 for a malformed-but-authentic payload is deliberate. A 4xx other than 408, 425, and 429 stops retries — which is correct, because the same bad JSON will fail identically five more times. Reserve 5xx for problems that might clear. See retries and replay.

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.