Skip to content

Latest commit

 

History

History
403 lines (308 loc) · 16.2 KB

README.md

File metadata and controls

403 lines (308 loc) · 16.2 KB

qeet-notify-go

Go Version License: MIT

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.


Table of contents

Concepts

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.

Requirements

  • Go 1.23+ (the SDK uses iter.Seq2 range-over-func iterators for pagination).
  • A Qeet Notify account and a server-side API key (qn_live_…) — create one in the console or via client.APIKeys.Create.

Installation

go get github.com/qeetgroup/qeet-notify-go
import "github.com/qeetgroup/qeet-notify-go"

Quick start

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.

Without vs. with this SDK

Sending an event safely — so a network hiccup and retry can't double-send — is the difference.

Without this SDKWith 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.

Configuration

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.

Resource reference

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.

Core concepts, in code

Triggering events + idempotency

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"))

Pagination

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

Webhooks

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.

India compliance (DLT / DND)

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
}

Error handling

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.

Observability

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)
}

Architecture

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]
Loading

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.

Testing your integration

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})

Examples

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

Documentation

Versioning and compatibility

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.

Contributing

See CONTRIBUTING.md for local setup, code conventions, and how to add a new resource.

Security

See SECURITY.md to report a vulnerability. Please don't open a public issue for security reports.

License

MIT