Norq

Go SDK

The norq-go package provides a Go client for sending and linting notification templates.

Install

Coming soon. The Go module is not yet published. See norq.sh for updates.

The SDK runs entirely in-process — no norq binary or daemon is invoked at runtime.

Quick start

package main
 
import (
    "context"
    "fmt"
    "github.com/suprsend/norq/sdks/go"
)
 
func main() {
    client, err := norq.New(norq.WithProjectDir("./my-project"))
    if err != nil {
        panic(err)
    }
 
    ctx := context.Background()
 
    // Send
    result, err := client.Send(ctx, "transactional/order-shipped", norq.SendOpts{
        To:   &norq.Recipient{Email: "gaurav@example.com"},
        Data: map[string]any{"user": map[string]any{"first_name": "Gaurav"}},
    })
 
    for _, r := range result.Results {
        fmt.Printf("%s: success=%v\n", r.Channel, r.Success)
    }
}

Constructor options

client, _ := norq.New(
    norq.WithProjectDir("./my-project"),       // project root (required)
    norq.WithStrict(true),                      // runtime template validation (default: true)
    norq.WithRecipientResolver(myResolver),     // user ID -> Recipient
)
Option Type Description
WithProjectDir(dir) string Path to norq project root. Required — templates are read from this directory and compiled in-process.
WithStrict(s) bool Reject sends that reference missing data or unknown pipes. Default true.
WithRecipientResolver(r) RecipientResolver Resolves user IDs to *Recipient objects

API

Send(ctx, id, opts)

Compile and deliver a notification.

result, err := client.Send(ctx, "transactional/welcome", norq.SendOpts{
    To: &norq.Recipient{
        Email: "user@example.com",
        Cc:    []string{"audit@example.com"},  // email-channel CC/BCC slices
    },
    Data:           map[string]any{...},
    Sample:         "New user",                // alternative to Data
    Channels:       []string{"email"},          // optional filter
    DryRun:         false,
    IdempotencyKey: "order-shipped-ORD-123",   // optional — see below
})

IdempotencyKey is forwarded to providers that honour it (Resend, SuprSend) as the Idempotency-Key: header. When empty, the SDK generates a UUID v4 per send and applies it to every channel — supplying a stable, user-derived key extends dedup across retries and processes.

Recipient.Cc / Bcc apply to the email channel only; providers that support them (Resend) include them in the request body, others ignore.

SendOpts.Attachments is a slice of Attachment{Filename, Content []byte, ContentType, ContentID, Disposition}. The SDK base64-encodes Content automatically. ContentType is optional — falls back to filename-extension inference for the curated whitelist. Use Disposition: "inline" + a ContentID to embed images, then reference them as <img src="cid:logo">. Provider caps: Resend 40 MB total, SendGrid 30 MB. See the Attachments reference for the full whitelist.

Batch(ctx, id, channel)

Create a batch for sending a single channel template to many recipients. The template is compiled once, then rendered per-recipient. Requests auto-flush at the provider’s max batch size.

batch, err := client.Batch(ctx, "transactional/welcome", "email")
if err != nil {
    panic(err)
}
 
// Add recipients (auto-flushes at provider's max batch size)
for _, user := range users {
    err := batch.Add(ctx, user.Email, map[string]any{
        "user": map[string]any{"first_name": user.Name},
    })
    if err != nil {
        log.Printf("add failed: %v", err)
    }
}
 
// Flush remaining and get all results
results := batch.Flush(ctx)

Lint(ctx, id)

results, err := client.Lint(ctx, "")        // all
results, err := client.Lint(ctx, "transactional/welcome") // one
 
for _, r := range results {
    for _, d := range r.Diagnostics {
        fmt.Printf("%s: %s [%s]\n", d.Severity, d.Message, d.Rule)
    }
}

RecipientResolver

To send by user ID instead of passing a full *Recipient, implement the RecipientResolver interface:

type MyResolver struct {
    db *sql.DB
}
 
