SDKs
Go SDK
The Go SDK is a single module with no third-party dependencies. Every call takes a context.Context, errors are values you can inspect with errors.As, and workers stop cleanly when their context is canceled.
On this page
Install
Add the package to your project:
go get halyard.example/go/halyard@v3go get halyard.example/go/halyard@v3Requirements:
- Go 1.22 or newer (generics are used for typed handlers)
Keep your API key and webhook secret out of source control. The examples on this page read them from the environment:
HALYARD_KEY=hy_test_51Hq8Zx2rTmV9cKpL3dHALYARD_WEBHOOK_SECRET=whsec_7mYb2Qe9LkT4Configure the client
Create one client and share it across your app. It holds a connection pool, so making a new client per request wastes connections.
package jobs import ( "os" "time" "halyard.example/go/halyard") var Client = halyard.NewClient( os.Getenv("HALYARD_KEY"), halyard.WithRegion("eu"), // keep data in Frankfurt halyard.WithTimeout(10*time.Second), // per request halyard.WithMaxRetries(3), // network errors, 429 and 5xx)Client options
WithRegionstringdefault: "us"WithTimeouttime.Durationdefault: 30sWithMaxRetriesintdefault: 2WithBaseURLstringhttptest.Server in tests.WithHTTPClient*http.ClientCreate a job
A job needs a queue, a task name and a JSON payload. It runs as soon as a worker is free, or at run_at if you set one.
job, err := jobs.Client.Jobs.Create(ctx, &halyard.JobCreateParams{ Queue: "emails", Task: "send-welcome-email", Payload: map[string]any{"user_id": "usr_8KfQ2"}, RunAt: halyard.Time(time.Now().Add(10 * time.Minute)), // optional MaxAttempts: halyard.Int(5),})if err != nil { return fmt.Errorf("enqueue welcome email: %w", err)} log.Printf("queued %s (%s)", job.ID, job.Status) // job_3Rk9Tq7Xw2 (scheduled)Optional fields are pointers; use the halyard.Int, halyard.String and halyard.Time helpers to set them. Payload accepts any value that encoding/json can marshal, including your own structs.
Run a worker
Workers long-poll Halyard for jobs on the queues you name and call the handler registered for each task. Run as many worker processes as you like; Halyard hands each job to exactly one of them at a time.
package main import ( "context" "log" "os" "os/signal" "syscall" "example.com/app/internal/jobs" "halyard.example/go/halyard") func main() { w := jobs.Client.NewWorker(halyard.WorkerOptions{ Queues: []string{"emails"}, Concurrency: 10, }) w.Handle("send-welcome-email", func(ctx context.Context, job *halyard.Job) error { var p struct { UserID string `json:"user_id"` } if err := job.Decode(&p); err != nil { return halyard.Permanent(err) // malformed: don't retry } return sendWelcomeEmail(ctx, p.UserID) }) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err := w.Run(ctx); err != nil { log.Fatal(err) }}Return nil and the attempt succeeds. Return an error and Halyard records it and schedules a retry with backoff. Wrap it in halyard.Permanent to skip the remaining attempts. Run blocks until the context is canceled, then waits for in-flight jobs to finish.
Publish an event
Events describe something that happened. Halyard fans each one out to every subscription that matches its type, whether that subscription starts a job or calls a webhook. Publishing is fire-and-forget: you get an evt_ ID back in a few milliseconds.
event, err := jobs.Client.Events.Publish(ctx, &halyard.EventPublishParams{ Type: "order.paid", Data: map[string]any{"order_id": "ord_77Hc1", "amount": 4200, "currency": "eur"},})if err != nil { return err}log.Println(event.ID) // evt_9Vb2Lm4Qa1Verify webhooks
Halyard signs every webhook delivery with your endpoint's secret. Verify the signature before you trust the body; the SDK does the HMAC check, the timestamp check and the JSON parsing in one call.
func HalyardWebhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil { http.Error(w, "body too large", http.StatusRequestEntityTooLarge) return } event, err := halyard.VerifyWebhook( body, r.Header.Get("Halyard-Signature"), os.Getenv("HALYARD_WEBHOOK_SECRET"), ) if err != nil { http.Error(w, "invalid signature", http.StatusBadRequest) return } if event.Type == "job.failed" { log.Printf("job failed: %s", event.Data.Job.ID) } w.WriteHeader(http.StatusNoContent)}VerifyWebhook checks the HMAC SHA-256 signature and rejects timestamps older than five minutes. Use halyard.VerifyWebhookWithTolerance to change the window.
Handle errors
Failed API calls raise a typed error with the API's type, code and the request_id to quote to support. Retryable failures have already been retried by the time you see them.
_, err := jobs.Client.Jobs.Create(ctx, params) var apiErr *halyard.Errorswitch {case errors.As(err, &apiErr) && apiErr.Type == halyard.ErrRateLimited: log.Printf("slow down, retry in %s", apiErr.RetryAfter)case errors.As(err, &apiErr) && apiErr.Type == halyard.ErrInvalidRequest: log.Printf("bad parameter %s: %s", apiErr.Param, apiErr.Message)case errors.As(err, &apiErr): log.Printf("%s %s %s", apiErr.Type, apiErr.Code, apiErr.RequestID) // req_4Tn8Wc2Ke7case err != nil: log.Printf("connection error: %v", err)}| Error | Raised when |
|---|---|
| *halyard.Error | Every API error. Inspect Type, Code, Status, Param, RequestID and RetryAfter. |
| halyard.ErrInvalidRequest | Type constant for 400 and 422 responses (invalid_request). |
| halyard.ErrAuthentication | Type constant for 401 (authentication_failed). |
| halyard.ErrPermissionDenied | Type constant for 403 (permission_denied). |
| halyard.ErrNotFound | Type constant for 404 (not_found). |
| halyard.ErrConflict | Type constant for 409 (conflict and idempotency_mismatch). |
| halyard.ErrRateLimited | Type constant for 429 (rate_limited); RetryAfter is a time.Duration. |
| *url.Error | Network failures are returned unwrapped from net/http, so errors.Is(err, context.DeadlineExceeded) works. |
The full list of types and codes is on the Errors page.
Typed payloads
Payloads are JSON on the wire, but they don't have to be untyped in your code. Describe each task's payload once and the SDK checks both ends: the code that enqueues and the handler that runs.
type WelcomeEmail struct { UserID string `json:"user_id"` Locale string `json:"locale,omitempty"`} // Handler side: the payload is decoded for you.halyard.HandleTyped(w, "send-welcome-email", func(ctx context.Context, job *halyard.TypedJob[WelcomeEmail]) error { return mailer.SendWelcome(ctx, job.Payload.UserID, job.Payload.Locale) }) // Producer side: pass the struct as the payload._, err := Client.Jobs.Create(ctx, &halyard.JobCreateParams{ Queue: "emails", Task: "send-welcome-email", Payload: WelcomeEmail{UserID: "usr_8KfQ2"},})Go methods can't take type parameters, so typed handlers use the package-level HandleTyped function. A payload that fails to decode is dead-lettered at once rather than retried, with the decoding error in the job log.
Framework integrations
The SDK works anywhere your language runs. These are the patterns we recommend for the frameworks we see most.
net/http
The webhook handler above is a plain http.HandlerFunc, so it works with the standard ServeMux and any router built on it. Run the worker in the same binary or its own.
mux := http.NewServeMux()mux.HandleFunc("POST /webhooks/halyard", web.HalyardWebhook)mux.HandleFunc("POST /signup", web.SignUp) srv := &http.Server{Addr: ":8080", Handler: mux}log.Fatal(srv.ListenAndServe())Worker and server in one binary
For small services, run the worker next to your HTTP server with errgroup and share the shutdown context.
g, ctx := errgroup.WithContext(ctx)g.Go(func() error { return worker.Run(ctx) })g.Go(func() error { return srv.ListenAndServe() })g.Go(func() error { <-ctx.Done() return srv.Shutdown(context.Background())})log.Fatal(g.Wait())Reference
Every method on the client maps to an endpoint in the API reference. Source, issues and release notes for the Go SDK live at code.halyard.example/sdks/halyard-go.
Other SDKs