github.com/HookbaseApp/hookbase-go
The Hookbase Go SDK provides a typed client for sending webhooks to your customers. It requires Go 1.21 or newer and uses only the standard library. Every method takes a context.Context first, and every method also accepts per-request options as trailing arguments.
Source: github.com/HookbaseApp/hookbase-go
Installation
go get github.com/HookbaseApp/hookbase-go@v1.13.0Quick Start
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/HookbaseApp/hookbase-go"
)
func main() {
ctx := context.Background()
client := hookbase.New(os.Getenv("HOOKBASE_API_KEY"))
// An application represents one of your customers
app, err := client.Applications.Create(ctx, &hookbase.CreateApplicationParams{
Name: "Acme Corp",
UID: hookbase.Ptr("customer_123"),
})
if err != nil {
log.Fatal(err)
}
// The endpoint the customer receives webhooks on
endpoint, err := client.Endpoints.Create(ctx, app.ID, &hookbase.CreateEndpointParams{
URL: "https://example.com/webhooks",
})
if err != nil {
log.Fatal(err)
}
// An event type, and a subscription from the endpoint to it
orderCreated, err := client.EventTypes.Create(ctx, &hookbase.CreateEventTypeParams{
Name: "order.created",
})
if err != nil {
log.Fatal(err)
}
if _, err := client.Subscriptions.Create(ctx, app.ID, &hookbase.CreateSubscriptionParams{
EndpointID: endpoint.ID,
EventTypeID: orderCreated.ID,
}); err != nil {
log.Fatal(err)
}
// Send a webhook
sent, err := client.Messages.Send(ctx, app.ID, &hookbase.SendMessageParams{
EventType: "order.created",
Payload: map[string]interface{}{"orderId": "ord_123", "amount": 99.99},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s queued to %d endpoint(s)\n", sent.EventID, sent.MessagesQueued)
}Client Configuration
import (
"net/http"
"os"
"time"
"github.com/HookbaseApp/hookbase-go"
)
// Defaults: https://api.hookbase.app, 30 s timeout, 3 retries.
// hookbase.New(os.Getenv("HOOKBASE_API_KEY")) alone uses them all.
client := hookbase.New(os.Getenv("HOOKBASE_API_KEY"),
hookbase.WithBaseURL("https://api.hookbase.app"), // Optional
hookbase.WithTimeout(30*time.Second), // Optional http.Client timeout
hookbase.WithMaxRetries(3), // Optional retry count
hookbase.WithHTTPClient(&http.Client{}), // Optional custom http.Client
)New panics when the API key is empty. Create one client per API key and share it across goroutines. A client passed to WithHTTPClient is used for every request, and its own Timeout applies instead of WithTimeout.
Optional fields on params structs are pointers. Use the hookbase.Ptr helper to set them inline (Description: hookbase.Ptr("Production")). A nil field is left out of the request.
Applications
Applications represent your customers. Each customer should have one application.
Create Application
app, err := client.Applications.Create(ctx, &hookbase.CreateApplicationParams{
Name: "Acme Corp",
UID: hookbase.Ptr("customer_123"), // Your internal customer ID, sent as externalId
Metadata: map[string]interface{}{"plan": "enterprise"},
})Get or Create Application
// Returns the existing app or creates a new one
app, err := client.Applications.GetOrCreate(ctx, "customer_123", &hookbase.CreateApplicationParams{
Name: "Acme Corp",
})app.Created is true when the call created the application and false when it already existed. CreateApplicationParams and UpdateApplicationParams also take RateLimitPerSecond, RateLimitPerMinute and RateLimitPerHour, and UpdateApplicationParams takes IsDisabled and DisabledReason.
List Applications
page, err := client.Applications.List(ctx, &hookbase.ListApplicationsParams{
Limit: hookbase.Ptr(50),
})
if err != nil {
return err
}
for _, a := range page.Data {
fmt.Println(a.Name)
}See Pagination to fetch every page.
Get by External ID
app, err := client.Applications.GetByUID(ctx, "customer_123")Event Types
Event types define the webhook events your customers can subscribe to.
Create Event Type
eventType, err := client.EventTypes.Create(ctx, &hookbase.CreateEventTypeParams{
Name: "order.created",
DisplayName: hookbase.Ptr("Order Created"),
Category: hookbase.Ptr("orders"),
Schema: map[string]interface{}{
"type": "object",
"required": []string{"orderId"},
},
})ExamplePayload takes any JSON value as a json.RawMessage, for example json.RawMessage(`{"orderId":"ord_1"}`). The SDK sends Schema and ExamplePayload as the JSON strings the API expects. Set ValidatePayloads to reject sends whose payload does not match the schema, and DefaultPriority to set the queue priority (0 to 4).
List Event Types
page, err := client.EventTypes.List(ctx, &hookbase.ListEventTypesParams{
Category: hookbase.Ptr("orders"),
})Subscriptions
// Subscribe an endpoint to an event type
sub, err := client.Subscriptions.Create(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", &hookbase.CreateSubscriptionParams{
EndpointID: "e25aaf14-ecb3-5f7c-9597-225979d06ed9",
EventTypeID: "d14e4a97-4566-51bb-8130-baeae1cbcef8",
})
// Several at once
result, err := client.Subscriptions.BulkSubscribe(ctx, "e25aaf14-ecb3-5f7c-9597-225979d06ed9", []string{"c448e8e1-8501-53a3-be4d-a6278a863484", "b371f66f-f84a-5ee6-ae1d-82ceb50672ab"})
fmt.Println(result.Created, "created,", result.Skipped, "already subscribed")
// Narrow a subscription, then remove the filter again
_, err = client.Subscriptions.Update(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", sub.ID, &hookbase.UpdateSubscriptionParams{
FilterExpression: hookbase.Ptr("amount > 100"),
})
_, err = client.Subscriptions.Update(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", sub.ID, &hookbase.UpdateSubscriptionParams{
Clear: []hookbase.SubscriptionField{hookbase.SubscriptionFilterExpression},
})Subscriptions also take LabelFilters, LabelFilterMode, TransformID, PriorityOverride and EventVersion ("latest" or a version label to pin). A nil field is left unchanged, so name a field in Clear to remove it. The API addresses subscriptions by ID alone, so the applicationID argument of Get, Update and Delete is not sent.
Messages
Messages are the webhooks you send to your customers.
Send Message
sent, err := client.Messages.Send(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", &hookbase.SendMessageParams{
EventType: "order.created",
Payload: map[string]interface{}{"orderId": "ord_123"},
Labels: map[string]string{"region": "eu"},
IdempotencyKey: "order-ord_123-created", // Optional
})
if err != nil {
return err
}
fmt.Printf("%s queued to %d endpoint(s)\n", sent.EventID, sent.MessagesQueued)Every Send carries an idempotency key: params.IdempotencyKey, else one from WithIdempotencyKey, else a generated UUID. The API deduplicates on it, so a send retried after a timeout is delivered once. Set EndpointIDs to deliver to some of the application's endpoints only, Priority (0, critical, to 4, bulk) to change its queue priority, and EventVersion when the payload is in an older version of the event type.
List Messages and Attempts
page, err := client.Messages.List(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", &hookbase.ListOutboundMessagesParams{
Status: hookbase.Ptr(hookbase.MessageFailed),
})
if err != nil {
return err
}
for _, m := range page.Data {
attempts, err := client.Messages.ListAttempts(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", m.ID)
if err != nil {
return err
}
fmt.Println(m.ID, m.Status, len(attempts))
}Retry Failed Message
// Replays the message (creates a new message)
replay, err := client.Messages.Replay(ctx, "5c588ab6-d167-5c12-8735-fd1df20421b0")
if err != nil {
return err
}
fmt.Println(replay.NewMessageID)Messages that ran out of retries are also listed in the dead-letter queue, client.DLQ (List, GetStats, Retry, RetryBulk, Delete, DeleteBulk).
Endpoints
Endpoints are managed by your customers through the Portal, but you can also manage them programmatically.
Create Endpoint
// The signing secret is returned only here; give it to your customer.
endpoint, err := client.Endpoints.Create(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", &hookbase.CreateEndpointParams{
URL: "https://example.com/webhooks",
Description: hookbase.Ptr("Production"),
})
if err != nil {
return err
}
secret := endpoint.Secret
// An Ed25519 endpoint: Hookbase keeps the private key and returns only the public key
edEndpoint, err := client.Endpoints.Create(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", &hookbase.CreateEndpointParams{
URL: "https://example.com/webhooks",
SignatureScheme: hookbase.Ptr(hookbase.SignatureSchemeEd25519),
})
if err != nil {
return err
}
publicKey := *edEndpoint.PublicKey // whpk_...
// A polling endpoint has no URL: the customer pulls messages from PollURL
polling, err := client.Endpoints.Create(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", &hookbase.CreateEndpointParams{
EndpointType: hookbase.Ptr(hookbase.EndpointTypePolling),
})
if err != nil {
return err
}
fmt.Println(*polling.PollURL)EndpointType also takes the provider types (EndpointTypeSQS, EndpointTypeSNS, EndpointTypeEventBridge, EndpointTypePubSub, EndpointTypeS3), which are configured through Destination. Authentication sets basic or OAuth 2.0 client-credentials auth for HTTP endpoints; to remove it, name hookbase.EndpointAuth in UpdateEndpointParams.Clear.
Get returns a *NotFoundError with code application_mismatch when the endpoint belongs to a different application than the one you pass. Update, Delete, Enable, Disable, RotateSecret, Test and RecoverCircuit make the same check first, with one extra GET, and change nothing on a mismatch. Pass "" to skip the check and the extra request.
List Endpoints
page, err := client.Endpoints.List(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", nil)Get Endpoint Statistics
stats, err := client.Endpoints.GetStats(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", "e25aaf14-ecb3-5f7c-9597-225979d06ed9")
if err != nil {
return err
}
fmt.Printf("%.1f%% of %d delivered\n", stats.SuccessRate, stats.TotalMessages)SuccessRate is a percentage from 0 to 100. GetStats makes two requests. The lifetime counters (TotalMessages, TotalSuccesses, TotalFailures, AverageLatency) and ConsecutiveFailures come from the endpoint itself. RecentFailures, the failed deliveries in the last 24 hours, comes from the endpoint analytics. When the API key can't read analytics (a 403), RecentFailures stays 0 and the counters are still returned.
Send a Test Event
result, err := client.Endpoints.TestWithParams(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", "e25aaf14-ecb3-5f7c-9597-225979d06ed9", &hookbase.TestEndpointParams{
EventType: hookbase.Ptr("order.created"),
Payload: map[string]interface{}{"orderId": "test_123"},
})
if err != nil {
return err
}
fmt.Println(result.Success, result.Delivery.Status, result.Delivery.ResponseStatus)Rotate the Signing Secret
// Returns the new secret (or, for an Ed25519 endpoint, the new whpk_ public key).
// The old one keeps signing for the API's default grace period of one hour.
newSecret, err := client.Endpoints.RotateSecret(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", "e25aaf14-ecb3-5f7c-9597-225979d06ed9")
// Set the grace period (seconds, 0-86400) and get the full result
rotated, err := client.Endpoints.RotateSecretWithParams(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", "e25aaf14-ecb3-5f7c-9597-225979d06ed9",
&hookbase.RotateSecretParams{GracePeriodSeconds: hookbase.Ptr(3600)})
if err != nil {
return err
}
fmt.Println(rotated.Secret, rotated.PreviousSecretExpiresAt)For an Ed25519 endpoint, rotated.Secret is empty: read rotated.PublicKey, and rotated.PreviousPublicKey for the key that keeps signing during the grace period. Rotation isn't retried after a server error or a dropped connection, because a second rotation would replace the secret the first one returned.
Portal Tokens
Portal tokens authenticate the embedded Portal component.
Create Portal Token
token, err := client.PortalTokens.Create(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", &hookbase.CreatePortalTokenParams{
Name: hookbase.Ptr("Customer dashboard"),
Scopes: []string{"read", "write"},
ExpiresInDays: hookbase.Ptr(30),
})
if err != nil {
return err
}
// Return the token to your frontend; it is only returned on create
plaintext := *token.TokenPortalTokens.List returns the application's tokens without the plaintext, with LastUsedAt, UseCount, IsExpired and IsRevoked. Revoke revokes one by ID.
Webhook Verification
Help your customers verify incoming webhooks. Always verify the raw bytes of the body: JSON that was parsed and re-serialized no longer matches the signature.
Verify a Request
import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"github.com/HookbaseApp/hookbase-go"
)
var webhook = hookbase.NewWebhook(os.Getenv("HOOKBASE_WEBHOOK_SECRET")) // whsec_...
func receive(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
headers := map[string]string{
"webhook-id": r.Header.Get("webhook-id"),
"webhook-timestamp": r.Header.Get("webhook-timestamp"),
"webhook-signature": r.Header.Get("webhook-signature"),
}
var event struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
if err := webhook.VerifyAndParse(body, headers, &event); err != nil {
var verr *hookbase.WebhookVerificationError
if errors.As(err, &verr) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
} else {
http.Error(w, "Malformed JSON", http.StatusBadRequest) // signed, but not valid JSON
}
return
}
log.Println("Event:", event.Type)
w.WriteHeader(http.StatusOK)
}Verify checks the webhook-id, webhook-timestamp and webhook-signature headers (map keys are case-insensitive), rejects timestamps more than 5 minutes from now, and accepts any of the space-separated signatures sent during a secret rotation. Use VerifyWithTolerance to change the window, and VerifyAndParse to verify and decode in one call. A failure is a *hookbase.WebhookVerificationError.
NewWebhook verifies v1 (HMAC-SHA256) signatures with a whsec_ secret. For endpoints using the ed25519 scheme, build the verifier from the endpoint's whpk_ public key, and it checks v1a signatures. NewWebhook also accepts a whsk_ Ed25519 secret key, which can both verify and sign test headers. NewWebhook panics on a malformed key, which suits a key fixed at startup. NewWebhookPublicKey and NewWebhookSecretKey return an error instead. See Ed25519 (v1a) Signatures.
verifier, err := hookbase.NewWebhookPublicKey(publicKey) // whpk_...
if err != nil {
return err
}
err = verifier.Verify(body, headers)Generate Test Headers
webhook := hookbase.NewWebhook("whsec_dGVzdF9zZWNyZXRfa2V5X2Zvcl90ZXN0aW5n")
payload := []byte(`{"orderId":"test_123"}`)
headers := webhook.GenerateTestHeaders(payload, "") // "" uses the ID msg_test
// Use these headers to test your webhook handlerFor v1a test headers, build the verifier from a whsk_ secret key, or sign with an ed25519.PrivateKey directly. GenerateTestHeaders panics on a verifier built from a whpk_ public key, which has no key to sign with.
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
return err
}
verifier, err := hookbase.NewWebhookPublicKey(hookbase.EncodeEd25519PublicKey(pub))
if err != nil {
return err
}
payload := []byte(`{"orderId":"test_123"}`)
err = verifier.Verify(payload, hookbase.GenerateEd25519TestHeaders(priv, payload, ""))
// Or as a whsk_ key, which signs and verifies
signer := hookbase.NewWebhook(hookbase.EncodeEd25519SecretKey(priv))
err = signer.Verify(payload, signer.GenerateTestHeaders(payload, ""))Error Handling
Errors are typed. Match them with errors.As:
| Type | When |
|---|---|
*APIError | Any error response: Status, Code, Message, RequestID, Details |
*AuthenticationError | 401 |
*ForbiddenError | 403, including plan limits and disabled features |
*NotFoundError | 404 |
*ConflictError | 409 |
*ValidationError | 400 / 422, with ValidationErrors (field name to messages) |
*RateLimitError | 429, with RetryAfter (seconds) |
*TimeoutError | A WithRequestTimeout attempt timed out and the retries ran out, or the caller's context was cancelled or expired |
*NetworkError | Could not connect, the connection dropped, or the http.Client Timeout (WithTimeout, default 30 s) expired |
*WebhookVerificationError | Signature verification failed |
Each status-specific type embeds APIError and unwraps to it, so errors.As(err, &apiErr) matches any error response. *TimeoutError unwraps to the context error, so errors.Is(err, context.Canceled) and errors.Is(err, context.DeadlineExceeded) work.
import (
"errors"
"fmt"
"github.com/HookbaseApp/hookbase-go"
)
_, err := client.Endpoints.Get(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", "invalid_id")
var notFound *hookbase.NotFoundError
var rateLimited *hookbase.RateLimitError
var invalid *hookbase.ValidationError
var conflict *hookbase.ConflictError
var apiErr *hookbase.APIError
switch {
case err == nil:
case errors.As(err, ¬Found):
fmt.Println("Endpoint not found")
case errors.As(err, &rateLimited):
fmt.Printf("Rate limited, retry after %ds\n", rateLimited.RetryAfter)
case errors.As(err, &invalid):
fmt.Println("Invalid:", invalid.ValidationErrors)
case errors.As(err, &conflict):
fmt.Println("Conflict:", conflict.Message)
case errors.As(err, &apiErr):
fmt.Printf("API error %d (%s): %s\n", apiErr.Status, apiErr.Code, apiErr.Message)
default:
fmt.Println("Request failed:", err)
}Retries
Failed requests are retried up to WithMaxRetries times (default 3; a negative value means no retries):
- 429 is retried for every method, after the
Retry-Afterthe API sends (seconds or an HTTP date). A 429 without a validRetry-Afterwaits 60 seconds before each retry. ARetry-Afterlonger than 60 seconds (a quota window, for example) is not waited out: the*RateLimitErroris returned at once withRetryAfterset. - 5xx responses, network errors and
WithRequestTimeouttimeouts are retried with exponential backoff (1, 2, 4 ... seconds, at most 10, plus up to 1 second of jitter) for every method except POST. A POST is retried only when it carries an idempotency key. Of the outbound calls, that isMessages.Send, which always has one. - Other 4xx responses, including 409, are never retried.
Backoff waits stop as soon as the context is done. A cancelled or expired context is never retried: the call returns a *TimeoutError at once.
Request Options
Every method takes request options as trailing arguments:
sent, err := client.Messages.Send(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117",
&hookbase.SendMessageParams{
EventType: "order.created",
Payload: map[string]interface{}{},
},
hookbase.WithRequestTimeout(60*time.Second),
hookbase.WithRequestRetries(5),
hookbase.WithIdempotencyKey("unique_key"),
)WithRequestTimeout bounds each attempt, and every retry gets a fresh timeout. It can't extend past the http.Client's own Timeout, which still applies. The two fail differently: a WithRequestTimeout or context deadline gives a *TimeoutError, while the http.Client Timeout (WithTimeout, default 30 s) gives a *NetworkError. To bound a call as a whole, retries included, give it a context with a deadline:
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
app, err := client.Applications.Get(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117")Pagination
List methods return one page. Outbound lists page by cursor: page.Data holds the items, and page.HasMore and page.NextCursor say whether there is another page. There is no auto-paging iterator, so loop on the cursor:
params := &hookbase.ListEndpointsParams{Limit: hookbase.Ptr(100)}
for {
page, err := client.Endpoints.List(ctx, "519c05c7-ada5-5d3e-9b80-2c0dc9743117", params)
if err != nil {
return err
}
for _, ep := range page.Data {
fmt.Println(ep.URL)
}
if !page.HasMore || page.NextCursor == nil {
break
}
params.Cursor = page.NextCursor
}Inbound lists (sources, destinations, routes, deliveries ...) return a PageResponse that pages by page number, with Total, Page and PageSize. Events page by cursor.
Models
Responses are plain structs in the hookbase package, named after the API's resources (Application, Endpoint, EventType, Subscription, OutboundMessage, MessageAttempt, PortalToken, Source, InboundEvent, Delivery ...). Field names are the API's JSON names in Go case (ExternalID for externalId). Timestamps are ISO-8601 strings, optional values are pointers, and free-form JSON is a map[string]interface{} or json.RawMessage.
The SDK also covers the inbound API (sources, destinations, routes, events, deliveries, transforms, filters, schemas, provider catalog) and admin resources (analytics, API keys, tunnels, cron jobs), following the same pattern. See the README and examples for more.