# LogDuck Documentation > LogDuck is an event logging and notification service for developers. This file is the full documentation as markdown; the same content is rendered at https://logduck.com/docs. Events are posted to https://api.logduck.com/v1/events. ## Getting Started LogDuck is an event logging and notification service for developers. Send events from your applications and receive push notifications when important things happen. ### Quick Start **1. ****Create an Organization** - Sign up and create your first organization in the dashboard. **2. ****Create a Project** - Projects represent your apps or services. Each project has its own API keys and event types. **3. ****Generate an API Key** - Create an API key in your project settings to authenticate your requests. **4. ****Send Events** - Post events to the API when things happen in your application. ### Your First Event Ready to send your first event? You only need two fields: `type` and `source`. **curl** ```bash curl -X POST https://api.logduck.com/v1/events \ -H "X-API-Key: YOUR_API_KEY" \ -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \ -H "Content-Type: application/json" \ -d '{"type": "user.signup", "source": "my-backend"}' ``` **JavaScript** ```javascript await fetch('https://api.logduck.com/v1/events', { method: 'POST', headers: { 'X-API-Key': 'YOUR_API_KEY', 'Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'user.signup', source: 'my-backend', }), }); ``` **Python** ```python import requests import uuid requests.post( 'https://api.logduck.com/v1/events', headers={ 'X-API-Key': 'YOUR_API_KEY', 'Idempotency-Key': str(uuid.uuid4()), }, json={ 'type': 'user.signup', 'source': 'my-backend', } ) ``` **Swift** ```swift var request = URLRequest(url: URL(string: "https://api.logduck.com/v1/events")!) request.httpMethod = "POST" request.setValue("YOUR_API_KEY", forHTTPHeaderField: "X-API-Key") request.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONSerialization.data(withJSONObject: [ "type": "user.signup", "source": "my-backend" ]) URLSession.shared.dataTask(with: request).resume() ``` **Kotlin** ```kotlin val client = OkHttpClient() val body = """{"type": "user.signup", "source": "my-backend"}""" .toRequestBody("application/json".toMediaType()) val request = Request.Builder() .url("https://api.logduck.com/v1/events") .post(body) .addHeader("X-API-Key", "YOUR_API_KEY") .addHeader("Idempotency-Key", UUID.randomUUID().toString()) .build() client.newCall(request).execute() ``` **C#** ```csharp using System.Net.Http.Json; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", "YOUR_API_KEY"); client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString()); await client.PostAsJsonAsync( "https://api.logduck.com/v1/events", new { type = "user.signup", source = "my-backend" } ); ``` `type` identifies what happened (e.g., `user.signup`, `payment.completed`). `source` identifies where it came from (e.g., `web-backend`, `mobile-app`). Want to add more context? See the [Events Reference](#events-reference) for optional fields like `subject`, `data` and `emoji`. ### Raw HTTP or an SDK? The examples above are plain HTTPS calls with no dependency — that is the whole API, and it works from any language. Official SDKs are a convenience on top of it: they generate the `Idempotency-Key` for you, reuse it across a retry so a retry can never double-post, honour `Retry-After` on a rate limit, and validate an event before it leaves your process. There are SDKs for .NET, JavaScript/TypeScript, Python and Swift — see [Client SDKs](#sdks-overview). Going direct is a first-class option; you just take on those four things yourself. ## Authentication All API requests must be authenticated using an API key. Include your API key in the `X-API-Key` header. ### Example ```bash curl -X POST https://api.logduck.com/v1/events \ -H "X-API-Key: YOUR_API_KEY" \ -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \ -H "Content-Type: application/json" \ -d '{"type": "user.signup", "source": "my-backend"}' ``` > **Warning: Keep Your API Key Secure** > Never expose your API key in client-side code. Always make API calls from your server. ### Managing API Keys You can create, view, and revoke API keys in your project settings. Each project can have multiple API keys, which is useful for rotating keys or having separate keys for different environments. ### Server or client keys Creating a key asks whether it will be held by a `server` or by a `client` — a backend you run, or an app on someone's device. The answer is recorded on the key and copied onto every event it posts, as `sourcetype`. It matters because the two are indistinguishable once an event is stored, and they mean opposite things on a map: a client key's location is roughly where your user is, while a server key's is where your servers are hosted. Analysis can filter on it for exactly this reason. Changing a key's type later takes effect within about ten minutes. ## Events Reference Events are the core of LogDuck. When something happens in your application—a user signs up, a payment is processed, an error occurs—you send an event to LogDuck. ### Endpoint ```text POST https://api.logduck.com/v1/events ``` ### Required Headers `X-API-Key` — Your project API key (starts with `ld_`). Used to authenticate the request. `Idempotency-Key` — A unique string (16–36 characters, alphanumeric, hyphens, or underscores). Can be any string as long as it is unique between requests — UUID4 and ULID are good options. If you retry a failed request, reuse the same key so the server can deduplicate and prevent duplicate events. ### Request Body ```typescript interface EventPayload { // Required fields type: string; // Event type (e.g., "user.signup") source: string; // Origin identifier (e.g., "web-backend") // Optional fields subject?: string; // Resource identifier (e.g., "user-123") sessionid?: string; // Groups related events (max 256 chars) message?: string; // Human-readable summary (max 500 chars) time?: string; // ISO 8601 timestamp (defaults to server time) data?: object; // Custom event payload - any JSON emoji?: string; // Visual indicator (max 10 chars) } ``` ### Required Fields `type` — The event type identifier. Must start with a letter and contain only letters, numbers, underscores and dots: `user.signup` and `payment_failed` are valid; a hyphen is rejected with a 400. Capitals are not — the server lowercases the type before storing it, so `User.Signup` is accepted and stored as `user.signup`. Dots are the convention for grouping events into categories. **`source`** — Identifies where the event originated. This helps distinguish events from different parts of your system (e.g., `mobile-app`, `web-backend`, `payment-service`). ### Optional Fields **`subject`** — A resource identifier that the event relates to. Use this to link events to specific entities (e.g., `user-123`, `order-456`). `sessionid` — Groups related events that happened in the same session, so a whole user journey can be followed rather than isolated events. Max 256 characters. Lowercase: a CloudEvents extension attribute name must be. `message` — A human-readable summary of what happened, max 500 characters. This is what a push notification shows; without it the body falls back to listing the first few `data` keys, which reads like `total: 4999, currency: NOK`. **`time`** — ISO 8601 timestamp of when the event occurred. If omitted, the server will use the current time. Use this when you need to backfill events or when the event time differs from the submission time. **`data`** — A flexible JSON object for custom event data. Use this for any event-specific information like user details, transaction amounts, feature names, error details, or categorization tags. Anything outside the fields listed above is discarded rather than stored, so put custom values inside `data` rather than at the top level. For a human-readable summary, use `message` — a push notification shows it verbatim, and falls back to listing `data` keys without it. **`emoji`** — A visual indicator for the event. Must be a single emoji character. If omitted, the default emoji for the event type will be used. ### Request context LogDuck records where each event came from and stores it on the event as flat fields. You do not send these, and _cannot_ — they are derived from the connection, so an event can never claim an origin that is not its own. `requestip` — The IP address the request came from. `requestagent` — The User-Agent header of the request. `requestcountry`, `requestcontinent`, `requestregion` and `requestcity` — Coarse location derived from the IP. Country is an ISO 3166-1 alpha-2 code and continent one of AF, AN, AS, EU, NA, OC or SA. Region is the first-level subdivision — a state, province or region — by name. A country resolves for nearly every routable address; a city for rather fewer. Region and city are independent — an address can resolve to a region with no city, or the reverse — so do not guard one on the other. `requestlat`, `requestlon`, `requesttimezone` and `requestcityid` — Present only when a city was identified, and absent otherwise rather than guessed. The coordinates are the approximate centre of the city as decimal-degree strings; they locate a city, not a device, and the underlying accuracy radius is typically 5–200 km. The time zone is an IANA name such as Europe/Brussels. The city id is the city's numeric GeoNames identifier, which distinguishes places that share a name — Portland, Oregon from Portland, Maine. These are stored on the event and available in analytics. If you need location at a finer grain than city, resolve it yourself and put it in `data`. Location is resolved from the IP using the GeoLite2 database created by [MaxMind](https://www.maxmind.com). It is an approximation: a country is almost always right, a city often is not. ### Data Payload Patterns The `data` field accepts any JSON object. Here are suggested patterns for common event types: ```typescript // User events data: { userId: "u_123", email: "user@example.com", name: "John", sessionId: "sess_abc" } // Payment events data: { amount: 99.00, currency: "USD", transactionId: "txn_xyz" } // Subscription events data: { plan: "pro", tier: "annual", previousPlan: "free" } // Error events data: { error: "ConnectionTimeout", message: "Database unreachable", code: 500 } // Feature usage data: { feature: "export", action: "pdf_download", duration: 1250 } // Categorization data: { tags: ["mobile", "ios", "premium"] } ``` ### Minimal example Only type and source are required. Every variable used below is defined in the example itself. **curl** ```bash curl -X POST https://api.logduck.com/v1/events \ -H "X-API-Key: ld_your_api_key" \ -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \ -H "Content-Type: application/json" \ -d '{"type": "order.placed", "source": "checkout-api"}' ``` **JavaScript** ```javascript const apiKey = process.env.LOGDUCK_API_KEY; // your key, starts with `ld_` await fetch('https://api.logduck.com/v1/events', { method: 'POST', headers: { 'X-API-Key': apiKey, 'Idempotency-Key': crypto.randomUUID(), // unique per event, reused only on retry 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'order.placed', source: 'checkout-api', }), }); ``` **Python** ```python import os import uuid import requests api_key = os.environ["LOGDUCK_API_KEY"] # your key, starts with `ld_` requests.post( "https://api.logduck.com/v1/events", headers={ "X-API-Key": api_key, "Idempotency-Key": str(uuid.uuid4()), # unique per event, reused only on retry }, json={ "type": "order.placed", "source": "checkout-api", }, ) ``` **Swift** ```swift import Foundation let apiKey = ProcessInfo.processInfo.environment["LOGDUCK_API_KEY"]! // starts with `ld_` var request = URLRequest(url: URL(string: "https://api.logduck.com/v1/events")!) request.httpMethod = "POST" request.setValue(apiKey, forHTTPHeaderField: "X-API-Key") request.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: [ "type": "order.placed", "source": "checkout-api" ]) URLSession.shared.dataTask(with: request).resume() ``` **Kotlin** ```kotlin import java.util.UUID import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody val apiKey = System.getenv("LOGDUCK_API_KEY") // starts with `ld_` val client = OkHttpClient() val body = """{"type": "order.placed", "source": "checkout-api"}""" .toRequestBody("application/json".toMediaType()) val request = Request.Builder() .url("https://api.logduck.com/v1/events") .post(body) .addHeader("X-API-Key", apiKey) .addHeader("Idempotency-Key", UUID.randomUUID().toString()) .build() client.newCall(request).execute() ``` **C#** ```csharp using System.Net.Http.Json; var apiKey = Environment.GetEnvironmentVariable("LOGDUCK_API_KEY"); // starts with ld_ using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", apiKey); client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString()); await client.PostAsJsonAsync( "https://api.logduck.com/v1/events", new { type = "order.placed", source = "checkout-api" } ); ``` ### Complete example Every field the API accepts, in one payload. The language tabs annotate each one; JSON has no comments, so the curl tab relies on the field reference above. **curl** ```bash curl -X POST https://api.logduck.com/v1/events \ -H "X-API-Key: ld_your_api_key" \ -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \ -H "Content-Type: application/json" \ -d '{ "type": "order.placed", "source": "checkout-api", "subject": "order_9127", "sessionid": "sess_8f21c", "message": "Order placed on the Pro plan", "time": "2026-08-04T14:32:11.000Z", "emoji": "🛒", "data": { "total": 4999, "currency": "NOK" } }' ``` **JavaScript** ```javascript const apiKey = process.env.LOGDUCK_API_KEY; // your key, starts with `ld_` const orderId = 'order_9127'; // your own identifiers const sessionId = 'sess_8f21c'; const response = await fetch('https://api.logduck.com/v1/events', { method: 'POST', headers: { 'X-API-Key': apiKey, 'Idempotency-Key': crypto.randomUUID(), // unique per event, reused only on retry 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'order.placed', // required - max 100 chars source: 'checkout-api', // required - max 200 chars subject: orderId, // optional - max 500 chars sessionid: sessionId, // optional - max 256 chars, lowercase on the wire message: 'Order placed on the Pro plan', // optional - the notification body, max 500 time: new Date().toISOString(), // optional - defaults to server receipt time emoji: '🛒', // optional - max 10 chars data: { // optional - any JSON object total: 4999, currency: 'NOK', }, }), }); console.log(response.status); // 200 on success ``` **Python** ```python import os import uuid from datetime import datetime, timezone import requests api_key = os.environ["LOGDUCK_API_KEY"] # your key, starts with `ld_` order_id = "order_9127" # your own identifiers session_id = "sess_8f21c" response = requests.post( "https://api.logduck.com/v1/events", headers={ "X-API-Key": api_key, "Idempotency-Key": str(uuid.uuid4()), # unique per event, reused only on retry "Content-Type": "application/json", }, json={ "type": "order.placed", # required - max 100 chars "source": "checkout-api", # required - max 200 chars "subject": order_id, # optional - max 500 chars "sessionid": session_id, # optional - max 256, lowercase on the wire "message": "Order placed on the Pro plan", # optional - notification body, max 500 "time": datetime.now(timezone.utc).isoformat(), # optional - defaults to server time "emoji": "🛒", # optional - max 10 chars "data": { # optional - any JSON object "total": 4999, "currency": "NOK", }, }, ) print(response.status_code) # 200 on success ``` **Swift** ```swift import Foundation let apiKey = ProcessInfo.processInfo.environment["LOGDUCK_API_KEY"]! // starts with `ld_` let orderId = "order_9127" // your own identifiers let sessionId = "sess_8f21c" var request = URLRequest(url: URL(string: "https://api.logduck.com/v1/events")!) request.httpMethod = "POST" request.setValue(apiKey, forHTTPHeaderField: "X-API-Key") request.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key") // unique per event request.setValue("application/json", forHTTPHeaderField: "Content-Type") let payload: [String: Any] = [ "type": "order.placed", // required - max 100 chars "source": "checkout-api", // required - max 200 chars "subject": orderId, // optional - max 500 chars "sessionid": sessionId, // optional - lowercase on the wire "message": "Order placed on the Pro plan", // optional - notification body "time": ISO8601DateFormatter().string(from: Date()), // optional "emoji": "🛒", // optional - max 10 chars "data": [ // optional - any JSON object "total": 4999, "currency": "NOK" ] ] request.httpBody = try JSONSerialization.data(withJSONObject: payload) URLSession.shared.dataTask(with: request).resume() ``` **Kotlin** ```kotlin import java.util.UUID import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject val apiKey = System.getenv("LOGDUCK_API_KEY") // starts with `ld_` val orderId = "order_9127" // your own identifiers val sessionId = "sess_8f21c" val client = OkHttpClient() val json = JSONObject().apply { put("type", "order.placed") // required - max 100 chars put("source", "checkout-api") // required - max 200 chars put("subject", orderId) // optional - max 500 chars put("sessionid", sessionId) // optional - lowercase on the wire put("message", "Order placed on the Pro plan") // optional - notification body put("time", java.time.Instant.now().toString()) // optional put("emoji", "🛒") // optional - max 10 chars put("data", JSONObject().apply { // optional - any JSON object put("total", 4999) put("currency", "NOK") }) } val request = Request.Builder() .url("https://api.logduck.com/v1/events") .post(json.toString().toRequestBody("application/json".toMediaType())) .addHeader("X-API-Key", apiKey) .addHeader("Idempotency-Key", UUID.randomUUID().toString()) // unique per event .build() client.newCall(request).execute() ``` **C#** ```csharp using System.Net.Http.Json; var apiKey = Environment.GetEnvironmentVariable("LOGDUCK_API_KEY"); // starts with ld_ var orderId = "order_9127"; // your own identifiers var sessionId = "sess_8f21c"; using var client = new HttpClient(); client.DefaultRequestHeaders.Add("X-API-Key", apiKey); client.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString()); // unique per event var response = await client.PostAsJsonAsync( "https://api.logduck.com/v1/events", new { type = "order.placed", // required - max 100 chars source = "checkout-api", // required - max 200 chars subject = orderId, // optional - max 500 chars sessionid = sessionId, // optional - lowercase on the wire message = "Order placed on the Pro plan", // optional - notification body time = DateTimeOffset.UtcNow, // optional emoji = "🛒", // optional - max 10 chars data = new { // optional - any JSON object total = 4999, currency = "NOK" } } ); Console.WriteLine((int)response.StatusCode); // 200 on success ``` > **Tip: Event Types** > Use dot notation for event types (e.g., `user.signup`, `payment.completed`) to organize events into categories. LogDuck will auto-create event types when you send events. > **Note: Sent from your backend?** > Request context describes whoever called LogDuck. If your server posts events on behalf of users, that is your server — not them. ## API Reference The LogDuck API is a REST API that accepts JSON requests and returns JSON responses. ### Base URL ```text https://api.logduck.com ``` ### Endpoints **`POST /v1/events`** - Send a new event ### Required Headers `X-API-Key` — Your project API key for authentication. `Idempotency-Key` — A unique string (16–36 characters, alphanumeric, hyphens, or underscores). Can be any string as long as it is unique between requests — UUID4 and ULID are good options. Enables safe retries by deduplicating requests with the same key. ### Response Codes `200` - Success `400` - Bad Request (invalid payload) `401` - Unauthorized (invalid or missing API key) `429` - Rate Limited Two separate limits return a 429. Ingestion is capped at **1,000 events per API key per minute** in a fixed window, which resets at the top of each minute — a burst that exceeds it is rejected rather than queued. Separately, exhausting your plan's monthly event allowance rejects further events until the limit resets on your billing cycle. Both are safe to retry with the same `Idempotency-Key`; the official SDKs retry a 429 once, honouring `Retry-After`. > **Note: Monthly Event Limits** > Event limits are based on your subscription plan. The **Free** tier includes 1,000 events per month. The **Pro** tier includes 100,000 events per month per project. Limits reset on your billing cycle. ## Events Events are the core data unit in LogDuck. Each event captures something that happened in your application — a user action, a system state change, or an error — and is displayed in a real-time feed filtered by environment. ### Event Feed The Events tab in the dashboard shows a live, scrollable feed of events for the selected environment. New events appear in real time via live updates — there is no need to refresh the page. Each row shows the emoji, event type, the subject (falling back to the source) and how long ago it happened. ### Filtering The event feed can be filtered by event type. Select one or more event types from the filter dropdown to narrow the feed to specific kinds of events. All events are scoped to the currently selected environment. ### Event Detail Clicking an event in the feed opens a detail view showing all fields: type, source, subject, session ID, message, data payload, request context (IP address, user agent and derived location) and timestamp. The data payload is rendered as formatted JSON. ## Event Types Event types define the schema and display settings for a class of events. They are auto-created when an event with a new `type` value is posted, or can be created manually from the Event Types tab. ### Customizable Fields Each event type has a name, emoji, color, and optional description. These settings control how events of this type appear in the dashboard feed and notifications. You can edit these fields at any time from the Event Types tab. ### Notification Threshold Each event type has a notification threshold that controls how push notifications are batched. When multiple events of the same type arrive in quick succession, LogDuck groups them into a single notification rather than sending one per event. The threshold determines how many events trigger a batched notification. > **Tip: Auto-Creation** > You do not need to create event types before sending events. When LogDuck receives an event with a type it has not seen before, it automatically creates the event type with default settings. You can customize it afterward. ## Categories Categories group event types together using wildcard patterns (e.g., `user.*`). They provide a higher-level view in charts and summaries, letting you see trends across related event types. ### Preset Categories Six preset categories are created with each new environment: User, Payment, System, Error, Notification, and Security. Each preset comes with a default wildcard pattern (e.g., `user.*` for the User category). You can modify preset patterns or add your own categories. ### Wildcard Patterns Each category has one or more wildcard patterns that match event type names. For example, `payment.*` matches `payment.completed`, `payment.failed`, and so on. When a new event type is created, categories with auto-assign enabled will automatically claim it if its name matches a pattern. ### Charts and Summaries Categories appear in the Overview dashboard's category distribution chart and can be used as targets for scheduled summaries. This gives you an aggregated view of activity across all event types within a category. ## Environments (Pro plan) Environments provide full data isolation within a project, similar to Stripe's test/live modes. Every project starts with a Production environment. Additional environments (Staging, Dev, etc.) can be created on the Pro plan. ### Data Isolation Each environment has its own events, API keys, event types, categories, webhooks, and summaries. Data in one environment is completely separate from data in another. This means you can test your integration against a Staging environment without affecting Production data. ### Environment Prefix Each environment has a unique 2-4 character prefix (e.g., `prod`, `stg`, `dev`). This prefix is used in API keys to scope requests to the correct environment. It also serves as the Firestore document ID for the environment. ### Dashboard Selector The dashboard includes an environment selector that lets you switch between environments. The selected environment is persisted in the URL via a `?env=` query parameter, so you can bookmark or share links to a specific environment view. > **Note: Production Environment** > The Production environment is always present and cannot be deleted. It is created automatically when a project is set up. ## Analysis Analysis holds two views of an environment: event volume over time, and where those events came from. > **Note: Not live, unlike the Events page** > Both views read an analytics warehouse that is loaded on a schedule, so they lag behind ingestion — usually by hours. The Events page reads events directly and is the one to use when you need to see something that just happened. The live map layer described below is the exception: it streams events as they arrive. ### Event volume Events over time, across the last 30 or 90 days, 6 or 12 months, or a year. Shorter ranges are grouped by day and longer ones by month. ### Where events come from The map counts events by the location LogDuck derived from each request's IP address — the same `requestcountry` and `requestcity` fields stored on the event. Countries are shaded by volume and cities are drawn as marks; selecting a country focuses it and lists its cities. The region control zooms to a continent rather than filtering — nothing is excluded from the counts, you are only choosing what to look at. ### Filtering by traffic source Server and client traffic mean different things on a map, so the source control separates them. **Devices only** shows events from keys marked as client — roughly a map of your users. **Servers only** shows events from keys marked as server, which plot wherever your backend is hosted rather than anywhere a person is. Events posted with a key created before this distinction existed carry no source type and appear only under **All traffic**. Setting the type on those keys in **API Keys** applies to events posted from then on; it does not relabel history. ### Live Live streams events onto the map as they arrive, marking each place they come from. A mark brightens as more events land on it and fades over about 30 seconds, so a busy place stays lit while a quiet one disappears. It is deliberately bounded: at most 60 places are marked at once, and the stream stops itself after a couple of thousand events with a button to resume. Leaving a dashboard open on a busy project would otherwise read continuously for as long as the tab is open. ### How accurate is the location? It is an approximation from the IP address. A country is almost always right; a city often is not, and resolves for rather fewer addresses. City marks are placed at the centre of the city rather than at a device, and the underlying accuracy radius is typically 5–200 km. Treat the map as a distribution, never as a position. ## Action Groups (Pro plan) Action groups bundle email recipients and webhooks into reusable notification targets. They are shared across environments at the project level and are referenced by summaries and alert rules. ### Members and Webhooks Each action group can contain any number of email addresses and webhook URLs. When a summary or alert fires and targets an action group, all members receive an email and all webhooks receive an HTTP POST payload. ### Project-Level Scope Action groups are defined at the project level, not per environment. This means a single action group can be referenced by summaries and alerts across all environments within a project. ### Usage Create action groups from the Action Groups page in the dashboard. Once created, they appear as destination options when configuring scheduled summaries or alert rules. ## Webhooks Webhooks deliver HTTP POST payloads to external URLs when events occur. They can be scoped to specific event types or used as targets in action groups for integration with Slack, Discord, or custom endpoints. ### Creating Webhooks Create webhooks from the Webhooks tab in the dashboard. Each webhook requires a destination URL and can optionally be scoped to one or more event types. If no event types are selected, the webhook fires for all events in the environment. ### Delivery Logs Each webhook delivery is logged with the request payload, response status, and timestamp. You can view delivery logs in the dashboard to troubleshoot failed deliveries or verify that payloads are reaching your endpoints. ### Webhooks in Action Groups Webhooks can also be added to action groups. When an action group is triggered by a summary or alert, all webhooks in the group receive the notification payload alongside any email recipients. ## Summaries (Pro plan) Summaries send scheduled daily digests for a specific event type or category to an action group. Each summary runs at a configurable time and timezone, and generates a persistent report with event counts, trends, and a per-event-type breakdown. ### Configuration When creating a summary, you configure the target (a specific event type or category), the delivery time and timezone, and the destination action group. Summaries run daily at the configured time. ### Reports Each time a summary runs, it generates a report stored in the environment. Reports include event counts for the period, trends compared to the previous period, and a per-event-type breakdown. You can view past reports from the Summaries tab. ### Empty Summaries Each summary has a `sendEmpty` toggle. When enabled, the summary sends an email even if no events occurred during the period. When disabled, the summary is skipped on days with no matching events. ## Alerts (Pro plan) Alerts fire notifications when an event type exceeds a count threshold within a rolling time window. Each rule has a configurable threshold, window, cooldown period, and destination action group. ### Configuration Each alert rule is configured with a target event type, a count threshold, a time window (1-60 minutes), a cooldown period, and a destination action group. When the number of events of that type within the window exceeds the threshold, the alert fires. ### Cooldown After an alert fires, it enters a cooldown period during which it will not fire again. This prevents notification floods when an event type is producing a sustained burst of activity. Once the cooldown expires, the alert re-arms and can fire again. ### Status Each alert rule shows a live status in the dashboard: either firing (threshold exceeded) or OK (below threshold). This gives you an at-a-glance view of which thresholds are currently being breached. ## Client SDKs Official SDKs wrap POST /v1/events so you do not have to. Every one of them behaves the same way: the same validation limits, one retry on 5xx and network failures, and an Idempotency-Key generated per event and reused across that retry so a retry can never double-post. None of them are required. The API is plain JSON over HTTPS and works from any language — see the [API Reference](#api-reference). ### Installation **.NET** ```bash dotnet add package LogDuck ``` **npm** ```bash npm install logduck ``` **Python** ```bash pip install logduck ``` **Swift** ```swift .package(url: "https://github.com/Log-Duck/logduck-swift.git", from: "1.0.0") ``` **Gradle** ```kotlin dependencies { implementation("com.logduck:logduck:1.0.0") } ``` ### Sending your first event **C#** ```csharp // Registered once at startup, then injected wherever you need it. public class CheckoutService(ILogDuckClient logDuck) { public async Task OnOrderPlaced(string orderId) => await logDuck.SendEventAsync(new LogDuckEvent { Type = "order.placed", Subject = orderId }); } ``` **TypeScript** ```typescript import { LogDuckClient } from 'logduck'; const logduck = new LogDuckClient({ apiKey: process.env.LOGDUCK_API_KEY!, source: 'checkout-api', }); await logduck.send({ type: 'order.placed', subject: 'order_9127' }); ``` **Python** ```python import os from logduck import LogDuckClient logduck = LogDuckClient( api_key=os.environ["LOGDUCK_API_KEY"], source="checkout-api", ) logduck.send("order.placed", subject="order_9127") ``` **Swift** ```swift import LogDuck let logduck = LogDuckClient( options: LogDuckOptions(apiKey: "ld_your_key_here", source: "ios-app") ) try await logduck.send(LogDuckEvent(type: "order.placed", subject: "order_9127")) ``` **Kotlin** ```kotlin import com.logduck.LogDuckClient import com.logduck.LogDuckEvent import com.logduck.LogDuckOptions val logduck = LogDuckClient( LogDuckOptions(apiKey = System.getenv("LOGDUCK_API_KEY"), source = "checkout-api") ) logduck.send(LogDuckEvent(type = "order.placed")) ``` > **Note: Naming your event type** > The server lowercases `type` and requires it to match `^[a-z][a-z0-9_.]*$` — letters, digits, underscores and dots, starting with a letter. So `order.placed` works and `order-placed` is rejected with a 400. Dots are the conventional separator. ## .NET SDK The official .NET client library for LogDuck. Supports .NET 8, 9, and 10. Provides dependency injection via `AddLogDuck()`, automatic retries with idempotency keys, and configurable error handling. ### Installation ```bash dotnet add package LogDuck ``` ### Minimal example The fewest settings that work — an API key and a source. ```csharp // Program.cs using LogDuck; var builder = WebApplication.CreateBuilder(args); builder.Services.AddLogDuck(options => { options.ApiKey = builder.Configuration["LogDuck:ApiKey"]!; options.Source = "checkout-api"; }); ``` ```csharp // CheckoutService.cs using LogDuck; public class CheckoutService(ILogDuckClient logDuck) // injected by AddLogDuck above { public async Task OnOrderPlaced(string orderId) { await logDuck.SendEventAsync(new LogDuckEvent { Type = "order.placed" }); } } ``` ### Complete example Every option, with its default. Only the first two are required; the rest are shown set to the value you get by omitting them. ```csharp // Program.cs using LogDuck; var builder = WebApplication.CreateBuilder(args); builder.Services.AddLogDuck(options => { options.ApiKey = builder.Configuration["LogDuck:ApiKey"]!; // required - starts with ld_ options.Source = "checkout-api"; // required - max 200 chars options.ThrowOnError = false; // optional, default false - true throws instead of returning null options.RetryEnabled = true; // optional, default true - retry once on 5xx, network errors and 429 options.MaxRetryDelay = TimeSpan.FromSeconds(10); // optional, default 10s - never block your caller longer than this options.Timeout = TimeSpan.FromSeconds(30); // optional, default 30s - per-request timeout }); // CheckoutService.cs using LogDuck; public class CheckoutService(ILogDuckClient logDuck) // injected by AddLogDuck above { public async Task OnOrderPlaced(string orderId) // e.g. "order_9127" { var sessionId = "sess_8f21c"; // your own session identifier LogDuckResponse? response = await logDuck.SendEventAsync(new LogDuckEvent { Type = "order.placed", // required - max 100 chars, naming rule below Subject = orderId, // optional - what the event is about, max 500 chars SessionId = sessionId, // optional - groups related events, max 256 chars Message = "Order placed on the Pro plan", // optional - the push notification body, max 500 Time = DateTimeOffset.UtcNow, // optional - defaults to when the server received it Data = new Dictionary // optional - any JSON-serialisable values { ["total"] = 4999, ["currency"] = "NOK" }, Emoji = "🛒" // optional - shown next to the event, max 10 chars }); // null when the send failed and ThrowOnError is false (the default). Console.WriteLine(response?.EventId); } } ``` ### Retries and idempotency Each request includes a unique `Idempotency-Key` header (a UUID). The same key is reused across retries, so the server deduplicates and a retry can never create a duplicate event. A rate-limited request (429) is retried once, waiting for the period the server asks for — but never longer than `MaxRetryDelay`. The limit resets in one-minute windows, so blocking a caller for the full window inside a logging call is worse than failing. Past the cap the client gives up immediately and the failure carries `LogDuckException.RetryAfter`, so you can queue the event rather than lose it. Full reference and changelog: [github.com/Log-Duck/logduck-dotnet](https://github.com/Log-Duck/logduck-dotnet) ## JavaScript & TypeScript SDK The official JavaScript and TypeScript client. Ships ESM and CommonJS builds with bundled type definitions, and runs on Node 18+, Deno, Bun, Cloudflare Workers and the browser — anywhere fetch exists. ### Installation ```bash npm install logduck ``` ### Minimal example The fewest settings that work — an API key and a source. ```typescript import { LogDuckClient } from 'logduck'; const logduck = new LogDuckClient({ apiKey: process.env.LOGDUCK_API_KEY!, source: 'checkout-api', }); await logduck.send({ type: 'order.placed' }); ``` ### Complete example Every option, with its default. Only the first two are required; the rest are shown set to the value you get by omitting them. ```typescript import { LogDuckClient } from 'logduck'; const logduck = new LogDuckClient({ apiKey: process.env.LOGDUCK_API_KEY!, // required - your key, starts with `ld_` source: 'checkout-api', // required - which app sent this, max 200 chars throwOnError: false, // optional, default false - true throws instead of returning null retryEnabled: true, // optional, default true - retry once on 5xx, network errors and 429 maxRetryDelayMs: 10_000, // optional, default 10000 - never block your caller longer than this timeoutMs: 30_000, // optional, default 30000 - per-request timeout baseUrl: 'https://api.logduck.com', // optional - override only for a self-hosted deployment logger: console, // optional, default console - anything with a .warn() method fetch: globalThis.fetch, // optional - inject a fetch implementation, mainly for tests }); const orderId = 'order_9127'; // your own identifiers const sessionId = 'sess_8f21c'; const response = await logduck.send({ type: 'order.placed', // required - max 100 chars, naming rule below subject: orderId, // optional - what the event is about, max 500 chars sessionId, // optional - groups related events, max 256 chars message: 'Order placed on the Pro plan', // optional - the push notification body, max 500 time: new Date(), // optional - defaults to when the server received it data: { total: 4999, currency: 'NOK' }, // optional - any JSON-serialisable object emoji: '🛒', // optional - shown next to the event, max 10 chars }); // null when the send failed and throwOnError is false (the default). console.log(response?.eventId); ``` ### Errors By default a failed send is logged and returns `null`, so a logging call can never take down the code it was only meant to observe. Opt into exceptions when you want to handle them: ```typescript import { LogDuckClient, LogDuckError } from 'logduck'; const logduck = new LogDuckClient({ apiKey: process.env.LOGDUCK_API_KEY!, source: 'checkout-api', throwOnError: true, }); try { await logduck.send({ type: 'order.placed' }); } catch (error) { if (error instanceof LogDuckError && error.retryAfterMs) { // Rate limited for longer than the client will wait. // Queue it rather than lose it. } } ``` Full reference and changelog: [github.com/Log-Duck/logduck-js](https://github.com/Log-Duck/logduck-js) ## Python SDK The official Python client. Supports Python 3.9+, ships type hints (py.typed), and offers both a synchronous and an asyncio client so a web handler never blocks just to record that something happened. ### Installation ```bash pip install logduck ``` ### Minimal example The fewest settings that work — an API key and a source. ```python import os from logduck import LogDuckClient logduck = LogDuckClient( api_key=os.environ["LOGDUCK_API_KEY"], source="checkout-api", ) logduck.send("order.placed") ``` ### Complete example Every option, with its default. Only the first two are required; the rest are shown set to the value you get by omitting them. ```python import os from datetime import datetime, timezone from logduck import LogDuckClient logduck = LogDuckClient( api_key=os.environ["LOGDUCK_API_KEY"], # required - your key, starts with `ld_` source="checkout-api", # required - which app sent this, max 200 chars throw_on_error=False, # optional, default False - True raises instead of returning None retry_enabled=True, # optional, default True - retry once on 5xx, network errors and 429 max_retry_delay=10.0, # optional, default 10.0 - never block your caller longer than this timeout=30.0, # optional, default 30.0 - per-request timeout, seconds base_url="https://api.logduck.com", # optional - override only for a self-hosted deployment ) order_id = "order_9127" # your own identifiers session_id = "sess_8f21c" response = logduck.send( "order.placed", # required - max 100 chars, naming rule below subject=order_id, # optional - what the event is about, max 500 chars session_id=session_id, # optional - groups related events, max 256 chars message="Order placed on the Pro plan", # optional - the push notification body, max 500 time=datetime.now(timezone.utc), # optional - defaults to when the server received it data={"total": 4999, "currency": "NOK"}, # optional - any JSON-serialisable mapping emoji="🛒", # optional - shown next to the event, max 10 chars ) # None when the send failed and throw_on_error is False (the default). print(response.event_id if response else None) ``` ### Async Every option above applies to `AsyncLogDuckClient` too. Used as a context manager it releases the connection pool on exit. ```python import os from logduck import AsyncLogDuckClient async with AsyncLogDuckClient( api_key=os.environ["LOGDUCK_API_KEY"], source="checkout-api", ) as logduck: await logduck.send("order.placed") ``` Full reference and changelog: [github.com/Log-Duck/logduck-python](https://github.com/Log-Duck/logduck-python) ## Swift SDK The official Swift client, for iOS 13+, macOS 10.15+, tvOS, watchOS and Linux. Distributed through Swift Package Manager — there is no registry, the git tag is the release. ### Installation Add it to the `dependencies` of your `Package.swift`, or use File → Add Package Dependencies in Xcode: ```swift dependencies: [ .package(url: "https://github.com/Log-Duck/logduck-swift.git", from: "1.0.0") ] ``` ### Minimal example The fewest settings that work — an API key and a source. ```swift import LogDuck let logduck = LogDuckClient( options: LogDuckOptions(apiKey: "ld_your_key_here", source: "ios-app") ) try await logduck.send(LogDuckEvent(type: "order.placed")) ``` ### Complete example Every option, with its default. Only the first two are required; the rest are shown set to the value you get by omitting them. ```swift import Foundation import LogDuck let logduck = LogDuckClient( options: LogDuckOptions( apiKey: "ld_your_key_here", // required - your key, starts with `ld_` source: "ios-app", // required - which app sent this, max 200 chars throwOnError: false, // optional, default false - true throws instead of returning nil retryEnabled: true, // optional, default true - retry once on 5xx, network errors and 429 maxRetryDelay: 10, // optional, default 10 - never block your caller longer, seconds timeout: 30, // optional, default 30 - per-request timeout, seconds baseURL: URL(string: "https://api.logduck.com")! // optional - only for a self-hosted deployment ) ) let orderId = "order_9127" // your own identifiers let sessionId = "sess_8f21c" let response = try await logduck.send( LogDuckEvent( type: "order.placed", // required - max 100 chars, naming rule below subject: orderId, // optional - what the event is about, max 500 chars sessionId: sessionId, // optional - groups related events, max 256 chars message: "Order placed on the Pro plan", // optional - the push notification body, max 500 time: Date(), // optional - defaults to when the server received it data: ["total": 4999, "currency": "NOK"], // optional - JSONValue, see the repo README emoji: "🛒" // optional - shown next to the event, max 10 chars ) ) // nil when the send failed and throwOnError is false (the default). print(response?.eventId ?? "not sent") ``` ### Fire and forget When a failed log line should never affect anything, discard the result — `send` returns `nil` rather than throwing unless you ask it to: ```swift import LogDuck let logduck = LogDuckClient( options: LogDuckOptions(apiKey: "ld_your_key_here", source: "ios-app") ) _ = try? await logduck.send(LogDuckEvent(type: "screen.viewed")) ``` > **Note: Why the explicit _ =** > `send` is `@discardableResult`, but `try?` wraps its result in a second Optional and that attribute does not carry through — so without the discard the compiler warns. Full reference and changelog: [github.com/Log-Duck/logduck-swift](https://github.com/Log-Duck/logduck-swift) ## Kotlin & Java SDK The official Kotlin client, published to Maven Central. It compiles to Java 11 bytecode, so it works from server-side Kotlin and Java as well as from an Android app. ### Installation Add it to the dependencies of your Gradle build. ```kotlin dependencies { implementation("com.logduck:logduck:1.0.0") } ``` ### Minimal example The fewest settings that work — an API key and a source. One client is enough for a process: it holds no per-request state and is safe to share between threads. ```kotlin import com.logduck.LogDuckClient import com.logduck.LogDuckEvent import com.logduck.LogDuckOptions val logduck = LogDuckClient( LogDuckOptions(apiKey = System.getenv("LOGDUCK_API_KEY"), source = "checkout-api") ) logduck.send(LogDuckEvent(type = "order.placed")) ``` ### Complete example Every option, with its default. Only the first two are required; the rest are shown set to the value you get by omitting them. ```kotlin import com.logduck.HttpUrlConnectionTransport import com.logduck.LogDuckClient import com.logduck.LogDuckEvent import com.logduck.LogDuckOptions import com.logduck.StderrLogger import java.time.Duration import java.time.Instant val logduck = LogDuckClient( options = LogDuckOptions( apiKey = System.getenv("LOGDUCK_API_KEY"), // required - your key, starts with `ld_` source = "checkout-api", // required - which app sent this, max 200 chars throwOnError = false, // default false - true throws instead of returning null retryEnabled = true, // default true - retry once on 5xx, network errors and 429 maxRetryDelay = Duration.ofSeconds(10), // default 10s - never block your caller longer than this timeout = Duration.ofSeconds(30), // default 30s - per-request timeout baseUrl = "https://api.logduck.com", // override only for a self-hosted deployment ), transport = HttpUrlConnectionTransport(), // swap the HTTP layer, e.g. for OkHttp logger = StderrLogger, // default StderrLogger ) logduck.send( LogDuckEvent( type = "order.placed", // required - max 100 chars subject = "order_9127", // optional - what the event is about, max 500 chars sessionId = "sess_8f21c", // optional - groups related events, max 256 chars message = "Order placed on the Pro plan", // optional - the notification body, max 500 chars time = Instant.now(), // optional - defaults to server receipt time data = mapOf("total" to 4999, "currency" to "NOK"), // optional - any JSON-shaped map emoji = "\uD83D\uDED2", // optional - max 10 chars ) ) ``` ### From Java `send` is a `suspend` function. From Java, or anywhere without a coroutine scope, use `sendBlocking`. ```java import com.logduck.LogDuckClient; import com.logduck.LogDuckEvent; import com.logduck.LogDuckOptions; LogDuckClient logduck = new LogDuckClient( new LogDuckOptions(System.getenv("LOGDUCK_API_KEY"), "checkout-api") ); logduck.sendBlocking(new LogDuckEvent("order.placed")); ``` > **Note: It does not throw by default** > `send` returns `null` and logs a warning when an event does not get through — a logging SDK that throws can take down the code it was only meant to observe. Set `throwOnError = true` for a `LogDuckException` instead, which carries `status`, `body` and `retryAfter`. Note that a `catch` block sees nothing at all until you turn that option on. `sessionId` goes out on the wire as `sessionid`. A CloudEvents extension attribute name must be lowercase alphanumeric; the SDK maps it for you, and you only need this when comparing against the raw HTTP API. Full reference and changelog: [github.com/Log-Duck/logduck-kotlin](https://github.com/Log-Duck/logduck-kotlin)