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#

Shell
go get hookchat.dev/go@v0.5.0

The 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.

Go
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))
}
OptionDefaultMeaning
WithBaseURL(url)https://hookchat.devThe API origin. A trailing slash is ignored.
WithHTTPClient(c)a fresh http.ClientYour own transport for proxies or tracing.
WithTimeout(d)30sPer-attempt timeout on top of the caller's context. Zero disables it.
WithMaxRetries(n)2Retries after the first attempt. Zero disables retries.
WithUserAgent(ua)HookChat/<version> (Jiffi; go)The User-Agent header.
WithTenant(slug)noneA 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.

Go
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.

Go
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.

ServiceMethodsRoutes
client.PingGET /v1/ping
client.ConversationsList, All, GetGET /v1/conversations, GET /v1/conversations/:id
client.MessagesReply, SendAsHumanAgent, GetHold, WaitForHold, List, All, GetPOST /v1/messages/reply, POST /v1/messages/human-agent, GET /v1/messages/holds/:hold_id, GET /v1/messages, GET /v1/messages/:id
client.AccountsListGET /v1/accounts
client.AuditList, AllGET /v1/audit
client.StatsGetGET /v1/stats
client.KeysCreate, List, RevokePOST /v1/keys, GET /v1/keys, DELETE /v1/keys/:id
client.WebhooksCreate, List, Get, Update, Delete, RotateSecret, Test/v1/webhooks, /v1/webhooks/:id, .../rotate, .../test
client.Webhooks.DeliveriesList, All, Summary, Replay, ReplayAll/v1/webhooks/:id/deliveries, .../summary, .../:deliveryId/replay, .../replay
client.RealtimeTicketPOST /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.

A tour of the reads
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.

Go
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.

Go
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.

Go
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.

Go
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.

Next#