Skip to main content

Go samples

bubbl3 has no Go SDK. These samples call the API with Go's standard library only (Go 1.22 or later) and are checked against the same OpenAPI document as the API reference on every change. Copy them into your own package and use a test key (b3_test_...) and test numbers while you build, so messages only reach the simulator.

The client​

Every call sends your key as Authorization: Bearer <key>. Writes send an Idempotency-Key named after what you're doing, so a retry after a timeout can't send twice; see idempotency.

client.go
// Package bubbl3sample calls the bubbl3 API from Go with the standard library only.
package bubbl3sample

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)

// APIURL is the bubbl3 API.
const APIURL = "https://api.bubbl3.com"

// Client holds your API key. Use a test key (b3_test_...) while you build.
type Client struct {
APIKey string
BaseURL string
HTTP *http.Client
}

// NewClient returns a client for the bubbl3 API.
func NewClient(apiKey string) *Client {
return &Client{APIKey: apiKey, BaseURL: APIURL, HTTP: &http.Client{Timeout: 30 * time.Second}}
}

// APIError is bubbl3's error body: {"error": {"code", "message", "request_id"}}.
type APIError struct {
Status int
Body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
} `json:"error"`
}
}

func (e *APIError) Error() string {
return fmt.Sprintf("bubbl3 %d %s: %s (request %s)",
e.Status, e.Body.Error.Code, e.Body.Error.Message, e.Body.Error.RequestID)
}

// do sends one request. A write passes an idempotency key named after what it does, so a retry
// after a timeout can't send twice.
func (c *Client) do(ctx context.Context, method, path string, in any, idempotencyKey string, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
res, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
apiErr := &APIError{Status: res.StatusCode}
_ = json.NewDecoder(res.Body).Decode(&apiErr.Body)
return apiErr
}
if out == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(out)
}

Send a message​

POST /v1/messages/check says first whether bubbl3 would send, hold or refuse the text, and why. The send itself goes through the same consent, opt-out, quiet-hours and pacing checks whatever the check said.

send_message.go
package bubbl3sample

import (
"context"
"fmt"
)

// SendMessageRequest is the body of POST /v1/messages and POST /v1/messages/check.
type SendMessageRequest struct {
To string `json:"to"`
Body string `json:"body"`
}

// Message is the part of a message this sample reads.
type Message struct {
ID string `json:"id"`
Status string `json:"status"`
}

// CheckResult is the answer of POST /v1/messages/check.
type CheckResult struct {
Recipients []struct {
To string `json:"to"`
Decision string `json:"decision"`
Code *string `json:"code"`
Reason *string `json:"reason"`
} `json:"recipients"`
}

// SendMessage checks a text first, then sends it. bubbl3 applies the same consent, opt-out,
// quiet-hours and pacing checks to the send whatever the check said; the check only lets you
// stop early and show why.
func (c *Client) SendMessage(ctx context.Context, to, body, idempotencyKey string) (*Message, error) {
in := SendMessageRequest{To: to, Body: body}
var check CheckResult
if err := c.do(ctx, "POST", "/v1/messages/check", in, "", &check); err != nil {
return nil, err
}
for _, r := range check.Recipients {
if r.Decision == "reject" && r.Reason != nil {
return nil, fmt.Errorf("bubbl3 won't send this text: %s", *r.Reason)
}
}
var msg Message
if err := c.do(ctx, "POST", "/v1/messages", in, idempotencyKey, &msg); err != nil {
return nil, err
}
return &msg, nil
}

// ExampleSendMessage texts a test number, which only reaches bubbl3's device simulator.
func ExampleSendMessage(ctx context.Context, apiKey string) error {
c := NewClient(apiKey)
msg, err := c.SendMessage(ctx, "+19175550142", "Hi from Go", "order-1042-confirmation")
if err != nil {
return err
}
fmt.Println(msg.ID, msg.Status)
return nil
}

Upsert a contact​

Finds the contact by phone number with GET /v1/contacts?phone=, then updates it with PATCH /v1/contacts/{id} or creates it with POST /v1/contacts. A number that opted out stays opted out.

upsert_contact.go
package bubbl3sample

import (
"context"
"net/url"
)

