The official server-side Go SDK for Qeet Notify — the multi-channel transactional notification platform (email, SMS, WhatsApp, in-app, push, webhooks). One client, typed methods across events, subscribers, templates, workflows, providers, India DLT/DND compliance, suppression, the dead-letter queue, analytics, and audit logs — with idempotency-aware event sending, automatic retries, auto-pagination, and outbound-webhook signature verification built in, on zero third-party dependencies.
client := qeetnotify.New(qeetnotify.Config{APIKey: os.Getenv("QEETNOTIFY_API_KEY")})
_, err := client.Events.Trigger(ctx, qeetnotify.TriggerEventInput{
Event: "user.welcome",
SubscriberID: "usr_123",
Payload: map[string]any{"name": "Sai"},
})New to notification infrastructure? Read Concepts first — it explains the moving parts in plain English before you touch any code.
- Concepts
- Requirements
- Installation
- Quick start
- Without vs. with this SDK
- Configuration
- Resource reference
- Core concepts, in code
- Architecture
- Testing your integration
- Examples
- Documentation
- Versioning and compatibility
- Contributing
- Security
- License
A quick glossary before the code.
| Term | What it means here |
|---|---|
API key (qn_live_…) |
A long-lived secret your backend uses to call the management API. Sent in the X-Qeet-Api-Key header. Never ships to a browser. Keys are scoped full / read / send. |
| Event | A named trigger (user.welcome, order.shipped) you fire for a subscriber. Qeet Notify matches it to workflows and fans out to channels. This is the core send path — Events.Trigger. |
| Subscriber | A recipient. Identified by your own external_id; PII (email/phone) is encrypted at rest and never returned by the standard read endpoints. |
| Template | A per-channel, per-locale Handlebars message body. |
| Workflow | An event-triggered DAG (branch → delay → channel → action) that decides what actually gets sent. |
| Provider | A per-channel vendor config (SES/Resend for email, MSG91 for SMS, Meta for WhatsApp, …). |
| Idempotency key | A per-request token that lets the backend de-duplicate a retried Trigger for 24h, so a network retry never double-sends. The SDK sets one for you. |
| DLT / NDNC | India-specific compliance: every outbound SMS body must match a registered DLT template regex, and numbers on the national DND (NDNC) list must be scrubbed from promotional sends. |
| Suppression list | Addresses that must never be sent to (hard bounces, complaints, unsubscribes). Stored as hashes. |
| Webhook | Qeet Notify calling your server when a delivery event happens. You must verify the signature before trusting it — see Webhooks. |
- Go 1.23+ (the SDK uses
iter.Seq2range-over-func iterators for pagination). - A Qeet Notify account and a server-side API key (
qn_live_…) — create one in the console or viaclient.APIKeys.Create.
go get github.com/qeetgroup/qeet-notify-goimport "github.com/qeetgroup/qeet-notify-go"package main
import (
"context"
"fmt"
"log"
"os"
"github.com/qeetgroup/qeet-notify-go"
)
func main() {
// One client, backed by your secret API key. Reuse it for the lifetime of
// your process — it's safe for concurrent use.
client := qeetnotify.New(qeetnotify.Config{
APIKey: os.Getenv("QEETNOTIFY_API_KEY"), // a server-side qn_live_… key
})
// Every method takes a context and returns (result, error) — no exceptions,
// no hidden panics on a 404 or a bad request.
ctx := context.Background()
res, err := client.Events.Trigger(ctx, qeetnotify.TriggerEventInput{
Event: "user.welcome",
SubscriberID: "usr_123",
Payload: map[string]any{"name": "Ada"},
})
if err != nil {
log.Fatal(err)
}
fmt.Println("event", res.Status) // "accepted"
}Warning
Never embed an API key in client-side code (browser, mobile). This SDK is for servers, workers, and CLIs only — it authenticates with a secret key that must never leave your backend.
Sending an event safely — so a network hiccup and retry can't double-send — is the difference.
| Without this SDK | With this SDK |
|---|---|
// Marshal, set X-Qeet-Api-Key, generate + attach an
// Idempotency-Key, POST, then decide which failures are
// safe to retry (429/5xx) with backoff — and be sure to
// reuse the SAME idempotency key on every retry, or you
// risk double-sending.
body, _ := json.Marshal(map[string]any{
"event": "user.welcome", "subscriber_id": "usr_123",
})
req, _ := http.NewRequest("POST", base+"/v1/events",
bytes.NewReader(body))
req.Header.Set("X-Qeet-Api-Key", key)
req.Header.Set("Idempotency-Key", generateKey())
// ...retry loop, backoff, Retry-After, error decoding... |
res, err := client.Events.Trigger(ctx,
qeetnotify.TriggerEventInput{
Event: "user.welcome",
SubscriberID: "usr_123",
})One call. The Idempotency-Key is generated and reused across retries for you; 429/5xx backoff and typed errors are built in. |
client := qeetnotify.New(qeetnotify.Config{
APIKey: os.Getenv("QEETNOTIFY_API_KEY"), // required
BaseURL: "https://notify.api.qeet.in", // default; override for staging/local
Timeout: 10 * time.Second, // default; used only if HTTPClient is nil
MaxRetries: 2, // default; 429 + 5xx on idempotent calls
Headers: http.Header{"X-Trace-Id": {traceID}}, // sent on every request
UserAgent: "myapp/1.2.0", // prepended to the SDK's own User-Agent
HTTPClient: &http.Client{ /* custom transport */ }, // full override
Logger: myLogger{}, // optional per-request hook
})| Field | Default | Notes |
|---|---|---|
APIKey |
— (required) | Server-side secret key (qn_live_…), sent as X-Qeet-Api-Key |
BaseURL |
https://notify.api.qeet.in |
Staging: https://notify-staging.api.qeet.in; local: http://localhost:8080 |
Timeout |
10s |
Ignored if HTTPClient is set |
MaxRetries |
2 |
Retry budget for 429/5xx on idempotent requests |
Headers |
none | Sent on every request; cannot override X-Qeet-Api-Key/Accept/Content-Type/User-Agent |
UserAgent |
none | Prepended to (not replacing) the SDK's own UA string |
HTTPClient |
internal default | Full transport override — proxying, custom TLS, etc. |
Logger |
none (no-op) | Per-request observability hook |
Build once, reuse everywhere — *Client is safe for concurrent use.
Every resource is a *XService field directly on Client — one field access away, no nesting. The categories are documentation groupings only (comment banners in client.go), not distinct Go types.
Messaging — sending, and the assets that shape it
| Field | Manages |
|---|---|
Events |
Trigger a named event for a subscriber (the core send path); idempotency-aware |
Templates |
Handlebars templates per channel/locale — CRUD, publish, auto-pagination |
Workflows |
Event-triggered DAG journeys — CRUD, activate/pause, list runs, auto-pagination |
Notifications |
Read-only delivery log — list (filter by subscriber/channel/status) + get with delivery-event history |
Subscribers — recipients
| Field | Manages |
|---|---|
Subscribers |
CRUD + auto-pagination, channel/category preferences, push devices, DPDP consent ledger, and a DPDP data export |
Deliverability — how messages get out
| Field | Manages |
|---|---|
Providers |
Per-channel vendor configs (encrypted credentials) |
Suppressions |
Suppression list (hashed addresses) |
DLT |
India TRAI DLT template registrations |
NDNC |
National DND registry — list, Check(phone), register |
DLQ |
Dead-letter queue — list, get, redrive, discard |
Operations — developer tooling
| Field | Manages |
|---|---|
APIKeys |
Scoped API keys (create returns the raw key once) |
Analytics |
Aggregate delivery funnel totals |
AuditLogs |
Audit trail — filter + auto-pagination |
Plus package-level VerifyWebhookSignature / ConstructEvent / ConstructEventFromRequest for inbound webhooks.
Events.Trigger is the send path. It returns as soon as the event is accepted (HTTP 202); delivery happens asynchronously. Every call carries an Idempotency-Key so a retry — whether the SDK's own automatic one or your outer retry — is de-duplicated by the backend for 24h and never double-sends.
// The SDK generates and reuses an idempotency key for you.
res, err := client.Events.Trigger(ctx, qeetnotify.TriggerEventInput{
Event: "order.shipped", SubscriberID: "usr_123",
Payload: map[string]any{"tracking": "1Z…"},
})
// Pin your own key to make an outer, cross-process retry idempotent too — e.g.
// key it off the domain event that caused the send.
_, err = client.Events.Trigger(ctx, qeetnotify.TriggerEventInput{
Event: "order.shipped", SubscriberID: "usr_123",
}, qeetnotify.WithIdempotencyKey("order-9001-shipped"))Every listable resource has an All(...) iterator that walks offset pages lazily. Break out early and paging stops immediately.
for sub, err := range client.Subscribers.All(ctx, qeetnotify.ListParams{}) {
if err != nil {
return err
}
fmt.Println(sub.ExternalID)
}The lower-level List(...) returning { Data, Total } is still there if you want to drive Limit/Offset yourself (e.g. one page per HTTP request in your own API).
Qeet Notify calling your server is only trustworthy once you've checked the signature — anyone can POST a fake payload to a public URL. Always verify against the raw request body, before any JSON re-serialization.
func handleWebhook(w http.ResponseWriter, r *http.Request) {
event, err := qeetnotify.ConstructEventFromRequest(r, os.Getenv("QEETNOTIFY_WEBHOOK_SECRET"))
if err != nil {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
var payload map[string]any
_ = event.Data(&payload)
w.WriteHeader(http.StatusOK)
}Deliveries are signed with X-Qeet-Signature-256: sha256=<hmac> and carry an X-Qeet-Timestamp. VerifyWebhookSignature(body, sigHeader, secret) and ConstructEvent(body, sigHeader, timestampHeader, secret) are available when you already have the raw bytes.
Before any SMS send in India, the body must match a registered DLT template regex, and promotional traffic must skip numbers on the national DND list.
// Register a DLT template (starts "pending" approval).
id, _ := client.DLT.Register(ctx, qeetnotify.RegisterDLTTemplateInput{
Carrier: "jio", TemplateIDExt: "17071600…", TemplateName: "otp_login",
Category: "transactional", BodyRegex: `Your OTP is \d{6}`,
})
// Scrub before a promotional send.
if onDND, _ := client.NDNC.Check(ctx, "+919812345678", "promotional"); onDND {
// skip this recipient
}Every failed API call returns a *qeetnotify.Error — never a generic fmt.Errorf, never a panic. Inspect it with errors.As:
var e *qeetnotify.Error
if errors.As(err, &e) {
switch {
case e.IsUnauthorized(): // 401 — bad/missing API key
case e.IsForbidden(): // 403 — key scope too narrow for this call
case e.IsNotFound(): // 404
case e.IsRateLimited():
time.Sleep(time.Duration(e.RetryAfterSeconds) * time.Second)
}
log.Printf("qeetnotify error %d %s (request %s)", e.Status, e.Code, e.RequestID)
}e.RequestID (from the X-Request-Id response header) is the value to quote in a support ticket.
Config.Logger is an optional hook invoked once per request, after retries settle, with method/path/status/duration/request-ID. The SDK core has zero logging dependencies, so adopting it never pulls in slog/zap/zerolog on your behalf.
type slogLogger struct{}
func (slogLogger) LogRequest(method, path string, status int, d time.Duration, requestID string) {
slog.Info("qeetnotify request",
"method", method, "path", path, "status", status,
"duration_ms", d.Milliseconds(), "request_id", requestID)
}flowchart LR
A[Your application] --> B["qeetnotify.Client<br/>(13 *XService fields)"]
B --> C["*XService<br/>(EventsService, SubscribersService, ...)"]
C --> D["internal/transport<br/>X-Qeet-Api-Key · retry/backoff · JSON · typed errors"]
D --> E[Qeet Notify API]
The public API is one flat package; the implementation is layered into focused internal/ packages (transport, pagination, constants, ...), each independently unit-tested and inaccessible from outside this module. See docs/architecture/ARCHITECTURE.md and docs/design-decisions/ for the trade-offs.
The SDK doesn't ship a mock client — Config.HTTPClient and Config.BaseURL are the extension points. Point at an httptest.Server for unit tests, or swap BaseURL for a staging Qeet Notify instance:
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"status":"accepted"}`))
}))
defer srv.Close()
client := qeetnotify.New(qeetnotify.Config{APIKey: "qn_live_test", BaseURL: srv.URL})Runnable examples live in examples/, grouped the same way as the resources:
| Example | Demonstrates |
|---|---|
messaging/trigger-event |
Fire an event with automatic idempotency |
subscribers/manage |
Create a subscriber, set preferences, auto-paginate |
deliverability/dlt-sms |
Register a DLT template + DND check |
operations/analytics |
Delivery funnel + audit-log walk |
webhooks/verify |
Verify inbound webhook deliveries |
- docs/architecture/ARCHITECTURE.md — internal package layering, why the API is flat
- docs/design-decisions/ — ADRs for the non-obvious calls
- docs/FUTURE.md — surfaces this SDK doesn't cover yet
- pkg.go.dev/github.com/qeetgroup/qeet-notify-go — generated API reference
This SDK follows Semantic Versioning. It is currently pre-1.0 — minor versions may include breaking changes, documented in CHANGELOG.md. Supported Go versions: the two most recent major Go releases (currently 1.23 and 1.24), matching the CI matrix in .github/workflows/ci.yml.
See CONTRIBUTING.md for local setup, code conventions, and how to add a new resource.
See SECURITY.md to report a vulnerability. Please don't open a public issue for security reports.