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 github.com/jiffi-co/jiffi-message-gateway/packages/hookchat-go@v0.1.1

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

Go
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))
}
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-go/<version>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. 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.

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

ServiceMethodsRoutes
client.PingGET /v1/ping
client.ConversationsList, All, GetGET /v1/conversations, GET /v1/conversations/:id
client.MessagesReply, SendAsHumanAgent, List, All, GetPOST /v1/messages/reply, POST /v1/messages/human-agent, 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 "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.

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

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

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

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

Next#