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 github.com/jiffi-co/jiffi-message-gateway/packages/hookchat-go@v0.1.1The package name is hookchat. The module lives in a monorepo, so releases are tagged packages/hookchat-go/vX.Y.Z; the downloads page lists them along with the changelog.
Create a client#
New takes the API key and options. A single-tenant key derives its tenant from the key; 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 "github.com/jiffi-co/jiffi-message-gateway/packages/hookchat-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-go/<version>
)
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-go/<version> | 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. An empty ActorID, or a send with neither Text nor Attachments, is refused locally with the code the server would have returned and Status 0.
package main
import (
"context"
"fmt"
"log"
"os"
hookchat "github.com/jiffi-co/jiffi-message-gateway/packages/hookchat-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.
followUp, 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 sent", followUp.PlatformMessageID)
}Services and methods#
| Service | Methods | Routes |
|---|---|---|
client.Ping | GET /v1/ping | |
client.Conversations | List, All, Get | GET /v1/conversations, GET /v1/conversations/:id |
client.Messages | Reply, SendAsHumanAgent, List, All, Get | POST /v1/messages/reply, POST /v1/messages/human-agent, 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 "github.com/jiffi-co/jiffi-message-gateway/packages/hookchat-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 returns a *hookchat.Error with Code (the stable machine code), Status, Message, Detail, RequestID, Body, Method and Path. 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.
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
hookchat "github.com/jiffi-co/jiffi-message-gateway/packages/hookchat-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().
package main
import (
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
hookchat "github.com/jiffi-co/jiffi-message-gateway/packages/hookchat-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 "github.com/jiffi-co/jiffi-message-gateway/packages/hookchat-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 "github.com/jiffi-co/jiffi-message-gateway/packages/hookchat-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.