# Hober — full documentation > Generated from the same sources as https://docs-staging.hober.io. Index: https://docs-staging.hober.io/llms.txt --- # Getting started: Introduction Source: https://docs-staging.hober.io/docs/getting-started/introduction # Introduction Welcome to the Hober documentation. Hober is a customer-messaging platform for product and growth teams: one composer, one audience model, and one set of delivery controls across every channel your users are on. ## Channels | Channel | Status | Notes | |---|---|---| | iOS push | Available | APNs delivery with your app credentials, native SDK | | Android push | Available | FCM delivery with your app credentials, native SDK | | Web push | Available | Browser SDK with permission and token handling | | Email | Available | SendGrid delivery with SPF/DKIM/DMARC domain authentication | | In-app | Available | Real-time in-product messages — no external provider to configure; included on every plan | | SMS | Available | Twilio-compatible API with your account and numbers | | WhatsApp | Available | Approved-template enforcement, template catalog sync | ## What you can send - **Notifications** — one-off or scheduled messages, with per-channel content, A/B variants, and [templates](/docs/guides/notification-templates). - **[Campaigns](/docs/guides/campaigns)** — multi-message send containers with per-message reports. - **[Journeys](/docs/guides/journeys)** — event- and segment-triggered automation flows with waits, branches, and entry/exit rules. - **Recurring sends** — [preset schedules](/docs/guides/recurring-notifications) with timezone-safe delivery. - **In-app experiences** — cards and fullscreen messages targeted by screen and behavior; see the [in-app guide](/docs/guides/in-app-experiences). ## Targeting and controls - **Subscribers, lists, and behavioral segments** — filter on attributes and tracked events; see [segments](/docs/guides/segments) and [audience filters](/docs/guides/audience-filters). - **Events** — track behavior from the client SDKs or your backend via [server-side event tracking](/docs/guides/server-side-event-tracking). - **Delivery guardrails** — consent, quiet hours, and frequency caps are enforced on every send, including sends proposed by an AI agent. - **Measurement** — per-channel delivery and engagement analytics, holdout groups with lift readouts, and [A/B testing](/docs/guides/ab-testing). ## Ways to integrate - **Client SDKs** — [Browser](/docs/browser-sdk/quickstart), [React Native](/docs/react-native-sdk/quickstart), [iOS](/docs/ios-sdk/quickstart), and [Android](/docs/android-sdk/quickstart). - **Server SDKs** — [Go, Node.js, Python, and Java](/docs/server-sdks/overview) for event tracking from your backend; sends go through the REST API. - **REST API** — everything the dashboard does; start with the [API reference](/docs/api-reference/overview). - **Integrations** — Segment, Shopify, Zapier, Mixpanel, Amplitude, Slack, and more; see the [integrations overview](/docs/integrations/overview). - **AI agents** — an MCP server with human-in-the-loop approvals; see [agent access](/docs/guides/agent-access-mcp). Switching from another platform? See the migration guides for [Braze](/docs/migration/from-braze) and [Klaviyo](/docs/migration/from-klaviyo). ## For AI coding assistants Integrating with an AI assistant (Claude Code, Cursor, Copilot, …)? Point it at: - **[/llms.txt](https://docs-staging.hober.io/llms.txt)** — a machine-readable index of every page here; **[/llms-full.txt](https://docs-staging.hober.io/llms-full.txt)** inlines the full documentation in one fetch. Hober is newer than most models' training data — an assistant that fetches these integrates against the real API instead of a guessed one. - **The integration skill** — [`skills/hober-integration`](https://github.com/hoberhq/hober/tree/main/skills/hober-integration) encodes the integration procedure (platform decision tree, install → initialize → verify loops, key-security rules) for skill-consuming assistants. Copy it into `.claude/skills/` (Claude Code) or zip-upload it (claude.ai). Its sibling [`skills/hober`](https://github.com/hoberhq/hober/tree/main/skills/hober) covers *operating* Hober through the [MCP server](/docs/guides/agent-access-mcp) once you're integrated. ## Next steps 1. Read the [core concepts](/docs/getting-started/concepts) — five minutes, and the rest of the docs will make sense. 2. Pick an SDK quickstart above and send yourself a test message. 3. Invite your team — [roles and permissions](/docs/guides/team-and-roles) cover marketers, developers, and analysts. --- # Getting started: Core Concepts Source: https://docs-staging.hober.io/docs/getting-started/concepts # Core Concepts ## Subscribers A **subscriber** is a person in your product, identified by an `externalId` you control (usually your user ID). Everything else — devices, consent, events, segment membership — hangs off the subscriber. Call `identifySubscriber` from a client SDK, or upsert subscribers from your backend. ## Devices A **device** is a specific browser or app installation belonging to a subscriber. Each device carries a push token (FCM token, APNs token, or Web Push subscription) that Hober uses for push delivery. A subscriber can have many devices; invalid tokens are detected on send and feed your deliverability stats. ## Channels A **channel** is a configured sending endpoint owned by your workspace: an iOS app, an Android app, a web push domain, an email sender, or an SMS/WhatsApp number. You register channels in the dashboard, attach provider credentials, and every send goes out through one or more of them. > Not to be confused with Android's notification channels — the OS-level categories (e.g. "Promotions", "Alerts") that Android 8.0+ uses to group notifications. In the SDKs that concept appears as `channelId` during `Hober.init()`. ## Notifications, campaigns, and journeys A **notification** is a single message: content per channel, an audience, and a send time (now, scheduled, or recurring). Notifications can carry **variants** for A/B testing. A **campaign** groups several related messages into one container with per-message reports — the shape of a product launch or a promotion with multiple touches. A **journey** is an automation flow: subscribers enter on an event or segment condition, move through waits and branches, and receive messages along the way. Entry and exit rules keep people from getting messages that stopped being relevant. ## Templates A **template** is reusable message content with [Liquid merge tags](/docs/guides/message-personalization) (`{{ subscriber.first_name }}`). Compose from a template, preview with real subscriber data, and keep wording consistent across campaigns. See the [templates guide](/docs/guides/notification-templates). ## Lists and segments A **list** is a static set of subscribers you manage explicitly. A **segment** is a live audience defined by rules over attributes and tracked events — membership updates as behavior happens. Both can be send targets; see [audience filters](/docs/guides/audience-filters). ## Events An **event** is a named action a subscriber performed (`order_completed`, `level_up`). Events come from the client SDKs' `track()` or from your backend via the [server-side tracking API](/docs/guides/server-side-event-tracking). Events drive segments, journey triggers, and in-app message display rules. ## Consent and delivery controls Hober enforces delivery rules at dispatch time, on every send path: recorded **consent** per channel (including one-click unsubscribe and STOP replies), **quiet hours**, and **frequency caps**. A send that violates them is suppressed and reported — it does not go out because a campaign, journey, API call, or AI agent asked. ## API keys - **SDK key** — authenticates client-side SDKs; sent as the `X-SDK-Key` header. Safe to ship in your app; it can register devices and track events, not read your data. - **Server key** (`hober_srv_…`) — authenticates backend calls: sends, subscriber management, server-side event ingestion. Treat it like any secret. Both are issued per environment in the dashboard. ## Workspace and roles Your **workspace** holds channels, audiences, and content, and your teammates join it with a role — owner, admin, developer, marketer, or analyst — that scopes what they can see and send. See [team and roles](/docs/guides/team-and-roles). --- # Guides: Notification Templates Source: https://docs-staging.hober.io/docs/guides/notification-templates # Notification Templates Notification templates let you define reusable message content — a title and body — that you can apply consistently across campaigns and API calls. Instead of hard-coding notification text every time you send, you create a template once and reference it by ID. This guide walks through the full lifecycle: creating a template in the dashboard, using it from the Composer, referencing it directly in the API, updating it, and deleting it safely. --- ## 1. Create a Template via the Dashboard 1. Log in to the [Hober Dashboard](https://app.hober.io) and navigate to **Templates** in the left sidebar. 2. Click **New Template** (or go directly to `/templates/new`). 3. Fill in the fields: - **Name** — a unique, human-readable identifier (e.g. `order-shipped`). Names must be unique within your workspace. - **Title** — the notification title that recipients will see. - **Body** — the notification body text. 4. Click **Save Template**. The dashboard saves the template and redirects you to the template detail page, where you can copy the template ID for use in the API. --- ## 2. Use the Template Picker in the Composer When composing a notification in the dashboard (Composer), you can select an existing template instead of typing the content manually. 1. Open the **Composer** from the main navigation. 2. In the **Message** section, click **Use a template**. 3. A picker appears listing all templates in your workspace. Use the search box to filter by name. 4. Click a template to populate the **Title** and **Body** fields automatically. 5. You can still edit the pre-filled fields before sending — changes made here apply only to this notification job and do not modify the saved template. --- ## 3. Use a Template via the API To send a notification that uses a template, pass the template's `id` as `templateId` in the request body. The API resolves the title and body at send time from the template content at that moment. ```bash curl -X POST https://api.hober.io/api/v1/notifications \ -H "X-SDK-Key: YOUR_SDK_KEY" \ -H "Content-Type: application/json" \ -d '{ "subscriberIds": ["user-123", "user-456"], "templateId": "tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890" }' ``` You can still override the title or body for an individual send by also supplying `title` and/or `body` fields. When both `templateId` and explicit content fields are present, the explicit fields take precedence. --- ## 4. Update a Template You can update a template's name or content at any time using the API or the dashboard. ### Via the dashboard 1. Navigate to **Templates** and click the template you want to edit. 2. Modify the **Name**, **Title**, or **Body** fields. 3. Click **Save Changes**. ### Via the API ```bash curl -X PATCH https://api.hober.io/api/v1/templates/tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ -H "X-SDK-Key: YOUR_SDK_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": { "title": "Your order is on its way!", "body": "Track your shipment in the app." } }' ``` ### Snapshot semantics Hober uses a **send-time snapshot** model. When the API dispatches a notification, it reads the template content at the moment of sending. Notification jobs that have already been sent are not retroactively updated — the recipient saw whatever content was in the template at send time. However, **scheduled** (future-dated) notification jobs will use the template content as it exists when the job actually runs, not at the time the job was created. If you update a template while jobs referencing it are queued, those jobs will pick up the new content. --- ## 5. Delete a Template :::danger Template deletion is irreversible. There is no undo. ::: Before deleting a template, check whether any notification jobs are scheduled to send using it. Scheduled jobs that still reference the deleted template ID will fail to send when their scheduled time arrives. **Recommended steps before deleting:** 1. In the dashboard, navigate to **Templates** and open the template. 2. Click **View scheduled jobs** to list any pending notification jobs that reference this template. 3. Cancel or reassign those jobs before proceeding. 4. Return to the template detail page and click **Delete Template**, then confirm in the modal. ### Via the API ```bash curl -X DELETE https://api.hober.io/api/v1/templates/tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ -H "X-SDK-Key: YOUR_SDK_KEY" ``` A successful deletion returns `204 No Content` with an empty body. --- # Guides: Recurring Notifications Source: https://docs-staging.hober.io/docs/guides/recurring-notifications # Recurring Notifications :::warning Growth plan required Recurring schedules are available on the **Growth** plan and above. You will receive a `403 PLAN_FEATURE_UNAVAILABLE` error if you attempt to use schedules on the Free or Starter plan. To upgrade your plan, visit your [billing settings](https://app.hober.io/billing). ::: Recurring notifications let you send a push notification on a repeating cadence — every Monday morning, the first of each month, every six hours, or any other pattern you can express as a cron expression. This guide walks through every step: upgrading your plan, creating a template, setting up a schedule in the dashboard, verifying delivery, handling timezones, and pausing or resuming a schedule. --- ## 1. Prerequisites: Upgrade to the Growth Plan Recurring schedules require the **Growth** plan or above. Before you can create a schedule, make sure your workspace is on the correct plan. 1. Log in to the [Hober Dashboard](https://app.hober.io) and go to **Settings > Billing**. 2. Confirm the **Current Plan** field shows **Growth** (or higher). 3. If not, click **Upgrade Plan** and follow the checkout steps. After upgrading, the **Schedules** section becomes visible in the left sidebar. --- ## 2. Create a Notification Template Every schedule references a notification template for its title and body content. If you have not already created a template for your recurring notification, do that first. See the [Notification Templates guide](./notification-templates.md) for a full walkthrough. At minimum, you need a template with a **Title** and **Body** before continuing. Once you have saved the template, copy its ID from the template detail page — you will need it when creating the schedule. --- ## 3. Create a Schedule via the Dashboard 1. Navigate to **Schedules** in the left sidebar. 2. Click **New Schedule** (or go directly to `/schedules/new`). 3. Fill in the form: | Field | What to enter | |-------|--------------| | **Template** | Select the template you created in step 2. | | **Channels** | Choose one or more channels (e.g. browser, iOS, Android) the notification will be sent through. | | **Target audience** | Choose **All subscribers** or a specific segment. | | **Cron expression** | Enter a 5-field cron expression (see the [cron reference table](../api-reference/schedules.md#cron-expression-reference)). | | **Timezone** | Select an IANA timezone (e.g. `America/New_York`, `Europe/London`, `UTC`). | | **Active** | Leave toggled on to start the schedule immediately. | 4. Click **Save Schedule**. The dashboard confirms the schedule was created and shows the **Next run** time computed from your cron expression and timezone. ### Creating a schedule via the API If you prefer the API directly: ```bash curl -X POST https://api.hober.io/api/v1/schedules \ -H "X-SDK-Key: YOUR_SDK_KEY" \ -H "Content-Type: application/json" \ -d '{ "cron_expression": "0 9 * * 1", "timezone": "America/New_York", "template_id": "tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"], "target": { "type": "all" }, "active": true }' ``` A successful response returns `201 Created` with the full schedule object including the `next_run_at` timestamp. --- ## 4. Verify the Schedule Fires After the first cron window passes, confirm the notification was sent: 1. Navigate to **History** in the left sidebar (or go to `/history`). 2. Use the **Source** filter and select **Schedule** to show only schedule-triggered notifications. 3. Locate the row matching your schedule's first expected run time. 4. Click the row to see delivery details: recipients targeted, successful deliveries, and any failures. If the expected run does not appear in history, check: - The schedule's **Active** toggle is on. - The **Next run** time shown on the schedule detail page has already passed (accounting for the timezone you chose). - Your account is still on the **Growth** plan (or higher). --- ## 5. Timezone Handling Always specify a named IANA timezone — never a UTC offset string like `+05:30`. Named timezones handle Daylight Saving Time (DST) transitions automatically; raw offsets do not. ### DST gotchas When a DST transition shifts the clock, the **wall-clock time stays the same** but the UTC offset changes. For example, a schedule set to `0 9 * * 1` in `America/New_York` fires at: - **9:00 AM EST (UTC-5)** during winter — fires at 14:00 UTC - **9:00 AM EDT (UTC-4)** during summer — fires at 13:00 UTC This is usually the correct behaviour: you want the notification to reach users at 9 AM their local time, regardless of the season. :::tip If you need a notification to fire at an exact UTC time regardless of local clock changes (e.g. a financial cut-off), use `timezone: "UTC"` and adjust your cron expression accordingly. ::: ### Recommended timezones | Region | IANA identifier | |--------|----------------| | US Eastern | `America/New_York` | | US Pacific | `America/Los_Angeles` | | UK | `Europe/London` | | Central Europe | `Europe/Berlin` | | India | `Asia/Kolkata` | | UTC | `UTC` | --- ## 6. Pause and Resume a Schedule To stop a schedule from firing without deleting it, set `active` to `false`. ### Via the dashboard 1. Open the schedule in the **Schedules** list. 2. Toggle the **Active** switch off. 3. The toggle turns grey and the **Next run** field shows **Paused**. To resume, toggle the switch back on. The next run is recalculated from the current time. ### Via the API ```bash # Pause curl -X PATCH https://api.hober.io/api/v1/schedules/sch_a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ -H "X-SDK-Key: YOUR_SDK_KEY" \ -H "Content-Type: application/json" \ -d '{ "active": false }' # Resume curl -X PATCH https://api.hober.io/api/v1/schedules/sch_a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ -H "X-SDK-Key: YOUR_SDK_KEY" \ -H "Content-Type: application/json" \ -d '{ "active": true }' ``` Both requests return `200 OK` with the updated schedule object. --- ## 7. Cron Expression Tips Cron expressions follow the standard 5-field format. Here are the most common patterns — see the full [cron reference table](../api-reference/schedules.md#cron-expression-reference) for more examples. | Expression | Fires | |------------|-------| | `0 9 * * 1` | Every Monday at 9:00 AM | | `0 */6 * * *` | Every 6 hours | | `30 8 1 * *` | 1st of every month at 8:30 AM | | `0 12 * * 1-5` | Weekdays at noon | | `0 0 * * *` | Daily at midnight | **Tips:** - Use `*/N` in the hour field to repeat every N hours (e.g. `*/6` = every 6 hours). - Use a range in the day-of-week field (e.g. `1-5`) to target Monday through Friday. - Test your expression with an online cron parser before saving a production schedule. - Remember that the expression is always evaluated in the `timezone` you set on the schedule, not UTC. --- # Guides: A/B Variant Sending Source: https://docs-staging.hober.io/docs/guides/ab-testing # A/B Variant Sending :::warning Growth plan required A/B variant sending is available on the **Growth** plan and above (the `ab_testing` feature). You will receive a `403 PLAN_FEATURE_UNAVAILABLE` error if you attempt to use variants on the Free or Starter plan. To upgrade your plan, visit your [billing settings](https://app.hober.io/billing). ::: A/B variant sending lets you deliver two different versions of a push notification to separate segments of your audience and compare per-variant delivery counts. You define a split — for example, 60% of devices see Variant A and 40% see Variant B — and the platform handles the assignment automatically. This guide covers how variant assignment works, current limitations, full API examples, how to read per-variant results, and a dashboard walkthrough. --- ## 1. Overview When you create a notification job with variants, Hober splits your target audience across **2–4 content variants** according to the percentages you specify (they must sum to 100). Each device in the target receives exactly one variant. After the job runs, the notification detail page shows a per-variant breakdown of delivered, failed, and pending counts. **What A/B testing in Hober does (Sprint 11 scope):** - Sends 2–4 content variants to different device segments in a single notification job - Tracks delivery counts, failures, and pending sends separately per variant - Tracks **outcomes** per variant: taps, attributed conversions, and refund-netted revenue per currency — using the same [revenue attribution semantics](/docs/guides/revenue-attribution) as every other surface, so the breakdown answers "which variant made more money", not just "which got delivered" - Uses a deterministic, hash-based assignment so the split is stable and reproducible - Optionally runs as an **auto-winner experiment**: send the variants to a test slice first, evaluate a metric after a window you choose, and roll the winning content out to the rest of the audience automatically — see [Auto-Winner Experiments](#8-auto-winner-experiments) - Supports per-message variants (and auto-winner) on **campaign messages** **What A/B testing does not do in this release:** - No statistical significance calculator — the auto-winner gate is a reliability heuristic (minimum sample sizes), not a significance test - No multi-armed bandits — traffic split percentages are fixed for the life of the job Holdout groups — withholding the send from a slice of the audience to measure incremental lift — are a separate, complementary feature. See [Holdout Groups](#7-holdout-groups) below. --- ## 2. How Variant Assignment Works Variant assignment is **hash-based and deterministic per device ID**. The same device always receives the same variant for any given split percentage, regardless of when or how many times you query the assignment. ### Assignment algorithm For each device in the target audience, Hober computes: ``` bucket = hash(device_id) % 100 ``` The `bucket` is an integer between `0` and `99` (inclusive). The device is then assigned based on the split you configured: The variants' percentages partition the bucket space `0`–`99` in order: with a 60/40 split, buckets `0`–`59` receive Variant A and `60`–`99` receive Variant B; with a 50/30/20 three-way split, buckets `0`–`49` receive A, `50`–`79` receive B, and `80`–`99` receive C. ### Stability of the split Because the hash input is the device ID alone — with no job ID or timestamp component — the same 60% of devices always fall into Variant A for any job using a 60/40 split. The assignment does not shift between jobs. This means: - **Reproducible:** re-running the same job delivers the same variant to the same devices. - **Consistent:** a device's assigned variant is predictable and auditable. - **Not cross-job randomized:** if you want a different 60% to see Variant A on a follow-up job, you must change the split percentages. --- ## 3. Limitations | Limitation | Detail | |-----------|--------| | Percentages must sum to 100 | The `percentage` fields of both variants must add up to exactly `100`. | | No significance calculator | The auto-winner gate checks minimum sample sizes, not statistical significance. | | Variants are immutable | After a job is created, its variant content and percentages cannot be changed. Create a new job to adjust the split or content. | | Cannot mix with `content` or `templateId` | A job that uses `variants` must not also include a top-level `content` object or a `templateId`. These are mutually exclusive. | --- ## 4. API Example ### Create a notification with variants ```json POST /api/v1/notifications Authorization: Bearer Content-Type: application/json { "channelIds": ["chan-uuid"], "target": { "type": "all" }, "variants": [ { "label": "A", "content": { "title": "Hello!", "body": "Variant A body" }, "percentage": 60 }, { "label": "B", "content": { "title": "Hi there!", "body": "Variant B body" }, "percentage": 40 } ] } ``` A successful response returns `202 Accepted`: ```json { "id": "job-uuid", "status": "scheduled" } ``` ### Validation errors (422 Unprocessable Entity) The API returns `422` with a descriptive error message in the following cases: | Condition | Error message | |-----------|--------------| | Percentages do not sum to 100 | `"variants.percentage must sum to 100"` | | Variant count out of range | `"between 2 and 4 variants are required"` | | `variants` used alongside `content` | `"variants and content are mutually exclusive"` | | `variants` used alongside `templateId` | `"variants and templateId are mutually exclusive"` | --- ## 5. Reading Variant Results Retrieve the job details using `GET /api/v1/notifications/:id`. When the job was created with variants, the response includes a `variantBreakdown` array alongside the aggregate `deliveryStats`. ```json { "id": "job-uuid", "deliveryStats": { "delivered": 1000, "failed": 20, "pending": 5 }, "variantBreakdown": [ { "label": "A", "variantId": "v-uuid-a", "delivered": 600, "failed": 12, "pending": 3 }, { "label": "B", "variantId": "v-uuid-b", "delivered": 400, "failed": 8, "pending": 2 } ] } ``` ### Field reference | Field | Description | |-------|-------------| | `deliveryStats.delivered` | Total devices that successfully received the notification across both variants. | | `deliveryStats.failed` | Total devices where delivery failed across both variants. | | `deliveryStats.pending` | Total devices where delivery has not yet been confirmed (in-flight or queued). | | `variantBreakdown[].label` | The label you assigned when creating the job (`"A"` or `"B"`). | | `variantBreakdown[].variantId` | The unique ID of this variant record, useful for filtering logs or support queries. | | `variantBreakdown[].delivered` | Devices that received this specific variant. | | `variantBreakdown[].failed` | Devices where delivery of this variant failed. | | `variantBreakdown[].pending` | Devices awaiting delivery confirmation for this variant. | ### Interpreting the counts The aggregate `deliveryStats` counts are always equal to the sum of the corresponding fields across `variantBreakdown`. For example: ``` deliveryStats.delivered (1000) = variantA.delivered (600) + variantB.delivered (400) ``` To compare variant performance, look at the **delivered rate** for each variant: divide `delivered` by the total attempts (`delivered + failed + pending`) for that variant. A large discrepancy in failure rates between variants may indicate a content-related issue (such as a title exceeding device character limits) rather than a delivery infrastructure issue. :::tip Results are updated in near-real time as delivery confirmations arrive. If a job is still in progress, refresh the job detail page or poll `GET /api/v1/notifications/:id` until `pending` reaches `0`. ::: --- ## 6. Dashboard Walkthrough ### Step 1: Enable the A/B Test toggle 1. Log in to the [Hober Dashboard](https://app.hober.io) and open the **Composer** from the main navigation. 2. In the **Message** section, locate the **A/B Test** toggle and switch it on. The Composer expands to show two content panels — one for Variant A and one for Variant B — and a percentage slider. ### Step 2: Set the percentage split Use the **Split** slider to set how much of your audience receives Variant A. The remaining percentage is automatically assigned to Variant B. The two percentages always sum to 100. For example, dragging the slider to 60 gives Variant A 60% and Variant B 40%. ### Step 3: Enter content for each variant Fill in the **Title** and **Body** fields in the **Variant A** panel, then do the same in the **Variant B** panel. Both panels must be complete before you can submit. ### Step 4: Submit the job Select your target audience and channel(s) as you normally would, then click **Send Notification**. The job appears in **Notification History** with a small **A/B** badge indicating it is a variant job. ### Step 5: Review results 1. Navigate to **History** in the left sidebar. 2. Locate your job and click its row to open the job detail page. 3. Scroll to the **A/B Variant Breakdown** section. The breakdown table shows delivered, failed, and pending counts side-by-side for Variant A and Variant B. Use these counts to determine which variant performed better before promoting the winner. ### Promoting the winner If the send was created with an [auto-winner experiment](#8-auto-winner-experiments), the platform promotes the winner for you (or offers a one-click **Send to the rest** on the detail page when the result is inconclusive). For a plain variant send, promote manually: 1. From the job detail page, copy the winning title and body. 2. Open the **Composer**, ensure the **A/B Test** toggle is **off**, and paste the winning content. 3. Send as a standard notification targeting your full audience. --- ## 7. Holdout Groups A **holdout group** answers a different question than an A/B test. Variants compare *which message* performs better; a holdout measures *whether sending at all* made a difference. You choose a percentage of the target audience that is deliberately **not** sent the message, and later compare conversion rates between the treated group and the held-out group — the gap is the send's incremental lift. :::warning Growth plan required Holdout groups ride the same plan feature as A/B variants. On other plans the API returns the same `403` as variant sends. ::: ### Enabling a holdout Add `holdout_percentage` (1–99) to the create request. It works on both standard and variant sends; variant percentages still sum to 100 and split the *treated* remainder. ```json POST /api/v1/notifications Authorization: Bearer Content-Type: application/json { "channel_ids": ["chan-uuid"], "target": { "type": "all" }, "content": { "title": "Flash sale", "body": "Ends tonight" }, "holdout_percentage": 10 } ``` In the dashboard Composer, check **Hold out a control group** and pick a percentage (the UI ranges 1–50%; 5–20% is typical). ### How holdout assignment works Holdout bucketing is deliberately different from variant assignment in two ways: ``` bucket = hash(subscriber_id + job_id) % 100 bucket < holdout_percentage → held out ``` - **Per subscriber, not per device.** Lift is a behavioral comparison between people; a subscriber with two devices is either fully held out or fully treated, never split. - **Seeded with the job ID.** Membership re-randomizes on every send, so no subscriber is permanently starved of messages the way a device permanently sticks to Variant A. Devices without an identified subscriber are always treated — they cannot contribute to a subscriber-level comparison. ### Behavior details - Held-out subscribers consume no quota and produce no delivery records; their membership is recorded before any delivery starts and survives retries. - A job whose entire (small) audience lands in the holdout completes successfully with zero sends — it is not a failure. - Holdout membership is recorded at fan-out and survives retries; assignment is deterministic per (subscriber, job). ### Reading the lift The notification detail page shows a **Holdout Lift** section for any job sent with a holdout: the lift delta in percentage points, and a received-vs-held-out table with group sizes, converters, conversion rates, and revenue. The same data is available from the API: ```json GET /api/v1/insights/lift?job_id= { "job_id": "job-uuid", "treated": { "size": 900, "converters": 90, "conversion_rate": 0.10, "revenue_cents": 45000 }, "holdout": { "size": 100, "converters": 5, "conversion_rate": 0.05, "revenue_cents": 2500 }, "lift_percentage_points": 5, "window_days": 7, "conversion_configured": true } ``` How the numbers are computed: - Each **treated** subscriber is anchored at their first delivered touch for the job; each **held-out** subscriber at the moment of assignment. A subscriber counts as converted when they have at least one conversion within the window of their anchor. - The window is the **widest** window across your configured conversion events (default 7 days). - **Lift** is the treated conversion rate minus the holdout rate, in percentage points. Negative lift is shown as-is — it means the held-out group converted more. - `conversion_configured: false` means you have no conversion events defined, so both sides are vacuously zero. Configure conversion events in **Insights → Settings** before running holdout sends, and remember that small groups make noisy lift — judge the rates alongside the group sizes. --- ## 8. Auto-Winner Experiments An auto-winner experiment turns a variant send into a two-wave rollout: the variants go to a **test slice** of the audience first, the platform evaluates a metric after a window you choose, and the winning content is sent to the **remainder** automatically. ### Creating an experiment Add an `experiment` object to a variant send (it requires `variants`): ```json POST /api/v1/notifications { "channel_ids": ["chan-uuid"], "target": { "type": "all" }, "variants": [ { "label": "A", "content": { "title": "Hello!", "body": "A" }, "percentage": 50 }, { "label": "B", "content": { "title": "Hi there!", "body": "B" }, "percentage": 50 } ], "experiment": { "test_percentage": 20, "evaluation_window_seconds": 14400, "metric": "click_rate" } } ``` | Field | Meaning | |-------|---------| | `test_percentage` | Share of the audience in the test wave, `5`–`90`. The variants split *this slice* according to their percentages; the rest waits for the winner. | | `evaluation_window_seconds` | How long after the (scheduled) send the evaluation runs. | | `metric` | `click_rate` (default), `conversion_rate`, or `revenue_per_recipient`. | The two waves are disjoint by construction: subscribers are bucketed `0`–`99` per experiment, the test wave targets `[0, test_percentage)` and the remainder wave targets `[test_percentage, 100)`. **Conversion and revenue metrics require an evaluation window of at least 24 hours** — a shorter window would truncate attribution and reward the variant whose conversions merely arrived first. Shorter experiments must use `click_rate` (the API rejects the combination with a `422`). ### The decision rule When the window elapses, each variant becomes an **arm** with its delivered count and metric events (taps for `click_rate`, attributed conversions for the other two; `revenue_per_recipient` uses refund-netted revenue). The rule: > The best metric value wins **iff every arm passes the reliability gate**: > at least **500 delivered** and **20 metric events** per arm. Anything else is **inconclusive** — a gate failure on any arm, an exact tie, or (for the revenue metric) arms earning in different currencies. Inconclusive means *nothing auto-sends*: the test wave stands, and you pick manually from the detail page. The gate is deliberately strict because a wrong winner auto-sends to the majority of your audience. ### The remainder wave The winner rollout is an ordinary notification job carrying the winning variant's content. It inherits the platform's normal delivery semantics — including quiet hours and frequency guardrails — exactly like a send you created yourself. ### Experiment states | State | Meaning | |-------|---------| | `testing` | Test wave sent (or scheduled); waiting for the evaluation window. | | `evaluating` | The sweep is computing the decision (transient). | | `rolled_out` | A winner passed the gate; the remainder job was created. | | `inconclusive` | No winner may auto-send; the stated reason is in the decision. You can still pick a winner manually. | | `overridden` | You picked a winner manually; the remainder job was created. | | `canceled` | You ended the experiment; the remainder is never sent. | ### Reading and overriding `GET /api/v1/notifications/:id` includes an `experiment` object on experiment jobs: the state, the configuration, and — once decided — a `decision` with the per-arm metric values and gate verdicts. The notification detail page renders the same data as an **Experiment** card, with per-arm gate check marks and, for undecided or inconclusive experiments, two actions: - **Send ⟨variant⟩ to the rest** — `POST /api/v1/experiments/:id/winner` with `{"variant_id": "..."}`. Rolls the chosen variant out now; recorded as an override in the decision audit stamp. - **Cancel** — `POST /api/v1/experiments/:id/cancel`. Ends the experiment with no rollout. When each action is allowed: - The **winner pick** works while the experiment is still running (`testing`, `evaluating`) *and after an `inconclusive` conclusion* — that second case is the designed follow-through when the gate fails: the test wave stands, nothing auto-sent, and you decide from the recorded arms. Overriding an inconclusive experiment replaces its decision stamp with the override (`"mode": "override"`) and creates the remainder job at that moment. - **Cancel** works only while the experiment is still running. An inconclusive experiment needs no cancel — nothing will send unless you pick a winner. - `rolled_out`, `overridden`, and `canceled` are final: once a rollout happened (or was declined), no further action is accepted and the API returns `409 CONFLICT`. --- ## 9. Campaign Message Variants Campaign messages support the same machinery. On any message in a campaign, supply `variants` (and optionally `experiment`) instead of `content`: ```json POST /api/v1/campaigns { "name": "Launch", "target": { "type": "all" }, "messages": [ { "channel_ids": ["chan-uuid"], "position": 0, "variants": [ { "label": "A", "content": { "title": "It's here", "body": "..." }, "percentage": 50 }, { "label": "B", "content": { "title": "Launch day", "body": "..." }, "percentage": 50 } ], "experiment": { "test_percentage": 20, "evaluation_window_seconds": 14400, "metric": "click_rate" } } ] } ``` When the campaign is released, each variant message enqueues as a variant job (with its experiment, when configured) and reports per-variant results on the campaign's per-message report. `variants` and `content`/`template_id` are mutually exclusive per message. In the dashboard campaign builder, check **A/B test this message** on a message to author a two-variant split, with an optional **Auto-winner** configuration. --- # Guides: AI Copy Assist Source: https://docs-staging.hober.io/docs/guides/ai-assist # AI Copy Assist AI assist generates subject-line and message-body candidates in the Composer, grounded in your brand's own context. It proposes — **you** pick, edit, and send. Nothing is ever sent automatically. :::info Plan availability Assist is available on **Starter plans and above**, metered by monthly generation credits (see [Credits](#credits)). Free workspaces see no assist UI and the API returns `403 feature_not_available`. ::: ## What the model sees — and what it never sees Every generation is grounded in **aggregate-only** context: - your workspace's **brand name** and the free-text **brand voice notes** (Settings), - your own **recent subject lines** from completed sends, - the intent, tone, and draft you type into the assist panel. The model **never** receives subscriber data — no emails, names, attributes, events, or delivery records. That is a hard design rule of the AI layer, not a configuration. ## Using assist in the Composer 1. Click **✨ Assist** next to the **Title** or **Message** field. 2. Describe the campaign intent ("flash sale ends tonight"), optionally a tone, and pick the output language — **English and Spanish** are supported from day one. 3. **Generate** returns up to 5 candidates. Click one to apply it into the field (in an A/B test, it applies to the variant you're editing). Edit freely — it's your copy. The panel shows your remaining credits when it opens — and, on the premium tier, the 4-credit cost before you click — then keeps the count current after each generation. ## Credits Generations draw from a **monthly credit allowance** that refills on the first of each calendar month (UTC): | Plan | Credits / month | |---|---| | free / basic | 0 (assist off) | | starter | 100 | | growth | 500 | | scale / enterprise | 2000 | | agency | 2000 + 200 per active client workspace | When the whole pool is exhausted the API returns `402` and the Composer says so plainly. There is **no overage billing** — spend is always prepaid and bounded. ### Top-up credits Beyond the monthly allowance, your workspace can hold **top-up credits**: courtesy credits granted by support, and — as purchase rolls out — one-time credit packs. Top-ups are spent **only after** the monthly allowance runs out, oldest grant first, and purchased packs **never expire** (fully-unconsumed packs are refundable within 14 days of purchase). Courtesy credits occasionally carry an expiry date — when one does, it is shown on the card ahead of time and the entry stays listed after it lapses. ### Seeing where credits go **Settings → AI assist → Credits** is the transparency surface: - **Balance** — credits used against this month's allowance, plus any top-up remainder. Agency workspaces see the pool composition spelled out (base + per-client × clients). - **Top-up history** — every grant as a line item: amount, source (courtesy credit or purchased pack), date, remaining, and expiry. Expired and used-up grants stay listed; a balance never changes without a visible reason. - **Credit activity** — this month's **generations delivered**: date, what was generated (subject lines, message copy, or a client-report narrative), tier, credits, and which member ran it. Throttled or failed generations never appear because they never spend. Rows from before activity tracking shipped are shown as unattributed rather than guessed. ### Model tiers | Tier | Model class | Cost per generation | |---|---|---| | **standard** (default) | fast | 1 credit | | **premium** | most capable | 4 credits | The premium tier is available on growth+ plans, switchable by an owner/admin under **Settings → AI assist**. The higher burn is the fee — pricing tracks the real model-cost difference, with no separate add-on to manage. The assist panel shows your remaining credits and, on the premium tier, the 4-credit cost **before** you generate. ### Brand voice **Settings → AI assist** also holds your **brand voice** — free-text tone guidance (up to 2000 characters) that grounds every generation, e.g. *"warm and direct, light humor, never salesy"*. It rides the cached portion of the prompt, so a detailed voice costs you nothing extra per generation. Owners and admins edit it; leaving it empty is fine — assist then grounds only on your brand name and recent subject lines. ### Agencies Agency workspaces draw from a **pooled** allowance (2000 + 200 per active client). Generations made by agency staff draw from the agency pool — including while operating a client workspace, where the assist UI is **not shown** in this release: assist is an agency-side tool; client-visible exposure will come later if partners want it. One system feature also draws from the pool: **AI narratives on scheduled client reports**. Those runs appear in the credit activity as *client report narrative* with no member — automated spend is a visible ledger line, never an anonymous counter tick. ## API ```json POST /api/v1/assist/subject-lines (or /api/v1/assist/body-copy) { "intent": "flash sale ends tonight", "channel_type": "ios", "tone": "playful", "language": "es", "draft": "optional current draft to improve" } ``` Returns `candidates` (up to 5), `credits_spent`, `credits_remaining` (`-1` = unlimited), `tier`, and `low_balance` (true once ≥90% of the pool is burned). Requires a marketer-capable role (`manage_campaigns`). Failures are honest: `403` plan gate, `402` credits exhausted (the body carries `topup_available` when packs can be bought), `429` model throttled (retry shortly), `503` backend unavailable — **failed generations never consume credits**. ```json GET /api/v1/assist/credits ``` Returns the full transparency read behind the Settings card: `credits_used`, `credits_allowance` (with the `allowance_base` / `allowance_per_client` / `allowance_clients` composition), `grant_balance`, `credits_remaining`, `tier`, the `grants` ledger, this month's `activity`, and the purchasable `packs` catalog when purchase is enabled. ## What assist does not do - No automatic sending or auto-application of copy — a human always confirms. - No subscriber-level personalization by the model (merge tags keep working as before — they're rendered at delivery, not by the model). - No chat interface over your account data. --- # Guides: Message Personalization Source: https://docs-staging.hober.io/docs/guides/message-personalization # Message Personalization Personalization lets each recipient receive their own version of a message. Write [Liquid](https://shopify.github.io/liquid/) variables into any text field — title, body, subtitle, URL, or email HTML — and Hober renders them per recipient at send time from the subscriber's attributes. ```liquid Hi {{ subscriber.first_name | default: "there" }}, your {{ subscriber.plan }} plan renews tomorrow. ``` For a subscriber with `{"first_name": "Ada", "plan": "Growth"}`, this delivers *"Hi Ada, your Growth plan renews tomorrow."* A subscriber without `first_name` gets *"Hi there, …"*. --- ## Variables Two namespaces are available, and only these two — bare variables like `{{ first_name }}` are not bound and render blank: | Namespace | Source | Example | |---|---|---| | `subscriber.*` | The subscriber's attributes (set via SDK `identify`, the ingest API, or the dashboard) | `{{ subscriber.first_name }}` | | `device.*` | The receiving device's attributes | `{{ device.model }}` | Attribute names are the keys you set on the subscriber — there is no fixed schema. As soon as any subscriber carries an attribute, its key appears in the dashboard's **Insert variable** menus. ### Missing values and fallbacks A variable the subscriber doesn't have renders as an empty string — the send does not fail. When blank text would read badly, declare a fallback with the `default` filter: ```liquid {{ subscriber.first_name | default: "there" }} ``` ### Filters The standard Shopify Liquid filter set is available — `default`, `upcase`, `downcase`, `capitalize`, `date`, `truncate`, and the rest. Referencing a filter that doesn't exist fails validation when you save (see below). --- ## Where personalization applies - **Push, in-app, SMS, WhatsApp-adjacent text**: title, body, subtitle, and URL personalize on every channel. For SMS the rendered length varies per recipient, so segment counts shown at compose time are estimates. - **Email**: subject (the title), the HTML body, and the plain-text body all personalize. In the drag-and-drop email editor, insert variables through the editor's **Merge Tags** menu. - **SendGrid dynamic templates**: not rendered by Hober — those templates carry their own Handlebars variables, which SendGrid substitutes. In the compose and template forms, the **`{ }` Insert variable** button next to each field lists your workspace's known attribute keys and inserts the tag at the cursor. --- ## Previewing When a field contains Liquid, a **Personalization preview** panel appears below the content form. Pick one of your subscribers (or "No subscriber" to see fallback behavior) and click **Preview** to see the exact rendered output — the preview uses the same rendering engine as delivery, so what you see is what recipients get. --- ## Validation and failure behavior - **At save time**: malformed Liquid (an unterminated `{% if %}`, an unknown tag, a misspelled filter) is rejected when you create the notification or save the template, with the failing field named. Requests rejected here never consume quota. - **At send time**: if a recipient's attributes cannot be read or a field fails to render for them, that recipient's delivery is recorded as failed with the reason in the delivery log — other recipients are unaffected, and the failure is not retried. - **Unclosed output tags** (`{{ first_name` with no closing braces) are not an error — Liquid treats them as literal text, matching Shopify behavior. The preview panel is the fastest way to catch these. --- ## Worked example Subscriber attributes: ```json { "first_name": "Rocío", "tier": "vip", "credits": 12 } ``` Compose body: ```liquid {% if subscriber.tier == "vip" %}Thanks for being a VIP, {{ subscriber.first_name }}!{% else %}Hi {{ subscriber.first_name | default: "there" }}!{% endif %} You have {{ subscriber.credits | default: 0 }} credits left. ``` Delivered to this subscriber: ``` Thanks for being a VIP, Rocío! You have 12 credits left. ``` --- # Guides: Server-Side Event Tracking Source: https://docs-staging.hober.io/docs/guides/server-side-event-tracking # Server-Side Event Tracking Track events from your backend — order placed, subscription renewed, invoice paid — straight into Hober segmentation and journeys. These are the events that never happen in a browser or app, so they need a **server key**, not the client SDK key. The HTTP API is the contract: everything below works with `curl` alone. Official server SDKs (Node.js, Python, Go, Java) wrap the same API and are coming next; nothing here requires them. ## Which key goes where | | Client SDK key | Server key | |---|---|---| | Looks like | `sk_…` | `hober_srv_…` | | Secret? | **No** — publishable, ships inside apps and web pages | **Yes** — server-side only, shown once at creation | | Header | `X-SDK-Key: sk_…` | `Authorization: Bearer hober_srv_…` | | Batch limit | 50 events/request | 1000 events, 500 KB/request, 32 KB/event | | `occurred_at` backdating | up to 48 hours | up to 90 days | | `external_id` as actor | ✗ | ✓ | | Revocable individually | ✗ (rotate the tenant secret) | ✓ (Settings → Server Keys) | :::warning The `sk_` prefix is not a secret key Despite reading like Stripe's "secret key", the `sk_…` client SDK key is **publishable by design**. The `hober_srv_…` key is the secret one — never embed it in a client application, and revoke it immediately if it leaks. ::: ## 1. Create a server key Dashboard → **Settings → Server Keys** → **Create Server Key**. The key is shown once — copy it into your secret manager. Keys carry the `events:write` scope and can be revoked individually at any time. ## 2. Track an event ```bash curl -X POST https://api.hober.io/v1/events \ -H "Authorization: Bearer $HOBER_SERVER_KEY" \ -H "Content-Type: application/json" \ -d '{ "event_name": "order.placed", "external_id": "user-483", "idempotency_key": "0197a3b2-7c1e-7f7e-9d7c-1b2a3c4d5e6f", "properties": { "order_id": "ord_921", "total_cents": 12900 } }' ``` ```json { "accepted": 1 } ``` `external_id` is **your** user identifier — the same value you registered the subscriber with. No need to look up Hober's subscriber UUID first. An unknown `external_id` fails the request with `422` so integration bugs surface immediately instead of silently dropping events. Batches use the `events` envelope: ```bash curl -X POST https://api.hober.io/v1/events \ -H "Authorization: Bearer $HOBER_SERVER_KEY" \ -H "Content-Type: application/json" \ -d '{ "events": [ { "event_name": "subscription.renewed", "external_id": "user-483", "properties": { "plan": "growth" } }, { "event_name": "invoice.paid", "external_id": "user-517", "properties": { "amount_cents": 4900 } } ] }' ``` ## 3. Make retries safe Ingestion is idempotent per `idempotency_key` (unique within your tenant). Supply your own — an outbox row id, a UUIDv7 per event — and retry the whole batch on any network error or `5xx`: already-stored events are skipped, new ones are ingested, and nothing is double-counted. ## Backdating `occurred_at` defaults to receipt time and accepts RFC3339 timestamps up to **90 days** in the past on the server tier (48 hours on the client tier). Anything older is rejected with `422` — bulk historical imports are a separate surface, not the live endpoint. ## Trust tiers in your data Every event records the tier it arrived through as `source: client | server`, stamped from the credential — a `source` field in the payload is ignored. Segments and analytics can rely on server-sourced events not being forgeable by anything shipped inside your app. ## Revoking a key Settings → Server Keys → **Revoke**. Requests with a revoked key start failing with `401` within about a minute (validation results are briefly cached on the ingest path). ## Reference - Endpoint details: [Events API](../api-reference/events.md) - Machine-readable contract: [`docs/api/events-openapi.yaml`](https://github.com/hoberhq/hober/blob/main/docs/api/events-openapi.yaml) --- # Guides: In-App Experiences Source: https://docs-staging.hober.io/docs/guides/in-app-experiences # In-App Experiences In-app messages reach users inside your product: a durable inbox delivers them even when the user was offline, and authored **banner** and **modal** formats render on web with no code change in your app. This guide covers authoring, display rules, the prebuilt web components, and engagement tracking. Availability: every plan, including Free (since 2026-07-11). The retention caps — 30-day message expiry and a 100-message inbox per subscriber — apply on all tiers. --- ## Formats Author rendered experiences in the **In-App studio** (`Messaging › In-App`): pick a format, add images and buttons, target an audience, schedule a live window, and publish. Compose still delivers headless title/body sends to the inbox when an in-app channel is targeted. | Format | What happens | |---|---| | **Headless** (default) | Your app receives `{title, body, data}` through the SDK and renders it itself — the original behavior. | | **Banner** | A dismissible strip rendered at the top of the page by the browser SDK. | | **Modal** | A centered dialog with optional image and call-to-action buttons. | | **Card** | A persistent floating card in the corner — stays until dismissed, no auto-hide. | | **Fullscreen** | A viewport takeover with a hero image, title, body, and CTAs. | | **Inbox only** | Delivered silently to the message inbox — no on-screen surface. | SDK versions that predate a format degrade gracefully: mobile SDKs render unknown formats as modals; older browser SDKs suppress them (the message stays in the inbox). Banners and modals support an optional **image URL** and a **CTA button** (label + link). Clicking the CTA opens the link and records an `inapp_click` with the button's id. **Quiet hours:** an *inbox only* send that targets only in-app channels is exempt from quiet-hours deferral — it interrupts nobody. Frequency caps still apply. Any other format, or a send that also targets push/email, respects quiet hours normally. --- ## Display rules Display rules are evaluated **client-side** by the SDK — it knows session state; the server does not. - **Show**: *Immediately* (on receipt), *At next session start*, or *After an event*. A "session start" message that arrives while the user is mid-session is held; the durable inbox delivers it when the SDK next connects. An "after an event" message surfaces when your app tracks the named behavioral event — every SDK evaluates it locally against its own `track()` calls (browser `Hober.track()`, iOS `Hober.track()`, Android `Hober.track()`, React Native `useHober().track()`), so the message appears immediately, no server round-trip. Messages whose event never fires stay safely in the inbox. - **Frequency**: *Once* (ever, per message, per browser) or *Once per session*. - **Priority** (1–10, default 5): when several messages are pending, the highest-priority one displays first; the rest queue. --- ## Rendering on web One call replaces the headless setup — banners and modals then display automatically: ```ts import { createRenderedClient } from '@hoberhq/browser-sdk'; const { client } = createRenderedClient({ apiKey: 'pk_your_sdk_key', subscriberID: currentUser.id, }); client.connect(); ``` The surfaces are plain DOM with `hober-inapp-*` class names, themeable without JavaScript: ```css :root { --hober-inapp-bg: #1f2937; --hober-inapp-fg: #f9fafb; --hober-inapp-accent: #f59e0b; } ``` ### Notification-center widget Embed an inbox bell (unread badge, message list, mark-all-read, dismiss): ```ts import { InAppClient, NotificationCenter } from '@hoberhq/browser-sdk'; const client = new InAppClient({ apiKey: 'pk_…', subscriberID: currentUser.id }); new NotificationCenter(client, { container: document.querySelector('#bell')! }); ``` ## Rendering on mobile Each mobile SDK ships the same display-rule engine and engagement semantics as the web renderer. Construct the in-app client with impressions disabled (the renderer reports them at display time) and attach the platform's renderer: **iOS (SwiftUI, presented in a passthrough window):** ```swift let client = HoberInAppClient(apiKey: key, subscriberID: userID, reportImpressions: false) let renderer = HoberInAppRenderer(client: client) client.connect() ``` **Android** renders through a surface port — the SDK owns the rules and engagement, your app (or the example app's ready-made implementation) owns the views, so surfaces match your design system: ```kotlin val client = HoberInAppClient(apiKey, subscriberID, reportImpressions = false) val renderer = InAppRenderer(client, surface = mySurface) // implements InAppSurfacePort client.connect() ``` **React Native** — mount the surface once near the app root: ```tsx ``` On all three, `once` frequency persists per install where the platform allows (UserDefaults on iOS; bring a SharedPreferences/AsyncStorage-backed `ShownStore` on Android and React Native — adapters are documented in the SDK sources). ### Tracking events from mobile All four SDKs now record behavioral events with the same semantics: batched to `POST /v1/events` (10 pending or 500ms debounce), anonymous until `identifySubscriber`, stitched to the subscriber afterwards. Tracked events feed segments, journey waits, and event-triggered in-app messages: ```swift try Hober.track("cart_abandoned", properties: ["value": 129]) ``` ```kotlin Hober.track("cart_abandoned", mapOf("value" to 129)) ``` ```tsx const { track } = useHober() track('cart_abandoned', { value: 129 }) ``` P1 buffering notes: buffers are in-memory (a killed app loses unflushed events), and the anonymous id is per-install on iOS/web but per-process on Android and per-runtime on React Native unless you inject a stable one. ### Staying headless Nothing changes for existing integrations: `onMessage` still delivers every message, now including the authored fields (`format`, `imageUrl`, `actions`, `display`) so you can render them your own way on any platform. --- ## Engagement The renderer and widget report engagement automatically through the interactions pipeline: | Event | When | |---|---| | `inapp_impression` | A banner/modal actually displays (not merely delivers) | | `inapp_click` | A CTA button is tapped (carries the action id) | | `inapp_dismiss` | The user closes the surface or dismisses from the inbox | These events feed segments ("clicked an in-app message in the last 7 days") and analytics like any other behavioral event. Headless integrations report the same events via `client.reportImpression`, `client.reportClick`, and `client.dismiss`. --- ## Journeys and analytics In-app engagement flows through the same behavioral pipeline as every other event, so it composes with the rest of the platform without extra setup: - **Journeys**: a *Wait for event* step can wait on in-app engagement. The event names are `notification.inapp_click`, `notification.inapp_impression`, and `notification.inapp_dismiss` — e.g. "send the in-app offer → wait up to 7 days for `notification.inapp_click` → branch to the converted path, else send a reminder". - **Segments**: target subscribers by in-app engagement like any behavioral event ("clicked an in-app message in the last 7 days"). - **Funnels**: campaign and journey funnels count in-app impressions as opens and in-app clicks as clicks automatically. --- ## Retention Inbox messages expire after 30 days, and each subscriber's inbox keeps the most recent 100 messages (oldest evicted). Read state is subscriber-scoped: marking a message read on one device syncs to all of them. --- # Guides: Journeys Source: https://docs-staging.hober.io/docs/guides/journeys # Journeys A journey is an automated, multi-step messaging flow: a **trigger** that pulls a subscriber in, followed by a graph of **sends, delays, branches, and waits** that each subscriber moves through individually. Where a [campaign](./campaigns.md) is a planned broadcast you release once, a journey runs continuously — every subscriber who matches the trigger starts their own run and advances at their own pace. You build journeys on a visual canvas under **Journeys** in the dashboard. ## Create a journey You can start from scratch or from a playbook. ### From scratch 1. Open **Journeys** and enter a name under **New journey** (for example, "Welcome series"). 2. Click **New journey**. The journey is created as a **draft** and opens straight into the canvas editor. ### From a playbook The **Start from a playbook** panel offers ready-made journey drafts: | Playbook | What it does | |---|---| | Win-back: proven buyers gone quiet | Re-engages recent buyers who have been silent for 14 days — a send, a 3-day wait for an app open, and a follow-up for those who stay quiet | | Cross-channel welcome cadence | Fires on your signup event: an immediate message, another two days later, and a final nudge after three more | | Abandoned checkout rescue | Fires on checkout start, waits 24 hours for the purchase, then sends up to two reminders — buyers exit the moment they purchase | | Post-purchase thank-you → review ask | A thank-you the day after purchase, then a review request five days later — skipped for repeat buyers via a branch | | Trial-to-paid countdown | A reminder ten days into the trial, a 3-day wait for the upgrade, and a final offer for those who let it lapse | | Sunset: re-permission before goodbye | One honest "still want these?" send to subscribers with no engagement in 30 days, a week listening for any open, then a final notice | Click **Use this journey** to create the draft. Playbooks that enter subscribers from a behavioral cohort also create (or reuse) the matching segment for you — the card tells you which. Every playbook lands as a draft with the send steps' channels left empty: pick channels and content in the builder, then activate. ## Configure the trigger Click the **Trigger** node on the canvas to open the inspector. Three trigger types are available: - **Enters segment** — a subscriber starts a run when they enter the chosen [segment](./segments.md). Pick the segment from the dropdown. - **Exits segment** — a subscriber starts a run when they leave the chosen segment. - **Performs event** — a subscriber starts a run when they perform the named event (see [server-side event tracking](./server-side-event-tracking.md) for how events reach the platform). ### Entry rules Also on the trigger node: - **Allow subscribers to re-enter after completing** — by default each subscriber runs a journey **once**. Check this to let subscribers re-enter after completing a run, optionally gated by a **re-entry window** (for example `24h` or `7d`). - **Only enter subscribers matching** — an eligibility condition, edited with the same condition builder as segments (nested ALL/ANY groups, attribute and event conditions). Subscribers who trigger the journey but do not match are not entered. ## Add and connect steps The left-hand **Add step** palette offers five step types: | Step | What it does | Configuration | |---|---|---| | Send | Deliver a message | Channels and a [template](./notification-templates.md) ID supplying the content | | Delay | Wait a fixed time | A duration such as `1h`, `30m`, or `3d` | | Branch | Split on a condition | A condition built with the full condition builder; runs matching it take the `then` path, others take `else` | | Wait for event | Pause until behavior | An event name and a timeout window (for example `7d`); runs take the `on_event` path when the subscriber acts, or `on_timeout` when the window elapses | | Exit | End the journey | None — the subscriber leaves the journey here | Click a step in the palette to drop it on the canvas, then drag connections between nodes to define the flow. Branch and wait steps route from two handles (`then`/`else` and `on_event`/`on_timeout`); each handle connects to exactly one target — drawing a new connection from a handle replaces the old one. Select any node to edit its configuration in the right-hand inspector, **Duplicate** it (the copy keeps the configuration but not the connections), or **Remove** it. The **Auto-layout** button re-arranges the canvas into tidy layers. :::tip Branch conditions have the same power as the segment builder: nested ALL/ANY groups, attribute conditions, and windowed event conditions. Use a branch after a delay to route, say, repeat buyers away from a review request. ::: ## Save, activate, pause The editor **autosaves** your draft as you work — the Saving/Saved indicator in the header shows the current state. Click **Activate journey** to go live. Activation is validated: problems such as a missing trigger connection or dangling steps are listed in a warning panel and highlighted on the offending nodes, and the button stays disabled until they are fixed. The server performs its own authoritative validation on activation and surfaces any remaining issue inline. An active journey shows a **Pause** button in the same spot. Journey statuses are: | Status | Meaning | |---|---| | draft | Being edited; nobody enters | | active | Live — subscribers matching the trigger enter and advance | | paused | Temporarily stopped | Each run is pinned to the journey **version** it entered on (the version is shown on the journey card), so editing a journey does not scramble subscribers already mid-flow. ## Monitor performance - **Journey list** — each card shows how many subscribers **entered**, how many **completed**, the step count, and a completion meter. - **On the canvas** — once a journey has traffic, each step carries a badge showing how many runs **passed** through it and how many sit on the step **now**. This makes drop-off visible exactly where it happens: a wait step with many runs "here now" is a cohort that has not yet acted. ## Delete a journey From the journey list, click **Delete** on a card to remove the journey. --- # Guides: Campaigns Source: https://docs-staging.hober.io/docs/guides/campaigns # Campaigns A campaign is a planned send as a first-class object: its audience, content, channels, and timing in one place, with a report attached. Every planned send in Hober is a campaign — one-off blasts, scheduled sends, and multi-message sequences alike. Where a [journey](./journeys.md) reacts to individual subscriber behavior, a campaign is a coordinated broadcast you plan and release. You manage campaigns under **Campaigns** in the dashboard. ## Where campaigns come from - **The campaign builder** — click **New campaign** on the Campaigns page to author a draft directly (see below). - **Compose** — every send you create in the Composer produces a campaign automatically. The Composer's optional **Campaign name** field names it; left blank, the campaign is auto-named. This is also where per-send options such as [A/B variants and holdout groups](./ab-testing.md) live — a holdout chosen at compose time appears as the campaign's **Holdout** percentage on its detail page. - **Recurring schedules** — a recurring send is represented as a campaign too; its detail page shows the cadence (see [recurring notifications](./recurring-notifications.md)). ## Build a campaign Click **New campaign** and fill in: 1. **Campaign name** — for example, "Spring launch". 2. **Audience** — **All subscribers**, or one of your [segments](./segments.md). 3. **Messages** — one or more messages, each with: - a **title** and **body**, - one or more **channels** (pick from your configured channels), - an optional **send time**. Leave it empty to send the message with the campaign; set it to schedule that message individually. Use **Add message** to build a multi-message sequence — for example an announcement now and a reminder two days later — and **Remove** to drop one. Every message needs a title and at least one channel before you can save. Click **Create draft**. The campaign is saved as a **draft**: nothing sends yet. ## Release a draft From the campaign's detail page: - **Edit** — reopen the builder to adjust the draft. - **Release** — send it. Each message becomes its own notification job. If every message goes out immediately the campaign turns **live**; if any message is timed for the future it turns **scheduled** and the timed messages go out at their send times. ## Campaign lifecycle | Status | Meaning | |---|---| | draft | Created, not yet released | | scheduled | Released and awaiting its send window, or a recurring campaign between fires | | live | Currently sending | | completed | The send has finished | | paused | A scheduled or live campaign put on hold | | archived | Retired and hidden from active lists | Actions on the detail page follow the status: **Pause** (scheduled or live), **Resume** (paused), **Archive** (anything not archived, with a confirmation — recurring sends stop), and **Unarchive**, which restores the campaign to the exact status it held when archived. The campaign list shows active campaigns by default; use the status filter to view **Paused**, **Completed**, or **Archived** ones. ## The detail page A campaign's detail page summarizes: - **Audience** — all subscribers, a segment, or a subscriber list. - **Timing** — "Send now", a scheduled time, or "Recurring" with the cadence and timezone for schedule-driven campaigns. - **Category** — the send category. - **Holdout** — the percentage of the audience withheld as a control group, when one was configured (see [A/B testing and holdout groups](./ab-testing.md)). - **Channels** and, when the content comes from a [template](./notification-templates.md), the template. - **Messages** — for multi-message campaigns, each message's title, channels, and send time. ## Read the report Every campaign detail page ends with its report. Once the campaign starts sending you get: - **KPI cards** — delivery rate, open rate, click rate, conversions, and revenue. - **Funnel** — proportional bars for Sent → Delivered → Opened → Clicked → Converted. - **By channel** — delivered, opened, and clicked per channel. - **By message** — for multi-message campaigns, the same breakdown per message, so you can see which step of the sequence carries the results. :::tip Per-message numbers are the fastest way to prune a sequence: a follow-up message with a healthy delivery count but a fraction of the opens of message one is a candidate to reword, retime, or drop. ::: --- # Guides: Segments Source: https://docs-staging.hober.io/docs/guides/segments # Segments A segment is a named, reusable cohort of subscribers defined by rules over their **attributes** (country, tier, any property your SDKs report) and their **behavior** (events they did — or did not — perform). Once created, a segment is a target you can point sends at: - as the audience of a send in **Compose**, - as the audience of a [campaign](./campaigns.md), - as a [journey](./journeys.md) trigger — "enters segment" and "exits segment" start runs from segment membership changes. Segments differ from [audience filters](./audience-filters.md): a filter is an ad-hoc predicate attached to a single send, while a segment is a saved, named cohort — with behavioral (event) conditions that filters do not have. You build segments under **Segments** in the dashboard. ## Build a segment 1. Open **Segments** and give the segment a name (for example, `dormant_30d`). 2. Add conditions with the condition builder (below). 3. Watch the **Estimated audience** panel — it refreshes as you edit, showing how many subscribers currently match. Consent gaps are excluded at send time, not in the estimate. 4. Click **Create segment**. At least one condition is required. ## The condition builder A segment's rule is a group of conditions matched with **all** (every condition must hold) or **any** (one is enough). Three kinds of condition can be added: ### Attribute conditions An attribute condition compares a subscriber attribute against a value: pick the field (with autocomplete from the attribute keys seen in your data), an operator, and a value. | Operator | Meaning | |---|---| | equals / does not equal | exact comparison | | is greater than / is at least / is less than / is at most | ordered comparison | | is one of | matches any value in a comma-separated list | Numeric values are compared as numbers — `orders_count is greater than 3` behaves as you expect. ### Event conditions An event condition matches on behavior: *did* an event, *at least* N times, *within* a window such as `30d`, `24h`, or `45m`. Event names autocomplete from the events your integrations and SDKs have already sent — see [server-side event tracking](./server-side-event-tracking.md) for getting events flowing. ### Condition groups **+ Condition group** nests a whole new all/any group inside the current one, and groups nest to any depth. This is how you express mixed logic like: > tier equals `premium` **and** (did `purchase` within `30d` **or** did `add_to_cart` within `7d`) — an outer *all* group containing the attribute condition plus an inner *any* group with the two event conditions. ## Plan limits on event conditions Event conditions are validated against your plan when you save. Limits scale by tier — from 3 event conditions per segment, 30-day windows, and 5 auto-updating segments on entry plans, up to 25 conditions, all-time windows, and 100 auto-updating segments at the top tier. A rule that exceeds a cap is rejected with a message naming the violated limit, so you always see exactly which cap applies to your plan. ## Auto-updating vs. static segments The **Auto-updating** toggle (a paid-tier feature) decides how membership behaves after creation: - **Auto-updating (dynamic)** — membership is re-evaluated continuously as tracked events arrive: subscribers enter when they start matching and exit when they stop. These entered/exited transitions are exactly what journey segment triggers fire on, so use an auto-updating segment when a journey should react to the change. - **Static** — membership is not kept up to date automatically as behavior changes. The list marks each segment with a **dynamic** or **static** pill. Plans cap how many auto-updating segments you can have (limits above); on plans without the feature, creating one is rejected with an upgrade prompt. ## Edit and delete - **Edit** loads a segment back into the builder — change the name and conditions, then **Save changes**. The auto-updating setting cannot be changed after creation. - **Delete** asks for an inline confirmation before removing the segment. :::note Segment names are unique within your workspace — saving a segment under a name you already use is rejected. ::: --- # Guides: GDPR Data Retention Policy Source: https://docs-staging.hober.io/docs/guides/gdpr-data-retention # GDPR Data Retention Policy This guide explains how the Push Notification System (Hober) stores, retains, and deletes personal data in compliance with GDPR. It covers retention periods per data category, deletion cascade behaviour, audit logging, bulk deletion constraints, the API endpoints for triggering deletion, and an operator runbook for handling manual data removal requests. ## Retention periods | Data category | Table(s) | Retention period | Notes | |---|---|---|---| | Subscriber PII | `subscribers` | Until deletion requested | `external_id`, `email`, `attributes` | | Device tokens | `devices` | Until deletion requested | Cascade-deleted with subscriber | | List memberships | `subscriber_list_memberships` | Until deletion requested | Cascade-deleted with subscriber | | Delivery logs | `notification_delivery_logs` | 90 days | Anonymised on subscriber delete; row retained for analytics | | Audit logs | `subscriber_deletion_audit` | 7 years | Required for compliance evidence; never deleted | Hober does not proactively expire subscriber PII. Deletion is always initiated by an operator or by the subscriber via your product's account-deletion flow (which should call the Hober deletion API described below). ## Deletion cascade behaviour When a subscriber is deleted, Hober applies the following cascade rules atomically within a single database transaction: ### Hard deletes The following rows are permanently removed: - `subscribers` — the subscriber record itself - `devices` — all devices belonging to the subscriber (`ON DELETE CASCADE`) - `subscriber_list_memberships` — all list memberships for the subscriber (`ON DELETE CASCADE`) Once hard-deleted, these records cannot be recovered. Ensure any downstream systems (CRM, analytics warehouse) are notified before triggering deletion. ### Anonymisation (delivery logs) `notification_delivery_logs` rows are **not deleted**. Instead, the foreign keys are nullified to preserve aggregate delivery metrics while removing the link to the individual: ```sql subscriber_id = NULL -- anonymised device_id = NULL -- anonymised ``` The log row (timestamp, status, provider response) is retained for 90 days from delivery date to support throughput analytics and SLA reporting. After 90 days the row is removed by a scheduled purge job. ## Audit log — `subscriber_deletion_audit` Every deletion operation (single or bulk) writes one row to `subscriber_deletion_audit`. This table is append-only and is never purged. | Column | Type | Description | |---|---|---| | `id` | `uuid` | Primary key | | `tenant_id` | `uuid` | The tenant that owns the deleted subscriber | | `subscriber_id` | `uuid` | UUID of the deleted subscriber | | `deleted_by` | `uuid` | User ID of the operator or service account that triggered the deletion | | `subscriber_count` | `integer` | `1` for single-subscriber deletions; `N` for bulk deletions | | `deleted_at` | `timestamptz` | Wall-clock time of the deletion (UTC) | Use this table to produce GDPR erasure evidence. For bulk deletions, one audit row is written per subscriber in the batch, all sharing the same `deleted_at` timestamp. ## Bulk deletion limits and rate limiting The bulk deletion endpoint accepts a maximum of **1,000 subscriber IDs per request**. Requests exceeding this limit are rejected with HTTP `422 Unprocessable Entity`. IDs included in the request but not found in the tenant's subscriber set are returned in the `not_found` array of the response body and are **not treated as errors** — the overall request succeeds for the IDs that were found and deleted. Rate limiting applies at the tenant level: | Plan | Bulk deletion rate limit | |---|---| | Starter | 10 requests / minute | | Growth | 50 requests / minute | | Premium | 200 requests / minute | For large-scale erasure operations (for example, a data-subject access request requiring deletion of tens of thousands of subscribers), batch requests in chunks of 1,000 and respect the rate limit for your plan. See the [operator runbook](#operator-runbook) below for a scripted approach. ## Triggering deletion via the API See the [Subscribers API reference](../api-reference/subscribers.md) for full request/response schemas. ### Single subscriber deletion ```http DELETE /api/v1/subscribers/{id} Authorization: Bearer ``` `{id}` is the Hober subscriber UUID (the `id` field returned by `POST /api/v1/subscribers/upsert`). **Success response — 204 No Content.** **Error responses:** | Status | Meaning | |---|---| | `401` | Missing or invalid bearer token | | `404` | Subscriber not found | ## Operator runbook Use this runbook when a data subject submits a GDPR erasure request and you need to delete one or more subscribers manually. ### Step 1 — Identify the subscriber UUID If you have the subscriber's `external_id` (your internal user ID), look up the Hober UUID: ```bash curl -s -X GET \ "https://api.hober.io/api/v1/subscribers?external_id=" \ -H "Authorization: Bearer $Hober_API_KEY" \ | jq '.id' ``` Record the returned UUID as `$SUBSCRIBER_ID`. ### Step 2 — Trigger deletion ```bash curl -s -X DELETE \ "https://api.hober.io/api/v1/subscribers/$SUBSCRIBER_ID" \ -H "Authorization: Bearer $Hober_API_KEY" ``` A `204 No Content` response confirms deletion. If you receive `404`, the subscriber has already been deleted or the UUID is incorrect. ### Step 3 — Verify the deletion Repeat the lookup from Step 1: a `404` (or an empty result for the `external_id` query) confirms the subscriber is gone. Every deletion is also recorded in Hober's internal deletion audit trail — including when and by whom — which is retained for compliance evidence. ### Step 4 — Bulk erasure For erasure requests covering many subscribers, use the bulk endpoint — up to 1,000 IDs per call in a single GDPR-compliant transaction. Replace `ids.txt` with a file containing one Hober subscriber UUID per line. ```bash #!/usr/bin/env bash set -euo pipefail TOKEN="${HOBER_ACCESS_TOKEN:?HOBER_ACCESS_TOKEN is required}" BASE_URL="https://api.hober.io" IDS_FILE="${1:?Usage: $0 ids.txt}" BATCH_SIZE=1000 mapfile -t ALL_IDS < "$IDS_FILE" TOTAL=${#ALL_IDS[@]} DELETED=0 for ((i = 0; i < TOTAL; i += BATCH_SIZE)); do BATCH=("${ALL_IDS[@]:i:BATCH_SIZE}") PAYLOAD=$(printf '%s\n' "${BATCH[@]}" | jq -R . | jq -sc '{subscriber_ids: .}') BODY=$(curl -sf -X POST "$BASE_URL/api/v1/subscribers/bulk-delete" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "$PAYLOAD") DELETED=$((DELETED + $(echo "$BODY" | jq '.deleted'))) NOT_FOUND=$(echo "$BODY" | jq '.not_found | length') echo "Batch $((i / BATCH_SIZE + 1)): deleted so far=$DELETED, not_found=$NOT_FOUND" done echo "Done. Total deleted: $DELETED / $TOTAL" ``` IDs already deleted show up in `not_found` rather than failing the batch, so the script is safe to re-run. ### Step 5 — Document the erasure Record the following in your erasure request ticket: - Date and time of deletion (UTC) - Subscriber UUID(s) deleted - `deleted_by` user ID used - Confirmation of the verification lookup (Step 3) Retain this evidence for the 7-year audit log period required by GDPR Article 5(2). :::caution Irreversible operation Subscriber deletion is permanent. Hard-deleted rows cannot be recovered from the database. Ensure you have confirmed the correct subscriber UUID before proceeding. ::: :::info Delivery log retention Delivery log rows are anonymised (subscriber and device foreign keys set to NULL), not deleted, when a subscriber is removed. The anonymised rows are purged automatically after 90 days. This means aggregate delivery metrics are unaffected by erasure operations. ::: --- # Guides: Holdout Groups & Lift Source: https://docs-staging.hober.io/docs/guides/holdout-groups # Holdout Groups & Lift A **holdout group** is a slice of your target audience that is deliberately **not** sent a message. After the send, you compare conversion rates between the subscribers who received the message (the *treated* group) and those who were held out — the gap between the two rates is the send's **incremental lift**: what the message actually caused, rather than what would have happened anyway. Holdouts answer a different question than A/B variants. Variants compare *which message* performs better; a holdout measures *whether sending at all* made a difference. The two combine freely — see [A/B Variant Sending](./ab-testing.md). :::warning Plan availability Holdout groups are gated by the same plan feature as A/B variant sending. On plans without that feature, the API returns the same `403 PLAN_FEATURE_UNAVAILABLE` error as a variant send. ::: --- ## 1. Setting a holdout ### In the dashboard Composer 1. Open the **Composer** and fill in your message as usual. 2. Below the content card, check **Hold out a control group** — the label reminds you what it does: a percentage of the audience receives nothing, for lift measurement. 3. A **Holdout %** slider appears, defaulting to **5%**. The dashboard slider ranges from 1% to 50%; the readout below it shows the split live (for example *Held out: 10% / Receiving: 90%*). 4. Send as usual. The held-out share is applied when the job fans out. A holdout of 5–20% is typical: large enough to measure against, small enough not to cost you much reach. ### Via the API Add `holdout_percentage` to the create request. The API accepts any value from `0` (no holdout, the default) to `99`; values outside that range are rejected as a validation error. ```json POST /api/v1/notifications Authorization: Bearer Content-Type: application/json { "channel_ids": ["chan-uuid"], "target": { "type": "all" }, "content": { "title": "Flash sale", "body": "Ends tonight" }, "holdout_percentage": 10 } ``` Holdouts work on standard sends and on A/B variant sends. When combined with variants, the variant percentages still sum to 100 and split only the *treated* remainder. ### On campaigns Campaigns accept a campaign-level `holdout_percentage` on create and update. When the campaign is released, the same holdout share is stamped onto **every message** in the campaign, and the campaign detail page shows the configured **Holdout** percentage. Note that holdout membership is re-drawn per send (see below), so within a multi-message campaign a subscriber may be held out of one message and treated on another. Lift is read per message, using each message's job ID. --- ## 2. How holdout assignment works Assignment is deterministic, per **subscriber**: ``` bucket = hash(subscriber_id + job_id) % 100 bucket < holdout_percentage → held out ``` This is deliberately different from variant assignment in two ways: - **Per subscriber, not per device.** Lift is a behavioral comparison between people. A subscriber with two devices is either fully held out or fully treated — never half of each. - **Seeded with the job ID.** Membership re-randomizes on every send, so no subscriber is permanently starved of messages the way a device permanently sticks to one variant. Devices that are not linked to an identified subscriber are **always treated** — an anonymous device cannot contribute to a subscriber-level comparison. Because the hash of the same (subscriber, job) pair always produces the same result, the holdout set is stable across retries and redeliveries: re-processing a job reproduces exactly the same membership. --- ## 3. What happens to held-out subscribers - Their membership is **recorded before any delivery starts**, so the control group exists even if the send is interrupted and retried. - They receive nothing: no delivery is attempted, no delivery records are produced, and held-out subscribers consume **no send quota**. - A job whose entire (small) audience happens to land in the holdout completes successfully with zero sends — it is not a failure. --- ## 4. Reading the lift ### Prerequisite: conversion events Lift is measured in conversions, so you need at least one **conversion event** defined before the readout means anything. Configure conversion events on the **Insights** page — each definition names a tracked event (optionally with a revenue value and an attribution window). Without any definitions, the lift readout reports `conversion_configured: false` and both sides are vacuously zero. See [Server-Side Event Tracking](./server-side-event-tracking.md) for how to send the underlying events. ### In the dashboard Open **History**, click the job, and scroll to the **Holdout Lift** section (shown only for jobs that carried a holdout). It displays: - The **lift** in percentage points, with the measurement window (for example *Lift: +5.0 points over a 7-day window*). Negative lift is shown as-is — it means the held-out group converted more. - A **Received vs Held out** table with each group's subscriber count, converters, conversion rate, and attributed revenue (per currency, refund-netted). - A **Revenue lift** block when the holdout is large enough to be meaningful (at least 100 members and 5 converters): net revenue per 1,000 recipients in each arm, their difference, and an **estimated incremental revenue** for the send. The amounts are measured; the incremental figure is a model — the difference per recipient scaled to the treated group — and is labeled as an estimate wherever it appears. See the [revenue attribution semantics](/docs/guides/revenue-attribution) for the measured-vs-modeled line. Agencies: the white-label client report includes a **Measured lift** section listing the period's reliable holdout sends with their estimated incremental revenue and an approximate period total — carrying the same modeled-estimate disclosure. ### Via the API ```json GET /api/v1/insights/lift?job_id= { "job_id": "job-uuid", "treated": { "size": 900, "converters": 90, "conversion_rate": 0.10, "revenue": [{ "currency": "USD", "gross_cents": 45000, "refunded_cents": 0, "net_cents": 45000 }] }, "holdout": { "size": 100, "converters": 5, "conversion_rate": 0.05, "revenue": [{ "currency": "USD", "gross_cents": 2500, "refunded_cents": 0, "net_cents": 2500 }] }, "lift_percentage_points": 5, "window_days": 7, "conversion_configured": true, "revenue_lift": [{ "currency": "USD", "treated_net_per_1k_cents": 50000, "holdout_net_per_1k_cents": 25000, "delta_per_1k_cents": 25000, "estimated_incremental_cents": 22500 }], "revenue_lift_reliable": true, "estimated_incremental_normalized": null } ``` | Field | Description | |-------|-------------| | `treated` / `holdout` | Per-group counts: `size` (subscribers), `converters`, `conversion_rate` (converters ÷ size), `revenue` (attributed conversion revenue per currency, refund-netted). | | `lift_percentage_points` | Treated conversion rate minus holdout conversion rate, in percentage points. May be negative. | | `window_days` | The conversion window applied to both groups. | | `conversion_configured` | `false` when no conversion events are defined — the counts are meaningless until you configure one. | | `revenue_lift` | Per currency: net revenue per 1,000 members in each arm (`net × 1000 ÷ size`), their delta, and `estimated_incremental_cents` = delta × treated size ÷ 1000 — a **model**, not a measurement. | | `revenue_lift_reliable` | `false` when the holdout has fewer than 100 members or 5 converters — the per-arm facts stand, but the delta is noise and the dashboard suppresses it. | | `estimated_incremental_normalized` | The incremental estimates approximated in your reporting currency at current rates; `null` when not computable. | ### How the numbers are computed - Each **treated** subscriber is anchored at their **first delivered touch** for the job; each **held-out** subscriber is anchored at the moment of assignment. - A subscriber counts as converted when they have at least one conversion event within the window of their anchor. - The window is the **widest** attribution window across your configured conversion events, defaulting to **7 days** when none specify one. :::tip Small groups make noisy lift. A 5% holdout on a 500-subscriber audience is a control group of ~25 people — judge the rates alongside the group sizes before drawing conclusions. ::: --- # Guides: Quiet Hours, Frequency Caps & Consent Source: https://docs-staging.hober.io/docs/guides/delivery-controls # Quiet Hours, Frequency Caps & Consent Reaching your audience is only half the job — reaching them *respectfully* is the other half. Hober enforces three delivery controls on your behalf: **consent** (only message people who opted in), **frequency caps** (don't message the same person too often), and **quiet hours** (don't wake anyone up at 3 a.m.). These controls are evaluated by the platform itself, per recipient, as messages are delivered. They apply to **every send path** — one-off sends from the Composer, API sends, campaigns, journeys, recurring schedules, and sends initiated by a connected AI agent (see [Agent Access (MCP)](./agent-access-mcp.md)). There is no client that bypasses them. --- ## 1. Message categories: marketing vs. transactional Every message carries a **category**, and every category is either **marketing** or **transactional**: - **Transactional** messages (receipts, security alerts, delivery updates) always deliver. They are exempt from consent checks, suppression lists, and frequency caps. - **Marketing** messages are **opt-in**: a recipient with no recorded subscription for the channel and category is not sent the message. Because the transactional exemption is absolute, reserve transactional categories for messages the recipient genuinely needs — misusing them for promotions is the fastest way to spam complaints. --- ## 2. The delivery guardrail For each marketing message, each recipient passes through a fixed sequence of checks. The first blocking rule wins: | Order | Check | Suppress reason | |---|---|---| | 1 | Transactional category? | — (always delivered, no further checks) | | 2 | Recipient on the suppression list (hard bounce, spam complaint, unsubscribe)? | `bounced`, `spam`, or `unsubscribed` | | 3 | Recipient opted in to this channel + category? | `unsubscribed` | | 4 | Frequency cap for the category exceeded? | `frequency_capped` | | 5 | [Weekly fatigue budget](./fatigue-budgets.md) exhausted (all marketing, all channels, rolling 7 days)? | `budget_exhausted` | A recipient who fails any check is **skipped before any delivery is attempted** — nothing is queued, sent, or retried for them. Your job still completes normally; the delivered totals simply reflect only the recipients who passed the guardrail. A heavily suppressed audience is not an error, it is the controls working. Want to see how hard these rules would bite a real audience before sending? A [simulated send](./test-mode.md) evaluates every check above and reports the suppressions — without delivering anything. --- ## 3. Consent ### Opt-in policy per channel You choose how consent is established per channel under **Settings**. Each messaging channel — iOS push, Android push, web push, in-app, email, SMS, and WhatsApp — can use one of two opt-in modes: - **Implicit** — registering the device or address counts as consent. Typical for push channels, where the recipient already granted an OS-level permission prompt. - **Explicit** — only an explicitly recorded consent counts. Recipients without one are treated as unsubscribed. Typical for email, SMS, and WhatsApp. ### The suppression list Independently of opt-in state, recipients land on the suppression list when their address hard-bounces, when they file a spam complaint, or when they unsubscribe. Suppressed recipients block **before** the consent check — re-subscribing them requires a fresh, explicit action from the recipient, not from you. --- ## 4. Frequency caps A frequency cap limits how many marketing messages a single subscriber can receive in a **category** over a rolling window — for example, at most 3 messages per 24 hours. Caps are defined as a maximum delivery count plus a window length, per category: - The count is based on **actual deliveries** recorded for that subscriber in the category, not on attempts. - When a subscriber is at the cap, further sends in that category are suppressed with reason `frequency_capped` until older deliveries roll out of the window. - Transactional categories are never capped. - A workspace-level default cap can apply to marketing categories that have no explicit policy of their own. Frequency cap policies are managed per workspace and are not yet self-serve editable in the dashboard. --- ## 5. Quiet hours Quiet hours define a daily window during which scheduled messages are **deferred**, not dropped. The default window is **22:00–08:00**. ### How the window is evaluated - The window is evaluated in **each recipient's local timezone**, not in UTC and not in yours. When a recurring schedule fires, delivery fans out per timezone group, and each group's send time is checked against the window in that timezone. Recipients whose stored timezone is unknown or stale fall back to the schedule's fallback timezone. - Both overnight windows (e.g. 22:00–08:00) and intraday windows (e.g. 13:00–14:00) are supported. - A send that lands inside the window is moved to the **end of the quiet window** — for a 22:00–08:00 window, a message that would have fired at 23:30 goes out at 08:00 the next morning instead. ### Audit trail Each deferral is recorded in the job's delivery log with status `quiet_hours_deferred`, including the original scheduled time and the time it was deferred to, so you can always see why a scheduled send arrived later than configured. ### Exemptions - Schedules can set a **quiet-hours override** (`quiet_hours_override`) for messages that must go out on time, such as transactional alerts. - **Inbox-only in-app messages** are exempt: they wait silently in the user's in-app inbox rather than interrupting anyone, so there is nothing to defer. See [In-App Experiences](./in-app-experiences.md). The **Settings** page shows your workspace's **delivery timezone**, which is used for delivery scheduling and quiet hours and is separate from the display timezone used for dashboard charts. --- ## 6. What you see after a send - **Delivered / failed counts** on the notification detail page include only recipients that passed the guardrail; suppressed recipients are excluded from the attempt entirely. - **Quiet-hours deferrals** appear in the delivery log with status `quiet_hours_deferred` and the deferred-to time. - Suppression is per recipient, per message — a subscriber capped today is eligible again once their window rolls over, and a subscriber unsubscribed from one category can still receive others they are opted in to. If your delivered totals look low, check the audience's opt-in state and category before assuming a delivery problem: with an explicit opt-in policy and no recorded consents, a marketing send legitimately delivers to no one. ## Optimal send time On growth+ plans, the Composer offers **Optimal time** beside *Send now* and *Schedule for later*: each recipient receives the notification at their historically best engagement hour within a window you choose (6–48 hours, default 24). Recipients without their own engagement history follow your audience's most common engagement hour; if your workspace has no engagement history at all, they deliver at the window start. Delivery spreads across the window by design — **not for urgent sends**; use *Send now* when timing matters more than engagement. Every targeted recipient is guaranteed to deliver within the window: the job's final wave picks up anyone whose best hour changed while the send was in flight, and a recipient is never sent the same notification twice. The notification detail reports the split honestly: how many recipients delivered at their best hour vs immediately (current-hour matches and best hours outside the window deliver in the first pass), and how many delivery waves remain while the job is still running. --- # Guides: Revenue Attribution Source: https://docs-staging.hober.io/docs/guides/revenue-attribution Revenue attribution ties dollars to the campaigns and journeys that earned them. This page states the exact semantics — what counts, in which currency, how refunds affect it, and over what window — so every number in a report is defensible. ## Measured vs. modeled The two halves of "attributed revenue" have different epistemic weight, and Hober keeps them separate: - **The revenue is measured.** Amounts come from your own order data — the value property on a conversion event (for Shopify, the order's `total_price`). Hober never estimates or extrapolates an amount. - **The attribution is modeled.** Which message gets the credit is decided by a stated model: **last-touch** over a configurable window (default **7 days**), where an engagement (open or click) beats a mere delivery, and touches after the conversion are never credited. One conversion earns exactly one credit. When a Hober surface shows revenue, the amount is a fact and the assignment is a model. Both halves are inspectable: the conversion configuration is editable in **Insights → Conversion events**, and the credited source appears on every rollup. ## What counts as revenue A conversion event counts revenue when its definition names a **value property** (the event property carrying the amount, in major units — `19.99`) and, optionally, a **currency property** (an ISO 4217 code like `USD`). Amounts without a recognizable currency are recorded and reported in a separate "uncurrencied" bucket rather than guessed. For Shopify, the **Track Shopify revenue** preset configures this in one click: `order_placed` with `total_price` and `currency`, plus its refund counterpart (below). Attribution is **forward-only from connector activation** — historical orders are not backfilled, because pre-existing orders cannot be honestly attributed to messages that never targeted them. Configure **one money event per funnel**. If two definitions that can fire for the same purchase (say, a checkout event and an order event) both carry a value property, that purchase's revenue counts once per event — the dashboard warns when this is set up. ## Refunds Refunds make revenue net down, with two rules that keep reports stable: - A refund is recorded **when it happens**, as a negative amount dated at the refund, and credited to the same campaign or journey as the original order (matched by order id). A July order refunded in August reduces August's net — **July's report never changes after the fact**. - Reports show **gross, refunded, and net** per currency, so the netting is visible rather than silent. Refunds adjust revenue but are never counted as conversions. There is no time limit on netting: a refund reduces its campaign's lifetime net whenever it arrives. (Operationally, the order-id match looks back 180 days; older refunds are recorded and flagged rather than matched.) For Shopify, `refunds/create` drives netting (partial refunds net exactly what was returned). A cancelled order nets only when it was **voided** — never charged — because a cancelled *paid* order refunds through `refunds/create`, and counting both would double the netting. ## Currencies Revenue is reported **per currency, never summed across currencies**. A tenant selling in USD and PEN sees two totals, each netted independently. Amounts recorded before currency capture existed appear in the "uncurrencied (legacy)" bucket. Alongside the per-currency truth, surfaces may show one **normalized figure**: the per-currency nets converted into your **reporting currency** (Settings → Display preferences; defaults to your billing currency) at **current exchange rates**, marked with ≈. It is an approximation by definition — re-opening a report on another day may move the normalized figure slightly as rates move, while the per-currency amounts never change. The legacy bucket cannot be converted and is excluded from the normalized total. Ranking of top campaigns and journeys uses the same conversion, so a big earner in a small currency no longer outranks a bigger earner at face value. ## Where it appears - **Insights funnels** — attributed revenue per campaign or journey. - **Campaign reports** — campaign totals and, for multi-message campaigns, revenue per message. - **Holdout lift** — revenue per treated and holdout group plus the size-normalized revenue lift: net per 1,000 recipients per arm and an estimated incremental revenue (a model, labeled as such — see [Holdout Groups & Lift](/docs/guides/holdout-groups)). - **Client reports** — top campaigns and journeys by revenue in the shared white-label report. ## API Revenue fields appear as arrays of per-currency slices: ```json "revenue": [ { "currency": "USD", "gross_cents": 10000, "refunded_cents": 4000, "net_cents": 6000 } ] ``` See the [Insights API reference](/docs/api-reference/insights) for the full response shapes. --- # Guides: Agent Access (MCP) Source: https://docs-staging.hober.io/docs/guides/agent-access-mcp # Agent Access (MCP) Connect your own AI agents to the platform over the [Model Context Protocol](https://modelcontextprotocol.io). A connected agent can analyse campaign results, build segments, operate journeys and multi-message campaigns, run auto-winner experiments, rehearse sends in simulated mode, read cohort retention, check deliverability and import jobs, work the agency client portfolio (including minting white-label report links), and schedule notifications — with every write held for **your approval** before it takes effect. :::warning Plan availability MCP agent access is available on **Premium** (read & analytics tools, up to 2 agent applications) and **Enterprise** (all tools, unlimited applications, custom approval policies). ::: ## Connect an agent 1. In the dashboard, open **Agent → Applications → New agent application** (requires the **owner** or **admin** role — see [Team & Roles](/docs/guides/team-and-roles)). 2. Pick the scopes the agent needs (write scopes require Enterprise) and create the application. The MCP key is shown **once** — store it in your agent's configuration; rotate it if lost. 3. Point any MCP-compatible runner at the server: ```json { "mcpServers": { "push-notification-system": { "type": "http", "url": "https://api.hober.io/v1/mcp", "headers": { "Authorization": "Bearer hober_mcp_..." } } } } ``` The server speaks Streamable HTTP and is stateless: every request is authenticated with the key, and the agent only ever sees the tools its scopes allow. ## Human-in-the-loop approval Each application has an approval mode: | Mode | Behaviour | |---|---| | `writes_only` *(default)* | Reads run immediately; writes wait for your approval | | `strict` | Every tool call waits for approval | | `custom` | Per-tool overrides on top of the `writes_only` default | | `off` | Everything runs immediately | When a gated call arrives, the agent receives `{"status": "pending_approval", "approval_id": ...}` and the request appears in **Agent → Approval queue** (also emitted as an `agent.approval_requested` webhook). Approve to execute exactly what the agent submitted, or reject with a reason the agent reads on its next poll of `get_approval`. Pending approvals expire after the application's TTL (default 24 h) and count as rejections. Approvals are executed with idempotency keys, so approving can never double-send a campaign; every tool invocation — executed, pending, rejected, expired or failed — lands in the append-only **Audit log**. ## What agents can do - **Campaigns**: list, inspect results, check quota; schedule and cancel (gated). - **Segments**: list, preview rule sets, create (gated). `recommend_segment` suggests starting rules for re-engagement, win-back and upsell goals. - **Journeys**: inspect definitions and runs; activate and pause (gated). - **Authoring**: `draft_journey` and `draft_campaign` turn a natural-language brief ("a 3-touch win-back for lapsed VIPs") into a complete draft — the segment, step graph, timing and per-step copy pre-filled from your real segments, channels and templates. Drafts land **inert** — nothing sends — for you to review and edit in the builder, then activate or release yourself. AI generation is credit-fenced and plan-gated (gated). - **Insights**: conversion funnels, attribution summaries, campaign comparisons, send-time recommendations (deterministic, sample-size-honest). - **Guardrails**: `get_guardrail_posture` explains your quiet hours and enforcement. Agent sends pass the same consent, frequency-cap and quiet-hours checks as every other send — **no agent can bypass them**. Agents also get resources (`hober://campaigns/recent`, `hober://segments/catalog`, …) for context and prompts (`campaign_brief`, `results_report`, …) that walk them through complete workflows, approval mechanics included. ## Teach your agent Hober: the skill Connecting the MCP server gives an agent *access*; the **Hober skill** gives it *expertise* — the workflows, approval discipline, and domain vocabulary a practiced operator follows (campaign golden paths, the segment rule grammar with worked examples, in-app authoring, diagnostics). The skill lives in the repository at [`skills/hober`](https://github.com/hoberhq/hober/tree/main/skills/hober) and is versioned alongside the MCP server so it never drifts from the tool surface. **Install:** - **Claude Code**: copy the `skills/hober` folder into your project's `.claude/skills/` (or `~/.claude/skills/` for all projects). - **claude.ai / Claude desktop**: zip the `skills/hober` folder and upload it under **Settings → Capabilities → Skills**. - Any agent runtime that supports Agent Skills: point it at the folder — `SKILL.md` is the entry point. ## Webhooks and callbacks Subscribe webhook endpoints to `agent.approval_requested`, `agent.approval_expired` and `agent.tool_executed` to follow agent activity from your own systems (same HMAC signing as delivery webhooks). Optionally set a **callback URL** on the application: decisions are POSTed to it so agents don't need to poll — treat the callback as a wake-up and confirm via `get_approval`. --- # Guides: Audience Filters Source: https://docs-staging.hober.io/docs/guides/audience-filters # Audience Filters Audience filters narrow a notification's recipients by **device** and **subscriber** attributes — the tenant-defined metadata your SDKs report (OS version, locale, SDK version, custom properties). Filters are applied at the source when the audience is resolved, so a filtered send never fans out to non-matching devices. ## Where filters go `device_filters` and `subscriber_filters` are top-level siblings of `target` in the create-notification request. `device_filters` matches against each device's attributes; `subscriber_filters` matches against the owning subscriber's attributes. When both are present they are **ANDed** — a device is included only when its own attributes satisfy `device_filters` **and** its subscriber's attributes satisfy `subscriber_filters`. ```json { "target": { "type": "all" }, "device_filters": { "locale": { "$in": ["en-US", "en-GB"] } }, "subscriber_filters": { "tier": { "$eq": "premium" } } } ``` ## Operators | Operator | Meaning | |---|---| | `$eq` | equals (a bare value is an implicit `$eq`) | | `$ne` | not equal (the key must be present) | | `$gt`, `$gte`, `$lt`, `$lte` | ordered comparison | | `$in` | value is in the array | | `$nin` | value is not in the array (the key must be present) | | `$exists` | `true` requires the key; `false` requires its absence | All predicates across keys are ANDed. ### Missing values For comparison operators (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`), a device whose attribute is **absent is excluded**. Use `{"$exists": false}` to match devices that lack a key, and `{"$exists": true}` to require it. ## Scalar attributes String attributes compare by value; ordering operators compare **lexicographically**. ```json { "device_filters": { "locale": { "$ne": "fr-FR" } } } ``` ## Version attributes Versions are compared **numerically, segment by segment** — `17.10` is greater than `17.2`, which plain string comparison gets wrong. To make this unambiguous, versions are stored and queried as a **structured object**: ```json { "major": 17, "minor": 2, "patch": 1 } ``` `minor` and `patch` default to `0` when omitted. The known version attributes — **`os_version`**, **`sdk_version`**, and **`app_version`** — are parsed into this structured form automatically at ingestion, so a device that reports `"17.2.1"` is stored as `{"major":17,"minor":2,"patch":1}`. Filter on them with object operands: ```json { "device_filters": { "os_version": { "$gte": { "major": 17, "minor": 0, "patch": 0 } } } } ``` ```json { "device_filters": { "sdk_version": { "$in": [ { "major": 3, "minor": 2, "patch": 1 }, { "major": 4, "minor": 0, "patch": 0 } ] } } } ``` :::note Version-typed keys require **object** operands. A string operand on `os_version`/`sdk_version`/`app_version` (e.g. `{"$gte": "17.0"}`) is rejected with a `422 VALIDATION_ERROR`. Comparing a version operand against an attribute that is not a structured version object excludes that device. ::: ## Custom version fields You can treat your own attribute as a version. Report it in the structured `{major, minor, patch}` shape from your SDK (custom keys are **not** auto-parsed from strings — only the three known keys are), then filter on it the same way: ```json { "device_filters": { "firmware_version": { "$gte": { "major": 2, "minor": 5, "patch": 0 } } } } ``` :::caution Indexing Equality, set, and existence predicates (`$eq`/`$in`/`$exists`) are index-accelerated for **any** key. Version **range** predicates (`$gt`/`$gte`/`$lt`/`$lte`) are index-accelerated only for the three known keys (`os_version`, `sdk_version`, `app_version`), which have dedicated expression indexes. Range filters on a custom version key still return correct results but scan more rows; add a matching expression index if you rely on them at scale. ::: ## Complete example Send to premium subscribers on iOS 17+ in English locales: ```json { "target": { "type": "all" }, "channel_ids": ["chan-ios"], "content": { "title": "New for you", "body": "Tap to explore" }, "device_filters": { "locale": { "$in": ["en-US", "en-GB"] }, "os_version": { "$gte": { "major": 17, "minor": 0, "patch": 0 } } }, "subscriber_filters": { "tier": { "$eq": "premium" } } } ``` ## Predictive scores (growth+) Hober computes two predictive attributes nightly for every subscriber with behavioral events in the last 90 days, and they filter like any other attribute in the segment builder: - **`hober_engagement_score`** (0–100): 30-day event frequency (capped at 20 events → 0–45 points), plus recency (30 − 1.5 × days since the last event → 0–30 points), plus 30-day notification taps (capped at 10 taps → 0–25 points). Tapping your notifications is the strongest engagement signal you have, so it weighs in directly. - **`hober_churn_risk`** (`low` | `medium` | `high`): last event within 7 days → low; within 30 days → medium; older → high. These are **published heuristics, not machine-learning claims** — the formula above is the whole model, chosen to be auditable and explainable. Subscribers with no recorded events carry neither key: an absent score is honest no-data, not a zero. Scores refresh nightly around 03:00 UTC. Example: a re-engagement segment might filter `hober_churn_risk is high` and pair it with a win-back journey; a VIP segment might filter `hober_engagement_score greater than 80`. --- # Guides: Cohort Analysis Source: https://docs-staging.hober.io/docs/guides/cohort-analysis # Cohort Analysis Cohorts group subscribers by a shared starting point — the period they were **acquired**, or the period they **first did something** — and track how each group keeps engaging, converting, and spending over the following periods. The output is the classic **retention triangle** on **Insights → Cohorts**: rows are cohorts (newest on top), columns are period offsets (P0 = the joining period), and each cell is the share of that cohort active in that period. ## Reading the triangle - **The denominator is fixed at acquisition**: a cohort's size is the number of subscribers who joined in that period, and every cell divides by it. Percentages can only tell you about the original group — that's what makes cohort curves comparable. - **Blank cells have not elapsed yet** — that's the triangle's edge, not missing data. A `0%` cell is an honest zero: the period passed and nobody in the cohort did the thing. - **Buckets use your workspace timezone** (the delivery timezone in Settings), truncated to the grain — daily, weekly (ISO weeks, starting Monday), or monthly. ## Cohort kinds - **Acquisition date** — subscribers bucketed by when they were created in Hober. Works for every tenant with no integration. - **First event** — subscribers bucketed by the first time they performed a tracked event you name (e.g. `signup`, `purchase`). Requires [server-side or SDK event tracking](server-side-event-tracking.md). ## Metrics | Metric | A subscriber counts in a period when they… | Requires | |---|---|---| | **Engagement** (default) | opened or tapped any notification | nothing extra — every sending tenant has this signal | | **Any tracked event** | performed any behavioral event | `track()` integration | | **Conversion** | performed a configured conversion event | [conversion tracking](revenue-attribution.md) | | **Revenue** | — cells show the cohort's summed conversion value instead of a percentage | conversion tracking with values | Engagement is the default because it exists for every tenant that sends through Hober; an events-based default would show empty grids for tenants without event tracking. ## Ad-hoc grids vs saved cohorts The builder computes **ad-hoc grids live**, looking back up to **90 days** of raw activity — enough to validate a cohort definition at daily or weekly grain. **Saving a cohort** removes that cap. Saved cohorts get a nightly rollup (around 02:00 UTC): membership is **frozen** the first time a subscriber qualifies, and each period's cells are written once, after the period closes, while the raw activity still exists. Written cells are final — so a saved cohort's grid keeps growing month after month, long past the raw-event retention windows, up to the **24-month history promise**. The current, still-open period is computed live and may still move until it closes. Practical consequence: **save cohorts you care about early.** The rollup can only capture a period while its raw activity exists — history before a cohort was saved (beyond the 90-day window) cannot be reconstructed. ## Retention promises - Saved-cohort grid history: **24 months** of cohort buckets. - Conversion and touch records: 24 months (aligned with the grid promise). - Raw behavioral events: 90 days; notification interactions: 12 months — unchanged, and the reason the rollup exists. ## Worked example A weekly acquisition cohort with the engagement metric answers: *"Of the subscribers we acquired the week of July 6th, what share still opened or tapped a notification 1, 2, 3… weeks later?"* Comparing the rows tells you whether your onboarding changes are improving early retention — if the August rows hold their P1–P4 percentages better than the June rows did, they are. --- # Guides: Send-Time Insights Source: https://docs-staging.hober.io/docs/guides/send-time-optimization # Send-Time Insights When you send matters almost as much as what you send. Hober builds a **best-time-to-send heatmap** from your own audience's engagement history and surfaces it in three places: the Insights page, a one-click suggestion in the Composer's schedule picker, and a recommendation tool for connected AI agents. Send-time insights are **advisory**: the platform shows you when your audience engages and suggests a time, but never silently moves your sends. You stay in control of the schedule. --- ## 1. The heatmap Open **Insights** in the dashboard and scroll to the **Best time to send** section: *"Open rate by send hour and weekday — spot when your audience engages."* - The grid covers all **7 weekdays × 24 hours**, in **UTC**, over the **last 90 days** of engagement. - Cell intensity encodes the **open rate** for that weekday-hour bucket: opened ÷ delivered. Darker cells are stronger windows; the legend runs from *Lower open rate* to *Higher*. - Hovering a cell shows the exact numbers, e.g. `Tue 09:00 · 42% open · 210/500`. - Before you have engagement history the section shows: *"No engagement data yet — the heatmap fills in as sends are opened."* The heatmap is computed from your delivered and opened engagement events. Buckets are keyed by when the delivery occurred, in UTC — there is no per-recipient timezone normalization in the chart itself (recipient timezones are handled at delivery time; see [Quiet Hours, Frequency Caps & Consent](./delivery-controls.md)). --- ## 2. Reading it via the API ``` GET /api/v1/insights/heatmap?days=90 Authorization: Bearer ``` - `days` — optional trailing window in days. Defaults to `90`, capped at `365`. ```json { "window_days": 90, "cells": [ { "weekday": 2, "hour": 9, "delivered": 500, "opened": 210 }, { "weekday": 4, "hour": 18, "delivered": 320, "opened": 96 } ] } ``` | Field | Description | |-------|-------------| | `window_days` | The trailing window the counts cover. | | `cells[].weekday` | `0` = Sunday through `6` = Saturday, in UTC. | | `cells[].hour` | Hour of day, `0`–`23`, in UTC. | | `cells[].delivered` | Deliveries that occurred in this bucket. | | `cells[].opened` | Opens recorded in this bucket. | Buckets with no activity are omitted from `cells` — treat missing weekday-hour pairs as zero. --- ## 3. Suggested send time in the Composer When you schedule a notification for later, the schedule picker checks your heatmap and — once your audience has enough history — surfaces a suggestion: > Your audience engages most around **Tue, 09:00 (your selected timezone)** Click **Use this time** to adopt it, or ignore it and pick your own time. The suggestion is the next upcoming occurrence of your strongest weekday-hour window, converted from UTC into the timezone you selected in the picker. Two honesty rules apply: - Suggestions appear only once your heatmap has at least **30 total opens**; below that, the picker shows *"Send-time suggestions appear once your audience has more engagement history."* - The suggestion is never applied automatically — scheduling always requires your explicit choice. --- ## 4. Recommendations for AI agents Connected agents get the same signal through the read-only MCP tool **`recommend_send_time`** (see [Agent Access (MCP)](./agent-access-mcp.md)). The tool returns: - The top recommended send hours (UTC), ranked by your audience's opens - The strongest weekday-hour windows with their open counts - A `sample_size` and a plain-language `note` describing how trustworthy the recommendation is When engagement history is too thin for the heatmap (fewer than 30 opens), the tool falls back to a delivery-rate heuristic over your recent campaigns and says so explicitly in the note — agents (and you) should treat that as weak evidence, not a pattern. --- ## 5. What send-time insights do not do - **No automatic send-time optimization.** There is no toggle that lets the platform pick or shift a job's send time on its own. - **No per-recipient send times.** A job goes out at one scheduled time (fanned out per timezone group for recurring schedules); individual recipients are not each given their own optimized moment. - **Not a guarantee.** The heatmap reflects when past sends were opened, which is partly a reflection of when you sent. Vary your send times occasionally so the data covers more of the grid. Delivery controls still apply on top of any time you pick: quiet hours can defer a scheduled send per recipient timezone, and consent and frequency caps are enforced at delivery. See [Quiet Hours, Frequency Caps & Consent](./delivery-controls.md). --- # Guides: Smart Channel Source: https://docs-staging.hober.io/docs/guides/smart-channel # Smart Channel When a send targets several channels — push, email, SMS — every reachable subscriber normally gets it on **all** of them. Smart channel flips that: each subscriber gets the message on **their single best channel**, and nothing else. You send less, land better, and never annoy someone on three surfaces at once. Smart channel is available on **Growth plans and above**, for campaigns and one-off sends. (Journey send steps keep explicit channels for now.) --- ## The rule, in full Smart channel is a published heuristic — no black box, no machine learning: 1. **Reachability first.** A subscriber is only ever considered for channels they can actually receive: an active device, a verified email address, a phone number — and consent for that channel. Consent and quiet-hours rules apply exactly as they do for explicit sends. 2. **90-day engagement ranking.** Channels are ranked by engagement rate — opens and taps divided by deliveries — over the trailing 90 days, per channel class (push, email, web push, SMS, WhatsApp). 3. **Personal history counts from 5 deliveries.** If a subscriber has received at least 5 deliveries on a channel class, their **own** engagement rate on that class decides. Below that, your audience-wide ranking decides — 4 data points are noise, not signal. 4. **Ties break deterministically** — higher delivery volume first, then a fixed channel order (push, email, web, SMS, WhatsApp). The same subscriber always resolves the same way on the same data. 5. **Fallback only on permanent failure.** If the chosen channel *hard-fails* — a dead push token, a bounced email, a provider rejection — the next-ranked reachable channel is tried. A message that merely wasn't opened is **never** re-sent on another channel, and a delivery that might have succeeded blocks any fallback. One message per subscriber, full stop. Rankings are recomputed nightly from your delivery and engagement history. ## Turning it on In the Composer, select the channels the send may use, then enable **Smart channel** beneath the channel picker. The selected channels define the *eligible set* — arbitration picks the best one per subscriber from among them. Via the API, add one field to the create call: ``` POST /api/v1/notifications ``` ```json { "channel_ids": ["ch-push", "ch-email"], "target": { "type": "all" }, "content": { "title": "Hello", "body": "…" }, "channel_arbitration_mode": "smart" } ``` `channel_arbitration_mode` accepts `explicit` (the default — today's behavior) or `smart`. On plans below Growth, `smart` returns `403` with `feature_not_available`. ## Reading the results Every smart send gets an honest per-job readout on its detail page (History → the send → **Smart channel**), and on `GET /api/v1/notifications/{id}` as `channel_arbitration`: ```json { "channel_arbitration": { "chosen": [ { "class": "push", "source": "personal", "subscribers": 1240 }, { "class": "email", "source": "tenant", "subscribers": 310 } ], "suppressed_devices": 1490, "rank_errors": 0, "fallback_steps": 3, "fallback_dedup_errors": 0 } } ``` | Field | Meaning | |-------|---------| | `chosen[].class` | The channel class that won for this bucket of subscribers. | | `chosen[].source` | What decided it: `personal` (the subscriber's own history), `tenant` (your audience-wide ranking), or `default` (no data — fixed order). | | `suppressed_devices` | Deliveries that would have happened on other channels and were skipped — the "sent less" number. | | `fallback_steps` | Deliveries retried on the next channel after a permanent failure. | | `rank_errors` | Subscribers delivered on all their channels because the ranking was momentarily unavailable (arbitration fails open, never blocks a send). | The readout is aggregate-only by design — no per-recipient decision log exists. ## What smart channel deliberately does not do - **No re-sends on silence.** Not opening a message never triggers another channel. - **No journeys yet.** Journey send steps keep their explicit channel configuration. - **No machine learning.** The ranking rule above is the whole algorithm; it can be audited from this page. - **No consent bypass.** Arbitration narrows the channels consent already allows — it never widens them. > **Rehearse it first**: a [simulated send](./test-mode.md) runs arbitration for real and shows the full decision readout — chosen classes, suppressed devices, fallbacks — without delivering anything. --- # Guides: Digest Batching Source: https://docs-staging.hober.io/docs/guides/digest-batching # Digest Batching Automated journeys are great at reacting to everything — and that's exactly the problem. A busy subscriber can trip three journey steps in an afternoon and get three separate pings. Digest batching lets a journey step say: *this update matters, but it doesn't need to interrupt anyone.* Digest messages queue quietly per subscriber and arrive as **one bundled notification**. Digest batching is available on **Growth plans and above**, on journey Send steps. Campaign and one-off sends always deliver immediately. --- ## The rule, in full Like [Smart Channel](./smart-channel.md), digest batching is a published heuristic — no black box: 1. **Per-step opt-in.** Each journey Send step chooses its delivery priority: `immediate` (the default — today's behavior) or `digest`. Nothing is digested unless the step's author said so. 2. **Transactional never digests.** Order confirmations, password resets, receipts — anything transactional always delivers immediately, enforced at authoring *and* again at delivery. 3. **A digest message queues per subscriber** instead of delivering, holding the step's title, body, and channels as a snapshot. 4. **The queue flushes as one bundled notification** when the first of three things happens: - **3 updates are waiting** — enough to be worth an interruption; - **the subscriber's best send hour arrives** (from their own 90-day engagement history) with anything waiting; - **the oldest update has waited 24 hours** — nothing waits longer than a day, ever. 5. **The flush is a real send.** It goes through the same delivery pipeline as everything else — sending quota, consent, and quiet hours all apply. It delivers on the union of the queued items' channels. 6. **One update arrives as itself.** A digest of one keeps its original title and body — no "1 update" framing. Bundles of two or more read "*N updates*" with one line per item. ## Turning it on In the journey builder, open a Send step and set **Delivery priority** to *Digest — bundle quietly*. Via the API, set the step's `priority` field: ```json { "id": "s2", "type": "send", "send": { "channel_ids": ["ch-push"], "template_id": "…", "priority": "digest" } } ``` `priority` accepts `immediate` (default) or `digest`. Saving a journey with a digest step on plans below Growth returns `403` with `{"error": "feature_not_available", "feature": "digest_batching"}`. ## Reading the results The journey canvas shows a digest readout when the journey has queued anything: **queued** (waiting for a bundle) and **delivered bundled** counts. The same numbers ride `GET /api/v1/journeys/{id}/stats` as an optional `digest` object: ```json { "digest": { "pending": 12, "flushed": 340 } } ``` ## What digest batching deliberately does not do - **No lost messages.** If queueing fails for any reason, the message delivers immediately instead — batching fails open, always. - **No transactional batching**, regardless of the flag. - **No configurable K.** The 3-update threshold and 24-hour cap are fixed and published; if real demand for tuning shows up, it becomes a setting later. - **No re-personalization at flush.** The bundle renders from the enqueue-time snapshots. --- # Guides: Team & Roles Source: https://docs-staging.hober.io/docs/guides/team-and-roles # Team & Roles A Hober workspace is operated by a team: each member has a **persona role** matching how teams actually divide the work. Manage everything under **Settings → Team**. ## The five roles | Capability | Owner | Admin | Developer | Marketer | Analyst | |---|---|---|---|---|---| | Read everything (campaigns, segments, analytics, settings) | ✅ | ✅ | ✅ | ✅ | ✅ | | Compose, schedule, and cancel campaigns; segments, journeys, templates | ✅ | ✅ | — | ✅ | — | | API keys, channels, webhooks, integrations | ✅ | ✅ | ✅ | — | — | | Team management (invite, change roles, remove) | ✅ | ✅ | — | — | — | | Billing & plan (view) | ✅ | ✅ | — | — | — | | Billing & plan (change plan, payment methods) | ✅ | — | — | — | — | | Agent applications (MCP) & agency operate-as | ✅ | ✅ | — | — | — | | Transfer ownership | ✅ | — | — | — | — | - **Developer** is for the people integrating SDKs and backends — full credential and channel access without the ability to message your real audience. - **Developer** sees subscriber records with PII redacted: email local parts are masked (`a•••@example.com`) and attribute values replaced with `•••` — external IDs and attribute keys stay visible for debugging registrations. - **Marketer** runs the messaging — without access to credentials, billing, or the team. - **Analyst** sessions are read-only end to end: every write is rejected, not just hidden. - There is exactly **one owner** — the person who registered the workspace. Ownership moves only via **Transfer ownership** (Settings → Team, on an admin's row). ## Inviting teammates 1. **Settings → Team → Invite a member**, pick the role. 2. Copy the generated link — it is shown **once**, is single-use, has the role baked in, and expires after **7 days**. 3. Send it however you like; the recipient creates their account at the link and lands directly in your workspace with the assigned role. Pending invitations are listed with a revoke button. Anyone with the owner or admin role can invite; nobody can be invited as owner. ## Changing roles and removing members Role changes and removals take effect **immediately** — the member's active sessions are revoked and their next sign-in (or token refresh) carries the new role. You cannot change your own role or remove yourself; ask another admin. ## Single sign-on Workspaces on SAML (Enterprise) can map an IdP attribute to the role. Values are normalized onto the five personas; anything unrecognized becomes **analyst** (least privilege), so a misconfigured IdP can never over-grant. --- # Guides: Webhooks Source: https://docs-staging.hober.io/docs/guides/webhooks # Webhooks Webhooks push notification events to your own systems as they happen: Hober sends an HTTPS `POST` to your endpoint the moment a subscriber is created, a send is dispatched, a campaign finishes, or a recipient interacts with a message. This is the same REST-hook surface that powers the [Zapier integration](../integrations/zapier.md) — anything Zapier can react to, your own automation can too. :::warning Plan availability Webhooks are a plan feature. On plans without it, webhook endpoints cannot be created and the API responds `403` with `"error": "feature_not_available"`. Each workspace can have up to **10** webhook endpoints. ::: --- ## 1. Creating an endpoint ### In the dashboard Open **Settings → Webhooks** (*"Receive HTTP callbacks when notification events occur."*): 1. Click **Add webhook**, enter your **Endpoint URL** (must be HTTPS), and tick the event types you want. 2. On save, the endpoint's **signing secret** (`whsec_…`) is shown **once** — store it now; it cannot be retrieved later. 3. Each endpoint row shows its subscribed events and offers **Test** (fires a test delivery to your URL) and **Delete**. A delivery log panel below shows recent webhook deliveries so you can debug your receiver. ### Via the API The REST-hook API authenticates with a **server key** (`hober_srv_…`, from **Settings → API Keys → Server keys**): ```json POST /v1/hooks Authorization: Bearer hober_srv_... Content-Type: application/json { "target_url": "https://example.com/webhooks", "events": ["subscriber.created", "notification.terminal"] } ``` A `201` response returns the endpoint — including the signing secret, this one time only: ```json { "id": "hook-uuid", "target_url": "https://example.com/webhooks", "events": ["subscriber.created", "notification.terminal"], "secret": "whsec_..." } ``` Related endpoints: | Method & path | Purpose | |---|---| | `GET /v1/hooks` | List your endpoints (secrets omitted). | | `DELETE /v1/hooks/:id` | Remove an endpoint. | | `GET /v1/hooks/samples/:event` | Returns a one-element array of sample payload data for an event type — handy when building an integration before real traffic exists. | The target URL must be a valid **HTTPS** URL, and every requested event type must be one of the known types below — anything else is rejected with a `422` validation error. --- ## 2. Event types | Event type | Fires when | |---|---| | `subscriber.created` | A net-new subscriber is created (updates to existing subscribers do not re-fire). | | `notification.dispatched` | A notification job is dispatched for sending. | | `notification.terminal` | A job reaches a terminal state (completed or failed) — final delivery counts included. | | `notification.interaction` | A recipient interacts with a message: tap, open, click, dismiss, and so on. | `notification.interaction` can be high-volume — one event per recipient interaction. Subscribe an endpoint to it deliberately. --- ## 3. Payloads Every delivery is a JSON `POST` with a common envelope; the `data` field carries the event-specific payload: ```json { "id": "delivery-uuid", "event_type": "notification.terminal", "tenant_id": "tenant-uuid", "timestamp": "2026-07-27T12:00:00Z", "data": { } } ``` ### `subscriber.created` ```json { "subscriber_id": "0f8e7d6c-5b4a-4392-8171-6e5d4c3b2a19", "external_id": "user-1042", "email": "ana@example.com", "attributes": { "plan": "growth", "first_name": "Ana" }, "created": true } ``` ### `notification.dispatched` ```json { "job_id": "job-uuid", "target_size_estimate": 5000, "scheduled_at": "2026-07-27T12:00:00Z" } ``` ### `notification.terminal` ```json { "job_id": "job-uuid", "status": "completed", "delivered": 1180, "failed": 20, "total": 1200 } ``` `status` is `"completed"` or `"failed"`. ### `notification.interaction` ```json { "job_id": "job-uuid", "interaction_type": "tapped", "platform": "ios", "variant_id": "variant-uuid" } ``` - `interaction_type` is one of: `foreground_received`, `tapped`, `action_tapped`, `dismissed`, `email_opened`, `email_clicked`, `inapp_impression`, `inapp_click`, `inapp_dismiss`. - `platform` is one of: `ios`, `android`, `web`, `email`, `server`. - `variant_id` is present only for [A/B variant sends](./ab-testing.md). --- ## 4. Delivery, retries, and headers Each delivery `POST`s the JSON body with these headers: | Header | Value | |---|---| | `Content-Type` | `application/json` | | `X-Hober-Event` | The event type, e.g. `notification.terminal`. | | `X-Hober-Delivery` | A unique delivery ID (UUID) — use it for idempotency if you receive a retry. | | `X-Hober-Signature` | `sha256=` followed by a hex HMAC of the body (see below). | | `X-Signature-256` | Legacy alias carrying the same value as `X-Hober-Signature`. | Delivery semantics: - A response with status **200–299** counts as delivered. Respond quickly (do heavy work asynchronously) — each attempt has a **10-second** timeout. - On a **5xx** response or a network error, delivery is retried up to **3 more times** with backoff of 1s, 2s, then 4s (4 attempts total). - A **4xx** response is treated as a permanent failure for that delivery and is **not retried**. - Every attempt is recorded; the dashboard's webhook delivery log shows the payload, attempt count, last status code, and outcome per delivery, and each endpoint tracks its consecutive-failure count. --- ## 5. Verifying signatures Always verify `X-Hober-Signature` before trusting a payload. The signature is an **HMAC-SHA256** over the raw request body. The HMAC key is not the secret itself but the **hex-encoded SHA-256 digest of your `whsec_…` secret string**: ``` key = hex( SHA-256( "whsec_..." ) ) // your stored secret, hashed once expected = "sha256=" + hex( HMAC-SHA256(key, raw_body) ) ``` For example, in Go: ```go sum := sha256.Sum256([]byte(secret)) // secret = "whsec_..." key := hex.EncodeToString(sum[:]) mac := hmac.New(sha256.New, []byte(key)) mac.Write(body) // raw request body bytes expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) valid := hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Hober-Signature"))) ``` Compare with a constant-time comparison, and reject the request if the signature does not match. --- ## 6. Notes - **Zapier**: the [Zapier app](../integrations/zapier.md)'s instant triggers (New Subscriber, Campaign Finished) are built on exactly these hooks. - **Slack summaries** are a separate feature: the [Slack integration](../integrations/slack.md) posts formatted campaign summaries to a Slack channel and is configured independently — it is not part of the webhook surface described here. - **Sending data in**: webhooks are outbound. To push events *into* Hober (for segments, journey triggers, and personalization), use the [Events API](../api-reference/events.md) with the same server key — see [Server-Side Event Tracking](./server-side-event-tracking.md). --- # Guides: Email Channel Setup Source: https://docs-staging.hober.io/docs/guides/email-setup # Email Channel Setup :::warning Growth plan required The `email` channel type is available on the **Growth** plan and above. Creating an email channel on a plan that does not include it returns `403` with `"upgrade_required": true` and an `allowed` list of the channel types your plan permits. To upgrade, visit your [billing settings](https://app.hober.io/billing). ::: Email in Hober is a first-class channel alongside push and in-app. Hober delivers email through **SendGrid**, authenticated as **your sending domain** — SPF, DKIM, and DMARC belong to you, not to a shared Hober identity. That means inbox placement depends on DNS records you control, and this guide walks through setting them up and verifying them from the dashboard. This guide covers creating an email channel, authenticating your sending domain, verifying the DNS records from the per-channel deliverability panel, and the compliance footers Hober adds to outbound email automatically. --- ## 1. How email sending works Responsibility splits cleanly between you and the platform: - **You** own domain authentication and list quality — who you import and how you got their consent. - **Hober** owns everything after the send button: suppression enforcement, consent checks, bounce and complaint processing, one-click `List-Unsubscribe` headers, and the feedback loops into your dashboard. Bounces, spam complaints, and unsubscribes reported back by SendGrid are recorded in a per-tenant suppression ledger and enforced on every subsequent send — see the [Deliverability guide](./deliverability.md) for how that ledger works and how to monitor it. :::note SMS and WhatsApp have their own guide: [SMS & WhatsApp Setup](./sms-whatsapp-setup.md). ::: --- ## 2. Create the email channel ### Dashboard 1. Log in to the [Hober Dashboard](https://app.hober.io) and open **Channels** from the main navigation. 2. Start the new-channel wizard and choose **Email (SendGrid)** as the channel type. 3. Give the channel a display name (unique per workspace, up to 128 characters). 4. In the credentials step, paste your **SendGrid API key** and the **verified sender email** (the From address you verified in SendGrid). The key is envelope-encrypted on upload and — like every provider credential — write-only: no API or screen returns it, so keep your own record of which key is live. The channel activates on upload. ### API ```json POST /api/v1/channels Authorization: Bearer Content-Type: application/json { "type": "email", "name": "Marketing Email" } ``` A successful response returns `201 Created` with the channel object: ```json { "id": "6f1d2a34-9c1b-4e8a-b0d2-1a2b3c4d5e6f", "type": "email", "name": "Marketing Email", "active": true, "status": "pending", "created_at": "2026-07-27T12:00:00Z" } ``` Then upload the credentials: ```json POST /api/v1/channels/{id}/credentials/sendgrid Authorization: Bearer Content-Type: application/json { "api_key": "SG.your-sendgrid-key", "sender_email": "hello@yourdomain.com" } ``` Both fields are required (`422` names the missing one). A `200` returns the credential metadata (never the key itself) and the channel becomes active. See the [Channels API reference](../api-reference/channels.md) for the full channel object, list/update/delete endpoints, and error responses. --- ## 3. Authenticate your sending domain Email providers decide inbox-vs-spam largely on whether your domain proves it authorized the mail. Domain authentication is configured in your **SendGrid account** (SendGrid calls it "Domain Authentication"); SendGrid then gives you a small set of DNS records to publish at your DNS host. What each record does: | Record | Type | Purpose | |---|---|---| | **DKIM 1** and **DKIM 2** | CNAME | Point to SendGrid-hosted DKIM signing keys. DKIM adds a cryptographic signature to every message that receiving servers verify against your domain — proof the mail wasn't altered and really came from you. Two records allow keys to be rotated without downtime. | | **Mail CNAME** | CNAME | Delegates a mail subdomain (the return-path) to SendGrid so bounces route back correctly and SPF aligns with your domain. | | **SPF** | CNAME/TXT | Declares SendGrid's servers as authorized senders for your domain. Receiving servers check the connecting IP against this record. | **DMARC** is a policy record you publish yourself (a TXT record at `_dmarc.yourdomain.com`). It tells receivers what to do with mail that fails SPF/DKIM alignment and where to send aggregate reports. Start with a monitoring policy (`p=none`) and tighten once your reports look clean. Hober's own transactional domains run the same SPF/DKIM/DMARC setup we ask of you. --- ## 4. Verify authentication from the dashboard Each email channel has a **Sender domain deliverability** panel on its channel detail page ("Verify DKIM and SPF records to ensure emails land in the inbox"). It checks your domain authentication status directly against SendGrid and shows the exact DNS records to verify: 1. Open **Channels**, then click your email channel to open its detail page. 2. In the **Sender domain deliverability** panel, paste your **SendGrid API Key** (the `SG.xxxx...` key from your SendGrid account) and click **Check deliverability**. 3. The panel lists every authenticated domain on the SendGrid account with each DNS record — **DKIM 1**, **DKIM 2**, **Mail CNAME**, and **SPF** — showing the record type, host, and value to publish, and a per-record **Verified** / **Pending** status. 4. Add any **Pending** records at your DNS host, wait for DNS propagation, then click **Re-check**. Use **Change API key** if you rotate the key. The check runs on demand — it is not a background job, so re-check after any DNS change. An invalid key returns `401` with the message `Invalid SendGrid API key — check your credentials`. The domain counts as verified only when **all** of its records validate. :::tip Authenticate before anything else. Sending unauthenticated mail is the fastest way to teach providers to distrust the domain — see [warming up a new domain](./deliverability.md#4-warming-up-a-new-sending-domain). ::: --- ## 5. Built-in compliance footers Mailbox providers now expect standards-compliant unsubscribe handling from senders. Hober adds this to outbound email automatically — you do not need to template it yourself: - **One-click unsubscribe headers.** Every email carries a `List-Unsubscribe` header plus `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (the RFC 8058 mechanism), so Gmail, Yahoo, and other providers can render their native "Unsubscribe" button. One-click unsubscribes are recorded in your suppression ledger the moment they arrive. - **A preference footer.** HTML bodies get a footer with **Manage your email preferences** and **Unsubscribe** links; plain-text bodies get the same two links in text form. Preference links open the hosted preference center, where subscribers manage per-category consent without logging in — the links are individually signed and expire. The footer is appended to email bodies that Hober renders. If you send through a SendGrid dynamic template, Hober does not inject the footer into the template — include your own unsubscribe and preference links in the template design. Unsubscribes and preference changes feed the same suppression ledger as bounces and complaints, and suppression is enforced at send time. See [Deliverability](./deliverability.md) for the full picture, and [Message Personalization](./message-personalization.md) for personalizing the email content itself. --- ## 6. Event webhook — bounces, spam reports, and unsubscribes Hober learns about bounces, spam reports, and list-unsubscribe events through SendGrid's **Event Webhook**. Without it, those signals never reach your suppression ledger — configure it before sending at scale. 1. Open your email channel in the dashboard (**Platform → Channels → your email channel**). The **Event webhook** panel shows your account's URLs — they embed your workspace and channel IDs, so copy them from there rather than constructing them by hand: - Event webhook: `https:///v1/webhooks/sendgrid//events?channel=` - One-click unsubscribe: `https:///v1/webhooks/sendgrid//unsubscribe` 2. In SendGrid, go to **Settings → Mail Settings → Event Webhook**, paste the event webhook URL, and enable at least **Bounced**, **Spam Reports**, and **Unsubscribes**. 3. Enable **Signed Event Webhook**. SendGrid then shows a **Verification Key** — copy it back into the **Signed webhook verification key** field on the same dashboard panel and save. Hober verifies every delivery's ECDSA signature against this key; until it is set, event deliveries are rejected (the endpoint never accepts unauthenticated events). Processed events land in the same suppression ledger described above and are enforced at send time. --- # Guides: Fatigue Budgets Source: https://docs-staging.hober.io/docs/guides/fatigue-budgets # Fatigue Budgets Every send decision in Hober optimizes *one message*. The fatigue budget is the backstop that watches the *sum*: a hard weekly ceiling on how many marketing messages any one subscriber receives — across push, email, SMS, and WhatsApp combined. Campaigns, journeys, and digests all count against the same line. Fatigue budgets are available on **every plan**. Protecting subscribers is not an upgrade feature. --- ## The rule, in full 1. **One number**: the most marketing messages a subscriber may receive in a **rolling 7-day window**, across all channels and all send types. Range 0–100; **0 means off, and off is the default** — Hober never starts dropping your messages without you turning the budget on. 2. **Transactional never counts and is never blocked.** Order confirmations, receipts, password resets always deliver, budget or no budget. 3. **Enforced at the same gate as consent.** The budget check runs inside the per-recipient delivery decision, *after* suppressions, opt-in consent, and per-category frequency caps — so a message held back by the budget is always a message that would otherwise have delivered, and the suppression reason (`budget_exhausted`) always means exactly that. 4. **Fail open.** If the budget can't be evaluated (an outage, a meter error), the message delivers. A protection feature must never become an availability risk. ## Turning it on **Settings → Weekly fatigue budget.** Set the number, save. Via the API: ``` PUT /api/v1/settings/fatigue-budget { "weekly_budget": 10 } ``` `GET` on the same path returns the setting together with its effect: ```json { "weekly_budget": 10, "weekly_suppressed": 47 } ``` `weekly_suppressed` is the trailing-7-day count of messages held back by the budget — the knob and its consequence on one endpoint, and side by side on the Settings page ("47 held back this week"). ## Reading the results Messages held back by the budget are counted like holdouts — honestly, in aggregate, never as per-recipient logs: - **Per send**: the send's detail page (History) shows "N held back by the weekly fatigue budget" when it happened. - **Per week**: the Settings page total above. ## How it composes | Control | Scope | Question it answers | |---|---|---| | Quiet hours | time of day | *When* is it okay to interrupt? | | Frequency caps | per category, per window | How often for *this kind* of message? | | [Digest batching](./digest-batching.md) | journey steps | Does this need to interrupt *at all*? | | **Fatigue budget** | everything, weekly | How much is *too much, in total*? | They stack: a message must pass all of them. Digests help you stay under the budget (a bundle is one message); the budget catches whatever still slips through. ## What fatigue budgets deliberately do not do - **No per-channel budgets** in v1 — the whole point is the cross-channel total. - **No ML pacing** — one published number, enforced literally. - **No transactional impact**, ever. - **No retroactive queueing** — a held-back message is suppressed and reported, not delayed. If it mattered enough to guarantee delivery, it should be transactional or inside the budget. --- # Guides: Deliverability Source: https://docs-staging.hober.io/docs/guides/deliverability # Deliverability Deliverability is whether your messages actually reach inboxes and devices — and it is earned, not configured once. Hober gives you the enforcement machinery (suppression, consent checks, compliant unsubscribes) and a dashboard to watch your sending health; this guide is the practical playbook for using both. It covers the deliverability dashboard and its API, domain authentication, warming up a new sending domain, list hygiene for email and push, and how suppression works. If you have not set up your email channel yet, start with [Email Channel Setup](./email-setup.md). --- ## 1. The deliverability dashboard Open **Deliverability** in the dashboard. It reports on a rolling window — **7**, **30**, or **90 days** (default 30) — and shows: **Suppression health cards** — tenant-wide rates over the window: | Card | Meaning | Warning threshold | |---|---|---| | **Bounce rate** | Bounce suppression events divided by total sent | Turns red above **2%** | | **Complaint rate** | Spam-report suppression events divided by total sent | Turns red above **0.1%** | | **Unsubscribe rate** | Unsubscribe events divided by total sent | None — informational | The thresholds are deliberately conservative: they are the levels at which mailbox providers start distrusting a sender, and they are the same numbers we watch. **By channel** — a per-channel table with **Sent**, **Delivered**, **Delivery rate**, **Failed**, and **Invalid token** counts. Comparing channels here is the quickest way to localize a problem: a delivery-rate dip on one push channel with rising invalid tokens is stale devices, not a content problem. **Delivery rate over time** — a daily delivery-rate trend for the window. Watch for dips that line up with specific sends or imports. **Sending domain authentication** — a callout linking to your email channel's configuration, where the per-channel panel verifies your SPF/DKIM records (see [Email Channel Setup](./email-setup.md#4-verify-authentication-from-the-dashboard)). ### API The same data is available programmatically: ```json GET /api/v1/deliverability?period=30d Authorization: Bearer { "window_days": 30, "channels": [ { "channel_id": "chan-uuid", "sent": 12000, "delivered": 11640, "failed": 240, "invalid_token": 120, "delivery_rate": 0.97 } ], "suppression": { "events_by_reason": { "bounce": 84, "spam": 6, "unsubscribe": 120 }, "active_by_reason": { "bounce": 84, "spam": 6, "unsubscribe": 118 }, "bounce_rate": 0.007, "complaint_rate": 0.0005, "unsubscribe_rate": 0.01 }, "trend": [ { "date": "2026-07-26", "delivery_rate": 0.97 } ] } ``` | Field | Description | |---|---| | `period` (query) | `7d`, `30d`, or `90d`. Defaults to `30d`. | | `channels[]` | Per-channel delivery outcomes for the window. `delivery_rate` is `delivered / sent` as a `0..1` fraction. | | `suppression.events_by_reason` | Suppression events recorded during the window, keyed by reason (`bounce`, `spam`, `unsubscribe`). | | `suppression.active_by_reason` | Currently active suppressions by reason. | | `suppression.bounce_rate` / `complaint_rate` / `unsubscribe_rate` | Events divided by total sent, as `0..1` fractions — the dashboard cards render these as percentages. | | `trend[]` | One entry per day in the window with that day's delivery rate. | --- ## 2. Domain authentication For email, authentication comes before everything else. SPF, DKIM, and DMARC belong to **your** domain — Hober sends through SendGrid authenticated as you, so providers judge your domain's reputation, and unauthenticated mail is the fastest way to poison it. The per-channel **Sender domain deliverability** panel checks your domain authentication status directly and shows the exact DNS records to verify, with per-record **Verified** / **Pending** status and a **Re-check** button. The full walkthrough — what each DNS record does and how to publish them — is in [Email Channel Setup](./email-setup.md#3-authenticate-your-sending-domain). Do not start a warm-up until every record shows **Verified**. **The honest limit:** Hober tenants currently send from SendGrid's shared IP pools. Your domain reputation is yours alone; IP reputation is shared with the pool. Dedicated IP options and inbox-placement testing are on the roadmap — until then, the suppression and consent enforcement described below is what keeps the pool healthy, and it applies to every tenant equally. --- ## 3. What protects your reputation Four mechanisms run on every send, without configuration: - **A per-tenant suppression ledger.** Bounces, spam complaints, and unsubscribes are recorded per tenant and enforced on every subsequent send. A suppressed address is not "less likely" to be contacted — it is structurally excluded at dispatch. - **Consent enforced at send time.** Every message — including ones an AI agent proposes — passes the consent, quiet-hours, and frequency-cap guardrail before delivery. Withdrawn consent suppresses immediately. - **Standards-compliant unsubscribes.** RFC 8058 one-click `List-Unsubscribe` headers and human-readable preference links ship on every marketing email — which is what mailbox providers now expect from senders. See [built-in compliance footers](./email-setup.md#5-built-in-compliance-footers). - **Visible health.** The deliverability dashboard shows delivery rate, bounce and complaint rates, and trend — with the warning thresholds above. --- ## 4. Warming up a new sending domain Providers treat a new domain (or a long-dormant one) as unknown until it builds an engagement history. Ramping too fast looks identical to spam. 1. **Authenticate before anything.** Verify SPF and DKIM in the channel's deliverability panel first. 2. **Start with your most engaged segment.** First sends should go to recent openers/clickers or fresh signups — recipients likely to engage, which is the signal providers read. 3. **Ramp volume gradually.** A practical schedule: hundreds per day in week one, doubling every few days as metrics hold. Avoid a first-week blast to a cold, imported list. 4. **Watch the dashboard thresholds.** If bounces exceed 2% or complaints exceed 0.1%, pause the ramp, clean the segment, and let the metrics recover before resuming. 5. **Keep suppression on.** Never re-import or work around suppressed addresses; the ledger is protecting the domain you are warming. --- ## 5. List hygiene ### Email: the suppression ledger Suppressions are recorded automatically from three sources: | Reason | Recorded when | |---|---| | `bounce` | SendGrid reports the address bounced | | `spam` | The recipient marked the message as spam | | `unsubscribe` | The recipient unsubscribed — one-click header, footer link, or the preference center | All three are enforced at dispatch: the email adapter checks the ledger before every send, so suppressed addresses are skipped even if they remain in your imported lists or segments. Practical rules: - **Never re-import around a suppression.** Re-importing a suppressed address does not clear it — dispatch still excludes it — and attempting to bypass suppressions is exactly the behavior that damages the shared pool. - **There is currently no self-serve way to remove an address from the suppression ledger.** Treat suppression as durable; if a subscriber genuinely wants back in, the compliant path is a fresh opt-in through the preference center. - **Watch `active_by_reason` vs `events_by_reason`.** Events tell you what happened in the window; active counts tell you the ledger's current size. ### Push: invalid token detection Push reputation is token hygiene, not IPs. Invalid device tokens are detected automatically at delivery time — when the platform reports a token as gone or unregistered, the attempt is recorded as `invalid_token` — and fed back into deliverability as the **Invalid token** column in the per-channel table. A rising invalid-token count means your device list is aging (app uninstalls, expired registrations). That is normal in absolute terms; a sudden spike usually means you targeted a long-inactive segment. For deleting subscriber data outright (GDPR requests rather than deliverability hygiene), see [GDPR Data Retention](./gdpr-data-retention.md). --- ## 6. Unsubscribes are not the enemy The **Unsubscribe rate** card is deliberately neutral, with no warning threshold: an easy unsubscribe is your friend. Recipients who cannot find the unsubscribe button click "mark as spam" instead — and spam complaints (threshold 0.1%) hurt your reputation roughly an order of magnitude more than unsubscribes do. This is why Hober ships one-click `List-Unsubscribe` headers and preference-center links on outbound email automatically, and why you should not hide or delay them in your own templates. A rising unsubscribe rate is a *content and frequency* signal — segment better, send less — not a deliverability emergency. --- ## 7. Monitoring playbook A lightweight routine that catches most problems early: 1. **After every large send**, check the suppression cards. Bounce above **2%** or complaints above **0.1%** → pause further sends to that audience, inspect the segment (Where did these addresses come from? How old is the consent?), and resume only after the rates recover. 2. **Weekly**, scan the **Delivery rate over time** trend for dips and the **By channel** table for outliers. Investigate channel-level drops before they compound. 3. **After any DNS or SendGrid account change**, re-run the domain authentication check on the email channel — records can silently break when DNS is migrated. 4. **When importing a list**, treat it as untrusted until proven: send to an engaged slice first and watch the bounce card before rolling out (see the warm-up schedule above — the same logic applies to new lists on a warm domain). SMS and WhatsApp are Beta channels with their own carrier- and template-driven reputation mechanics, not covered in this guide. --- # Guides: iOS Channel Setup Source: https://docs-staging.hober.io/docs/guides/ios-setup # iOS Channel Setup Connect your iOS app to Hober in three steps: get an APNs key from Apple, upload it in the dashboard, and integrate HoberKit. ## Step 1 — Get your APNs key from Apple Hober authenticates with the Apple Push Notification service using a token-based **`.p8` key**: 1. In the [Apple Developer portal](https://developer.apple.com/account), go to **Certificates, Identifiers & Profiles → Keys**. 2. Create a key with **Apple Push Notifications service (APNs)** enabled. 3. Download the `.p8` file. **Apple only lets you download it once** — store it safely. 4. Note the **Key ID** (10 characters, also in the filename `AuthKey_.p8`) and your **Team ID** (top-right of the portal, under your account name). This is a one-time step per Apple team: the same key works for all of your apps. ## Step 2 — Create the channel in Hober In the dashboard, go to **Platform → Channels → Add Channel** and choose **iOS**: 1. Enter your app's **bundle ID** (e.g. `com.example.app`) as the platform ID. 2. Upload the `.p8` file — the Key ID is auto-filled from the filename — and enter your Team ID. 3. On creation, Hober verifies the key against APNs directly (a signed probe; no notification is sent). A channel that reaches **Active** has working credentials. If verification fails, the channel shows the APNs error — re-check the Key ID, Team ID, and that the key has APNs enabled. You can re-run verification from the channel list at any time. ## Step 3 — Integrate HoberKit Your channel page shows a ready-to-paste snippet with your SDK key filled in: ```swift import Hober Hober.initialize(sdkKey: "YOUR_SDK_KEY") ``` Follow the [iOS SDK Quickstart](/docs/ios-sdk/quickstart) for push registration, device setup, and subscriber identification. Your SDK key also lives in the dashboard under **Settings → SDK Key**. ## Environments APNs distinguishes sandbox (development builds) and production. Hober targets the environment stored with your credentials; keep separate channels for development and production apps if you need both concurrently. --- # Guides: Test Mode (Simulated Sends) Source: https://docs-staging.hober.io/docs/guides/test-mode # Test Mode (Simulated Sends) The scariest moment in any messaging tool is the second after you click Send. Simulated sends remove it: the send runs through the **entire real delivery pipeline** — audience resolution, consent, frequency caps, the fatigue budget, quiet hours, smart channel arbitration, personalization rendering — but at the final step every delivery goes to a simulator instead of APNs, FCM, your email provider, or your SMS gateway. Nothing reaches a real subscriber. Nothing counts against your quota. The run still appears in History so you can read exactly what *would* have happened. Simulated sends are available on **every plan**. --- ## What a simulation is (and is not) A simulation is an honest rehearsal, not a mock: - **Everything upstream of delivery is real.** If the fatigue budget would have held back 40 recipients, the run's detail page shows "40 held back". If smart channel would have chosen email for a subscriber, the arbitration readout says so. If your Liquid template fails to render for a recipient, the leg is recorded as failed. - **Nothing downstream of delivery happens.** No provider is contacted, no credentials are read, no quota or usage is metered, no webhooks fire, and simulated activity never pollutes analytics, engagement history, frequency counters, or smart-channel rankings. Two honest limitations: a simulator cannot tell you whether a push token is *actually* still valid (only a real delivery attempt can), and simulated jobs always deliver in one immediate pass — send-time optimization windows are noted but not waited for. ## Simulating a test send **Compose → Send test to myself.** The dialog defaults to **Simulate** — running it costs nothing and touches nobody. Flip "Send for real to this recipient" only when you want an actual delivery to your own device (that path consumes quota, like any real send). Via the API: ``` POST /api/v1/notifications/test { "channel_ids": [""], "device_id": "", "content": { "title": "Hello", "body": "Testing safely" }, "simulate": true } ``` The response carries `"delivery_mode": "simulated"`. ## Simulating a full campaign **Compose → Simulate send** (beside the real Send button) runs your *exact* draft — audience, channels, filters, smart channel, holdout, category — as a simulation. It is deliberately a separate button, not a mode toggle: there is no state to forget, and each click's outcome is unambiguous. Via the API, add `delivery_mode` to a normal compose request: ``` POST /api/v1/notifications { "channel_ids": ["..."], "target": { "type": "segment", "segment_id": "..." }, "content": { "title": "...", "body": "..." }, "delivery_mode": "simulated" } ``` Notes: - A tenant at its quota cap can still simulate — rehearsal is exactly what you want when you're at cap. - Simulated runs never create a campaign container and are hidden from campaign lists by default. ## Reading the results Open the run from History (use **"Show test & simulated runs"** to reveal simulated runs in the list, or follow the link the Simulate button gives you). The detail page shows: - A **"Simulated"** badge next to the status — the run completed without a single real delivery. - The usual per-channel outcome counts, where `simulated` stands in for `delivered`. - Every guardrail readout as if the send were real: suppressions by consent, frequency caps, the [fatigue budget](./fatigue-budgets.md), and the [smart channel](./smart-channel.md) arbitration summary. Rehearse an arbitration strategy or check how hard a budget would bite — without sending anything. ## How it composes | Surface | Live behavior | Simulated behavior | |---|---|---| | Providers (push/email/SMS/WhatsApp) | Real delivery | Never contacted | | Quota & usage metering | Consumed | Untouched | | Guardrails (consent, caps, budget) | Enforced | Evaluated & reported, meters not consumed | | Analytics, engagement, rankings | Counted | Excluded by construction | | Webhooks & events | Fired | Suppressed | | History | Listed | Badged, behind the include toggle | ## Simulate vs sandbox — which one? Simulate is a rehearsal of one send inside your live workspace. A [sandbox workspace](./sandbox-workspaces.md) is a full shadow copy where *only* the simulator can ever run — seeded with synthetic subscribers and history. Rule of thumb: rehearsing one send → Simulate; experimenting over days with journeys, segments, or API integration → sandbox. --- # Guides: Android Channel Setup Source: https://docs-staging.hober.io/docs/guides/android-setup # Android Channel Setup Connect your Android app to Hober in three steps: export a Firebase service account, upload it in the dashboard, and integrate the Hober Android SDK. ## Step 1 — Export the Firebase service account Hober sends through Firebase Cloud Messaging (FCM) using a **service-account JSON** key: 1. In the [Firebase console](https://console.firebase.google.com), open the project your Android app is registered in. 2. Go to **Project settings → Service accounts**. 3. Click **Generate new private key** and download the JSON file. The service account must belong to the same Firebase project as your app — FCM rejects tokens issued for a different project. ## Step 2 — Create the channel in Hober In the dashboard, go to **Platform → Channels → Add Channel** and choose **Android**: 1. Enter your app's **package name** (e.g. `com.example.app`) as the platform ID. 2. Upload the service-account JSON. 3. On creation, Hober verifies the credentials against FCM directly (a validate-only probe; no notification is sent). A channel that reaches **Active** has working credentials. If verification fails, re-check that you exported the key from the right Firebase project. You can re-run verification from the channel list at any time. ## Step 3 — Integrate the Android SDK Your channel page shows a ready-to-paste snippet with your SDK key and channel ID filled in: ```kotlin Hober.init( context = AppContextImpl(this), sdkKey = "YOUR_SDK_KEY", channelId = "YOUR_CHANNEL_ID" ) ``` Follow the [Android SDK Quickstart](/docs/android-sdk/quickstart) for permission handling, device registration, and subscriber identification. Your SDK key also lives in the dashboard under **Settings → SDK Key**. --- # Guides: Sandbox Workspaces Source: https://docs-staging.hober.io/docs/guides/sandbox-workspaces # Sandbox Workspaces A sandbox is a full shadow copy of your workspace where **only the simulator can ever run**. Build campaigns, wire journeys, call the API, break things — nothing reaches a real subscriber, nothing touches your quota or billing, and your live data is never involved. Sandboxes are available on **every plan**, one per workspace. --- ## What makes it safe The "nothing sends for real" guarantee is structural, enforced at two independent layers — neither is a setting anyone can flip: 1. **A sandbox cannot hold provider credentials.** Uploading APNs keys, FCM service accounts, or any other real credential into a sandbox is refused by the platform. 2. **Delivery is forced to the simulator.** Every job belonging to a sandbox routes to the [simulator](./test-mode.md) at dispatch, regardless of what the request asked for. Either layer alone is sufficient; both would have to fail simultaneously for a sandbox to deliver anything real. ## Creating and entering **Settings → Sandbox workspace → Create sandbox** (workspace owner only). Creation takes a few seconds and mirrors your owner/admin teammates into it, so it appears in everyone's workspace switcher with an unmistakable **SANDBOX** badge. While you're inside, a persistent amber banner reminds you that nothing here sends for real. Via the API: ``` POST /api/v1/sandbox → 201 { "tenant_id", "name", "slug", "provisioned" } GET /api/v1/sandbox → the sandbox, or 404 when none exists ``` ## What's inside A sandbox opens populated, not empty: - **Your channels, copied** — same types and names (suffixed "(sandbox)"), active and usable, with no credentials behind them. - **50 synthetic subscribers** (`sandbox-user-01` … `sandbox-user-50`) with devices spread across your channel classes, varied attributes for filter and segment testing, and three ready-made lists. - **30 days of seeded engagement history** with realistic, class-distinct open/tap rates — so analytics, best send hour, and [smart channel](./smart-channel.md) rankings all have material from the first minute. Everything is synthetic. Production data is never copied into a sandbox. Your **plan is inherited live from the parent workspace**, so plan-gated features (smart channel, digest batching, optimal send time) are testable in the sandbox exactly as they'd behave live — and a plan change on the live workspace propagates. ## Working in a sandbox - Compose shows a single **"Send (simulated)"** action — there is no separate Simulate button because everything is simulated. - Runs land in History with the **Simulated** badge and full guardrail readouts, like any [simulated send](./test-mode.md). - Server API keys minted inside a sandbox use the **`hober_test_`** prefix, so a test key pasted into production config self-identifies. They authenticate only against the sandbox. - A sandbox has a generous daily job cap (simulation is free of provider cost, not of compute); hitting it returns 429 until the next UTC day. ## Resetting **Settings → Sandbox workspace → Reset sandbox** (owner only, with a typed confirmation) wipes everything the sandbox accumulated — sends, journeys, segments, edited subscribers — and re-seeds the original synthetic data. The sandbox's identity survives: same tenant id, same memberships, same API keys. ``` POST /api/v1/sandbox/reset → 200, idempotent (wipe-first, safe to re-run) ``` ## Promoting work out of the sandbox Templates you build in a sandbox can be copied to your live workspace: **Templates → Copy to live** on any template card (inside the sandbox), or via the API: ``` POST /api/v1/sandbox/promote/template/ → 201 { "template_id": "", "name": "Welcome flow (from sandbox)" } ``` The rules are deliberate and strict: - **One-way, one pair.** Promotion always flows from your current sandbox to *its* parent — the destination is never a parameter, and nothing ever copies *into* a sandbox from live. - **Copy, never overwrite.** The live workspace always gets a *new* template; a name collision gets a "(from sandbox)" suffix. Your live templates cannot be touched by a promotion. - **Never auto-activates.** A promoted template is inert until someone uses it in a live send — promotion can never cause delivery. - **Admin-gated on the live side.** You need an owner or admin role in the live workspace; being in the sandbox alone is not enough to write into live. Only templates promote in v1. Journeys and in-app experiences built in a sandbox still need to be rebuilt by hand — they carry channel and segment references that don't transfer between workspaces yet. ## Limits, honestly - **One sandbox per workspace**, and a sandbox cannot have a sandbox. - **Membership mirroring is point-in-time**: owners/admins at creation get access; teammates added later must be invited from the sandbox's own Team page. - **No production parity for provider quirks** — the simulator never tells you whether a real APNs token would have bounced. - **Promotion is templates-only** — see above. ## Simulate vs sandbox — which one? | You want to… | Use | |---|---| | Rehearse one send safely (audience, guardrails, arbitration) | [Simulate](./test-mode.md) in your live workspace | | Fire a single push at your own device | Test send (defaults to simulate) | | Experiment with journeys, segments, or API integration over days | **Sandbox** | | Let a teammate learn the product without risk | **Sandbox** | --- # Guides: SMS & WhatsApp Setup Source: https://docs-staging.hober.io/docs/guides/sms-whatsapp-setup # SMS & WhatsApp Setup Both channels are bring-your-own-provider: SMS sends through your Twilio-compatible account and numbers, WhatsApp through your Meta Cloud API credentials. Hober handles composition, targeting, consent enforcement, and delivery tracking; the provider relationship — and its per-message costs — stay yours. ## SMS ### 1. Create the channel In the dashboard, run the new-channel wizard and choose **SMS**, or via the API: ```json POST /api/v1/channels Authorization: Bearer Content-Type: application/json { "type": "sms", "name": "Transactional SMS" } ``` ### 2. Upload provider credentials The wizard collects them in the credentials step; via the API: ```json POST /api/v1/channels/{id}/credentials/sms Content-Type: application/json { "api_key": "ACxxxxxxxx", "api_secret": "your-auth-token", "sender_id": "+15550001111" } ``` All three fields are required. The field names are provider-agnostic — for Twilio, `api_key` is the Account SID and `api_secret` the auth token. `sender_id` is your sending number or alphanumeric sender. Credentials are envelope-encrypted and write-only: no API returns them after upload, so keep your own record of which account is live. The channel activates on upload; bad credentials surface at send time. ### 3. Opt-outs are automatic Inbound **STOP** replies are recorded as opt-outs and enforced at dispatch on every subsequent send — no configuration needed. Consent, quiet hours, and frequency caps apply to SMS like every other channel. ## WhatsApp ### 1. Prerequisites at Meta You need a WhatsApp Business Account (WABA) with the Cloud API set up: an access token, your Phone Number ID, and the WABA ID. ### 2. Create the channel and upload credentials Choose **WhatsApp** in the wizard, or via the API: ```json POST /api/v1/channels/{id}/credentials/whatsapp Content-Type: application/json { "access_token": "EAAG…", "phone_number_id": "123456789012345", "waba_id": "987654321098765" } ``` Meta access tokens expire — re-upload before expiry (uploads replace the stored credential, same write-only model as every provider). ### 3. Templates — enforced before send, not after failure WhatsApp business-initiated messages must use Meta-approved templates. Hober syncs your template catalog and enforces approval at send creation, so a campaign never fails mid-flight on an unapproved template: | Action | Endpoint | |---|---| | Create a template draft | `POST /api/v1/channels/{id}/whatsapp-templates` | | List templates + status | `GET /api/v1/channels/{id}/whatsapp-templates` | | Submit for Meta approval | `POST /api/v1/channels/{id}/whatsapp-templates/{templateId}/submit` | | Refresh approval status | `POST /api/v1/channels/{id}/whatsapp-templates/{templateId}/refresh` | ### 4. Opt-outs Inbound opt-out messages arrive via your WhatsApp webhook and are recorded automatically; dispatch enforces them on every send. ## Plan availability Both channel types are available on every plan — you bring the provider account, so there is no platform gate. A workspace whose plan carries an explicit channel-type restriction sees `403 channel_type_not_permitted` with the allowed list; see [Errors](../api-reference/errors.md). --- # Guides: Data Residency Source: https://docs-staging.hober.io/docs/guides/data-residency # Data Residency Where Hober stores and processes your data — stated as it is today, not as a roadmap. ## Current topology All customer data is stored and processed on Google Cloud in the **United States** (`us-central1`): subscriber records, devices, events, message content, and delivery logs. There is currently **one region** — Hober does not yet offer in-region storage for the EU or other jurisdictions. ## International transfers and GDPR If you have EU subscribers, their personal data is transferred to and processed in the US. That transfer is governed by our [Data Processing Addendum](https://www.hober.io/en/legal/dpa), and the full list of downstream processors is public on the [subprocessors page](https://www.hober.io/en/legal/subprocessors). What Hober provides regardless of region: - **Retention and erasure** — documented retention periods, a hard-delete cascade for GDPR Article 17 requests, and audit logging; see the [data retention guide](/docs/guides/gdpr-data-retention). - **Subscriber rights tooling** — a hosted preference center, one-click unsubscribe, and a data-subject request portal for access, export, and erasure. - **Consent enforcement** — recorded consent is checked at dispatch time on every send path. ## EU region An EU region (`europe-west1`) is on the roadmap but **not available today**. When it ships, this page and the [changelog](https://www.hober.io/en/changelog) will say so explicitly — we don't list capabilities before they serve traffic. If EU data residency is a hard requirement for your evaluation, contact us; roadmap timing is exactly the kind of thing we'd rather discuss honestly than imply. --- # API reference: API Reference Overview Source: https://docs-staging.hober.io/docs/api-reference/overview # API Reference The Hober REST API base URL is `https://api.hober.io`. ## Authentication Three mechanisms, depending on the surface — see [Authentication & API Keys](./authentication.md) for the full reference: | Mechanism | Header | Used by | |---|---|---| | **SDK key** (publishable) | `X-SDK-Key: sk_…` | Device registration, subscriber upsert, interaction reporting, in-app delivery | | **Server key** (secret) | `Authorization: Bearer hober_srv_…` | Event ingestion, event export, webhooks | | **Dashboard token** (JWT) | `Authorization: Bearer …` | Management surfaces: notifications, campaigns, journeys, segments, channels, insights, imports | ## Endpoints | Area | Base path | Auth | |---|---|---| | [Subscribers](./subscribers.md) | `POST /api/v1/subscribers/upsert` · `DELETE /api/v1/subscribers/:id` | SDK key · dashboard token | | [Devices](./devices.md) | `/api/v1/devices` | SDK key | | [Notifications](./notifications.md) | `/api/v1/notifications` | Dashboard token | | [Templates](./templates.md) | `/api/v1/templates` | Dashboard token | | [Schedules](./schedules.md) | `/api/v1/schedules` | Dashboard token | | [Events](./events.md) | `/v1/events` | Server key (or client tier) | | [Campaigns](./campaigns.md) | `/api/v1/campaigns` | Dashboard token | | [Journeys](./journeys.md) | `/api/v1/journeys` | Dashboard token | | [Segments](./segments.md) | `/api/v1/segments` | Dashboard token | | [In-App Messages](./in-app.md) | `/api/v1/in-app/*` · `/v1/in-app/*` (SDK) | Dashboard token · SDK key | | [Channels](./channels.md) | `/api/v1/channels` | Dashboard token | | [Analytics & Insights](./insights.md) | `/api/v1/insights` · `/api/v1/annotations` | Dashboard token | | [Imports & Exports](./imports-exports.md) | `/api/v1/lists/import` · `/v1/events/export` | Dashboard token · server key | Error envelopes, status codes, and rate limits are documented in [Errors & Rate Limits](./errors.md). --- # API reference: Subscribers Source: https://docs-staging.hober.io/docs/api-reference/subscribers # Subscribers API ## POST /api/v1/subscribers/upsert Creates or updates a subscriber record, keyed by `external_id`. Authenticated with the publishable SDK key (`X-SDK-Key` header). Idempotent: re-sending the same body converges on the same record, which is what makes retrying pipelines (Zapier, reverse-ETL) safe. ### Request Body ```json { "external_id": "user-123", "email": "user@example.com", "attributes": { "plan": "pro", "country": "US" } } ``` `external_id` is the only required field. An optional `consent` assertion records an express email opt-in alongside the upsert. ### Response `200` with the stored subscriber: ```json { "id": "8a2f6c1e-…", "external_id": "user-123", "email": "user@example.com", "attributes": "{\"plan\":\"pro\",\"country\":\"US\"}", "created_at": "2026-07-26T12:00:00Z", "updated_at": "2026-07-26T12:00:00Z" } ``` `attributes` travels as a JSONB-encoded string (schemaless by design). `401` — missing or invalid SDK key. `422` — validation failure naming the field. ## DELETE /api/v1/subscribers/{id} Authenticated with a dashboard bearer token (see [Authentication](./authentication.md)). Permanently deletes a single subscriber and all associated devices and list memberships. Delivery log rows are anonymised (foreign keys set to NULL) rather than deleted. For GDPR compliance details, cascade behaviour, and the operator runbook, see the [GDPR Data Retention Policy guide](../guides/gdpr-data-retention.md). ### Path parameters | Parameter | Type | Description | |---|---|---| | `id` | `string` (UUID) | Hober subscriber UUID | ### Response **204 No Content** on success. No response body. ### Error responses | Status | Meaning | |---|---| | `401` | Missing or invalid bearer token | | `404` | Subscriber not found | ## POST /api/v1/subscribers/bulk-delete Permanently deletes up to 1,000 subscribers in one GDPR-compliant transaction. Authenticated with a dashboard bearer token. IDs not found in your workspace are returned in `not_found` and are not treated as errors. ### Request body ```json { "subscriber_ids": ["uuid-1", "uuid-2", "uuid-3"] } ``` | Field | Type | Description | |---|---|---| | `subscriber_ids` | `string[]` | Hober subscriber UUIDs. 1–1,000 per request. | ### Response **200 OK:** ```json { "deleted": 2, "not_found": ["uuid-3"] } ``` ### Error responses | Status | Meaning | |---|---| | `401` | Missing or invalid bearer token | | `422` | Empty list, or more than 1,000 IDs supplied | For cascade behaviour see the [GDPR Data Retention Policy guide](../guides/gdpr-data-retention.md). --- # API reference: Devices Source: https://docs-staging.hober.io/docs/api-reference/devices # Devices API ## POST /api/v1/devices Registers a device push token. ### Request Body ```json { "token": "fcm-or-apns-token", "platform": "fcm", "channelId": "default" } ``` ### Response ```json { "id": "dev_xyz789", "token": "fcm-or-apns-token" } ``` --- # API reference: Notifications Source: https://docs-staging.hober.io/docs/api-reference/notifications # Notifications API ## POST /api/v1/notifications Sends a push notification to one or more subscribers. ### Request Body ```json { "subscriberIds": ["user-123", "user-456"], "title": "Hello!", "body": "You have a new message.", "data": { "screen": "inbox" } } ``` ### Response ```json { "id": "notif_def012", "sent": 2 } ``` ### Targets `target.type` is one of `all`, `list` (requires `list_id`), or `segment` (requires `segment_id`). A missing id or an unknown type returns `422` naming the field — targets are validated at creation so an unfannable job can never be published. ```json { "target": { "type": "segment", "segment_id": "9f3c2a10-…" } } ``` Sends with a future `scheduled_at` are published to delivery at fire time, not at creation — cancelling before the scheduled time reliably prevents the send. ### Smart channel `channel_arbitration_mode` — optional; `explicit` (default) or `smart`. Smart sends deliver each subscriber on their single top-ranked reachable channel class among the selected channels (90-day engagement ranking; personal history counts from 5 deliveries per class), with a fallback only on permanent delivery failure. Growth+ plans; smaller plans receive `403` with `{"error": "feature_not_available", "feature": "smart_channel"}`. See the [Smart Channel guide](../guides/smart-channel.md). On `GET /api/v1/notifications/{id}`, smart sends include a `channel_arbitration` readout: `chosen[]` buckets (`class`, `source` = `personal` | `tenant` | `default`, `subscribers`), `suppressed_devices`, `rank_errors`, `fallback_steps`, and `fallback_dedup_errors`. Aggregates only — there is no per-recipient decision log. ### Audience filters Optionally narrow the audience by device or subscriber attributes with the top-level `device_filters` and `subscriber_filters` fields (Mongo-style predicates). Both are optional; when present they are ANDed. ```json { "target": { "type": "all" }, "device_filters": { "os_version": { "$gte": { "major": 17, "minor": 0, "patch": 0 } } }, "subscriber_filters": { "tier": { "$eq": "premium" } } } ``` Supported operators: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$exists`. Version-typed keys (`os_version`, `sdk_version`, `app_version`) take structured `{major, minor, patch}` operands. See the [Audience Filters guide](../guides/audience-filters.md) for the full reference, custom version fields, and indexing notes. --- # API reference: Templates Source: https://docs-staging.hober.io/docs/api-reference/templates # Templates API Notification templates let you define reusable title and body content that can be referenced when sending notifications. Templates are scoped to your tenant and are never shared across accounts. ## POST /api/v1/templates Creates a new notification template. ### Request Body ```json { "name": "welcome-email", "content": { "title": "Welcome to Acme!", "body": "Thanks for signing up. Tap to get started." } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | A unique, human-readable identifier for the template. | | `content.title` | string | Yes | The notification title shown to recipients. | | `content.body` | string | Yes | The notification body text shown to recipients. | ### Response — 201 Created ```json { "id": "tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "welcome-email", "content": { "title": "Welcome to Acme!", "body": "Thanks for signing up. Tap to get started." }, "created_at": "2026-04-25T12:00:00Z" } ``` ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid bearer token. | | 422 | `name` is missing, a template with that name already exists, or one or more `content` fields (`title`, `body`) are absent. | --- ## GET /api/v1/templates Returns a paginated list of templates for the authenticated tenant. ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `page` | integer | `1` | Page number (1-based). | | `per_page` | integer | `20` | Number of results per page. | | `name` | string | — | Optional. Filter results to templates whose name contains this value (case-insensitive). | ### Response — 200 OK ```json { "data": [ { "id": "tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "welcome-email", "content": { "title": "Welcome to Acme!", "body": "Thanks for signing up. Tap to get started." }, "created_at": "2026-04-25T12:00:00Z" } ], "total": 12, "page": 1 } ``` --- ## GET /api/v1/templates/:id Retrieves a single template by its UUID. ### Response — 200 OK ```json { "id": "tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "welcome-email", "content": { "title": "Welcome to Acme!", "body": "Thanks for signing up. Tap to get started." }, "created_at": "2026-04-25T12:00:00Z" } ``` ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid bearer token. | | 404 | No template found with that ID, or it belongs to a different tenant. | --- ## PATCH /api/v1/templates/:id Updates an existing template. At least one of `name` or `content` must be provided. All supplied fields overwrite the stored values; omitted fields are left unchanged. ### Request Body ```json { "name": "welcome-push", "content": { "title": "Welcome aboard!", "body": "Tap here to explore your new account." } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | No | New unique name for the template. | | `content.title` | string | No | Updated notification title. | | `content.body` | string | No | Updated notification body. | At least one field (`name`, `content.title`, or `content.body`) must be present or the request returns 422. ### Response — 200 OK Returns the full updated template object: ```json { "id": "tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "welcome-push", "content": { "title": "Welcome aboard!", "body": "Tap here to explore your new account." }, "created_at": "2026-04-25T12:00:00Z" } ``` ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid bearer token. | | 404 | Template not found or cross-tenant access attempted. | | 422 | Request body is empty, or the new `name` conflicts with an existing template. | --- ## DELETE /api/v1/templates/:id Permanently deletes a template. This action is irreversible. :::warning Deleting a template does not cancel or update notification jobs that were already scheduled using it. Any scheduled notification that still references the deleted template ID will fail to send. Review and cancel affected jobs before deleting a template. ::: ### Response — 204 No Content An empty body is returned on success. ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid bearer token. | | 404 | Template not found or cross-tenant access attempted. | --- # API reference: Schedules Source: https://docs-staging.hober.io/docs/api-reference/schedules # Schedules API :::warning Growth plan required Recurring schedules are available on the **Growth** plan and above (the `scheduled_sends` feature). Attempting to use these endpoints on the Free or Starter plan returns `403 PLAN_FEATURE_UNAVAILABLE`. To upgrade, visit your [billing settings](https://app.hober.io/billing). ::: Schedules let you send a notification on a repeating cadence using a standard cron expression. Each schedule references a template and one or more channels, and fires according to the cron expression evaluated in the specified IANA timezone. --- ## POST /api/v1/schedules Creates a new recurring schedule. ### Request Body ```json { "cron_expression": "0 9 * * 1", "timezone": "America/New_York", "template_id": "tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"], "target": { "type": "all" }, "active": true } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `cron_expression` | string | Yes | A valid 5-field cron expression (minute hour day-of-month month day-of-week). | | `timezone` | string | Yes | An IANA timezone name (e.g. `America/New_York`, `Europe/London`, `UTC`). | | `template_id` | string | Yes | UUID of an existing notification template. | | `channel_ids` | array of strings | Yes | One or more channel UUIDs the notification will be sent through. | | `target.type` | string | Yes | Recipient targeting strategy. Currently supports `"all"` (all active subscribers). | | `active` | boolean | No | Whether the schedule should fire immediately. Defaults to `true`. | ### Response — 201 Created ```json { "id": "sch_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "cron_expression": "0 9 * * 1", "timezone": "America/New_York", "template_id": "tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"], "target": { "type": "all" }, "active": true, "created_at": "2026-04-25T12:00:00Z", "next_run_at": "2026-04-27T13:00:00Z" } ``` ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid bearer token. | | 403 | Your plan does not include recurring schedules (`PLAN_FEATURE_UNAVAILABLE`). | | 422 | `cron_expression` is not a valid 5-field cron string, `timezone` is not a recognised IANA timezone, or `template_id` does not refer to an existing template. | #### 403 response body ```json { "error": "PLAN_FEATURE_UNAVAILABLE", "message": "Recurring schedules require the Growth plan or above." } ``` --- ## GET /api/v1/schedules Returns a paginated list of schedules for the authenticated tenant. ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `page` | integer | `1` | Page number (1-based). | | `per_page` | integer | `20` | Number of results per page. | | `active` | boolean | — | Optional. When `true`, returns only active schedules. When `false`, returns only paused schedules. Omit to return all. | ### Response — 200 OK ```json { "data": [ { "id": "sch_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "cron_expression": "0 9 * * 1", "timezone": "America/New_York", "template_id": "tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"], "target": { "type": "all" }, "active": true, "created_at": "2026-04-25T12:00:00Z", "next_run_at": "2026-04-27T13:00:00Z" } ], "total": 4, "page": 1 } ``` --- ## GET /api/v1/schedules/:id Retrieves a single schedule by its UUID. ### Response — 200 OK ```json { "id": "sch_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "cron_expression": "0 9 * * 1", "timezone": "America/New_York", "template_id": "tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"], "target": { "type": "all" }, "active": true, "created_at": "2026-04-25T12:00:00Z", "next_run_at": "2026-04-27T13:00:00Z" } ``` ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid bearer token. | | 404 | No schedule found with that ID, or it belongs to a different tenant. | --- ## PATCH /api/v1/schedules/:id Partially updates an existing schedule. All fields are optional; only the fields you supply are updated. Omitted fields retain their current values. ### Request Body ```json { "cron_expression": "0 */6 * * *", "timezone": "UTC", "template_id": "tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"], "active": false } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `cron_expression` | string | No | Replacement cron expression. | | `timezone` | string | No | Replacement IANA timezone. | | `template_id` | string | No | Replacement template UUID. | | `channel_ids` | array of strings | No | Replacement list of channel UUIDs. Replaces the entire array, not merged. | | `active` | boolean | No | Set to `false` to pause the schedule; `true` to resume. | ### Response — 200 OK Returns the full updated schedule object (same shape as `GET /api/v1/schedules/:id`). ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid bearer token. | | 403 | Plan downgrade detected; recurring schedules are not available on the current plan (`PLAN_FEATURE_UNAVAILABLE`). | | 404 | Schedule not found or cross-tenant access attempted. | | 422 | `cron_expression` is invalid or `timezone` is not a recognised IANA timezone. | --- ## DELETE /api/v1/schedules/:id Permanently deletes a schedule. The schedule will not fire again after deletion. :::warning Deletion is irreversible. If you only want to stop the schedule temporarily, set `active: false` via `PATCH /api/v1/schedules/:id` instead. ::: ### Response — 204 No Content An empty body is returned on success. ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid bearer token. | | 404 | Schedule not found or cross-tenant access attempted. | --- ## Cron Expression Reference Schedules use the standard 5-field cron format: ``` ┌───────────── minute (0–59) │ ┌───────────── hour (0–23) │ │ ┌───────────── day of month (1–31) │ │ │ ┌───────────── month (1–12) │ │ │ │ ┌───────────── day of week (0–7, Sunday = 0 or 7) │ │ │ │ │ * * * * * ``` | Expression | Meaning | |------------|---------| | `0 9 * * 1` | Every Monday at 9:00 AM | | `0 */6 * * *` | Every 6 hours | | `30 8 1 * *` | 1st of every month at 8:30 AM | | `0 12 * * 1-5` | Weekdays at noon | | `0 0 * * *` | Daily at midnight | All times are evaluated in the `timezone` you specify on the schedule. See the [Recurring Notifications guide](../guides/recurring-notifications.md) for timezone best practices and DST considerations. --- # API reference: Events Source: https://docs-staging.hober.io/docs/api-reference/events # Events API Behavioral event ingestion and identity stitching. Authenticates with either credential tier — see the [Server-Side Event Tracking guide](../guides/server-side-event-tracking.md) for which key to use where. The machine-readable contract lives at [`docs/api/events-openapi.yaml`](https://github.com/hoberhq/hober/blob/main/docs/api/events-openapi.yaml). ## POST /v1/events Ingests a single event (bare object) or a batch (`{"events": [...]}`). Returns `202` once events are durably stored; segmentation runs asynchronously. ### Event fields | Field | Type | Notes | |---|---|---| | `event_name` | string | Required, 1–128 chars | | `subscriber_id` | string | Registry subscriber UUID | | `anonymous_id` | string | Pre-identify actor | | `external_id` | string | **Server tier only** — your own user id, resolved via the registry; unknown → `422` | | `platform` | string | Free-form; server SDKs default to `server` | | `properties` | object | ≤ 50 keys; keys and string values ≤ 256 chars | | `occurred_at` | RFC3339 | Defaults to receipt time; window: 48 h (client) / 90 d (server) | | `idempotency_key` | string | Unique per tenant; generated when omitted | Exactly one actor identifier is required per event. The trust tier is recorded as `source` from the credential; a `source` field in the payload is ignored. ### Limits | | Client (`X-SDK-Key`) | Server (`Bearer hober_srv_…`) | |---|---|---| | Events per request | 50 | 1000 | | Request body | — | 500 KB (`413` beyond) | | Per event | — | 32 KB | | `occurred_at` window | 48 h | 90 days | ### Responses | Status | Meaning | |---|---| | `202` | `{ "accepted": N }` — events stored | | `401` | Missing, malformed, or revoked credential | | `403` | Server key without the `events:write` scope | | `413` | Server-tier body over 500 KB | | `422` | Validation failure (batch caps, backdating window, unknown `external_id`, `external_id` on client tier, missing actor, property limits) | ## GET /v1/events/export Bulk export of the tenant's behavioral event history (the [data-portability policy](https://www.hober.io/legal/data-portability)'s self-serve path). **Server key only** — client SDK keys are publishable and cannot read history. ### Query parameters | Param | Type | Notes | |---|---|---| | `from` / `to` | RFC 3339 | Time window on `occurred_at`; defaults to all history up to now | | `cursor` | string | Opaque keyset cursor from the previous page | | `limit` | int | 1..1000 (default 1000) | ### Response `200` with the cursor envelope — `next_cursor` is empty when the page is not full: ```json { "items": [ { "id": "…", "subscriber_id": "…", "anonymous_id": null, "event_name": "order_placed", "platform": "", "properties": { "total_price": "19.99" }, "occurred_at": "2026-07-01T12:00:00Z", "received_at": "2026-07-01T12:00:01Z", "source": "server" } ], "next_cursor": "" } ``` `401` — missing/invalid credential. `403` — client-tier credential. `422` — malformed window, limit, or cursor. Rows ordered by `(occurred_at, id)`; pages are stable under concurrent ingestion. Erased subscribers' rows appear with both actor ids null (GDPR erasure anonymizes, it does not delete aggregates). ## POST /v1/events/identify Links an `anonymous_id` to a `subscriber_id` and backfills the subscriber onto previously stored anonymous events. ### Request ```json { "anonymous_id": "anon-9f2c", "subscriber_id": "8a2f6c1e-…" } ``` ### Response ```json { "linked": true, "backfilled": 12 } ``` --- # API reference: Campaigns Source: https://docs-staging.hober.io/docs/api-reference/campaigns # Campaigns API A campaign is a first-class send container: it holds an audience, one or more ordered messages, and a lifecycle. Single-message campaigns cover one-off sends; multi-message campaigns let you author a sequence of messages, release them together, and read a per-message performance report. These endpoints are authenticated with a bearer token: ```http Authorization: Bearer YOUR_ACCESS_TOKEN ``` ## The campaign object | Field | Type | Description | |-------|------|-------------| | `id` | string | Campaign UUID. | | `name` | string | Display name. | | `status` | string | One of `draft`, `scheduled`, `live`, `completed`, `paused`, `archived`. | | `category` | string | Messaging category: `transactional` or `marketing`. Defaults to `marketing`. | | `target` | object | Audience target. Defaults to `{"type": "all"}`. | | `content` | object | Inline notification content. Omitted when `template_id` supplies the content. | | `template_id` | string | Template UUID, when content comes from a template. | | `channel_ids` | array of strings | Channel UUIDs the campaign sends through. | | `holdout_percentage` | integer | Percentage of the audience held out as a control group. | | `scheduled_at` | string | RFC 3339 send time. Omitted for immediate sends. | | `messages` | array | The campaign's ordered messages (see below). The top-level `content` / `template_id` / `channel_ids` / `scheduled_at` mirror the first message for single-message consumers. | | `created_at` | string | Creation timestamp. | | `updated_at` | string | Last-update timestamp. | Each entry in `messages`: | Field | Type | Description | |-------|------|-------------| | `id` | string | Message UUID. | | `content` | object | Inline notification content. Omitted when `template_id` is set. | | `template_id` | string | Template UUID. | | `channel_ids` | array of strings | Channel UUIDs for this message. | | `scheduled_at` | string | RFC 3339 send time for this message. Omitted = sends with the campaign. | | `position` | integer | Zero-based order within the campaign. | | `job_id` | string | The notification job this message produced on release. Empty until released. | --- ## POST /api/v1/campaigns Creates a campaign as a **draft**. Supply either `messages` (a multi-message campaign) or the top-level `content` + `channel_ids` (a single message). Send the draft later with `POST /api/v1/campaigns/:id/release`. ### Request Body ```json { "name": "Spring onboarding", "category": "marketing", "target": { "type": "all" }, "holdout_percentage": 5, "messages": [ { "content": { "title": "Welcome!", "body": "Thanks for joining." }, "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"] }, { "template_id": "tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"], "scheduled_at": "2026-05-03T09:00:00Z" } ] } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Campaign name. | | `category` | string | No | `transactional` or `marketing`. Defaults to `marketing`. | | `target` | object | No | Audience target. Defaults to `{"type": "all"}`. | | `content` | object | Conditional | Inline content for a single-message campaign. Required (with `channel_ids`) when `messages` is absent. | | `template_id` | string | No | Template UUID supplying the content. Must belong to your tenant. | | `channel_ids` | array of strings | Conditional | Channel UUIDs. Required with `content` when `messages` is absent. | | `holdout_percentage` | integer | No | Percentage of the audience to hold out. | | `scheduled_at` | string | No | RFC 3339 send time. | | `messages` | array | Conditional | Ordered message list for a multi-message campaign. Each message takes `content`, `template_id`, `channel_ids`, and an optional per-message `scheduled_at`. | ### Response — 201 Created ```json { "id": "cmp_9f8e7d6c-5b4a-3210-fedc-ba9876543210", "name": "Spring onboarding", "status": "draft", "category": "marketing", "target": { "type": "all" }, "content": { "title": "Welcome!", "body": "Thanks for joining." }, "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"], "holdout_percentage": 5, "messages": [ { "id": "msg_00000000-0000-0000-0000-000000000001", "content": { "title": "Welcome!", "body": "Thanks for joining." }, "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"], "position": 0 }, { "id": "msg_00000000-0000-0000-0000-000000000002", "template_id": "tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"], "scheduled_at": "2026-05-03T09:00:00Z", "position": 1 } ], "created_at": "2026-05-01T12:00:00Z", "updated_at": "2026-05-01T12:00:00Z" } ``` ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid credentials. | | 422 | `name` missing, neither `messages` nor `content` + `channel_ids` supplied, invalid `scheduled_at`, unknown `category`, or `template_id` does not belong to your tenant. | --- ## GET /api/v1/campaigns Returns a paginated list of campaigns for the authenticated tenant. ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `page` | integer | `1` | Page number (1-based). | | `page_size` | integer | `20` | Number of results per page. | | `status` | string | — | Optional. Filter by lifecycle status (e.g. `draft`, `live`). | ### Response — 200 OK ```json { "items": [ { "id": "cmp_9f8e7d6c-5b4a-3210-fedc-ba9876543210", "name": "Spring onboarding", "status": "live", "category": "marketing", "target": { "type": "all" }, "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"], "holdout_percentage": 5, "messages": [], "created_at": "2026-05-01T12:00:00Z", "updated_at": "2026-05-02T09:00:00Z" } ], "total": 12, "page": 1, "page_size": 20 } ``` --- ## GET /api/v1/campaigns/:id Retrieves a single campaign, including its full `messages` list. ### Response — 200 OK Returns the full campaign object (same shape as the create response). ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid credentials. | | 404 | No campaign found with that ID, or it belongs to a different tenant. | --- ## PUT /api/v1/campaigns/:id Replaces a **draft** campaign's audience and messages. The entire message list is replaced, not merged. Campaigns that have left the draft state cannot be edited. ### Request Body ```json { "name": "Spring onboarding v2", "category": "marketing", "target": { "type": "all" }, "holdout_percentage": 10, "messages": [ { "content": { "title": "Welcome aboard", "body": "Glad you are here." }, "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"] } ] } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Campaign name. | | `category` | string | No | `transactional` or `marketing`. | | `target` | object | No | Replacement audience target. | | `holdout_percentage` | integer | No | Replacement holdout percentage. | | `messages` | array | Yes | Replacement message list. At least one message is required. | ### Response — 200 OK Returns the full updated campaign object. ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid credentials. | | 404 | Campaign not found. | | 409 | The campaign is no longer a draft. | | 422 | Empty `messages`, or a message has an invalid `scheduled_at`. | --- ## PATCH /api/v1/campaigns/:id Applies a lifecycle action to the campaign. Pausing or archiving also deactivates any recurring schedule owned by the campaign; resuming reactivates it. ### Request Body ```json { "action": "pause" } ``` | Action | Effect | Allowed from | |--------|--------|--------------| | `pause` | Stops sending; status becomes `paused`. | `scheduled`, `live` | | `resume` | Resumes sending; status becomes `scheduled`. | `paused` | | `archive` | Retires the campaign; status becomes `archived`. | Any non-archived status | | `unarchive` | Restores the exact pre-archive status. | `archived` | ### Response — 200 OK Returns the full updated campaign object. ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid credentials. | | 404 | Campaign not found. | | 409 | The action is not allowed from the campaign's current status. | | 422 | Unknown `action`. | --- ## POST /api/v1/campaigns/:id/release Sends a **draft** campaign: one notification job is created per message, each scheduled at its message's own `scheduled_at` (or immediately when absent). The campaign transitions out of `draft` and each message's `job_id` is populated. Release is idempotent: a message already linked to a job is skipped, so retrying after a partial failure resumes where it left off. ### Response — 200 OK Returns the full updated campaign object, with `status` advanced and `job_id` set on each released message. ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid credentials. | | 404 | Campaign not found. | | 409 | The campaign is not a draft (already released). | --- ## GET /api/v1/campaigns/:id/report Returns the campaign's funnel KPIs plus per-channel and per-message breakdowns over a time window. ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `start` | string | 30 days ago | RFC 3339 start of the reporting window. | | `end` | string | now | RFC 3339 end of the reporting window. | ### Response — 200 OK ```json { "sent": 4200, "delivered": 4012, "opened": 1830, "clicked": 402, "converted": 96, "revenue_cents": 481500, "by_channel": [ { "channel": "ios", "delivered": 2400, "opened": 1100, "clicked": 240 }, { "channel": "android", "delivered": 1612, "opened": 730, "clicked": 162 } ], "by_message": [ { "position": 0, "label": "Welcome!", "delivered": 2100, "opened": 990, "clicked": 220 }, { "position": 1, "label": "Message 2", "delivered": 1912, "opened": 840, "clicked": 182 } ] } ``` | Field | Type | Description | |-------|------|-------------| | `sent` | integer | Notifications sent in the window. | | `delivered` | integer | Notifications delivered. | | `opened` | integer | Opens recorded. | | `clicked` | integer | Clicks recorded. | | `converted` | integer | Attributed conversions. | | `revenue_cents` | integer | Attributed revenue, in cents. | | `by_channel` | array | Per-channel delivered/opened/clicked counts. | | `by_message` | array | Per-message breakdown, labeled by each message's content title (or `Message N`). Populated only for multi-message campaigns; empty otherwise. | ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid credentials. | | 404 | Campaign not found, or it belongs to a different tenant. | --- # API reference: Journeys Source: https://docs-staging.hober.io/docs/api-reference/journeys # Journeys API A journey is an automated, per-subscriber flow: a trigger starts a run for a subscriber, and the run walks a directed graph of steps (sends, delays, branches, event waits) until it completes or exits. These endpoints are authenticated with a bearer token: ```http Authorization: Bearer YOUR_ACCESS_TOKEN ``` ## The journey object | Field | Type | Description | |-------|------|-------------| | `id` | string | Journey UUID. | | `name` | string | Display name. | | `trigger` | object | What starts a run (see [Trigger](#trigger)). | | `entry_rules` | object | Who may enter and whether they may re-enter (see [Entry rules](#entry-rules)). `null` when unset. | | `steps` | object | The step graph (see [Step graph](#step-graph)). | | `status` | string | One of `draft`, `active`, `paused`, `archived`. | | `version` | integer | Definition version, incremented on update. | | `entered` | integer | Runs entered. Populated on the list endpoint; `0` elsewhere. | | `completed` | integer | Runs that reached the end. Populated on the list endpoint; `0` elsewhere. | | `created_at` | string | Creation timestamp. | | `updated_at` | string | Last-update timestamp. | ### Trigger | Field | Type | Description | |-------|------|-------------| | `type` | string | `segment_entered`, `segment_exited`, or `event`. | | `segment_id` | string | Segment UUID, for the segment trigger types. | | `event_name` | string | Event name, for the `event` trigger type. | ### Entry rules | Field | Type | Description | |-------|------|-------------| | `eligibility` | object | Optional attribute/rule predicate evaluated at entry; subscribers who do not match never enter. | | `allow_reentry` | boolean | Allow a subscriber to re-enter after completing a run. Default `false` (one run per subscriber). | | `reentry_window` | string | Minimum wait before re-entry, e.g. `"24h"`. | ### Step graph The `steps` object names a `start` step and maps step IDs to step nodes: ```json { "start": "s1", "steps": { "s1": { "id": "s1", "type": "send", "send": { "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"], "template_id": "tpl_a1b2c3d4-e5f6-7890-abcd-ef1234567890", "next": "s2" } }, "s2": { "id": "s2", "type": "delay", "delay": { "duration": "2d", "next": "s3" } }, "s3": { "id": "s3", "type": "wait_for_event", "wait_for_event": { "event_name": "app_opened", "within": "3d", "on_event": "done", "on_timeout": "s4" } }, "s4": { "id": "s4", "type": "send", "send": { "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"] } }, "done": { "id": "done", "type": "exit" } } } ``` Step types and their config objects: | Type | Config key | Fields | |------|-----------|--------| | `send` | `send` | `channel_ids`, `template_id` or `content`, `next` (empty `next` completes the run). | | `delay` | `delay` | `duration` (e.g. `"30m"`, `"2h"`, `"3d"`), `next` (required). | | `branch` | `branch` | `condition` (predicate), `then` (required), `else`. | | `wait_for_event` | `wait_for_event` | `event_name` (required), `within` (timeout window), `where` (property filter), `on_event` (required), `on_timeout`. | | `exit` | — | Terminal step; no config. | Durations accept whole-unit `m`, `h`, or `d` suffixes. The graph may also carry a top-level `exit_conditions` array of global early-exit predicates (each sets exactly one of `attribute`, `event`, or `rule`), evaluated on every tick — e.g. "exit the moment the subscriber purchases." --- ## GET /api/v1/journeys Returns the tenant's journeys, cursor-paginated (up to 100 per page). Each journey includes its `entered` / `completed` run rollup. ### Query Parameters | Parameter | Type | Description | |-----------|------|-------------| | `cursor` | string | Optional. The `next_cursor` from the previous page. | ### Response — 200 OK ```json { "journeys": [ { "id": "jrn_1a2b3c4d-5e6f-7890-abcd-ef1234567890", "name": "Win-back", "trigger": { "type": "segment_entered", "segment_id": "seg_00000000-0000-0000-0000-000000000001" }, "entry_rules": null, "steps": { "start": "s1", "steps": {} }, "status": "active", "version": 3, "entered": 1240, "completed": 987, "created_at": "2026-04-20T10:00:00Z", "updated_at": "2026-05-01T08:30:00Z" } ], "next_cursor": "" } ``` `next_cursor` is empty when there are no more pages. --- ## POST /api/v1/journeys Creates a journey as a **draft**. Draft saves are tolerant of an incomplete step graph, so you can persist work-in-progress; full validation runs on activation. ### Request Body ```json { "name": "Win-back", "trigger": { "type": "event", "event_name": "subscription_canceled" }, "entry_rules": { "allow_reentry": true, "reentry_window": "24h" }, "steps": { "start": "s1", "steps": { "s1": { "id": "s1", "type": "send", "send": { "channel_ids": ["ch_11111111-2222-3333-4444-555555555555"] } } } } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Journey name. | | `trigger` | object | Yes | What starts a run. | | `entry_rules` | object | No | Entry eligibility and re-entry policy. | | `steps` | object | Yes | The step graph. | ### Response — 201 Created Returns the full journey object with `status: "draft"` and `version: 1`. ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid credentials. | | 422 | Empty name, malformed JSON in `trigger` / `entry_rules` / `steps`, or an invalid trigger type. | --- ## GET /api/v1/journeys/:id Retrieves a single journey. ### Response — 200 OK Returns the full journey object. ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid credentials. | | 404 | No journey found with that ID, or it belongs to a different tenant. | --- ## PUT /api/v1/journeys/:id Replaces the definition (name, trigger, entry rules, steps) of a **draft** or **paused** journey and increments its `version`. Active journeys must be paused before editing. ### Request Body Same shape as `POST /api/v1/journeys`. ### Response — 200 OK Returns the full updated journey object. ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid credentials. | | 404 | Journey not found. | | 409 | The journey is active — pause it first. | | 422 | Invalid definition. | --- ## POST /api/v1/journeys/:id/activate Transitions a `draft` or `paused` journey to `active`. Activation runs full validation on the step graph: every step needs the config for its type, all `next` targets must exist, and an exit must be reachable from the start step. ### Response — 200 OK Returns the full journey object with `status: "active"`. ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid credentials. | | 404 | Journey not found. | | 409 | The journey cannot be activated from its current status. | | 422 | The definition fails validation (e.g. dangling step target, no reachable exit, invalid duration). | --- ## POST /api/v1/journeys/:id/pause Transitions an `active` journey to `paused`. In-flight runs stop advancing until the journey is activated again. ### Response — 200 OK Returns the full journey object with `status: "paused"`. ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid credentials. | | 404 | Journey not found. | | 409 | The journey is not active. | --- ## DELETE /api/v1/journeys/:id Permanently deletes a journey **and all of its runs**. :::warning Deletion is irreversible and removes run history. If you only want to stop the journey, use `POST /api/v1/journeys/:id/pause` instead. ::: ### Response — 204 No Content An empty body is returned on success. ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid credentials. | | 404 | Journey not found. | --- ## GET /api/v1/journeys/:id/stats Returns the journey's run overview: run counts by status and per-step traffic. ### Response — 200 OK ```json { "runs": { "active": 120, "completed": 987, "exited": 45, "canceled": 3 }, "steps": { "s1": { "active": 20, "outcomes": { "sent": 1100 } }, "s2": { "active": 100, "outcomes": { "branch:then": 640, "branch:else": 360 } } } } ``` | Field | Type | Description | |-------|------|-------------| | `runs` | object | Run counts keyed by status (`active`, `completed`, `exited`, `canceled`). | | `steps` | object | Keyed by step ID. `active` is how many runs currently sit on the step; `outcomes` counts recorded step outcomes (e.g. `sent`, `branch:then`, `wait:event`) — their sum is how many runs passed through. | | `digest` | object, optional | Digest-batching rollup ([guide](../guides/digest-batching.md)): `pending` items queued for a bundle, `flushed` items already delivered bundled. Present only when the journey's digest steps queued anything. | ### Send-step delivery priority A Send step's config accepts `priority`: `immediate` (default) or `digest` — digest messages queue per subscriber and deliver bundled (3 updates, the subscriber's best hour, or a 24-hour cap, whichever first). Growth+ plans; saving a digest step on smaller plans returns `403` with `{"error": "feature_not_available", "feature": "digest_batching"}`. Transactional sends never digest. See the [Digest Batching guide](../guides/digest-batching.md). --- ## GET /api/v1/journeys/counts Returns the tenant's active/total journey counts. ### Response — 200 OK ```json { "active": 4, "total": 11 } ``` --- # API reference: Segments Source: https://docs-staging.hober.io/docs/api-reference/segments # Segments API Segments are live behavioral audiences: a rule over subscriber attributes and tracked events, evaluated against your subscriber base. A **dynamic** segment is recomputed as new events arrive; a static segment keeps the membership it had when it was created. Segments can be targeted from notifications, journeys, and in-app experiences. All segment endpoints are authenticated with a tenant JWT bearer token (`Authorization: Bearer ...`) — the credential used by dashboard sessions. The publishable SDK key cannot manage segments. Plan limits apply to segment rules: the maximum event lookback window, the number of event conditions per rule, and the number of active dynamic segments all depend on your plan. Rule-shaped violations return `422` with a message naming the limit; creating a dynamic segment beyond your plan's cap returns `403 FEATURE_NOT_AVAILABLE`. --- ## The rule object A segment's `rule` is a boolean tree. Each node is exactly one of: - `attribute` — a predicate over subscriber attributes - `event` — a windowed, frequency-bounded predicate over tracked events - `group` — a nested rule set, for mixing AND and OR ```json { "op": "and", "nodes": [ { "attribute": { "tier": { "$eq": "premium" } } }, { "event": { "name": "order_placed", "within": "30d", "frequency": { "min": 2 } } }, { "group": { "op": "or", "nodes": [ { "attribute": { "country": { "$in": ["US", "CA"] } } }, { "attribute": { "locale": { "$eq": "en-GB" } } } ] } } ] } ``` | Field | Type | Description | |-------|------|-------------| | `op` | string | `"and"` or `"or"` — how the nodes combine. | | `nodes` | array | One or more nodes; each sets exactly one of `group`, `attribute`, `event`. | ### Attribute conditions An `attribute` node maps an attribute key to an operator object, using the same Mongo-style predicate DSL as [audience filters](../guides/audience-filters.md): `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$exists`. ```json { "attribute": { "plan": { "$eq": "pro" } } } ``` ### Event conditions | Field | Type | Description | |-------|------|-------------| | `name` | string | Required. The tracked event name. | | `within` | string | Recency window, e.g. `"30d"`, `"24h"`, `"45m"` (whole-unit `d`/`h`/`m`). Omitted = all time — only allowed on plans without a lookback cap. | | `frequency` | object | `{ "min": N }`, `{ "max": N }`, or both (inclusive bounds on the event count). Default is "at least once". | | `where` | object | Optional predicate over the event's `properties`, same operator DSL as attribute conditions. | ### Simple flat form The single-condition form is also accepted anywhere a rule is expected: an optional `attribute` condition and an optional `event` condition, combined with AND. At least one of the two is required. ```json { "attribute": { "key": "tier", "operator": "eq", "value": "premium" }, "event": { "name": "order_placed", "operator": "gte", "count": 2, "within_days": 30 } } ``` `attribute.operator` is a DSL operator without the `$` prefix (`eq`, `in`, `gte`, ...; defaults to `eq`). `event.operator` is `gte`, `lte`, or `eq` against `count`. --- ## GET /api/v1/segments Lists the tenant's segments (cursor-paginated, up to 100 per page). ### Query Parameters | Parameter | Type | Description | |-----------|------|-------------| | `cursor` | string | Opaque cursor from the previous page. Omit for the first page. | ### Response — 200 OK ```json { "segments": [ { "id": "9f5b2c1e-...", "name": "Premium repeat buyers", "is_dynamic": true, "needs_recompute": false, "created_at": "2026-07-01T12:00:00Z", "updated_at": "2026-07-01T12:00:00Z", "count": 1834, "rule": { "op": "and", "nodes": [] } } ], "next_cursor": "" } ``` | Field | Type | Description | |-------|------|-------------| | `is_dynamic` | boolean | Whether membership is recomputed as events arrive. | | `needs_recompute` | boolean | The rule changed since the last membership computation. | | `count` | integer | Number of currently active members. | | `rule` | object | The stored rule tree; omitted when the segment has no rules. | | `next_cursor` | string | Empty when there are no further pages. | --- ## POST /api/v1/segments Creates a segment. ### Request Body ```json { "name": "Premium repeat buyers", "is_dynamic": true, "rule": { "op": "and", "nodes": [ { "attribute": { "tier": { "$eq": "premium" } } }, { "event": { "name": "order_placed", "within": "30d", "frequency": { "min": 2 } } } ] } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Segment name (unique per tenant). | | `is_dynamic` | boolean | No | Defaults to `false`. Dynamic segments count against your plan's active-dynamic-segment cap. | | `rule` | object | Yes | The rule tree or simple flat form. | ### Response — 201 Created The created segment, same shape as one entry of `GET /api/v1/segments`. ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid bearer token. | | 403 | `FEATURE_NOT_AVAILABLE` — the plan's dynamic-segment cap is reached, or dynamic segments are not included. | | 409 | A segment with that name already exists. | | 422 | `VALIDATION_ERROR` — malformed rule, or the rule exceeds a plan cap (event-condition count, lookback window, all-time window on a capped plan). The message names the specific problem. | --- ## PATCH /api/v1/segments/:id Replaces a segment's name and rule. `is_dynamic` cannot be changed in place. Rule validation, including plan caps, mirrors create. ### Request Body ```json { "name": "Premium repeat buyers (90d)", "rule": { "op": "and", "nodes": [ { "event": { "name": "order_placed", "within": "90d", "frequency": { "min": 2 } } } ] } } ``` Both `name` and `rule` are required. ### Response — 200 OK The updated segment. ### Error Codes | Status | Reason | |--------|--------| | 401 | Missing or invalid bearer token. | | 404 | Segment not found. | | 409 | Another segment already uses that name. | | 422 | Malformed rule or plan-cap violation. | --- ## DELETE /api/v1/segments/:id Deletes a segment and, by cascade, its memberships. ### Response — 204 No Content `404` when the segment does not exist. --- ## POST /api/v1/segments/preview Evaluates a rule without persisting it and returns a live size estimate. The request body is the rule object itself (tree or simple flat form), not wrapped in an envelope. ### Request Body ```json { "op": "and", "nodes": [ { "event": { "name": "cart_abandoned", "within": "7d" } } ] } ``` ### Response — 200 OK ```json { "estimated_count": 412, "sample_subscriber_ids": ["8a2f6c1e-...", "1b9d4e7a-..."] } ``` | Field | Type | Description | |-------|------|-------------| | `estimated_count` | integer | Number of subscribers currently matching the rule. | | `sample_subscriber_ids` | array of strings | Up to 10 matching subscriber IDs. | `422` on a malformed rule or plan-cap violation. --- ## GET /api/v1/segments/attribute-keys Returns the tenant's known subscriber-attribute keys (up to 500) — useful for building rule editors and personalization pickers. ### Response — 200 OK ```json { "keys": ["plan", "country", "tier"] } ``` --- ## GET /api/v1/segments/event-names Returns the tenant's distinct tracked event names (up to 200) — the autocomplete source for event conditions. ### Response — 200 OK ```json { "names": ["order_placed", "cart_abandoned", "app_opened"] } ``` --- # API reference: In-App Messages Source: https://docs-staging.hober.io/docs/api-reference/in-app # In-App Messages API In-app messages render inside your app: as a banner, modal, card, or fullscreen takeover, or silently into a durable per-subscriber inbox. The API has two halves: - **Management (experiences)** — create, publish, and measure in-app experiences. Authenticated with a tenant JWT bearer token (`Authorization: Bearer ...`), the credential used by dashboard sessions. - **SDK delivery** — the endpoints your app (or our client SDKs) call to receive messages and report engagement. Authenticated with the publishable SDK key (`X-SDK-Key` header); a JWT bearer token is also accepted on the stream and inbox routes. Every in-app endpoint is gated on your plan's in-app messaging feature. When the plan does not include it, requests return `403`: ```json { "error": "in_app_not_permitted", "message": "in-app messaging is not included in your plan (plan: ...)", "upgrade_required": true } ``` --- ## Experiences An experience is the authored unit: content, a format, optional display rules, an audience, and an optional live window. Its lifecycle is `draft` → `live` → (`paused` ↔ `live`) → `ended` → `archived`; only drafts are editable. ### The experience object ```json { "id": "3f7a1c9e-...", "name": "Summer sale takeover", "format": "modal", "status": "draft", "content": { "title": "Summer sale", "body": "Up to 40% off this week only.", "image_url": "https://cdn.example.com/sale.png", "actions": [ { "id": "shop", "title": "Shop now", "url": "https://example.com/sale" }, { "id": "later", "title": "Maybe later" } ] }, "display": { "trigger": "event", "trigger_event": "cart_abandoned", "frequency": "once", "priority": 5, "screens": ["/cart", "/checkout"] }, "audience": { "type": "segment", "segment_id": "9f5b2c1e-..." }, "start_at": "2026-08-01T00:00:00Z", "end_at": "2026-08-08T00:00:00Z", "created_at": "2026-07-20T12:00:00Z", "updated_at": "2026-07-20T12:00:00Z" } ``` | Field | Type | Description | |-------|------|-------------| | `format` | string | `banner`, `modal`, `inbox_only`, `card`, or `fullscreen`. | | `status` | string | `draft`, `live`, `paused`, `ended`, or `archived`. | | `content.title` | string | Required. | | `content.body` | string | Required to publish. | | `content.image_url` | string | Optional image URL (see [Assets](#assets)). | | `content.actions` | array | Up to 4 CTAs. Each needs `id` and `title`; an empty `url` makes it a dismiss action. | | `display.trigger` | string | `immediate`, `session_start`, or `event`. | | `display.trigger_event` | string | Required when (and only when) `trigger` is `event`. | | `display.frequency` | string | `once` or `once_per_session`. | | `display.priority` | integer | 1–10. | | `display.screens` | array | Up to 20 route patterns, each starting with `/`. The SDKs only show the message on matching screens. | | `audience.type` | string | `all`, `list`, or `segment`; `list_id` / `segment_id` is required for the respective type. | | `start_at` / `end_at` | RFC3339 | Optional live window. `end_at` must be after `start_at`. | | `notification_id` | string | The send a publish produced; absent on drafts. | | `variant_b` | object | Optional A/B arm: `title` (required), `body`, `image_url`. | | `split_percentage` | integer | 1–99, the share receiving arm A (defaults to 50 when a B arm exists). | | `holdout_percentage` | integer | 0–99, share withheld for lift measurement. | | `archived_at` | RFC3339 | Set once archived. | Display rules are evaluated client-side by the SDKs. --- ## POST /api/v1/in-app/experiences Creates a draft experience. ### Request Body ```json { "name": "Summer sale takeover", "format": "modal", "content": { "title": "Summer sale", "body": "Up to 40% off this week only.", "actions": [{ "id": "shop", "title": "Shop now", "url": "https://example.com/sale" }] }, "display": { "trigger": "immediate", "frequency": "once" } } ``` `name` (at most 200 characters), `format`, and `content.title` are required; `display` is optional. ### Response — 201 Created The experience object, with `status: "draft"`. `400` names the invalid field; `401` on a missing or invalid bearer token. --- ## GET /api/v1/in-app/experiences Lists experiences (offset pagination). ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `page` | integer | `1` | 1-based page number. | | `page_size` | integer | `20` | Up to 100. | | `status` | string | — | Optional status filter (`draft`, `live`, `paused`, `ended`, `archived`). | ### Response — 200 OK ```json { "items": [], "total": 12, "page": 1, "page_size": 20 } ``` `items` contains experience objects. --- ## GET /api/v1/in-app/experiences/:id Returns one experience. `404` when it does not exist. --- ## PATCH /api/v1/in-app/experiences/:id Partially edits a **draft**. Absent fields are unchanged; `content`, `display`, and `audience` replace the whole block when provided. Editing a non-draft returns `422`. ### Request Body ```json { "audience": { "type": "list", "list_id": "ch_1111..." }, "start_at": "2026-08-01T00:00:00Z", "end_at": "", "variant_b": { "title": "Summer sale — B", "body": "40% off. Today." }, "split_percentage": 50, "holdout_percentage": 10 } ``` | Field | Type | Description | |-------|------|-------------| | `name`, `format`, `content`, `display`, `audience` | — | As on create. | | `start_at` / `end_at` | string | RFC3339 to set; empty string to clear; omit to leave unchanged. | | `variant_b` | object | Replaces the B arm. | | `remove_variant_b` | boolean | Turns the A/B test off. | | `split_percentage` | integer | 1–99. | | `holdout_percentage` | integer | 0–99. | ### Response — 200 OK The updated experience. --- ## Lifecycle Four transition endpoints, each taking no request body and returning the updated experience (`200`): | Endpoint | Transition | Notes | |----------|------------|-------| | `POST /api/v1/in-app/experiences/:id/publish` | draft → live | Requires an `audience` and `content.body` (and `variant_b.body` for A/B). Enqueues the send — immediately, or at `start_at` if it is in the future. Refused when `end_at` is already past. | | `POST /api/v1/in-app/experiences/:id/pause` | live → paused | Delivered messages are hidden; an undelivered scheduled send is canceled. | | `POST /api/v1/in-app/experiences/:id/resume` | paused → live | Refused when `end_at` has passed — end the experience instead. | | `POST /api/v1/in-app/experiences/:id/end` | live/paused → ended | Terminal; recalls delivered messages. Idempotent. | Invalid transitions return `400` with a message naming the current status. --- ## POST /api/v1/in-app/experiences/:id/archive Archives a `draft` or `ended` experience (idempotent). Live and paused experiences must leave their active status first. ### Response — 200 OK The experience with `status: "archived"` and `archived_at` set. --- ## POST /api/v1/in-app/experiences/:id/duplicate Copies an experience into a new draft. Authored fields are kept; the live window and send linkage are not. ### Request Body (optional) ```json { "name": "Summer sale takeover v2" } ``` When omitted, a "Copy of ..." name is derived. ### Response — 201 Created The new draft experience. --- ## GET /api/v1/in-app/experiences/:id/stats The experience's engagement rollup. ### Response — 200 OK ```json { "experience_id": "3f7a1c9e-...", "status": "live", "impressions": 5120, "unique_reach": 4980, "clicks": 812, "dismissals": 1204, "ctr": 0.1586, "dismiss_rate": 0.2352, "by_variant": [ { "variant_id": "a", "impressions": 2560, "clicks": 500, "dismissals": 600, "ctr": 0.1953 } ] } ``` `ctr` and `dismiss_rate` are ratios over impressions (0 when there are no impressions). `by_variant` is present for A/B experiences. --- ## Assets Image hosting for experience content, JWT-authenticated like the experience endpoints. Uploaded assets get an immutable public URL to reference from `content.image_url`. ### POST /api/v1/in-app/assets Multipart upload with a single `file` part. Returns `201`: ```json { "id": "b41d...", "filename": "sale.png", "content_type": "image/png", "size_bytes": 183220, "url": "https://storage.googleapis.com/.../sale.png", "created_at": "2026-07-20T12:00:00Z" } ``` `400` when the `file` part is missing or the file exceeds the size limit. ### GET /api/v1/in-app/assets Offset-paginated list (`page`, `page_size`), returning `{ "items": [], "total": 0, "page": 1, "page_size": 20 }` with asset objects in `items`. ### DELETE /api/v1/in-app/assets/:id Deletes the metadata row and the stored object. `204` on success. --- ## SDK delivery The endpoints an app calls at runtime. Auth: `X-SDK-Key` header, or a JWT bearer token. Every request identifies the end user with a `subscriber_id` (the Hober subscriber UUID). ### GET /v1/in-app/stream Server-Sent Events stream of live in-app messages for one subscriber. | Query parameter | Required | Description | |-----------------|----------|-------------| | `subscriber_id` | Yes | The subscriber to stream messages for. | The response is `text/event-stream`. Each `data:` frame carries one message as JSON; a keepalive comment is sent every 30 seconds. ```json { "id": "c9e3...", "notification_id": "7d2b...", "title": "Summer sale", "body": "Up to 40% off this week only.", "data": { "campaign": "summer" }, "sent_at": "2026-07-20T12:00:00Z", "format": "modal", "image_url": "https://cdn.example.com/sale.png", "actions": [{ "id": "shop", "label": "Shop now", "url": "https://example.com/sale" }], "display": { "trigger": "immediate", "frequency": "once" } } ``` `id` is the durable inbox message id — use it to mark the message read or dismissed, and to de-duplicate (redeliveries can emit the same message twice). `503` when the live transport is unavailable; the durable inbox below keeps working regardless. ### GET /v1/in-app/messages The durable inbox: messages delivered to a subscriber, newest first. Messages expire 30 days after delivery. | Query parameter | Required | Description | |-----------------|----------|-------------| | `subscriber_id` | Yes | The subscriber whose inbox to read. | | `status` | No | `unread`, `read`, or `dismissed`. | | `limit` | No | Default 50, up to 200. | #### Response — 200 OK ```json { "messages": [ { "id": "c9e3...", "notification_id": "7d2b...", "content": { "title": "Summer sale", "body": "Up to 40% off this week only.", "format": "modal" }, "status": "unread", "created_at": "2026-07-20T12:00:00Z", "expires_at": "2026-08-19T12:00:00Z" } ] } ``` `read_at` is present once the message has been read. `content` carries the delivered payload (title, body, and — for authored experiences — `format`, `image_url`, `actions`, `display`). ### POST /v1/in-app/messages/read Marks all of a subscriber's messages read. ```json { "subscriber_id": "8a2f6c1e-..." } ``` Response: `200` with `{ "updated": 7 }`. ### POST /v1/in-app/messages/:id/read Marks one message read. Body: `{ "subscriber_id": "..." }`. Returns the updated message (`200`), `404` when the message does not exist for that subscriber. ### POST /v1/in-app/messages/:id/dismiss Dismisses one message. Same body and responses as mark-read. --- ## Engagement reporting ### POST /api/v1/notifications/interactions Records an end-user interaction. SDK key auth (`X-SDK-Key`) only. This is the shared notification-interaction endpoint; the in-app interaction types feed the experience stats above. #### Request Body ```json { "job_id": "7d2b...", "interaction": "inapp_click", "platform": "ios", "action_id": "shop", "subscriber_id": "8a2f6c1e-...", "occurred_at": "2026-07-20T12:01:30Z" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `job_id` | string (UUID) | Yes | The `notification_id` carried in the message payload. | | `interaction` | string | Yes | For in-app: `inapp_impression`, `inapp_click`, or `inapp_dismiss`. | | `platform` | string | Yes | `ios`, `android`, `web`, `email`, or `server`. | | `action_id` | string | No | On `inapp_click`, names the tapped `actions[]` entry; omit for a plain body click. | | `url` | string | No | Destination URL for link clicks. | | `subscriber_id` | string | No | The interacting subscriber. | | `device_id` | string | No | The interacting device. | | `variant_id` | string | No | A/B variant attribution. | | `occurred_at` | RFC3339 | Yes | When the interaction happened. | #### Response — 202 Accepted Empty body. `422` on validation failure; `401` on a missing or invalid SDK key. --- # API reference: Channels Source: https://docs-staging.hober.io/docs/api-reference/channels # Channels API A channel is a configured delivery endpoint owned by your workspace — an iOS app, an Android app, a web-push origin, an email sender, or an in-app surface. Each channel carries provider credentials (uploaded separately, see [Credentials](#credentials)) and a verification status that reflects whether those credentials have been validated against the provider. These endpoints are authenticated with a bearer token: ```http Authorization: Bearer YOUR_ACCESS_TOKEN ``` ## The channel object | Field | Type | Description | |-------|------|-------------| | `id` | string | Channel UUID. | | `type` | string | One of `ios`, `android`, `web_push`, `email`, `in_app`, `sms`, `whatsapp`. | | `name` | string | Display name, unique per workspace. | | `active` | boolean | Whether the channel is enabled for sending. | | `status` | string | Credential verification state: `pending`, `active`, or `invalid`. | | `verified_at` | string | RFC 3339 timestamp of the last successful verification. Omitted until verified. | | `verification_error` | string | Provider error from the last failed verification. Omitted when there is none. | | `created_at` | string | Creation timestamp. | A channel starts in `status: "pending"`. Uploading credentials queues an asynchronous verification against the provider; on success the channel moves to `active`, on failure to `invalid` with `verification_error` populated. Re-uploading credentials or calling the verify endpoint resets the cycle. --- ## POST /api/v1/channels Creates a channel. ### Request Body ```json { "type": "ios", "name": "My iOS App" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | string | Yes | One of `ios`, `android`, `web_push`, `email`, `in_app`, `sms`, `whatsapp`. A type not allowed on your plan returns `403 channel_type_not_permitted` with the `allowed` list and `upgrade_required: true`. | | `name` | string | Yes | Display name, up to 128 characters. | ### Response — 201 Created ```json { "id": "6f1d2a34-9c1b-4e8a-b0d2-1a2b3c4d5e6f", "type": "ios", "name": "My iOS App", "active": true, "status": "pending", "created_at": "2026-07-27T12:00:00Z" } ``` ### Error responses | Status | Meaning | |---|---| | `401` | Missing or invalid bearer token | | `403` | Channel type not included in your plan. The body carries `"upgrade_required": true` and an `allowed` list of the channel types your plan permits. | | `422` | Validation failure (unknown `type`, missing or over-long `name`) | ## GET /api/v1/channels Lists all channels in the workspace. ### Response — 200 OK ```json { "channels": [ { "id": "6f1d2a34-9c1b-4e8a-b0d2-1a2b3c4d5e6f", "type": "ios", "name": "My iOS App", "active": true, "status": "active", "verified_at": "2026-07-27T12:05:00Z", "created_at": "2026-07-27T12:00:00Z" } ] } ``` ## GET /api/v1/channels/:id Fetches a single channel by UUID. Returns `200` with the channel object, or `404` if no channel with that ID exists in the workspace. ## PATCH /api/v1/channels/:id Updates a channel. Both fields are optional; omitted fields are unchanged. ### Request Body ```json { "name": "Renamed App", "active": false } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | No | New display name. | | `active` | boolean | No | Enable or disable the channel for sending. | ### Response — 200 OK The updated channel object. ## DELETE /api/v1/channels/:id Deletes a channel. ### Query parameters | Param | Type | Description | |---|---|---| | `force` | boolean | When `true`, deletes the channel even if it has credentials stored. | ### Response **204 No Content** on success. | Status | Meaning | |---|---| | `404` | Channel not found | | `409` | Channel has credentials stored and `force=true` was not supplied | ## POST /api/v1/channels/:id/verify Re-queues credential verification for the channel. Use this after fixing a provider-side problem (revoked key, changed bundle ID) without re-uploading the credentials. ### Response — 202 Accepted ```json { "status": "queued" } ``` Returns `404` if the channel has no credentials stored. Poll `GET /api/v1/channels/:id` to observe the resulting `status` transition. --- ## Credentials Provider credentials are **write-only secrets**. They are encrypted with a KMS-managed key at rest and are never returned by any API — the upload response confirms only the credential's metadata (`id`, `channel_id`, `type`, timestamps). If you lose a credential, upload a new one; there is no way to read it back. Uploading credentials to a channel that already has some replaces them and re-queues verification. ## POST /api/v1/channels/:id/credentials/apns Uploads an APNs token-signing key for an `ios` channel. The request is `multipart/form-data`. ### Form fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `key_file` | file | Yes | The `.p8` APNs auth key file from your Apple Developer account. | | `key_id` | string | No | The 10-character Key ID of the `.p8` key. | | `team_id` | string | No | Your Apple Developer Team ID. | | `bundle_id` | string | No | The app's bundle identifier (used as the APNs topic). | | `environment` | string | No | `sandbox` or `production`. Defaults to `sandbox`. | ### Response — 200 OK ```json { "id": "cred-1", "channel_id": "6f1d2a34-9c1b-4e8a-b0d2-1a2b3c4d5e6f", "type": "apns", "created_at": "2026-07-27T12:01:00Z", "updated_at": "2026-07-27T12:01:00Z" } ``` ## POST /api/v1/channels/:id/credentials/fcm Uploads a Firebase service-account key for an `android` channel. The request is `multipart/form-data`. ### Form fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `key_file` | file | Yes | The Firebase service-account JSON key file. | ### Response — 200 OK The credential confirmation object (same shape as APNs, with `"type": "fcm"`). ## POST /api/v1/channels/:id/credentials/vapid Generates a VAPID key pair server-side for a `web_push` channel. **No request body is required.** The response includes the generated public key so you can register it with the browser's push manager; the private key stays encrypted server-side and is never exposed. ### Response — 200 OK ```json { "id": "cred-2", "channel_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d", "type": "vapid", "vapid_public_key": "BODl3v8Yx...", "created_at": "2026-07-27T12:01:00Z", "updated_at": "2026-07-27T12:01:00Z" } ``` ## POST /api/v1/channels/:id/credentials/sms :::info SMS delivery uses your own Twilio-compatible provider account — see the [SMS & WhatsApp setup guide](../guides/sms-whatsapp-setup.md). ::: Uploads SMS provider credentials for an `sms` channel. JSON body. ### Request Body ```json { "api_key": "...", "api_secret": "...", "sender_id": "..." } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `api_key` | string | Yes | Provider API key. | | `api_secret` | string | Yes | Provider API secret. | | `sender_id` | string | Yes | Registered sender ID or phone number. | ### Response — 200 OK The credential confirmation object with `"type": "sms"`. ## POST /api/v1/channels/:id/credentials/whatsapp :::info WhatsApp delivery uses your own Meta Cloud API credentials and approved templates — see the [SMS & WhatsApp setup guide](../guides/sms-whatsapp-setup.md). ::: Uploads WhatsApp Business (Meta Cloud API) credentials for a `whatsapp` channel. JSON body. ### Request Body ```json { "access_token": "...", "phone_number_id": "...", "waba_id": "...", "app_secret": "...", "webhook_verify_token": "..." } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `access_token` | string | Yes | Meta system-user access token. | | `phone_number_id` | string | Yes | The WhatsApp Business phone number ID. | | `waba_id` | string | Yes | The WhatsApp Business Account ID. | | `app_secret` | string | No | Meta app secret — required only to receive inbound webhooks (opt-outs, template status callbacks). | | `webhook_verify_token` | string | No | Verify token for the Meta webhook subscription. | ### Response — 200 OK The credential confirmation object with `"type": "whatsapp"`. --- ## WhatsApp message templates :::caution Beta Part of the WhatsApp Beta — see above. ::: WhatsApp marketing messages must use a Meta-approved template. These endpoints manage the template lifecycle: draft locally, submit to Meta for review, then poll the approval status. ### The template object | Field | Type | Description | |-------|------|-------------| | `id` | string | Template UUID. | | `channel_id` | string | The owning `whatsapp` channel. | | `name` | string | Template name. | | `language` | string | Template language code. | | `category` | string | Meta template category. | | `body` | string | Template body text. | | `status` | string | Local approval state of the template. | | `provider_template_id` | string | Meta's template ID, present after submission. | | `status_reason` | string | Meta's rejection reason, when present. | | `created_at` | string | Creation timestamp. | | `updated_at` | string | Last-update timestamp. | ## POST /api/v1/channels/:id/whatsapp-templates Creates a template draft. ### Request Body ```json { "name": "order_shipped", "language": "en_US", "category": "MARKETING", "body": "Your order is on the way!" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Template name. | | `body` | string | Yes | Template body text. | | `language` | string | No | Language code. | | `category` | string | No | Meta template category. | Returns `201` with the template object. ## GET /api/v1/channels/:id/whatsapp-templates Lists the channel's templates. ### Response — 200 OK ```json { "items": [ { "id": "...", "name": "order_shipped", "status": "..." } ] } ``` ## POST /api/v1/channels/:id/whatsapp-templates/:templateId/submit Submits the draft to Meta for review. Returns `200` with the updated template object, including the `provider_template_id` assigned by Meta. ## POST /api/v1/channels/:id/whatsapp-templates/:templateId/refresh Pulls the current approval status from Meta and applies it. Returns `200` with the updated template object — check `status` and, on rejection, `status_reason`. --- # API reference: Analytics & Insights Source: https://docs-staging.hober.io/docs/api-reference/insights # Analytics & Insights API Read endpoints over the delivery, engagement, and conversion data the platform records for your workspace: campaign and journey funnels, attribution rollups, a best-time-to-send heatmap, holdout lift readouts, and chart annotations. Conversion tracking is configured through the same surface. These endpoints are authenticated with a bearer token: ```http Authorization: Bearer YOUR_ACCESS_TOKEN ``` Timestamps are RFC 3339. Where a `start` / `end` window is accepted, both are optional and the window defaults to the last 30 days. --- ## GET /api/v1/insights/funnel Returns the engagement funnel for one campaign or journey: how many messages were sent, delivered, opened, clicked, and how many conversions (with revenue) were attributed back to it. ### Query parameters | Param | Type | Required | Description | |---|---|---|---| | `source_type` | string | Yes | `campaign` or `journey`. | | `source_id` | string | Yes | UUID of the campaign or journey (the ID, not the name). | | `start` | RFC 3339 | No | Window start. Defaults to 30 days ago. | | `end` | RFC 3339 | No | Window end. Defaults to now. | ### Response — 200 OK ```json { "sent": 12000, "delivered": 11800, "opened": 4100, "clicked": 950, "converted": 120, "revenue_cents": 458800 } ``` `422` — missing or invalid `source_type`, or `source_id` is not a valid UUID. ## GET /api/v1/insights/summary Attribution summary grouped by campaign, journey, or channel: conversions and attributed revenue per source over the window. ### Query parameters | Param | Type | Required | Description | |---|---|---|---| | `group_by` | string | Yes | `campaign`, `journey`, or `channel`. | | `start` | RFC 3339 | No | Window start. Defaults to 30 days ago. | | `end` | RFC 3339 | No | Window end. Defaults to now. | ### Response — 200 OK ```json { "rows": [ { "source_id": "9c4e1f2a-7b6d-4c3e-8a1f-2b3c4d5e6f70", "conversions": 120, "revenue_cents": 458800 } ] } ``` `422` — missing or invalid `group_by`. ## GET /api/v1/insights/heatmap Best-time-to-send heatmap: engagement bucketed by weekday and hour (UTC) over a trailing window. Empty buckets are omitted from `cells`; fill the 7 × 24 grid client-side (`weekday` 0 = Sunday … 6 = Saturday, `hour` 0–23). ### Query parameters | Param | Type | Required | Description | |---|---|---|---| | `days` | int | No | Trailing window in days. Defaults to 90, capped at 365. | ### Response — 200 OK ```json { "window_days": 90, "cells": [ { "weekday": 2, "hour": 9, "delivered": 1800, "opened": 640 } ] } ``` ## GET /api/v1/insights/lift Treated-versus-holdout lift readout for a send that was created with a holdout percentage. Compares the conversion rate of subscribers who received the message against the held-out control group. ### Query parameters | Param | Type | Required | Description | |---|---|---|---| | `job_id` | string | Yes | The notification job ID of the send. | ### Response — 200 OK ```json { "job_id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", "treated": { "size": 9500, "converters": 240, "conversion_rate": 0.0253, "revenue_cents": 912000 }, "holdout": { "size": 500, "converters": 6, "conversion_rate": 0.012, "revenue_cents": 22800 }, "lift_percentage_points": 1.33, "window_days": 7, "conversion_configured": true } ``` | Field | Type | Description | |-------|------|-------------| | `treated` / `holdout` | object | Group size, converters within the attribution window, derived `conversion_rate` (0 when the group is empty), and conversion revenue. | | `lift_percentage_points` | number | Treated conversion rate minus holdout rate, in percentage points. | | `window_days` | number | The attribution window applied. | | `conversion_configured` | boolean | `false` when the workspace has no conversion events configured — the readout is vacuously zero; configure a conversion event first. | `422` — missing `job_id`. --- ## Conversion configuration Attribution needs to know which behavioral events count as conversions. Definitions apply workspace-wide. ## GET /api/v1/insights/config ### Response — 200 OK ```json { "definitions": [ { "event_name": "purchase", "value_property": "total_cents", "window_seconds": 604800 } ] } ``` ## POST /api/v1/insights/config Creates or updates a conversion event definition (upsert by `event_name`). ### Request Body ```json { "event_name": "purchase", "value_property": "total_cents", "window_seconds": 604800 } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `event_name` | string | Yes | The behavioral event name that counts as a conversion. | | `value_property` | string | No | Event property holding the revenue amount in cents. | | `window_seconds` | int | No | Attribution window after a message touch. | Returns **204 No Content**. ## DELETE /api/v1/insights/config Deletes a conversion event definition. The event name is passed as a query parameter (names may contain dots). ### Query parameters | Param | Type | Required | Description | |---|---|---|---| | `event_name` | string | Yes | Name of the definition to delete. | Returns **204 No Content**, or `404` if no definition with that name exists. --- ## Chart annotations Annotations are timestamped notes that render on analytics charts — some are recorded automatically by the platform (for example plan changes), and you can create your own. ### The annotation object | Field | Type | Description | |-------|------|-------------| | `id` | string | Annotation UUID. | | `occurred_at` | string | When the annotated moment happened. | | `ends_at` | string | Optional range end; absent for point annotations. | | `text` | string | The note text. | | `source` | string | `auto` (platform-recorded) or `manual` (user-created). | | `kind` | string | Annotation kind; manual annotations are always `custom`. | | `visibility` | string | `tenant` or `agency_only`. | | `ref_id` | string | Optional reference to the related object. | | `created_by` | string | User who created a manual annotation. | | `created_at` | string | Creation timestamp. | ## GET /api/v1/annotations Lists annotations overlapping a time range. ### Query parameters | Param | Type | Required | Description | |---|---|---|---| | `from` | RFC 3339 | Yes | Range start. | | `to` | RFC 3339 | Yes | Range end; must not precede `from`. | | `kinds` | string | No | Comma-separated kind filter, e.g. `custom,plan_change`. | ### Response — 200 OK ```json { "annotations": [ { "id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "occurred_at": "2026-07-20T09:00:00Z", "text": "Summer campaign launch", "source": "manual", "kind": "custom", "visibility": "tenant", "created_at": "2026-07-20T09:05:00Z" } ] } ``` `422` — `from` / `to` missing, not RFC 3339, or `to` precedes `from`. ## POST /api/v1/annotations Creates a manual annotation (`kind` is always `custom`). ### Request Body ```json { "occurred_at": "2026-07-20T09:00:00Z", "ends_at": "2026-07-21T09:00:00Z", "text": "Summer campaign launch", "visibility": "tenant" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `occurred_at` | RFC 3339 | Yes | When the annotated moment happened. | | `ends_at` | RFC 3339 | No | Range end for range annotations. | | `text` | string | Yes | Note text, up to 500 characters. | | `visibility` | string | No | Defaults to `tenant`. `agency_only` may only be set from an agency session operating on a client workspace. | Returns `201` with the created annotation object. `403` — read-only session, or `agency_only` requested outside an agency session. ## DELETE /api/v1/annotations/:id Deletes a manual annotation. Platform-recorded (`source: "auto"`) annotations cannot be deleted. Returns **204 No Content**, or `404` if the annotation does not exist. --- # API reference: Imports & Exports Source: https://docs-staging.hober.io/docs/api-reference/imports-exports # Imports & Exports API Bulk data movement in and out of your workspace: import subscribers into a list from a CSV file (with presets for Klaviyo and Braze exports), and export your behavioral event history. ## Authentication The two surfaces use different credentials: - **Subscriber imports** are authenticated with a bearer token, like the dashboard APIs: ```http Authorization: Bearer YOUR_ACCESS_TOKEN ``` - **Event export** requires a **server API key** (`hober_srv_…`) — see [GET /v1/events/export](#get-v1eventsexport) below. --- ## Subscriber imports Imports run as asynchronous jobs: upload a CSV, get a job ID back immediately, then poll the job until it completes. Rows that fail validation are counted in `failed_rows` without aborting the rest of the import. ### Import job statuses | Status | Meaning | |---|---| | `pending` | Accepted, waiting to be picked up | | `processing` | Rows are being processed | | `completed` | All rows attempted (terminal) | | `failed` | The job hit an unrecoverable error (terminal) — see `error_message` | ## POST /api/v1/lists/import Starts a subscriber import into an existing list. The request is `multipart/form-data`. ### Form fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `listId` | string | Yes | UUID of the target subscriber list. | | `file` | file | Yes | The CSV file. Must have a `text/csv` content type or a `.csv` extension. Maximum size 50 MB. | | `source` | string | No | Import preset: `csv` (default), `klaviyo`, or `braze`. | ### Import presets The preset controls how the CSV's columns are interpreted: - `csv` — the standard column contract (default when `source` is omitted). - `klaviyo` — accepts a Klaviyo profile export as-is: Klaviyo's headers are aliased to the standard fields, extra columns are packed into subscriber attributes, and Klaviyo consent statuses are translated conservatively. - `braze` — accepts a Braze user export as-is, with the equivalent header mapping. ### Response — 202 Accepted ```json { "import_job_id": "550e8400-e29b-41d4-a716-446655440000", "status": "pending" } ``` ### Error responses | Status | Meaning | |---|---| | `400` | Missing `listId` or `file`, or the file is not a CSV | | `401` | Missing or invalid bearer token | | `404` | The list does not exist in your workspace | | `413` | File larger than 50 MB | | `422` | Unknown `source` value | ## GET /api/v1/lists/import/:jobId Fetches the status of an import job. ### Response — 200 OK ```json { "import_job_id": "550e8400-e29b-41d4-a716-446655440000", "status": "processing", "processed_rows": 1500, "total_rows": 5000, "failed_rows": 10 } ``` | Field | Type | Description | |-------|------|-------------| | `import_job_id` | string | The job UUID. | | `status` | string | `pending`, `processing`, `completed`, or `failed`. | | `processed_rows` | int | Rows attempted so far. | | `total_rows` | int | Total rows in the file. Absent until processing starts. | | `failed_rows` | int | Rows that failed validation and were skipped. | | `error_message` | string | Terminal error reason. Present only on `failed` jobs. | `404` — no import job with that ID in your workspace. --- ## Event exports ## GET /v1/events/export Bulk export of the workspace's behavioral event history, paginated with an opaque cursor. This endpoint requires a **server API key** — publishable client SDK keys cannot read event history: ```http Authorization: Bearer hober_srv_YOUR_SERVER_KEY ``` This is the same endpoint documented on the [Events page](events.md) — see there for the event field semantics and ingestion counterpart. ### Query parameters | Param | Type | Required | Description | |---|---|---|---| | `from` | RFC 3339 | No | Window start on `occurred_at`. Defaults to all history. | | `to` | RFC 3339 | No | Window end. Defaults to now. | | `cursor` | string | No | Opaque cursor from the previous page's `next_cursor`. | | `limit` | int | No | Events per page, 1–1000. Defaults to 1000. | ### Response — 200 OK ```json { "items": [ { "id": "6a7b8c9d-0e1f-2a3b-4c5d-6e7f8a9b0c1d", "subscriber_id": "8a2f6c1e-1234-4abc-9def-567890abcdef", "anonymous_id": null, "event_name": "order_placed", "platform": "web", "properties": { "total_price": "19.99" }, "occurred_at": "2026-07-01T12:00:00Z", "received_at": "2026-07-01T12:00:01Z", "source": "server" } ], "next_cursor": "" } ``` Rows are ordered by `(occurred_at, id)`, so pages are stable under concurrent ingestion. Keep requesting with the returned `next_cursor` until it comes back empty — an empty `next_cursor` means the final page. ### Error responses | Status | Meaning | |---|---| | `401` | Missing, malformed, or revoked credential | | `403` | The credential is a client SDK key — export requires a server key | | `422` | Malformed `from` / `to`, `limit` out of range, or malformed `cursor` | --- # API reference: Authentication & API Keys Source: https://docs-staging.hober.io/docs/api-reference/authentication # Authentication & API Keys The Hober REST API base URL is `https://api.hober.io`. Every request is authenticated with one of three credential types, each scoped to what that kind of caller should be able to do. ## Credential types at a glance | Credential | Format | Sent as | Secrecy | Typical caller | |---|---|---|---|---| | Client SDK key | `sk_…` | `X-SDK-Key` header | Publishable | Mobile / browser SDKs embedded in your app | | Server key | `hober_srv_…` | `Authorization: Bearer` | Secret | Your backend (event tracking, export, REST hooks) | | Dashboard session token | JWT | `Authorization: Bearer` | Secret, short-lived | The Hober dashboard on behalf of a logged-in user | ## Client SDK key The client SDK key is a **publishable**, workspace-scoped key designed to be shipped inside mobile apps and web pages. Its format is a `sk_` prefix, an encoded workspace identifier, and an HMAC-SHA256 signature: ```text sk_XXXXXXXXXXXXXXXX.YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY ``` The signature is embedded in the key itself — requests are **not** individually signed. Send the key verbatim on every request: ```http X-SDK-Key: YOUR_SDK_KEY ``` You can copy your workspace's SDK key from the dashboard under **Settings → API keys**. The key is stable for the life of the workspace: the same key is returned every time you view it, and there is currently no self-serve rotation. ### What the client key can call Because the key is extractable from a shipped app, it only grants write-forward, device-facing operations: - `POST /api/v1/subscribers/upsert` — create or update a subscriber - `POST /api/v1/devices` — register a device push token - `POST /api/v1/notifications/interactions` — record opens, taps, and clicks - `POST /v1/events` and `POST /v1/events/identify` — behavioral event ingestion (client tier — see [Events](events.md) for tier limits) - The in-app message delivery stream used by the SDKs It cannot read or export data, send notifications, or manage any workspace resource. A missing or invalid key returns `401` with code `UNAUTHORIZED`. ## Server keys Server keys are **secret**, revocable credentials for server-to-server calls. Their format is the `hober_srv_` prefix followed by 32 URL-safe base64 characters. Send them as a bearer token: ```http Authorization: Bearer hober_srv_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ``` Create and revoke server keys in the dashboard under **Settings → API keys** (requires a role with credential-management permission). The full key is shown **exactly once** at creation — only its prefix is displayed afterwards, and the key is stored as a one-way hash. ### Scopes Each server key carries a set of scopes with the shape `domain:read` / `domain:write`. Keys created without explicit scopes get the default set: | Scope | Grants | |---|---| | `events:write` | Event ingestion (`POST /v1/events`, `/v1/events/identify`) — the default scope | A server key that reaches the events endpoint without `events:write` receives `403` with code `FORBIDDEN`. ### What server keys can call - `POST /v1/events`, `POST /v1/events/identify` — server-tier event ingestion, including `external_id` actor resolution and larger batch limits - `GET /v1/events/export` — bulk event history export - `GET /v1/whoami` — the identity of the presented key - `POST /v1/hooks`, `GET /v1/hooks`, `DELETE /v1/hooks/:id` — REST hook (webhook subscription) management, as used by the Zapier integration - `GET /v1/subscribers` — a paged subscriber listing for integrations See the [Server-Side Event Tracking guide](../guides/server-side-event-tracking.md) for choosing between the client and server tier. ### Revocation Revoking a key takes effect immediately for most surfaces; the event ingestion path caches successful validations briefly, so a revoked key can keep ingesting for up to about one minute. ## Dashboard session tokens Management surfaces — notifications, campaigns, templates, schedules, segments, journeys, channels, webhooks, team, and billing — are authenticated with the short-lived JWT bearer tokens that the dashboard obtains when a user logs in. Access tokens expire after 15 minutes and are refreshed transparently by the dashboard session; role-based permissions on the logged-in user additionally gate what each token may do (for example, read-only tokens receive `403` with code `READ_ONLY_TOKEN` on write requests). There is currently no long-lived personal access token for the management API. For programmatic server-to-server access, use a server key (events, export, and REST hooks) — or agent access over MCP, which uses its own `hober_mcp_` key type; see the [Agent access guide](../guides/agent-access-mcp.md). --- # API reference: Errors & Rate Limits Source: https://docs-staging.hober.io/docs/api-reference/errors # Errors & Rate Limits ## Error envelope All HTTP error responses use a single canonical JSON envelope: ```json { "code": "ERROR_CODE", "message": "Human-readable description safe to display to clients." } ``` | Field | Type | Description | |---|---|---| | `code` | string | Machine-readable error identifier (SCREAMING_SNAKE_CASE). Stable across releases — safe for client switch/case logic. | | `message` | string | Human-readable description. May change between releases; do not match on this field programmatically. | Some validation failures additionally include a `fields` object mapping field names to per-field messages. ## Standard codes | Code | HTTP status | Meaning | |---|---|---| | `BAD_REQUEST` | 400 | Malformed request that cannot be understood. | | `UNAUTHORIZED` | 401 | Authentication is required or credentials are invalid. | | `FORBIDDEN` | 403 | Authenticated but not permitted to perform this action. | | `NOT_FOUND` | 404 | The requested resource does not exist. | | `CONFLICT` | 409 | The resource already exists or is in a conflicting state. | | `VALIDATION_ERROR` | 422 | Request body or parameters failed validation. | | `INTERNAL_ERROR` | 500 | An unexpected server-side error occurred. | | `SERVICE_UNAVAILABLE` | 503 | A downstream dependency is unavailable. | ## Domain-specific codes | Code | HTTP status | Meaning | |---|---|---| | `PAYMENT_REQUIRED` | 402 | The plan quota for this billing period is exhausted. | | `SEND_CAP_REACHED` | 402 | Your own configured monthly send cap has been reached — raise the cap in Billing rather than upgrading the plan. | | `TENANT_SUSPENDED` | 403 | The workspace account has been suspended. | | `TENANT_DELETED` | 403 | The workspace account has been closed. | | `FEATURE_NOT_AVAILABLE` | 403 | The feature is not available on your current plan. | | `READ_ONLY_TOKEN` | 403 | The token is read-only and cannot authorize write operations. | | `FILE_TOO_LARGE` | 413 | An uploaded file exceeds the maximum allowed size. | | `TEMPLATE_NOT_FOUND` | 422 | The `template_id` referenced in the request does not exist in this workspace. | | `QUOTA_EXCEEDED` | 429 | The workspace has exceeded a plan quota. | | `RATE_LIMITED` | 429 | Too many attempts on a rate-limited surface (currently account registration). | ## Rate limits ### Per-workspace request limit Requests authenticated with a dashboard session token are rate limited per workspace over a fixed one-minute window. The limit depends on your plan: | Plan | Requests per minute | |---|---| | Free | 60 | | Starter | 300 | | Pro | 1,000 | | Enterprise | 10,000 | When the limit is exceeded, the API responds `429` with a `Retry-After` header giving the number of seconds until the window resets. `Retry-After` is the only rate-limit header — there are no `X-RateLimit-*` headers. Note that this particular response uses a different body shape from the standard envelope: ```json { "error": "rate limit exceeded", "retry_after": 42 } ``` Requests authenticated with a client SDK key or server key are not counted against this per-minute limit; the event ingestion endpoint instead enforces per-tier payload caps, below. ### Event ingestion caps `POST /v1/events` applies fixed caps that depend on the credential tier: | | Client (`X-SDK-Key`) | Server (`Bearer hober_srv_…`) | |---|---|---| | Events per request | 50 | 1,000 | | Request body | — | 500 KB (`413` beyond) | | Single event | — | 32 KB | | `occurred_at` backdating window | 48 hours | 90 days | Cap violations return `422` with code `VALIDATION_ERROR` (oversized server-tier bodies return `413`). See [Events](events.md) for the full endpoint reference. ### Registration limit Creating a new account is limited to 5 registrations per IP address per hour and 10 per email domain per day. Exceeding either limit returns `429` with code `RATE_LIMITED` and a `Retry-After` header. ### Quotas are not rate limits `QUOTA_EXCEEDED` (429), `PAYMENT_REQUIRED` (402), and `SEND_CAP_REACHED` (402) signal plan or billing limits, not request-frequency limits — retrying will not succeed until the quota resets, the plan changes, or the cap is raised. ## Handling errors - Branch on `code`, never on `message`. - On `429`, honor the `Retry-After` header before retrying; add jittered exponential backoff for repeated rejections. - Treat `500` and `503` as transient and safe to retry with backoff; 4xx responses (other than `429`) will not succeed on retry without changing the request. --- # Browser SDK (web push): Browser SDK Quickstart Source: https://docs-staging.hober.io/docs/browser-sdk/quickstart # Browser SDK Quickstart Add web push notifications to your web application in under 5 minutes. ## Prerequisites - An active Hober account and SDK key - A **Web Push channel** — create one in the dashboard under **Platform → Channels → Add Channel**. Hober generates the VAPID key pair for you; you'll need the channel ID and VAPID public key from the channel's detail page (see [Initialization](#initialization)) - A site served over **HTTPS** (or `localhost` for local development — see [Troubleshooting](#troubleshooting)) - A modern browser (see [Browser Compatibility](#browser-compatibility)) ## Installation ### Option 1 — npm Install the package from npm: ```bash npm install @hoberhq/browser-sdk ``` Then import it in your application entry point: ```js import Hober from '@hoberhq/browser-sdk'; ``` ### Option 2 — CDN (Script Tag) Add the following script tag to your HTML `` or just before ``: ```html ``` The `Hober` object is exposed as a global variable after the script loads. ## Service Worker Setup The Browser SDK relies on a service worker to receive push notifications in the background. ### Step 1 — Copy `hober-sw.js` to your web root Download or copy the `hober-sw.js` file and place it at the **root** of your public web server so that it is served at `/hober-sw.js`. **npm users** — the file ships inside the package: ```bash cp node_modules/@hoberhq/browser-sdk/dist/sw/hober-sw.js public/hober-sw.js ``` **CDN users** — save the file from: ``` https://cdn.hober.io/browser-sdk@1.0.0/hober-sw.js ``` Place it in your static/public directory so that it is reachable at `https://yourdomain.com/hober-sw.js`. :::important The service worker must be served from the root path (`/hober-sw.js`). If it is placed in a subdirectory its scope will be limited and push notifications will not work. ::: ### Step 2 — Verify the file is accessible Navigate to `https://yourdomain.com/hober-sw.js` in a browser. You should see the service worker JavaScript source. A 404 response means the file is not in the right location. ## Initialization Call `Hober.init()` once, as early as possible in your application lifecycle (e.g. in your app entry point or `DOMContentLoaded` handler): ```js Hober.init({ sdkKey: 'YOUR_SDK_KEY', channelId: 'YOUR_CHANNEL_ID', vapidPublicKey: 'YOUR_VAPID_PUBLIC_KEY', }); ``` `Hober.init()` must be called before any other SDK method. All three options are required: | Option | Where to find it | |--------|------------------| | `sdkKey` | Dashboard → **Settings > SDK Key** — the client key (`sk_…`), safe to embed in your site | | `channelId` | Dashboard → **Platform > Channels** → your Web Push channel → **Web push setup** | | `vapidPublicKey` | Same panel — the VAPID public key browsers subscribe against | The **Web push setup** panel on the channel detail page includes a ready-to-paste snippet with all three values already filled in. :::note The VAPID *public* key is safe to embed in client-side JavaScript — that is its purpose. The matching private key never leaves Hober's servers. ::: ## Request Notification Permission Prompt the user for notification permission. This must be triggered by a user gesture (button click, etc.) to satisfy browser security requirements: ```js const granted = await Hober.requestPermission(); if (granted) { console.log('Notification permission granted.'); } else { console.warn('Notification permission denied.'); } ``` `Hober.requestPermission()` returns a `Promise` — `true` when the user grants permission, `false` when they deny or dismiss. ## Identify a Subscriber Associate the current user with a Hober subscriber record. Call this after the user logs in: ```js await Hober.identifySubscriber({ externalId: 'user-123', email: 'user@example.com', }); ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `externalId` | string | Yes | Your internal user ID | | `email` | string | No | User email for segmentation | ## Register the Device Subscribe the current browser to push notifications. Call this after permission has been granted: ```js await Hober.registerDevice(); ``` This registers the browser with the Web Push Protocol and stores the subscription endpoint in Hober. After this call, the device will start receiving push notifications sent via the Hober API or dashboard. ## Complete Example ```html My App ``` ## Browser Compatibility The Web Push API is supported in all major modern browsers. The table below reflects support as of 2024: | Browser | Minimum Version | Notes | |---------|----------------|-------| | Chrome | 50+ | Full support | | Firefox | 44+ | Full support | | Safari | 16+ | Requires macOS Ventura or iOS 16.4+ | | Edge | 17+ | Full support (Chromium-based Edge recommended) | :::note Safari on iOS requires iOS 16.4+ and the site must be added to the Home Screen to receive push notifications via the Web Push standard. ::: ## Troubleshooting ### Permission Denied **Symptom:** `Hober.requestPermission()` returns `false` and the browser shows no permission prompt. **Cause:** The user has previously denied notification permission for your domain. **Fix:** Users must manually reset the permission in their browser settings: - Chrome: Settings > Privacy and security > Site settings > Notifications - Firefox: Preferences > Privacy & Security > Permissions > Notifications - Safari: Safari > Settings for This Website > Notifications - Edge: Settings > Cookies and site permissions > Notifications You cannot re-prompt a user who has denied permission. Show a help message directing them to browser settings. ### Service Worker Not Found **Symptom:** `Hober.registerDevice()` throws an error mentioning the service worker, or you see a 404 for `/hober-sw.js` in the browser DevTools Network panel. **Cause:** The `hober-sw.js` file is missing from your web root or is being served from the wrong path. **Fix:** 1. Confirm `hober-sw.js` is placed in your `public/` (or equivalent static) directory. 2. Visit `https://yourdomain.com/hober-sw.js` directly — you should see JavaScript source, not a 404. 3. If you are using a build tool (Webpack, Vite), ensure static files in `public/` are copied to the output directory. ### HTTPS Requirement **Symptom:** The SDK or browser throws an error about a secure context, or `navigator.serviceWorker` is undefined. **Cause:** Web Push and service workers require a **secure context** — the page must be served over HTTPS. The only exception is `localhost`, which browsers treat as secure for local development. **Fix:** - Ensure your production site is served over `https://`. - For local development, use `http://localhost` (not `http://127.0.0.1` or a custom hostname without HTTPS). - Do not test on `http://` staging environments — use a self-signed certificate or a tunnel (e.g. `ngrok`) if needed. --- ## Next Steps - [Browser SDK API Reference](/docs/browser-sdk/api-reference) — full TypeDoc-generated method signatures and type definitions - [Browser SDK Configuration](/docs/browser-sdk/configuration) — advanced configuration options - [Hober REST API Reference](/docs/api-reference/overview) — direct REST API access --- # Browser SDK (web push): Configuration Source: https://docs-staging.hober.io/docs/browser-sdk/configuration # Browser SDK Configuration | Option | Type | Required | Description | |--------|------|----------|-------------| | `sdkKey` | string | Yes | Your client SDK key (`sk_…`) — Dashboard → **Settings > SDK Key**. Safe to embed in client-side code | | `channelId` | string | Yes | The Web Push channel to register devices against (Dashboard → **Platform > Channels** → your channel → **Web push setup**) | | `vapidPublicKey` | string | Yes | The channel's VAPID public application server key — shown in the same **Web push setup** panel. Safe to embed in client-side code | | `baseUrl` | string | No | Override the API base URL (for staging) | | `serviceWorkerPath` | string | No | Custom path to hober-sw.js (default: `/hober-sw.js`) | --- # Browser SDK (web push): API Reference Source: https://docs-staging.hober.io/docs/browser-sdk/api-reference # Browser SDK API Reference Auto-generated from TypeDoc annotations in `@hoberhq/browser-sdk`. All methods are available on the default `Hober` export. ## Hober.init(options) Initializes the SDK. Must be called before any other method. **Signature** ```ts Hober.init(options: HoberInitOptions): void ``` **Parameters** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `options.sdkKey` | `string` | Yes | Your Hober SDK key from the dashboard. | | `options.serviceWorkerPath` | `string` | No | Path to `hober-sw.js`. Defaults to `"/hober-sw.js"`. | | `options.baseUrl` | `string` | No | Override the Hober API base URL (e.g. for staging). | **Returns:** `void` **Throws:** `HoberError` if `sdkKey` is empty or `options` is not provided. --- ## Hober.requestPermission() Requests the `Notification` permission from the browser. Returns `Promise`. **Signature** ```ts Hober.requestPermission(): Promise ``` **Returns:** `Promise` — resolves to `true` if the user grants permission, `false` if denied or dismissed. **Throws:** `HoberError` if the SDK has not been initialized. --- ## Hober.identifySubscriber(subscriber) Associates the current user with a Hober subscriber record by calling `POST /v1/subscribers/upsert`. **Signature** ```ts Hober.identifySubscriber(subscriber: HoberSubscriberOptions): Promise ``` **Parameters** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `subscriber.externalId` | `string` | Yes | Your application's user identifier. | | `subscriber.email` | `string` | No | Subscriber email address. | | `subscriber.attributes` | `Record` | No | Additional subscriber attributes. | **Returns:** `Promise` **Throws:** `HoberError` if `externalId` is empty or the API returns a non-2xx response. --- ## Hober.registerDevice() Registers the current browser as a push-enabled device by subscribing to the Web Push service and calling `POST /v1/devices`. **Signature** ```ts Hober.registerDevice(): Promise ``` **Returns:** `Promise` **Throws:** `HoberError` if the SDK has not been initialized, if the service worker is not registered, or the API returns a non-2xx response. --- ## Types ### HoberInitOptions ```ts interface HoberInitOptions { sdkKey: string; serviceWorkerPath?: string; baseUrl?: string; } ``` ### HoberSubscriberOptions ```ts interface HoberSubscriberOptions { externalId: string; email?: string; attributes?: Record; } ``` ### HoberError ```ts class HoberError extends Error { code: string; } ``` --- ## See Also - [Browser SDK Quickstart](/docs/browser-sdk/quickstart) - [Browser SDK Configuration](/docs/browser-sdk/configuration) - [Hober REST API Reference](/docs/api-reference/overview) --- # iOS SDK: iOS SDK Quickstart Source: https://docs-staging.hober.io/docs/ios-sdk/quickstart # iOS SDK Quickstart Native Swift SDK for iOS apps using APNs. ## Installation Add to your `Package.swift`: ```swift .package(url: "https://github.com/hoberhq/hober-ios-sdk.git", from: "1.0.0") ``` ## Initialize ```swift import Hober func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { Hober.initialize(sdkKey: "YOUR_SDK_KEY") return true } ``` --- ## Next Steps - [iOS SDK Guide](/docs/ios-sdk/guide) — APNs configuration, permissions, and advanced setup - [iOS SDK API Reference](/docs/ios-sdk/api-reference) — full DocC-generated method signatures and type definitions - [Hober REST API Reference](/docs/api-reference/overview) — direct REST API access --- # iOS SDK: Configuration Source: https://docs-staging.hober.io/docs/ios-sdk/configuration # iOS SDK Configuration | Option | Type | Required | Description | |--------|------|----------|-------------| | `sdkKey` | String | Yes | Your Hober SDK key | | `baseUrl` | String | No | Override the API base URL | --- # iOS SDK: Native iOS (Swift) Integration Guide Source: https://docs-staging.hober.io/docs/ios-sdk/guide # Native iOS (Swift) Integration Guide This guide walks you through integrating `HoberKit` into a native iOS application — from Swift Package Manager installation through foreground and background notification handling. **Minimum deployment target:** iOS 16 For the full API surface, see the [API Reference](./api-reference). --- ## Prerequisites - Xcode 15 or later - An active Hober account with a valid SDK key and channel ID - An Apple Developer account with the **Push Notifications** capability enabled on your App ID - APNs credentials configured in the Hober dashboard (APNs Auth Key `.p8` or APNs Certificate `.p12`) --- ## Step 1 — Install via Swift Package Manager Use **Xcode's Package Dependencies** UI to add `HoberKit`: 1. Open your project in Xcode and select **File > Add Package Dependencies…** 2. Enter the repository URL: ``` https://github.com/hoberhq/hober-ios-sdk.git ``` 3. Choose **Up to Next Major Version** starting from `1.0.0`. 4. Select the `HoberKit` library target and click **Add Package**. Alternatively, add it directly to `Package.swift`: ```swift dependencies: [ .package(url: "https://github.com/hoberhq/hober-ios-sdk.git", from: "1.0.0"), ], targets: [ .target( name: "YourApp", dependencies: ["HoberKit"] ), ] ``` --- ## Step 2 — Configure in AppDelegate Call `Hober.configure(sdkKey:channelId:)` as early as possible — inside `application(_:didFinishLaunchingWithOptions:)`: ```swift import UIKit import HoberKit @main class AppDelegate: UIResponder, UIApplicationDelegate { func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { Hober.configure(sdkKey: "YOUR_SDK_KEY", channelId: "YOUR_CHANNEL_ID") return true } // MARK: - APNs token registration func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { Hober.registerDevice(token: deviceToken) } func application( _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error ) { print("[Hober] Failed to register for remote notifications: \(error)") } } ``` :::tip Finding your credentials Your `sdkKey` and `channelId` are available in the Hober dashboard under **Settings > SDK Keys**. ::: --- ## Step 3 — Request Notification Permission Call `Hober.requestPermission()` at a meaningful moment in your onboarding flow — triggered by a user gesture such as tapping an "Enable Notifications" button. Avoid prompting immediately on launch; iOS only allows one system prompt per app install. ```swift import HoberKit class OnboardingViewController: UIViewController { @IBAction func enableNotificationsTapped(_ sender: UIButton) { Task { let granted = await Hober.requestPermission() if granted { print("[Hober] Notification permission granted.") } else { print("[Hober] Notification permission denied.") showPermissionDeniedHelp() } } } private func showPermissionDeniedHelp() { // Direct users to Settings > Notifications > YourApp to re-enable } } ``` `Hober.requestPermission()` calls `UNUserNotificationCenter.requestAuthorization` internally and also registers with APNs via `UIApplication.registerForRemoteNotifications()`. --- ## Step 4 — Identify a Subscriber After the user logs in or completes sign-in, associate them with a Hober subscriber record: ```swift import HoberKit // Call this after the user has been authenticated func onUserLoggedIn(user: User) { Task { try await Hober.identifySubscriber( externalId: user.id, email: user.email, attributes: ["plan": user.plan, "locale": user.locale] ) } } ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `externalId` | `String` | Yes | Your internal user ID | | `email` | `String?` | No | User email for segmentation | | `attributes` | `[String: String]?` | No | Custom key-value attributes | Call `Hober.identifySubscriber` again whenever the subscriber's data changes (e.g. plan upgrade). --- ## Step 5 — Register the APNs Device Token When iOS delivers the APNs device token, forward it to Hober. Add this to your `AppDelegate` (already shown in the boilerplate above): ```swift func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { Hober.registerDevice(token: deviceToken) } ``` `Hober.registerDevice(token:)` converts the raw `Data` token to a hex string and registers it with the Hober backend so your app can receive targeted push notifications. --- ## Step 6 — Handle Foreground Notifications By default, iOS suppresses notification banners when the app is in the foreground. Implement `UNUserNotificationCenterDelegate` to display them: ```swift import UserNotifications import HoberKit extension AppDelegate: UNUserNotificationCenterDelegate { // Called when a notification arrives while the app is in the foreground func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { // Show banner, sound, and badge even in the foreground completionHandler([.banner, .sound, .badge]) } // Called when the user taps the notification func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { let userInfo = response.notification.request.content.userInfo Hober.handleNotificationResponse(userInfo: userInfo) completionHandler() } } ``` Set the delegate early in `application(_:didFinishLaunchingWithOptions:)`: ```swift UNUserNotificationCenter.current().delegate = self ``` --- ## Step 7 — Handle Background (Silent) Notifications Silent pushes use the `content-available` flag to wake your app in the background. Enable the **Background Modes > Remote notifications** capability in Xcode, then handle the payload: ```swift // In AppDelegate func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { // content-available: 1 signals a silent push Hober.handleSilentNotification(userInfo: userInfo) completionHandler(.newData) } ``` :::note Background fetch budget iOS grants your app approximately 30 seconds to complete background processing. Keep the handler fast and always call `completionHandler`. ::: --- ## Complete AppDelegate Boilerplate Copy-paste this as a starting point and fill in your credentials: ```swift import UIKit import UserNotifications import HoberKit @main class AppDelegate: UIResponder, UIApplicationDelegate { func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { // 1. Configure HoberKit Hober.configure(sdkKey: "YOUR_SDK_KEY", channelId: "YOUR_CHANNEL_ID") // 2. Set notification center delegate for foreground handling UNUserNotificationCenter.current().delegate = self return true } // MARK: - APNs Token Registration func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { Hober.registerDevice(token: deviceToken) } func application( _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error ) { print("[Hober] Registration failed: \(error)") } // MARK: - Background / Silent Notifications func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { Hober.handleSilentNotification(userInfo: userInfo) completionHandler(.newData) } } // MARK: - UNUserNotificationCenterDelegate extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { completionHandler([.banner, .sound, .badge]) } func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { Hober.handleNotificationResponse(userInfo: response.notification.request.content.userInfo) completionHandler() } } ``` --- ## Troubleshooting ### APNs Auth Key vs APNs Certificate Hober supports two authentication methods for APNs: | Method | File | Expires | Recommended | |--------|------|---------|-------------| | APNs Auth Key | `.p8` | Never | Yes | | APNs Certificate | `.p12` | Annually | Legacy only | **Recommendation:** Use an APNs Auth Key (`.p8`) from the Apple Developer portal — it never expires and works across all your apps under the same Team ID. Upload it in the Hober dashboard under **Settings > APNs Credentials**. ### Sandbox vs Production APNs has two environments: - **Sandbox** — used automatically when the app is built and run via Xcode (development provisioning profile). - **Production** — used when the app is distributed via TestFlight or the App Store. Hober detects the environment from the APNs token automatically. Ensure that your APNs Auth Key or Certificate is configured for the correct environment in the Hober dashboard. Sending a sandbox token to the production APNs endpoint (or vice versa) will silently drop the notification. ### Entitlements Checklist Verify the following in your Xcode project: - [ ] **Push Notifications** capability is enabled under **Signing & Capabilities**. - [ ] The entitlement `aps-environment` is set to `development` (debug) or `production` (release) in your `.entitlements` file. - [ ] **Background Modes** capability is enabled and **Remote notifications** is checked (required for silent pushes). - [ ] Your provisioning profile includes the Push Notifications entitlement — regenerate it if you added the capability after the profile was created. ### Notifications Not Arriving in Simulator APNs push delivery to the iOS Simulator is supported from Xcode 11.4+ using `.apns` payload files dragged onto the simulator window. However, the Simulator cannot receive live APNs payloads from your server. Test real push delivery on a physical device. ### Permission Prompt Not Appearing If the system permission alert does not appear after calling `Hober.requestPermission()`: - The user may have already responded to the prompt. iOS only shows the alert once. - Check current authorization status: `UNUserNotificationCenter.current().getNotificationSettings { ... }`. - Direct the user to **Settings > Notifications > YourApp** to re-enable notifications manually. --- # iOS SDK: API Reference Source: https://docs-staging.hober.io/docs/ios-sdk/api-reference # iOS SDK API Reference Generated from DocC documentation in `HoberKit`. The `Hober` enum acts as a namespace; all methods are static. > **Note:** The full interactive DocC archive is hosted at `https://docs.hober.io/ios-sdk/docc/` and regenerated on every `hoberkit/v*` release tag. --- ## Hober.configure(sdkKey:channelId:) Configures the SDK with the provided credentials. Call this once, early in your app lifecycle — typically in `application(_:didFinishLaunchingWithOptions:)`. **Declaration** ```swift public static func configure(sdkKey: String, channelId: String) throws ``` **Parameters** | Parameter | Type | Description | |-----------|------|-------------| | `sdkKey` | `String` | Your project SDK key from the Hober dashboard. | | `channelId` | `String` | The notification channel identifier for this app. | **Throws:** `HoberError.invalidConfiguration` if either argument is empty. **Notes:** - Calling this method a second time overwrites the previous configuration and logs a warning. - Thread-safe — may be called from any thread. --- ## Hober.requestPermission(center:registrar:) Requests the user's permission to display push notifications. **Declaration** ```swift public static func requestPermission( center: UNAuthorizationRequesting = UNUserNotificationCenter.current(), registrar: RemoteNotificationRegistering = MainThreadRegistrar() ) async throws -> UNAuthorizationStatus ``` **Parameters** | Parameter | Type | Description | |-----------|------|-------------| | `center` | `UNAuthorizationRequesting` | The authorization center. Defaults to `UNUserNotificationCenter.current()`. | | `registrar` | `RemoteNotificationRegistering` | Used to register with APNs. Defaults to `MainThreadRegistrar()`. | **Returns:** `UNAuthorizationStatus` — the resolved authorization status after the user responds. **Throws:** - `HoberError.notConfigured` if `configure(sdkKey:channelId:)` has not been called. **Notes:** - When the user grants permission, registers the app with APNs on the main thread. - The APNs device token is delivered separately via `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)` and must be forwarded to `Hober.registerDevice(token:)`. - If already `.authorized`, returns immediately without showing a dialog. --- ## Hober.identifySubscriber(externalId:email:attributes:session:) Associates the current device session with a known subscriber by calling `POST /v1/subscribers/upsert`. **Declaration** ```swift public static func identifySubscriber( externalId: String, email: String? = nil, attributes: [String: String]? = nil, session: URLSession = .shared ) async throws -> HoberSubscriber ``` **Parameters** | Parameter | Type | Description | |-----------|------|-------------| | `externalId` | `String` | Your application's user identifier. Must not be empty. | | `email` | `String?` | Optional email address for the subscriber. | | `attributes` | `[String: String]?` | Optional key-value string attributes. | | `session` | `URLSession` | The URL session for networking. Defaults to `URLSession.shared`. | **Returns:** `HoberSubscriber` — the subscriber record returned by the Hober backend. **Throws:** - `HoberError.notConfigured` if `configure` has not been called. - `HoberError.invalidArgument` if `externalId` is empty. - `HoberError.serverError(statusCode:message:)` on 4xx/5xx responses. --- ## Hober.registerDevice(token:session:) Registers the APNs device token with the Hober backend by calling `POST /v1/devices`. Call this from `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`. **Declaration** ```swift public static func registerDevice( token: Data, session: URLSession = .shared ) async throws -> HoberDevice ``` **Parameters** | Parameter | Type | Description | |-----------|------|-------------| | `token` | `Data` | The raw APNs device token data provided by the OS. | | `session` | `URLSession` | The URL session for networking. Defaults to `URLSession.shared`. | **Returns:** `HoberDevice` — the device record returned by the Hober backend. **Throws:** - `HoberError.notConfigured` if `configure(sdkKey:channelId:)` has not been called. - `HoberError.subscriberNotIdentified` if `identifySubscriber` has not been called. - `HoberError.serverError(statusCode:message:)` on non-2xx responses. --- ## HoberAppDelegate A convenience `UIApplicationDelegate` subclass that forwards APNs token callbacks to `Hober.registerDevice(token:)` automatically. **Declaration** ```swift open class HoberAppDelegate: UIResponder, UIApplicationDelegate ``` **Usage** ```swift @main class AppDelegate: HoberAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { try? Hober.configure(sdkKey: "YOUR_SDK_KEY", channelId: "default") return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } ``` --- ## Types ### HoberSubscriber ```swift public struct HoberSubscriber: Decodable { public let id: String public let externalId: String public let email: String? } ``` ### HoberDevice ```swift public struct HoberDevice: Decodable { public let id: String public let token: String public let platform: String } ``` ### HoberError ```swift public enum HoberError: Error { case invalidConfiguration case notConfigured case invalidArgument case subscriberNotIdentified case serverError(statusCode: Int, message: String) } ``` --- ## See Also - [iOS SDK Quickstart](/docs/ios-sdk/quickstart) - [iOS SDK Guide](/docs/ios-sdk/guide) - [Hober REST API Reference](/docs/api-reference/overview) --- # iOS SDK: Rich Push Notifications (Images) Source: https://docs-staging.hober.io/docs/ios-sdk/rich-notifications # Rich Push Notifications (Images) Attach an image to a push notification — product shots, artwork, event photos — displayed by iOS as part of the notification itself. On **Android** and **web push** (Chromium browsers) images work with no app changes: set an image on the send and the platform renders it. On **iOS**, Apple requires the app to ship a small **Notification Service Extension (NSE)** that downloads and attaches the media before display. HoberKit provides the whole implementation — your extension is one line. ## How it works When a send carries an image, Hober delivers the APNs payload with `mutable-content: 1` and the image URL in the `hober_image_url` key. iOS hands the notification to your NSE before showing it; HoberKit's `HoberNotificationServiceExtension` downloads the image, attaches it, and delivers. If the download can't finish inside the system's time budget, the notification is delivered as text — your message always arrives. ## One-time setup ### Step 1 — Add the extension target In Xcode: **File → New → Target… → Notification Service Extension**. Name it (e.g. `NotificationService`), and activate the scheme when prompted. ### Step 2 — Add HoberKit to the new target Select the extension target → **General → Frameworks and Libraries** → add **HoberKit** (the same package your app target already uses). ### Step 3 — Subclass Replace the generated template class with: ```swift import HoberKit final class NotificationService: HoberNotificationServiceExtension { } ``` That's the entire extension. Override points exist if you need custom behavior — the class is `open`. ## Sending an image - **Dashboard**: Compose → **Push customization** → **Image URL**. - **API**: set `content.image_url` on the notification (see the [notifications API](/docs/api-reference/notifications)). The URL must be **HTTPS** and publicly reachable. Keep images reasonably sized (≲ 1 MB) — the extension runs under a tight system time budget, and a slow download means the notification falls back to text. ## Verifying 1. Send yourself a test from Compose with an Image URL set. 2. On a real device (the simulator supports NSEs but not remote push), long press the notification — the full image expands. 3. No image? Check, in order: the app target actually embeds the extension (Build Phases → Embed Foundation Extensions), the image URL is HTTPS and loads in Safari, and the device has network reachability beyond the push itself. ## Notes - Supported formats: JPEG, PNG, GIF, WebP, HEIC. - The attachment is downloaded fresh per notification; it does not count against your app's storage. - Silent (background) notifications never carry attachments — images apply to visible pushes only. --- # Android SDK: Android SDK Quickstart Source: https://docs-staging.hober.io/docs/android-sdk/quickstart # Android SDK Quickstart Native Kotlin SDK for Android apps using FCM. ## Installation Add the dependency to your app's `build.gradle`: ```groovy dependencies { implementation 'io.hober:sdk:1.0.0' } ``` ## Initialize ```kotlin class MyApplication : Application() { override fun onCreate() { super.onCreate() Hober.init( context = AppContextImpl(this), sdkKey = "YOUR_SDK_KEY", channelId = "default" ) } } ``` ## Request Permission (Android 13+) ```kotlin Hober.requestPermission(activity = AndroidActivityHost(this)) { granted -> if (granted) { // Permission granted — register device } } ``` ## Identify Subscriber ```kotlin Hober.identifySubscriber(externalId = "user-123", email = "user@example.com") { result -> when (result) { is SubscriberResult.Success -> { /* proceed */ } is SubscriberResult.Error -> { /* handle error */ } } } ``` ## Extend HoberFirebaseMessagingService ```kotlin class MyFirebaseService : HoberFirebaseMessagingService() ``` Register it in your `AndroidManifest.xml`: ```xml ``` --- ## Next Steps - [Android SDK Guide](/docs/android-sdk/guide) — FCM setup, notification customization, and advanced configuration - [Android SDK API Reference](/docs/android-sdk/api-reference) — full Dokka-generated KDoc method signatures and type definitions - [Hober REST API Reference](/docs/api-reference/overview) — direct REST API access --- # Android SDK: Configuration Source: https://docs-staging.hober.io/docs/android-sdk/configuration # Android SDK Configuration | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `context` | AppContext | Yes | Application context wrapper | | `sdkKey` | String | Yes | Your Hober SDK key | | `channelId` | String | Yes | Android notification channel ID | | `baseUrl` | String | No | Override the API base URL (default: `https://api.hober.io`) | ## Minimum Requirements - Android 8.0 (API 26) or higher - Firebase Cloud Messaging dependency --- # Android SDK: Native Android (Kotlin) Integration Guide Source: https://docs-staging.hober.io/docs/android-sdk/guide # Native Android (Kotlin) Integration Guide This guide walks you through integrating `hober-android-sdk` into a native Android (Kotlin) application — from Gradle setup through notification display customisation. **Minimum SDK:** API 26 (Android 8.0 Oreo) For the full API surface, see the [API Reference](./api-reference). --- ## Prerequisites - Android Studio Hedgehog (2023.1.1) or later - An active Hober account with a valid SDK key and channel ID - A Firebase project with **Cloud Messaging** enabled and `google-services.json` downloaded - minSdkVersion set to **26** or higher in your `build.gradle` --- ## Step 1 — Add the Gradle Dependency Add `mavenCentral()` to your project-level `build.gradle` (or `settings.gradle` if you use `dependencyResolutionManagement`): ```groovy // Project-level build.gradle repositories { mavenCentral() google() } ``` Then add the SDK dependency to your **app-level** `build.gradle`: ```groovy dependencies { implementation("io.hober:sdk:1.0.0") // Firebase Messaging is a required peer dependency implementation("com.google.firebase:firebase-messaging:24.0.0") } ``` Apply the Google Services plugin at the bottom of the same file: ```groovy plugins { id("com.google.gms.google-services") } ``` --- ## Step 2 — Place `google-services.json` Download `google-services.json` from the Firebase console (**Project Settings > Your Apps > Android app**) and place it in your **app module** directory: ``` MyApp/ ├── app/ │ ├── google-services.json ← here │ ├── src/ │ └── build.gradle └── build.gradle ``` --- ## Step 3 — Initialise in `Application.onCreate()` Create an `Application` subclass (if you don't have one) and call `Hober.init()` as early as possible: ```kotlin import android.app.Application import io.hober.sdk.Hober import io.hober.sdk.android.AppContextImpl class MyApplication : Application() { override fun onCreate() { super.onCreate() Hober.init( context = AppContextImpl(this), sdkKey = "YOUR_SDK_KEY", channelId = "default" ) } } ``` Register `MyApplication` in `AndroidManifest.xml`: ```xml ``` :::tip Finding your credentials Your `sdkKey` and `channelId` are available in the Hober dashboard under **Settings > SDK Keys**. ::: --- ## Step 4 — Request the POST_NOTIFICATIONS Permission (Android 13+) Android 13 (API 33) introduced the `POST_NOTIFICATIONS` runtime permission. You must request it before notifications can be displayed to the user. Declare the permission in `AndroidManifest.xml`: ```xml ``` Request it at a meaningful moment — after the user has had a chance to understand why notifications are useful. Avoid requesting it immediately on first launch: ```kotlin import android.Manifest import android.content.pm.PackageManager import android.os.Build import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity class OnboardingActivity : AppCompatActivity() { private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) { // Permission granted — Hober can display notifications } else { // User denied or dismissed — guide them to Settings showPermissionRationale() } } fun requestNotificationPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } // Below Android 13 / API 33, the permission is granted automatically } private fun showPermissionRationale() { // Direct user to Settings > Apps > YourApp > Notifications } } ``` Build.VERSION_CODES.TIRAMISU equals API 33 (Android 13). Devices on API 26–32 do not require the runtime permission; notifications are enabled by default for installed apps. --- ## Step 5 — Set Up `HoberFirebaseMessagingService` ### Automatic token registration (recommended) Extend `HoberFirebaseMessagingService` to handle FCM token updates and incoming messages automatically: ```kotlin import io.hober.sdk.fcm.HoberFirebaseMessagingService class MyFirebaseService : HoberFirebaseMessagingService() ``` Register it in `AndroidManifest.xml` inside the `` block: ```xml ``` `HoberFirebaseMessagingService` overrides `onNewToken` and `onMessageReceived` so that tokens are forwarded to the Hober backend automatically whenever FCM rotates them. ### Manual token registration If you need to extend your own `FirebaseMessagingService` subclass or you obtain the FCM token through a third-party library, call `Hober.registerDevice(token)` directly: ```kotlin import com.google.firebase.messaging.FirebaseMessaging import io.hober.sdk.Hober FirebaseMessaging.getInstance().token.addOnSuccessListener { token -> Hober.registerDevice(token = token) } ``` Also override `onNewToken` in your service to keep the registration current: ```kotlin override fun onNewToken(token: String) { super.onNewToken(token) Hober.registerDevice(token = token) } ``` Use `HoberFirebaseMessagingService` (automatic) unless you already have an existing `FirebaseMessagingService` that you cannot replace. --- ## Step 6 — Identify a Subscriber After the user logs in or completes sign-in, associate them with a Hober subscriber record: ```kotlin import io.hober.sdk.Hober // Call this after the user has been authenticated fun onUserLoggedIn(user: User) { Hober.identifySubscriber( externalId = user.id, email = user.email, attributes = mapOf("plan" to user.plan, "locale" to user.locale) ) { result -> when (result) { is SubscriberResult.Success -> { /* proceed */ } is SubscriberResult.Error -> { /* log and retry */ } } } } ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `externalId` | `String` | Yes | Your internal user ID | | `email` | `String?` | No | User email for segmentation | | `attributes` | `Map?` | No | Custom key-value attributes | Call `Hober.identifySubscriber` again whenever the subscriber's data changes (for example, after a plan upgrade). --- ## Step 7 — Customise Notification Display ### Notification icon Place a **white, alpha-only** PNG in `res/drawable/` and reference it in your `Hober.init()` call or via the Hober dashboard meta-data: ```xml ``` ### Notification accent color ```xml ``` Define the color in `res/values/colors.xml`: ```xml #4F46E5 ``` ### Notification channel importance Hober creates a default notification channel on first launch. Override its importance using the `channelId` you passed to `Hober.init()`: ```kotlin import android.app.NotificationChannel import android.app.NotificationManager import android.os.Build import androidx.core.content.getSystemService fun createHoberNotificationChannel(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( "default", // must match channelId in Hober.init() "Push Notifications", NotificationManager.IMPORTANCE_HIGH ).apply { description = "Hober promotional and transactional notifications" } context.getSystemService() ?.createNotificationChannel(channel) } } ``` Call `createHoberNotificationChannel()` **before** `Hober.init()` so the channel exists when the SDK first fires a notification. Use `IMPORTANCE_DEFAULT` for standard alerts or `IMPORTANCE_HIGH` for heads-up banners. --- ## Complete Application Boilerplate Copy-paste this as a starting point and fill in your credentials: ```kotlin import android.app.Application import io.hober.sdk.Hober import io.hober.sdk.android.AppContextImpl class MyApplication : Application() { override fun onCreate() { super.onCreate() // 1. Create the notification channel before init (Android 8.0+) createHoberNotificationChannel(this) // 2. Initialise the Hober SDK Hober.init( context = AppContextImpl(this), sdkKey = "YOUR_SDK_KEY", channelId = "default" ) } private fun createHoberNotificationChannel(context: android.content.Context) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { val channel = android.app.NotificationChannel( "default", "Push Notifications", android.app.NotificationManager.IMPORTANCE_HIGH ).apply { description = "Hober promotional and transactional notifications" } val manager = context.getSystemService(android.app.NotificationManager::class.java) manager.createNotificationChannel(channel) } } } ``` Companion `AndroidManifest.xml`: ```xml ``` --- ## Troubleshooting ### FCM not delivering notifications 1. **Verify `google-services.json` is current.** Download a fresh copy from the Firebase console if you recently added the Android app or changed package names. 2. **Check FCM registration token.** Add a log in `onNewToken` to confirm the token is generated and forwarded to Hober. 3. **Confirm the Hober SDK key and channel ID** match the values in the Hober dashboard. 4. **Test with the Firebase console** using **Cloud Messaging > Send test message** before involving Hober, to rule out an FCM configuration issue. 5. **Check network/battery restrictions.** Doze mode and manufacturer battery optimisations can prevent FCM delivery in the background. Whitelist your app in device battery settings for testing. ### Notification channel not found Android 8.0 (API 26) requires all notifications to be posted to a `NotificationChannel`. If Hober logs a warning about the channel or notifications are silently dropped: - Ensure `createNotificationChannel()` is called before `Hober.init()`. - The `channelId` passed to `Hober.init()` must **exactly match** the `NotificationChannel` ID you created. - Deleted channels cannot be recreated with the same ID in the same session; reinstall the app to reset channel state during development. ### POST_NOTIFICATIONS permission denied on Android 13 If notifications are not appearing on Android 13+ devices: - Confirm `android.permission.POST_NOTIFICATIONS` is declared in `AndroidManifest.xml`. - Check current permission status with `ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)`. - Android only shows the system permission dialog once. If denied, the user must manually re-enable notifications in **Settings > Apps > YourApp > Notifications**. - Use `ActivityCompat.shouldShowRequestPermissionRationale()` to determine whether to show an in-app explanation before re-requesting. --- ## API Reference For a complete list of classes, methods, and parameters, see the generated [Dokka/KDoc API Reference](./api-reference). The API reference is published alongside the Maven Central artifact and covers `Hober`, `HoberFirebaseMessagingService`, `AppContextImpl`, `SubscriberResult`, and all supporting types. --- # Android SDK: API Reference Source: https://docs-staging.hober.io/docs/android-sdk/api-reference # Android SDK API Reference Generated from Dokka/KDoc annotations in `hober-android-sdk`. The full Dokka HTML site is hosted at `https://docs.hober.io/android-sdk/dokka/` and regenerated on every `v*` release tag. > The Dokka HTML output is produced by `./gradlew :hober-android-sdk:dokkaHtml` and uploaded to GCS alongside the Maven Central publish. --- ## Hober Singleton entry point for the Android SDK. Call `Hober.init()` once from `Application.onCreate()` before using any other SDK method. ### Hober.init(context, sdkKey, channelId, baseUrl?, httpClient?) Initializes the SDK. Subsequent calls are a no-op — the SDK logs a warning but does not crash. **Signature** ```kotlin @JvmStatic @Synchronized fun init( context: AppContext, sdkKey: String, channelId: String, baseUrl: String = "https://api.hober.io", httpClient: HttpClientPort = DefaultHttpClient() ): Unit ``` **Parameters** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `context` | `AppContext` | Yes | Application context. Use `AppContextImpl(this)` from your `Application`. | | `sdkKey` | `String` | Yes | Your Hober SDK key, sent as the `X-SDK-Key` header. | | `channelId` | `String` | Yes | Notification channel ID for foreground-service notifications. | | `baseUrl` | `String` | No | Override the API base URL (default: `https://api.hober.io`). | | `httpClient` | `HttpClientPort` | No | Override the HTTP client (useful for tests). | **Throws:** N/A — double-init is silently ignored with a warning log. --- ### Hober.requestPermission(activity, onResult) Requests the `POST_NOTIFICATIONS` runtime permission on Android 13+ (API 33+). On Android 12 and below, `onResult` is invoked immediately with `true` — no dialog is shown and no permission is required. **Signature** ```kotlin @JvmStatic fun requestPermission( activity: ActivityHost, onResult: (Boolean) -> Unit ): Unit ``` **Parameters** | Parameter | Type | Description | |-----------|------|-------------| | `activity` | `ActivityHost` | Wraps the calling Android `Activity`. Use `AndroidActivityHost(activity)` in production; `FakeActivityHost` in tests. | | `onResult` | `(Boolean) -> Unit` | Callback invoked with `true` if permission is granted, `false` if denied. | **Notes:** - Requires `` in `AndroidManifest.xml`. - The system dialog is only shown once. If previously denied, the user must re-enable notifications in Settings. --- ### Hober.identifySubscriber(externalId, email?, attributes?, onResult) Associates the current user with a Hober subscriber record by calling `POST /v1/subscribers/upsert`. The call executes on a background thread; results are delivered through `onResult` (may be called on any thread). **Signature** ```kotlin @JvmStatic fun identifySubscriber( externalId: String, email: String? = null, attributes: Map? = null, onResult: (SubscriberResult) -> Unit ): Unit ``` **Parameters** | Parameter | Type | Description | |-----------|------|-------------| | `externalId` | `String` | Your application's user identifier. | | `email` | `String?` | Optional email address. | | `attributes` | `Map?` | Optional attributes. Values may be `String`, `Number`, or `Boolean`. | | `onResult` | `(SubscriberResult) -> Unit` | Callback invoked with `SubscriberResult.Success` or `SubscriberResult.Error`. | **Throws:** `IllegalStateException` if `init` has not been called. --- ### Hober.registerDevice(token, onResult) Registers an FCM device push token with the Hober backend by calling `POST /v1/devices`. Duplicate token registration is idempotent — the backend deduplicates. **Signature** ```kotlin @JvmStatic fun registerDevice( token: String, onResult: (DeviceResult) -> Unit ): Unit ``` **Parameters** | Parameter | Type | Description | |-----------|------|-------------| | `token` | `String` | The FCM device token from `FirebaseMessagingService.onNewToken`. | | `onResult` | `(DeviceResult) -> Unit` | Callback invoked with `DeviceResult.Success` or `DeviceResult.Error`. | **Throws:** `IllegalStateException` if `init` has not been called. --- ## HoberFirebaseMessagingService Extend this class to automatically handle FCM token refresh and incoming messages. It calls `Hober.registerDevice()` inside `onNewToken`. **Declaration** ```kotlin open class HoberFirebaseMessagingService : FirebaseMessagingService() ``` **Usage** ```kotlin class MyFirebaseService : HoberFirebaseMessagingService() ``` Register in `AndroidManifest.xml`: ```xml ``` **Overridable Methods** | Method | Description | |--------|-------------| | `onNewToken(token: String)` | Called when FCM issues a new token. Calls `Hober.registerDevice(token)` automatically. | | `onMessageReceived(message: RemoteMessage)` | Override to handle foreground messages. | --- ## Types ### SubscriberResult ```kotlin sealed class SubscriberResult { object Success : SubscriberResult() data class Error(val message: String) : SubscriberResult() } ``` ### DeviceResult ```kotlin sealed class DeviceResult { object Success : DeviceResult() data class Error(val message: String) : DeviceResult() } ``` ### AppContext ```kotlin interface AppContext { val applicationContext: android.content.Context } ``` Use `AppContextImpl(application)` for production. ### ActivityHost ```kotlin interface ActivityHost { val sdkInt: Int fun isPermissionGranted(permission: String): Boolean fun requestPermission(permission: String, onResult: (Boolean) -> Unit) } ``` Use `AndroidActivityHost(activity)` for production; `FakeActivityHost` for tests. --- ## See Also - [Android SDK Quickstart](/docs/android-sdk/quickstart) - [Android SDK Guide](/docs/android-sdk/guide) - [Hober REST API Reference](/docs/api-reference/overview) --- # React Native SDK: React Native SDK Quickstart Source: https://docs-staging.hober.io/docs/react-native-sdk/quickstart # React Native SDK Quickstart Add push notifications to your React Native app on iOS and Android in minutes using `@hoberhq/react-native-sdk`. ## Prerequisites - An active Hober account and SDK key - React Native 0.71 or later - For iOS: Xcode 14+, CocoaPods, an Apple Developer account with APNs entitlement enabled - For Android: Android Studio, a Firebase project with `google-services.json` ## Installation Install the package from npm: ```bash npm install @hoberhq/react-native-sdk ``` Then complete the platform-specific setup below before calling any SDK methods. --- ## iOS Setup ### 1. Install native dependencies via CocoaPods ```bash cd ios && pod install ``` This installs the underlying `react-native-push-notification-ios` dependency and links it to your project. ### 2. Enable APNs entitlement In Xcode, open your project target and navigate to **Signing & Capabilities**. Click **+ Capability** and add **Push Notifications**. This creates the required APNs entitlement in your `.entitlements` file. Also enable **Background Modes** and check **Remote notifications** so your app can wake in the background to process incoming pushes. ### 3. Update `AppDelegate` In `ios//AppDelegate.mm` (or `AppDelegate.swift`), register for remote notifications: ```objc // AppDelegate.mm #import #import @implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // ... existing setup ... UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; center.delegate = self; return YES; } // Required for receiving remote notifications - (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { [RNCPushNotificationIOS didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; } - (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error { [RNCPushNotificationIOS didFailToRegisterForRemoteNotificationsWithError:error]; } - (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler { [RNCPushNotificationIOS didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler]; } @end ``` --- ## Android Setup ### 1. Add `google-services.json` Download `google-services.json` from the [Firebase console](https://console.firebase.google.com/) and place it in `android/app/google-services.json`. ### 2. Update `build.gradle` files In `android/build.gradle` (project-level), add the Google Services plugin to the classpath: ```groovy buildscript { dependencies { // ...existing dependencies... classpath 'com.google.gms:google-services:4.4.0' } } ``` In `android/app/build.gradle` (app-level), apply the plugin and add the Firebase Messaging dependency: ```groovy apply plugin: 'com.google.gms.google-services' dependencies { // ...existing dependencies... implementation 'com.google.firebase:firebase-messaging:23.4.0' } ``` ### 3. Update `AndroidManifest.xml` In `android/app/src/main/AndroidManifest.xml`, declare the Hober Firebase messaging service and the notification permissions: ```xml ``` --- ## Initialize with `HoberProvider` Wrap your root component with `HoberProvider` and supply your SDK key. This initializes the SDK and makes the `useHober()` hook available throughout your component tree. ```jsx // App.js import React from 'react'; import { HoberProvider } from '@hoberhq/react-native-sdk'; import MainNavigator from './MainNavigator'; export default function App() { return ( ); } ``` | Prop | Type | Required | Description | |------|------|----------|-------------| | `sdkKey` | string | Yes | Your Hober SDK key from the dashboard | | `channelId` | string | No | Notification channel ID (Android). Defaults to `"default"` | --- ## Using the `useHober()` Hook The `useHober()` hook exposes all SDK methods inside any functional component: ```jsx import { useHober } from '@hoberhq/react-native-sdk'; function NotificationSetup() { const { requestPermission, identifySubscriber, registerDevice } = useHober(); const enableNotifications = async () => { const granted = await requestPermission(); if (!granted) return; await identifySubscriber({ externalId: 'user-123', email: 'user@example.com' }); await registerDevice(); }; return