docs

Docs › SDKs

Go SDK

github.com/handset-hq/handset-go — generated from the same OpenAPI spec as the API and the other SDKs. One typed …WithResponse method per endpoint over net/http, no runtime dependencies beyond the generated client. Go 1.24+.

Install & configure

shell
go get github.com/handset-hq/handset-go

One constructor, authenticated with your API key. A test key (hs_test_…) hits the simulated carrier, a live key (hs_live_…) the real network — the prefix picks the mode, the base URL is the same.

setup.go
import handset "github.com/handset-hq/handset-go"

client, err := handset.New(os.Getenv("HANDSET_API_KEY"))
if err != nil {
    log.Fatal(err)
}

Send a message

Every operation has a …WithResponse method. Optional fields are pointers — handset.Ptr wraps a value inline. Header and query params ride in a per-operation …Params struct; pass nil for none.

send.go
resp, err := client.SendMessageWithResponse(ctx, nil,
    handset.SendMessageJSONRequestBody{
        From: "num_01kzv…",
        To:   "+14155550134",
        Body: handset.Ptr("Your technician is 15 minutes out."),
    })
if err != nil {
    return err  // transport failure (DNS, connection, timeout)
}
fmt.Println(resp.JSON202.Id, resp.JSON202.Status)  // msg_…, "queued"

Errors & status codes

Methods don't return an error for a non-2xx — err is only a transport failure. Each documented status is a typed field on the response (JSON202, JSON200, …); when the one you expect is nil, read JSONDefault.Error:

errors.go
resp, err := client.SendMessageWithResponse(ctx, nil, body)
if err != nil {
    return err
}
if resp.JSON202 == nil {
    e := resp.JSONDefault.Error
    // e.Code e.g. "recipient_opted_out"; resp.StatusCode() for the HTTP status
    return fmt.Errorf("%s: %s", e.Code, e.Message)
}

Codes worth branching on: recipient_opted_out (they sent STOP — do not retry), campaign_not_approved (10DLC in review), rate_limited, tenant_not_found.

Idempotent sends

Attach an Idempotency-Key through the params struct — a retry with the same key within 24h returns the original result instead of sending twice:

idempotent.go
key := handset.IdempotencyKey("order-4417-confirm")
client.SendMessageWithResponse(ctx,
    &handset.SendMessageParams{IdempotencyKey: &key}, body)

The core loop

onboard.go
// 1. Your customer, as a tenant
tenant, _ := client.CreateTenantWithResponse(ctx, handset.CreateTenantJSONRequestBody{
    Name: "Bayview Dental", Timezone: handset.Ptr("America/Los_Angeles"),
})

// 2. A number in their area code, live on arrival
found, _ := client.SearchAvailableNumbersWithResponse(ctx,
    &handset.SearchAvailableNumbersParams{AreaCode: handset.Ptr("415")})
number, _ := client.PurchaseNumberWithResponse(ctx, handset.PurchaseNumberJSONRequestBody{
    TenantId: tenant.JSON201.Id, PhoneNumber: found.JSON200.Data[0].PhoneNumber,
})

// 3. Send — threading, opt-outs, 10DLC all happen server-side
client.SendMessageWithResponse(ctx, nil, handset.SendMessageJSONRequestBody{
    From: number.JSON201.Id, To: "+14155550123", Body: handset.Ptr("Welcome aboard!"),
})

Pagination

paginate.go
var after *string
for {
    page, err := client.ListMessagesWithResponse(ctx, &handset.ListMessagesParams{
        Limit: handset.Ptr(100), After: after,
    })
    if err != nil {
        return err
    }
    for _, m := range page.JSON200.Data {
        handle(m)
    }
    if !page.JSON200.HasMore {
        break
    }
    after = page.JSON200.NextCursor
}

Types & webhooks

Every request and response shape is an exported type, so the package doubles as your model library — handset.Message, handset.Tenant, handset.Voicemail. Inbound webhook deliveries are the handset.EventEnvelope type (each event name also has a named alias, handset.MessageReceivedJSONRequestBody and the rest), so a verified payload unmarshals into a typed value.

The SDK sends User-Agent: handset-go/<version> on every request, so Go traffic is visible in your API logs. Prefer TypeScript or Python? Same spec, same objects: TypeScript · Python.