func (r *MyResolver) Resolve(ctx context.Context, userID string) (*norq.Recipient, error) {
    user, err := r.db.GetUser(ctx, userID)
    if err != nil {
        return nil, err
    }
    return &norq.Recipient{
        Email: user.Email,
        Phone: user.Phone,
        Slack: func() *norq.SlackRecipient {
            if user.SlackID != "" {
                return &norq.SlackRecipient{ChannelID: user.SlackID}
            }
            return nil
        }(),
    }, nil
}
 
client, _ := norq.New(
    norq.WithProjectDir("./my-project"),
    norq.WithRecipientResolver(&MyResolver{db: db}),
)
 
// Now send by user ID
client.Send(ctx, "transactional/welcome", norq.SendOpts{
    To:   "user-123",  // resolved via RecipientResolver
    Data: map[string]any{"user": map[string]any{"first_name": "Alice"}},
})

Types

type Recipient struct {
    Email    string
    Phone    string
    Slack    *SlackRecipient
    Push     *PushRecipient
    WhatsApp *WhatsAppRecipient
    MsTeams  *MsTeamsRecipient
}
 
type SendResult struct {
    Success   bool
    Provider  string
    Channel   string
    MessageID string
    Error     string
}
 
type SendMultiResult struct {
    Results []SendResult
    Skipped []SkippedChannel
}

Sending push notifications

Push delivery is per-token. A *PushRecipient carries every device the user has registered, possibly across ios, android, and web. The SDK fans the send out one HTTP call per token and emits one SendResult per token in result.Results.

Token shape

type PushRecipient struct {
    Tokens []PushToken `json:"tokens,omitempty"`
}
 
type PushToken struct {
    Token    string         `json:"token"`              // FCM/APNs/Expo token, or Web Push endpoint URL
    Platform string         `json:"platform"`           // "ios" | "android" | "web" — required
    Provider string         `json:"provider,omitempty"` // optional override (e.g. "expo")
    Keys     *PushVapidKeys `json:"keys,omitempty"`     // required iff Platform == "web"
}
 
type PushVapidKeys struct {
    P256dh string `json:"p256dh"`
    Auth   string `json:"auth"`
}

APNs tokens additionally accept an environment field (sandbox | production) — pass it through the underlying JSON if you need per-token sandbox routing.

Mixed-platform recipient

result, err := client.Send(ctx, "transactional/order-shipped", norq.SendOpts{
    To: &norq.Recipient{
        Push: &norq.PushRecipient{
            Tokens: []norq.PushToken{
                {Token: "fcm-android-token", Platform: "android"},
                {Token: "apns-ios-token", Platform: "ios"},
                {
                    Token:    "https://updates.push.services.mozilla.com/wpush/v2/...",
                    Platform: "web",
                    Keys:     &norq.PushVapidKeys{P256dh: "BNc...", Auth: "tBHI..."},
                },
                {Token: "ExponentPushToken[xxx]", Platform: "ios", Provider: "expo"},
            },
        },
    },
    Data:     map[string]any{"user": map[string]any{"first_name": "Gaurav"}},
    Channels: []string{"push"},
})

Per-token result fields

Each push SendResult carries two extra fields:

Field Meaning
TokenIndex (with TokenIndexSet == true) Index into the original PushRecipient.Tokens slice — match results back to the token.
TokenStatus Normalised cross-provider status: "invalid" (FCM UNREGISTERED, APNs BadDeviceToken, Web Push 404/410) or "transient" (5xx, rate limits). Empty string on success.

Reaction guide

TokenStatus What it means What to do
"invalid" Token is permanently dead. Drop from DB; don’t retry.
"transient" Provider had a temporary failure. Retry with backoff; token still good.
"" on success Delivery accepted. No action.
for _, r := range result.Results {
    if r.Channel != "push" || !r.TokenIndexSet {
        continue
    }
    token := recipient.Push.Tokens[r.TokenIndex]
    switch r.TokenStatus {
    case "invalid":
        db.DeletePushToken(ctx, userID, token.Token)
    case "transient":
        retryQueue.Enqueue(ctx, userID, token, time.Now().Add(time.Minute))
    }
}

See Push provider errors for the full set of per-token failure modes.

Codegen

norq codegen --lang go --out internal/norq/types.go

Generates typed Go structs for each notification’s data shape.