Go SDK
hookchat-go is the official Go SDK. Standard library only, no third-party dependencies. Every method takes a context.Context first, every failed call returns a *hookchat.Error, and the pagination helpers use range-over-func iterators. The module declares go 1.26, so it requires Go 1.26 or newer.
Install#
go get hookchat.dev/go@v0.5.0The package name is hookchat. HookChat serves the module itself at hookchat.dev/go, so go get works with the go command's defaults: no setup and no account. The downloads page lists the releases along with the changelog.
Create a client#
New takes the API key and options. A key bound to one workspace needs nothing more; an operator key must name one with WithTenant, a Tenant field on list params, or the hookchat.ForTenant("slug") call option. A Client is safe for concurrent use.
package main
import (
"context"
"fmt"
"log"
"os"
"time"
hookchat "hookchat.dev/go"
)
func main() {
ctx := context.Background()
client, err := hookchat.New(os.Getenv("HOOKCHAT_API_KEY"), // hookchat_live_... or hookchat_test_...
hookchat.WithBaseURL(os.Getenv("HOOKCHAT_BASE_URL")), // default https://hookchat.dev
hookchat.WithTimeout(30*time.Second), // per attempt, default 30s
hookchat.WithMaxRetries(2), // default 2
hookchat.WithUserAgent("acme-crm/1.4"), // default HookChat/<version> (Jiffi; go)
)
if err != nil {
log.Fatal(err)
}
ping, err := client.Ping(ctx) // GET /v1/ping, unauthenticated
if err != nil {
log.Fatal(err)
}
fmt.Println("ok", ping.Name, ping.Version, ping.Time.Format(time.RFC3339))
}| Option | Default | Meaning |
|---|---|---|
WithBaseURL(url) | https://hookchat.dev | The API origin. A trailing slash is ignored. |
WithHTTPClient(c) | a fresh http.Client | Your own transport for proxies or tracing. |
WithTimeout(d) | 30s | Per-attempt timeout on top of the caller's context. Zero disables it. |
WithMaxRetries(n) | 2 | Retries after the first attempt. Zero disables retries. |
WithUserAgent(ua) | HookChat/<version> (Jiffi; go) | The User-Agent header. |
WithTenant(slug) | none | A default ?tenant for operator keys. |
The two send paths#
There is no generic send and no message tags. Reply sends inside the 24 hour window; SendAsHumanAgent sends between 24 hours and 7 days and requires ActorID. Set Text or Attachments, not both: up to 10 images go as one message, and a video, audio or file goes alone. An empty ActorID, a send with neither Text nor Attachments, a send with both, or a mix the API refuses is refused locally with the code the server would have returned and Status 0.
A human-agent send is held: SendAsHumanAgent returns a *Hold, and the message goes once the person who typed it passes a short check at VerifyURL, and WaitForHold waits for that. See Send as a human agent.
package main
import (
"context"
"fmt"
"log"
"os"
hookchat "hookchat.dev/go"
)
func main() {
ctx := context.Background()
client, err := hookchat.New(os.Getenv("HOOKCHAT_API_KEY"), hookchat.WithBaseURL(os.Getenv("HOOKCHAT_BASE_URL")))
if err != nil {
log.Fatal(err)
}
conversationID := os.Getenv("CONVERSATION_ID")
// Inside the 24 hour window.
reply, err := client.Messages.Reply(ctx, hookchat.ReplyParams{ConversationID: conversationID, Text: "Thanks, on it."})
if err != nil {
log.Fatal(err)
}
fmt.Println("reply sent", reply.PlatformMessageID)
// Between 24 hours and 7 days, typed by a human. ActorID is required.
// The message is held until agent-7 passes a short check at hold.VerifyURL.
hold, err := client.Messages.SendAsHumanAgent(ctx, hookchat.HumanAgentParams{
ConversationID: conversationID,
Text: "Following up.",
ActorID: "agent-7",
})
if err != nil {
log.Fatal(err)
}
fmt.Println("human-agent held", hold.Status)
}Upload a file#
client.Attachments.Upload sends a file from your computer to Meta in one call and returns an *UploadedAttachment; send it as hookchat.Attachment{Type: a.Type, AttachmentID: a.ID}. Set Path, or File (an io.Reader, with Size when you know it), and the name and content type come from the path unless you set Filename and ContentType. A file too big is CodeAttachmentTooLarge, a type the account does not take CodeUnsupportedMediaType. HookChat keeps the file only until Meta has it. See Send a file from your computer.
Upload waits while HookChat hands the file to Meta, for up to two minutes, and a cancelled ctx ends the wait; set WaitTimeout to change it. Past it, Upload returns *UploadTimeoutError, and IsTimeout reports it. Its UploadID names the upload, which goes on, and client.Attachments.Get(ctx, id) reads it later. A file Meta refuses is CodeSendFailed.
package main
import (
"context"
"fmt"
"log"
"os"
hookchat "hookchat.dev/go"
)
func main() {
ctx := context.Background()
client, err := hookchat.New(os.Getenv("HOOKCHAT_API_KEY"), hookchat.WithBaseURL(os.Getenv("HOOKCHAT_BASE_URL")))
if err != nil {
log.Fatal(err)
}
conversationID := os.Getenv("CONVERSATION_ID")
a, err := client.Attachments.Upload(ctx, hookchat.UploadParams{ConversationID: conversationID, Path: "./quote.pdf"})
if err != nil {
log.Fatal(err)
}
sent, err := client.Messages.Reply(ctx, hookchat.ReplyParams{
ConversationID: conversationID,
Attachments: []hookchat.Attachment{{Type: a.Type, AttachmentID: a.ID}},
})
if err != nil {
log.Fatal(err)
}
fmt.Println("file sent", a.Size, "bytes", sent.PlatformMessageID)
}Services and methods#
Pass hookchat.ThreadPage(50, cursor) to Conversations.Get to page through a thread. The returned NextCursor resumes older messages; an empty value means the end. Each Message has Attachments, a []MessageAttachment with Type and URL, empty when there is no media. The URL is Meta's and expires after a while; HookChat does not keep a copy.
| Service | Methods | Routes |
|---|---|---|
client.Ping | GET /v1/ping | |
client.Conversations | List, All, Get | GET /v1/conversations, GET /v1/conversations/:id |
client.Messages | Reply, SendAsHumanAgent, GetHold, WaitForHold, List, All, Get | POST /v1/messages/reply, POST /v1/messages/human-agent, GET /v1/messages/holds/:hold_id, GET /v1/messages, GET /v1/messages/:id |
client.Accounts | List | GET /v1/accounts |
client.Audit | List, All | GET /v1/audit |
client.Stats | Get | GET /v1/stats |
client.Keys | Create, List, Revoke | POST /v1/keys, GET /v1/keys, DELETE /v1/keys/:id |
client.Webhooks | Create, List, Get, Update, Delete, RotateSecret, Test | /v1/webhooks, /v1/webhooks/:id, .../rotate, .../test |
client.Webhooks.Deliveries | List, All, Summary, Replay, ReplayAll | /v1/webhooks/:id/deliveries, .../summary, .../:deliveryId/replay, .../replay |
client.Realtime | Ticket | POST /v1/realtime/ticket |
Call options adjust a single call: ForTenant(slug), IncludeTest() on reads, and Force() on a replay. Conversation ids contain # separators; the SDK URL-encodes them. Delivery rows use epoch millisecond timestamps as the API returns them, with CreatedTime(), UpdatedTime() and NextRetryTime() for time.Time values.
package main
import (
"context"
"fmt"
"log"
"os"
hookchat "hookchat.dev/go"
)
func main() {
ctx := context.Background()
client, err := hookchat.New(os.Getenv("HOOKCHAT_API_KEY"), hookchat.WithBaseURL(os.Getenv("HOOKCHAT_BASE_URL")))
if err != nil {
log.Fatal(err)
}
accounts, err := client.Accounts.List(ctx) // not paginated
if err != nil {
log.Fatal(err)
}
for _, a := range accounts {
if a.RefreshError != nil {
fmt.Printf("%s needs relinking: %s\n", a.Handle, *a.RefreshError)
}
}
stats, err := client.Stats.Get(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println("messages_24h", stats.Messages24h, "dlq_total", stats.DLQTotal, "endpoints", len(stats.Endpoints))
audit, err := client.Audit.List(ctx, hookchat.ListAuditParams{Limit: 5}) // most recent first
if err != nil {
log.Fatal(err)
}
for _, entry := range audit.Items {
fmt.Println(entry.At.Format("2006-01-02T15:04:05Z07:00"), entry.Actor, entry.Action, entry.Resource)
}
endpoints, err := client.Webhooks.List(ctx) // never returns a secret
if err != nil {
log.Fatal(err)
}
fmt.Println(len(endpoints), "endpoints,", len(accounts), "accounts")
}Errors#
Every failed call that got a reply returns a *hookchat.Error with Code (the stable machine code), Status, Message, Detail, RequestID, Body, Method and Path; DocsURL() is the troubleshooting entry for that code. Inspect it with errors.As, with errors.Is(err, &hookchat.Error{Code: hookchat.CodeWindowClosed}), or with the helpers IsWindowClosed, IsHumanAgentUnavailable, IsRateLimited, IsNotFound, IsAuth, IsInvalidRequest, IsReplayConflict and IsSendFailed. Temporary() reports whether a retry might succeed. A call that got no reply at all returns the wrapped transport error instead: hookchat.IsTimeout(err) says it ran out of time, hookchat.IsNetworkError(err) says the connection failed, and errors.Is(err, context.DeadlineExceeded) still works.
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
hookchat "hookchat.dev/go"
)
func main() {
ctx := context.Background()
client, err := hookchat.New(os.Getenv("HOOKCHAT_API_KEY"), hookchat.WithBaseURL(os.Getenv("HOOKCHAT_BASE_URL")))
if err != nil {
log.Fatal(err)
}
_, err = client.Messages.Reply(ctx, hookchat.ReplyParams{ConversationID: os.Getenv("CLOSED_CONVERSATION_ID"), Text: "Hello again"})
switch {
case hookchat.IsWindowClosed(err):
var apiErr *hookchat.Error
errors.As(err, &apiErr)
fmt.Println("refused:", apiErr.Code, "status", apiErr.Status, "request", apiErr.RequestID)
case hookchat.IsRateLimited(err):
fmt.Println("back off; the SDK never retries a send")
case hookchat.IsSendFailed(err):
fmt.Println("Meta refused; a retry may help")
case err != nil:
log.Fatal(err)
}
}Verifying webhooks#
VerifyWebhook(payload, headers, secret, opts...) authenticates a delivery from its raw body bytes and http.Header, accepts either signature during a rotation overlap, rejects timestamps more than 300 seconds from now, and returns the parsed *Event. A signature failure wraps ErrSignature; a body that verifies but is not an event wraps ErrInvalidEvent. Switch on event.Type and decode with Message(), Failure(), Test() or Account(). On message.sent, the message Direction is DirectionOutbound for a send through the API and DirectionEcho for one sent from the Instagram or Messenger app.
package main
import (
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
hookchat "hookchat.dev/go"
)
func main() {
secret := os.Getenv("WEBHOOK_SECRET") // the whs_ secret from Webhooks.Create
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Read the exact bytes; never re-serialise the body before verifying.
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
event, err := hookchat.VerifyWebhook(body, r.Header, secret)
if errors.Is(err, hookchat.ErrSignature) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
if err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
// Deliveries are at least once: dedupe on event.ID before acting.
switch event.Type {
case hookchat.EventTypeMessageReceived:
msg, _ := event.Message()
fmt.Println("inbound", msg.ID, "in", msg.ConversationID)
case hookchat.EventTypeMessageFailed:
failure, _ := event.Failure()
fmt.Println("send failed:", failure.Reason)
default:
fmt.Println("verified", event.Type, event.ID)
}
w.WriteHeader(http.StatusOK)
})
log.Fatal(http.ListenAndServe(":"+os.Getenv("PORT"), nil))
}WithTolerance(d) widens or narrows the timestamp window and WithNow(fn) injects a clock for tests. ParseEvent(body) decodes without verifying, and Sign and SignatureHeaderValue sign fixtures in your own tests.
Pagination#
List methods return a Page[T] with Items and Cursor; HasMore() reports whether another page follows. The All methods walk every page as an iter.Seq2[T, error], so breaking out of the loop stops fetching, MaxItems(n) bounds the walk, and Collect drains an iterator into a slice.
package main
import (
"context"
"fmt"
"log"
"os"
hookchat "hookchat.dev/go"
)
func main() {
ctx := context.Background()
client, err := hookchat.New(os.Getenv("HOOKCHAT_API_KEY"), hookchat.WithBaseURL(os.Getenv("HOOKCHAT_BASE_URL")))
if err != nil {
log.Fatal(err)
}
unanswered := 0
for conv, err := range client.Conversations.All(ctx, hookchat.ListConversationsParams{Limit: 100}, hookchat.MaxItems(500)) {
if err != nil {
log.Fatal(err)
}
if conv.Unanswered && conv.CanReply {
unanswered++
}
}
fmt.Println(unanswered, "unanswered conversations still inside the window")
entries, err := hookchat.Collect(client.Audit.All(ctx, hookchat.ListAuditParams{Limit: 100}, hookchat.MaxItems(3)))
if err != nil {
log.Fatal(err)
}
fmt.Println(len(entries), "audit entries")
}Retries and timeouts#
GET requests are retried on HTTP 429, 5xx and transport errors with jittered exponential backoff (250 ms base, 5 s cap), honouring Retry-After capped at 60 s. Webhooks.Test, Deliveries.Replay and Deliveries.ReplayAll are retried on 429 only. Every other write is never retried. The caller's context deadline is always respected: a retry that could not complete before the deadline is not attempted, and the last API error is returned instead of a bare context error.
package main
import (
"context"
"fmt"
"log"
"os"
"time"
hookchat "hookchat.dev/go"
)
func main() {
client, err := hookchat.New(os.Getenv("HOOKCHAT_API_KEY"),
hookchat.WithBaseURL(os.Getenv("HOOKCHAT_BASE_URL")),
hookchat.WithTimeout(10*time.Second),
hookchat.WithMaxRetries(3),
)
if err != nil {
log.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
stats, err := client.Stats.Get(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println("window_hours", stats.WindowHours)
}Versioning#
Releases are tagged packages/hookchat-go/vX.Y.Z. hookchat.Version reports the SDK version and is sent in the default User-Agent. Pre-1.0 minor versions may change the API; the changelog is linked from the downloads page.