// ContactInput is the body of POST /v1/contacts and PATCH /v1/contacts/{id}.
type ContactInput struct {
Phone string `json:"phone"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Email string `json:"email,omitempty"`
Labels []string `json:"labels,omitempty"`
}

// Contact is the part of a contact this sample reads.
type Contact struct {
ID string `json:"id"`
Phone string `json:"phone"`
ConsentStatus string `json:"consent_status"`
}

type contactList struct {
Data []Contact `json:"data"`
}

// UpsertContact finds a contact by phone number and updates it, or creates it. A number that
// opted out stays opted out: nothing here changes consent.
func (c *Client) UpsertContact(ctx context.Context, in ContactInput, idempotencyKey string) (*Contact, error) {
var found contactList
if err := c.do(ctx, "GET", "/v1/contacts?phone="+url.QueryEscape(in.Phone), nil, "", &found); err != nil {
return nil, err
}
var out Contact
if len(found.Data) > 0 {
err := c.do(ctx, "PATCH", "/v1/contacts/"+found.Data[0].ID, in, idempotencyKey, &out)
return &out, err
}
err := c.do(ctx, "POST", "/v1/contacts", in, idempotencyKey, &out)
return &out, err
}

// ExampleUpsertContact adds or updates a test contact.
func ExampleUpsertContact(ctx context.Context, apiKey string) error {
in := ContactInput{Phone: "+19175550143", FirstName: "Ada", Email: "ada@example.com", Labels: []string{"go-sample"}}
_, err := NewClient(apiKey).UpsertContact(ctx, in, "crm-contact-7781")
return err
}

Receive and verify a webhook​

The same check as the Node SDK's verifyWebhook: an HMAC-SHA256 of <t>.<raw body> with your endpoint's secret, compared in constant time with each v1 in the bubbl3-signature header, and a timestamp within 300 seconds. See the webhooks quick-start for retries and event ids.

webhook.go
package bubbl3sample

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"strings"
"time"
)

// SignatureHeader is the header bubbl3 signs every webhook delivery with.
const SignatureHeader = "bubbl3-signature"

// ToleranceSeconds is how far the signed time may be from now, either way.
const ToleranceSeconds = 300

// VerifyWebhook checks a delivery's bubbl3-signature header, "t=<unix seconds>,v1=<hex>", where
// each v1 is HMAC-SHA256(secret, "<t>.<raw body>"). Pass the raw body, before parsing JSON.
// During a secret rotation a delivery carries two v1 values; pass both secrets until you switch.
func VerifyWebhook(rawBody []byte, header string, secrets []string, now time.Time) bool {
var t string
var sigs []string
for _, part := range strings.Split(header, ",") {
k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
return false
}
switch k {
case "t":
t = v
case "v1":
sigs = append(sigs, v)
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || len(sigs) == 0 {
return false
}
if d := now.Unix() - ts; d > ToleranceSeconds || d < -ToleranceSeconds {
return false
}
if len(secrets) == 0 {
return false
}
for _, secret := range secrets {
// A missing secret (an unset environment variable) must never let a delivery through.
if strings.TrimSpace(secret) == "" {
return false
}
}
for _, secret := range secrets {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "." + string(rawBody)))
want := mac.Sum(nil)
for _, sig := range sigs {
got, err := hex.DecodeString(sig)
if err == nil && hmac.Equal(got, want) {
return true
}
}
}
return false
}

// WebhookHandler answers bubbl3's deliveries: 400 for a bad signature, else 200 straight away.
// Record the bubbl3-event-id header and skip an event you've already handled: delivery is at
// least once.
func WebhookHandler(secrets []string, handle func(eventID string, body []byte)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil || !VerifyWebhook(body, r.Header.Get(SignatureHeader), secrets, time.Now()) {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
go handle(r.Header.Get("bubbl3-event-id"), body)
}
}

Post a lead to a lead source​

A Lead URL (an inbound source of kind webhook in the API) takes leads signed with its own secret, no API key. Send a stable event_id (or Idempotency-Key) so a retry is the same lead. See the lead sources quick-start.

post_lead.go
package bubbl3sample

import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
)

// Lead is what a webhook lead source takes. event_id makes a retry count as the same lead.
type Lead struct {
EventID string `json:"event_id"`
Phone string `json:"phone"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Email string `json:"email,omitempty"`
}

// PostLead sends a lead to a webhook lead source, signed with the source's secret. No API key:
// the signature is the proof. The source's consent setting and workflow decide what happens next.
func PostLead(ctx context.Context, sourceID, sourceSecret string, lead Lead) error {
body, err := json.Marshal(lead)
if err != nil {
return err
}
t := strconv.FormatInt(time.Now().Unix(), 10)
mac := hmac.New(sha256.New, []byte(sourceSecret))
mac.Write([]byte(t + "." + string(body)))
req, err := http.NewRequestWithContext(ctx, "POST", APIURL+"/v1/inbound/leads/"+sourceID, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("bubbl3-signature", "t="+t+",v1="+hex.EncodeToString(mac.Sum(nil)))
req.Header.Set("Idempotency-Key", lead.EventID)
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("bubbl3 refused the lead: %d", res.StatusCode)
}
return nil
}

// ExamplePostLead sends a test lead.
func ExamplePostLead(ctx context.Context, sourceID, sourceSecret string) error {
return PostLead(ctx, sourceID, sourceSecret, Lead{EventID: "order-1042", Phone: "+19175550142", FirstName: "Ada"})
}