One module covers both transports: realtime.New holds a WebSocket for live subscriptions, realtime.NewRest makes stateless HTTPS calls. Source and reference live at github.com/Foony-Limited/realtime-go.
go get github.com/Foony-Limited/realtime-go
Client constructor
import realtime "github.com/Foony-Limited/realtime-go"
client, err := realtime.New(realtime.Options{
Key: os.Getenv("FOONY_API_KEY"),
})
| Option | Type | Default | Meaning |
|---|---|---|---|
Key |
string |
API key, for servers and trusted contexts | |
Token |
string |
Static JWT, for scripts and local dev | |
AuthCallback |
func(ctx) (string, error) |
Returns a fresh JWT, called on connect and re-auth | |
Endpoint |
string |
realtime.foony.io |
Host or full wss:// URL |
ClientID |
string |
Identity attached to key-auth connections | |
DisableAutoReconnect |
bool |
false |
Turn off the 1 s to 30 s exponential backoff |
DisableQueueing |
bool |
false |
Fail publishes while disconnected instead of buffering |
Batch |
*BatchOptions |
Auto-batching defaults for all channels |
Exactly one of Key, Token, or AuthCallback is required. Go usually runs server-side, so Key is the common choice; blocking calls take a context.Context, and listeners run one at a time on a dispatcher goroutine, so a listener may call blocking SDK methods.
Connection
client.Connection.On(func(change realtime.ConnectionStateChange) {
log.Println(change.Current, change.Reason)
})
err := client.Connect(ctx)
log.Println(client.Connection.ID(), client.Connection.ClientID())
client.Close()
States move through initialized, connecting, connected, disconnected, closing, closed, and failed. On reconnect the SDK re-attaches subscribed channels, re-enters presence it had entered, flushes queued publishes, and backfills any missed messages. Resent publishes carry stable ids, so the service deduplicates them.
Channels
channel := client.Channels.Get("chat:lobby")
client.Channels.Release("chat:lobby")
Get returns a stable instance per name and accepts realtime.WithCipher and realtime.WithBatchOptions options. Release detaches and forgets the channel. Names are 1 to 255 characters from A-Z a-z 0-9 : - _, colons for hierarchy, no leading colon.
Messages
off := channel.Subscribe(func(message *realtime.Message) {
log.Println(message.Name, string(message.Data))
})
channel.Subscribe(listener, "typing")
channel.Subscribe(listener, "added", "removed")
channel.Unsubscribe()
err := channel.Publish(ctx, "chat", map[string]string{"text": "hello"})
err = channel.PublishBatch(ctx, []realtime.BatchMessage{{Name: "a", Data: 1}, {Name: "b", Data: 2}})
err = channel.Publish(ctx, "cursor", position, realtime.WithEphemeral())
channel.Flush()
Message.Data is a json.RawMessage: unmarshal it into your own type. Published data goes through json.Marshal. See Publish and subscribe for batching, ephemeral semantics, and the message object fields.
History
result, err := channel.History(ctx, realtime.HistoryParams{Limit: 50, Before: oldestSerial})
log.Println(len(result.Messages), result.More)
Oldest first, with Before as an exclusive serial cursor for paging backward. Details in History.
Presence
channel.Presence.Subscribe(func(event *realtime.PresenceEvent) {
log.Println(event.Action, event.ClientID)
})
channel.Presence.On(realtime.PresenceLeave, listener)
err := channel.Presence.Enter(ctx, map[string]string{"name": "Acorn"})
err = channel.Presence.Update(ctx, map[string]any{"name": "Acorn", "busy": true})
err = channel.Presence.Leave(ctx)
Listeners receive the current members as enter events first, then live transitions. Details in Presence.
Channel lifecycle
err := channel.Attach(ctx)
err = channel.Detach(ctx)
log.Println(channel.State())
channel.On(func(change realtime.ChannelStateChange) {
log.Println(change.Current)
})
Subscribing attaches implicitly, so Attach is only for warming a channel early or surfacing capability errors. States are initialized, attaching, attached, detaching, detached, suspended, and failed.
Token minting
Backends mint short-lived, capability-scoped JWTs for their clients locally, with no network call:
token, err := realtime.CreateJWT(os.Getenv("FOONY_API_KEY"), realtime.CreateJWTParams{
ClientID: userID,
Capability: realtime.Capability{"chat:" + userID + ":*": {"subscribe", "publish"}},
TTL: time.Hour,
})
The key never leaves your server; hand the token to the client’s authCallback. See Auth and capabilities.
End-to-end encryption
Give a channel a symmetric key and every payload is AES-GCM encrypted before it leaves your process. The service only ever sees ciphertext, and subscribers with the same key decrypt transparently (the cipher is compatible with the JavaScript SDK’s):
key, err := realtime.GenerateRandomKey(256)
cipher, err := realtime.NewCipher(realtime.CipherParams{KeyBase64: key})
channel := client.Channels.Get("secure:room", realtime.WithCipher(cipher))
err = channel.Publish(ctx, "chat", map[string]string{"text": "only we can read this"})
Only Data is encrypted, not message names or client ids. History and REST reads decrypt the same way when given the cipher.
Rest client
rest, err := realtime.NewRest(realtime.RestOptions{Key: os.Getenv("FOONY_API_KEY")})
_, err = rest.Channels.Get("chat:lobby").Publish(ctx, "chat", map[string]string{"text": "from a job"})
history, err := rest.Channels.Get("chat:lobby").History(ctx, realtime.RestHistoryParams{Limit: 100})
members, err := rest.Channels.Get("chat:lobby").Presence.Get(ctx, realtime.RestPresenceParams{})
details, err := rest.Auth.RequestToken(ctx, realtime.TokenParams{ClientID: "user-123"})
serverTime, err := rest.Time(ctx)
Read methods return paginated results with Items, Next(ctx), HasNext(), and IsLast(). Failures return *RestError with the numeric Code and HTTP StatusCode from the error table.
Errors
Service errors are *realtime.ServerError values carrying the numeric code, exported as named constants, so handlers use errors.As instead of magic numbers:
err := channel.Publish(ctx, "chat", data)
var serverErr *realtime.ServerError
if errors.As(err, &serverErr) && serverErr.Code == realtime.CodeCapability {
promptForUpgrade()
}