Skip to main content

OCM Pulse — Client API Documentation

This document describes the public HTTP API for OCM Pulse. It covers how to create an API token, how to authenticate requests, how rate limiting works, and every endpoint available to API consumers, with request/response examples.

All paths in this document are relative to your OCM Pulse installation's base URL — e.g. https://Pulse.example.com. Replace pulse.orangeclickmedia.com in the examples with the actual host.


Table of Contents

  1. Overview
  2. Getting Started: Creating an API Token
  3. Authentication
  4. Token Abilities (Scopes)
  5. Rate Limiting
  6. Pagination
  7. Errors
  8. Data Types & Status Enums
  9. Client API endpoints
  10. Public (browser) endpoints — used by the JS SDK

Overview

The OCM Pulse API is organized into three tiers:

TierPrefixAuth
Client API/api/v1/client/*Laravel Sanctum personal access token with ability scopes
Public API/api/v1/public/*None (origin-checked CORS, browser-only)

Most integrators will only need the Client API, which is documented in full detail below.

Request/response bodies are JSON. All timestamps are ISO 8601 strings in UTC (e.g. 2026-04-15T10:30:00+00:00).


Getting Started: Creating an API Token

API tokens are issued per user account and are scoped to that user's organization. Only resources inside the user's organization are accessible.

Step 1 — Log into the dashboard

Sign in to the OCM Pulse dashboard with your regular credentials (any user role can mint a token for themselves):

https://pulse.orangeclickmedia.com/login

Step 2 — Open API Tokens settings

From the avatar menu or sidebar, navigate to Settings → API Tokens:

https://pulse.orangeclickmedia.com/settings/api-tokens

Step 3 — Create a new token

  1. Click Create token.
  2. Give the token a descriptive name (e.g. "CI deployment bot", "Newsroom automation").
  3. Select one or more abilities (scopes) that describe what the token can do. See Token Abilities below.
  4. Click Create.

The raw token string (e.g. 17|aBc1...XyZ) is shown only once. Copy it immediately and store it somewhere safe — you cannot retrieve it later. If lost, revoke the token and create a new one.

Step 4 — Revoke a token

From the same page you can revoke any existing token. Revocation is immediate; in-flight requests using that token will start returning 401 Unauthorized.


Authentication

Every Client API request must include an Authorization header with the token as a bearer credential:

Authorization: Bearer 17|aBc1...XyZ
Accept: application/json

Content type for POST/PATCH/PUT requests must be application/json:

Content-Type: application/json

Missing or invalid tokens return 401 Unauthorized. Tokens that lack the required ability for an endpoint return 403 Forbidden with a body like:

{ "message": "Token does not have the required ability: deliveries:write" }

Token Abilities (Scopes)

Tokens are granted one or more abilities. Endpoints check for the specific ability they need.

AbilityLabelDescription
apps:readView AppsView your apps and their configuration
apps:writeManage AppsCreate, update, and delete apps
subscriptions:readView SubscriptionsView subscriber lists and details
subscriptions:writeManage SubscriptionsManage subscriptions and tags
deliveries:readView DeliveriesView delivery history and status
deliveries:writeSend NotificationsCreate, send, and cancel push notifications
analytics:readView AnalyticsAccess analytics and statistics

Tip: Follow least-privilege. A newsroom automation that only publishes notifications only needs deliveries:write (plus apps:read if it looks up the app by identifier).


Rate Limiting

The Client API is rate limited to 60 requests per minute per token (per-user bucket). Exceeding the limit returns:

HTTP/1.1 429 Too Many Requests
Retry-After: 45

Respect the Retry-After header value (seconds) before retrying.


Pagination

List endpoints (GET .../apps, .../subscriptions, .../deliveries) return Laravel-style paginated collections.

Query parameters:

ParameterTypeDefaultMax
pageinteger1
per_pageinteger25100

Response envelope:

{
"data": [ /* array of resource objects */ ],
"links": {
"first": "https://pulse.orangeclickmedia.com/api/v1/client/apps?page=1",
"last": "https://pulse.orangeclickmedia.com/api/v1/client/apps?page=4",
"prev": null,
"next": "https://pulse.orangeclickmedia.com/api/v1/client/apps?page=2"
},
"meta": {
"current_page": 1,
"from": 1,
"last_page": 4,
"path": "https://pulse.orangeclickmedia.com/api/v1/client/apps",
"per_page": 25,
"to": 25,
"total": 92
}
}

Errors

All errors are returned as JSON with an appropriate HTTP status:

StatusMeaning
400Malformed request
401Missing/invalid token
403Token lacks the required ability or the resource does not belong to your organization
404Resource not found (or not visible to your org)
422Validation error
429Rate limited
5xxServer error

Validation errors use the standard Laravel shape:

{
"message": "The name field is required.",
"errors": {
"name": ["The name field is required."]
}
}

Data Types & Status Enums

Delivery status (status on a delivery):

pending, scheduled, queued, processing, partial, success, failed, cancelled

Subscription status: subscribed, unsubscribed


Client API Endpoints

Base path: https://pulse.orangeclickmedia.com/api/v1/client

App-scoped endpoints identify the app by its identifier (a slug-like string, not its numeric id) — e.g. acme-news. You can list apps to discover their identifiers.


Apps

Manage the apps owned by the authenticated user's organization.

List apps

GET /api/v1/client/apps

Ability: apps:read

Query parameters:

ParamTypeNotes
per_pageint1–100, default 25
pageint

Example

curl -H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
"https://pulse.orangeclickmedia.com/api/v1/client/apps?per_page=50"

Response 200 OK

{
"data": [
{
"identifier": "acme-news",
"name": "ACME News",
"site_domain": "https://news.acme.com",
"frequency_cap_max_notifications": 5,
"frequency_cap_window_minutes": 1440,
"default_ttl_seconds": 86400,
"default_require_interaction": false,
"default_silent": false,
"default_vibrate_pattern": [200, 100, 200],
"auto_delete_unsubscribed_enabled": true,
"auto_delete_unsubscribed_days": 90,
"default_query_params": [
{ "key": "utm_source", "value": "push" }
],
"subscriber_count": 12483,
"active_subscriber_count": 10210,
"created_at": "2026-01-05T09:12:44+00:00",
"updated_at": "2026-04-10T14:01:00+00:00"
}
],
"links": { "...": "..." },
"meta": { "...": "..." }
}

Get app

GET /api/v1/client/apps/{identifier}

Ability: apps:read

Example

curl -H "Authorization: Bearer $TOKEN" \
"https://pulse.orangeclickmedia.com/api/v1/client/apps/acme-news"

Returns the same resource shape as the list endpoint (single object inside data).

Create app

POST /api/v1/client/apps

Ability: apps:write

Body

FieldTypeRequiredNotes
namestring≤ 255 chars
site_domainurlMust not end with /
frequency_cap_max_notificationsint1–100; requires frequency_cap_window_minutes
frequency_cap_window_minutesintOne of 60, 360, 720, 1440, 10080
default_ttl_secondsint0–2 419 200 (28 days)
default_require_interactionbool
default_silentbool
default_vibrate_patternint[]≤ 10 values, each 0–10 000
auto_delete_unsubscribed_enabledbool
auto_delete_unsubscribed_daysint1–365; required when auto-delete is enabled
default_query_paramsarray≤ 20 {key, value} objects; keys unique, [\w\-]+
organization_idintsuper-admins only

Example

curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
"https://pulse.orangeclickmedia.com/api/v1/client/apps" \
-d '{
"name": "ACME News",
"site_domain": "https://news.acme.com",
"frequency_cap_max_notifications": 5,
"frequency_cap_window_minutes": 1440,
"default_ttl_seconds": 86400
}'

Response 201 Created — the created AppResource.

Update app

PUT  /api/v1/client/apps/{identifier}
PATCH /api/v1/client/apps/{identifier}

Ability: apps:write

Accepts the same body as create (fields are validated whether present or not — supply a full payload). Returns 200 OK with the updated resource.

Delete app

DELETE /api/v1/client/apps/{identifier}

Ability: apps:write

Returns 204 No Content on success.


Subscriptions

All subscription endpoints are scoped to an app.

List subscriptions

GET /api/v1/client/apps/{identifier}/subscriptions

Ability: subscriptions:read

Query parameters

ParamTypeNotes
searchstringFree-text search
statusstringsubscribed | unsubscribed
device_typestring
browserstring≤ 255 chars
countrystring2-letter ISO code
tagarraytag[utm_source]=newsletter matches subs tagged with key utm_source = newsletter
created_afterdate
created_beforedate
sortstringcreated_at | last_seen_at | last_clicked_at | notifications_sent
directionstringasc | desc (default desc)
per_pageint1–100

Example

curl -H "Authorization: Bearer $TOKEN" \
"https://pulse.orangeclickmedia.com/api/v1/client/apps/acme-news/subscriptions?status=subscribed&country=US&per_page=50"

Response 200 OK

{
"data": [
{
"id": 10231,
"user_identity": "uuid-abc-123",
"status": "subscribed",
"device_type": "desktop",
"browser_name": "Chrome",
"browser_version": "134",
"os_name": "macOS",
"os_version": "15.3",
"country": "US",
"notifications_sent": 42,
"notifications_clicked": 6,
"click_rate": 14.28,
"tags": { "utm_source": "newsletter", "plan": "pro" },
"created_at": "2026-02-03T11:04:22+00:00",
"last_seen_at": "2026-04-14T08:12:07+00:00",
"last_clicked_at": "2026-04-12T19:40:00+00:00",
"unsubscribed_at": null
}
],
"links": { "...": "..." },
"meta": { "...": "..." }
}

Get subscription

GET /api/v1/client/apps/{identifier}/subscriptions/{subscription_id}

Ability: subscriptions:read

Delete subscription

DELETE /api/v1/client/apps/{identifier}/subscriptions/{subscription_id}

Ability: subscriptions:write

Returns 204 No Content.

Update subscription tags

POST /api/v1/client/apps/{identifier}/subscriptions/{subscription_id}/tags

Ability: subscriptions:write

Body

{
"tags": {
"plan": "pro",
"signup_source": "paywall",
"newsletter": "daily"
}
}
  • tags is an object of up to 50 keys. Values are strings (≤ 255 chars).
  • Semantics are upsert: existing keys are overwritten, new keys added. Tags not listed are left untouched — to remove a tag, omit it from future writes (there is no dedicated delete endpoint).

Example

curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
"https://pulse.orangeclickmedia.com/api/v1/client/apps/acme-news/subscriptions/10231/tags" \
-d '{"tags":{"plan":"pro","newsletter":"daily"}}'

Returns the updated SubscriptionResource.


Deliveries

A delivery is a push notification send. Creating one queues it for the Go worker, which dispatches and reports back asynchronously.

List deliveries

GET /api/v1/client/apps/{identifier}/deliveries

Ability: deliveries:read

Query parameters

ParamTypeNotes
statusstringOne of the delivery status enum
namestringFilter by name
titlestringFilter by title
categorystringFilter by category name
created_afterdate
created_beforedate
per_pageint1–100

Example

curl -H "Authorization: Bearer $TOKEN" \
"https://pulse.orangeclickmedia.com/api/v1/client/apps/acme-news/deliveries?status=success&per_page=50"

Response (single resource shown):

{
"data": [
{
"id": 8821,
"identifier": "dlv_01HZ...",
"name": "Morning briefing",
"title": "Top 5 stories",
"body": "Everything you missed overnight",
"url": "https://news.acme.com/briefing",
"image_url": "https://cdn.acme.com/img/briefing.png",
"status": "success",
"source_type": "api",
"delivery_category_id": 3,
"segment_id": null,
"ignore_frequency_cap": false,
"action_buttons": [
{ "title": "Read", "url": "https://news.acme.com/briefing" }
],
"ttl_seconds": 3600,
"require_interaction": false,
"topic": null,
"scheduled_at": null,
"queued_at": "2026-04-15T06:00:00+00:00",
"started_at": "2026-04-15T06:00:04+00:00",
"completed_at": "2026-04-15T06:01:12+00:00",
"total_subscribers": 10210,
"capped_subscribers": 112,
"success_count": 10050,
"failed_count": 48,
"click_count": 843,
"click_rate": 8.39,
"error_message": null,
"created_at": "2026-04-15T05:59:50+00:00",
"updated_at": "2026-04-15T06:01:12+00:00"
}
],
"links": { "...": "..." },
"meta": { "...": "..." }
}

Get delivery

GET /api/v1/client/apps/{identifier}/deliveries/{delivery_id}

Ability: deliveries:read

Create delivery (send a notification)

POST /api/v1/client/apps/{identifier}/deliveries

Ability: deliveries:write

Body

FieldTypeRequiredNotes
namestringInternal label, ≤ 255 chars
titlestringPush title shown to users, ≤ 255 chars
bodystringPush body text, ≤ 255 chars
urlurlClick-through URL
image_urlurl | fileLarge image (PNG/JPG, ≤ 2 MB). URL is fetched and size-checked
delivery_category_idintMust exist in delivery_categories
segment_idintTarget a specific segment; omit to send to everyone
scheduled_atdatetimeISO 8601, must be in the future. Omit to send immediately
ignore_frequency_capboolDefault false
action_buttonsarray≤ 2 items, each { title (≤48 chars), url }
ttl_secondsint0–2 419 200 (28 days); falls back to app default
require_interactionboolPersistent notifications (Chrome/Edge)
topicstring≤ 32 chars, alpha_dash; used by the browser to replace older pushes in the same topic

Example — send immediately

curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
"https://pulse.orangeclickmedia.com/api/v1/client/apps/acme-news/deliveries" \
-d '{
"name": "Breaking: market update",
"title": "Markets plunge on earnings",
"body": "S&P500 down 2.4% — follow live coverage.",
"url": "https://news.acme.com/markets/live",
"image_url": "https://cdn.acme.com/img/markets.png",
"action_buttons": [
{ "title": "Live coverage", "url": "https://news.acme.com/markets/live" },
{ "title": "Dismiss", "url": "https://news.acme.com/dismiss" }
],
"ttl_seconds": 1800,
"topic": "markets"
}'

Example — schedule for later, segment-targeted

curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
"https://pulse.orangeclickmedia.com/api/v1/client/apps/acme-news/deliveries" \
-d '{
"name": "Weekend digest",
"title": "Your Saturday digest",
"body": "Handpicked for subscribers.",
"url": "https://news.acme.com/digest",
"segment_id": 12,
"scheduled_at": "2026-04-18T13:00:00Z",
"delivery_category_id": 4
}'

Response 201 Created — the created DeliveryResource. The delivery will initially be in pending, scheduled, or queued status — poll the get endpoint to see it move through processingsuccess / partial / failed.

Cancel delivery

POST /api/v1/client/apps/{identifier}/deliveries/{delivery_id}/cancel

Ability: deliveries:write

Only cancellable while the delivery has not completed. If the delivery is already finished or cannot be cancelled, returns 422 Unprocessable Entity with a human message.

Example

curl -X POST \
-H "Authorization: Bearer $TOKEN" \
"https://pulse.orangeclickmedia.com/api/v1/client/apps/acme-news/deliveries/8821/cancel"

Returns 200 OK with the refreshed DeliveryResource (status becomes cancelled).


Analytics

All analytics endpoints are app-scoped, cached for ~5 minutes on the server, and gated behind analytics:read.

Overview

GET /api/v1/client/apps/{identifier}/analytics/overview

Response

{
"total_subscribers": 12483,
"active_subscribers": 10210,
"total_deliveries": 312,
"total_clicks": 48210,
"avg_click_rate": 7.84,
"subscribers_by_device": { "desktop": 7211, "mobile": 4800, "tablet": 472 },
"subscribers_by_country": [
{ "country": "US", "count": 4012 },
{ "country": "GB", "count": 1980 }
]
}

Subscriber growth

GET /api/v1/client/apps/{identifier}/analytics/subscribers?days=30

days is optional (default 30, max 90).

{
"period_days": 30,
"growth": [
{ "date": "2026-03-17", "new_subscribers": 112 },
{ "date": "2026-03-18", "new_subscribers": 87 }
],
"active_vs_inactive": { "subscribed": 10210, "unsubscribed": 2273 }
}

Delivery performance

GET /api/v1/client/apps/{identifier}/analytics/deliveries?days=30
{
"period_days": 30,
"daily_stats": [
{
"date": "2026-04-15",
"total": 4,
"successful": 4,
"notifications_sent": 40120,
"clicks": 3184
}
],
"status_breakdown": {
"success": 280,
"partial": 18,
"failed": 9,
"cancelled": 5
}
}

Engagement

GET /api/v1/client/apps/{identifier}/analytics/engagement?days=30
{
"period_days": 30,
"engagement_by_day": [
{
"date": "2026-04-15",
"avg_click_rate": 7.84,
"total_clicks": 3184,
"total_sent": 40120
}
],
"top_performing_deliveries": [
{
"id": 8821,
"identifier": "dlv_01HZ...",
"title": "Markets plunge on earnings",
"click_rate": 18.4,
"click_count": 1840,
"success_count": 10000,
"completed_at": "2026-04-15T06:01:12+00:00"
}
]
}

Public Endpoints

These are for the browser-side JS SDK — they're CORS-protected by the app's site_domain and authenticated by origin + X-App-Id, not by bearer token. You usually won't call them directly; they're documented here for completeness.

Base path: https://pulse.orangeclickmedia.com/api/v1/public

Required headers:

X-App-Id: <app.identifier>
Origin: <app.site_domain>
Content-Type: application/json

In production, the Origin header must exactly match the configured site_domain of the app or the request returns 403 Invalid origin domain.

Public: Subscribe

POST /api/v1/public/subscribe

Body

{
"appId": "acme-news",
"userId": "browser-uuid-abc-123",
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/abcd...",
"expirationTime": null,
"keys": {
"p256dh": "BASE64_P256DH_KEY",
"auth": "BASE64_AUTH_SECRET"
}
},
"externalIds": {
"oid_cookie": "optional-id",
"lotame_id": "optional-id"
},
"categoryIds": [1, 3, 7]
}

Response 200 OK

{ "success": true }

On validation failure this endpoint intentionally returns a generic { "success": false, "message": "Invalid request." } with status 422 — no detailed error leak.

Public: Update Subscription

POST /api/v1/public/update_subscription

Used when the browser renews its push endpoint or reports new external IDs. Body is the same shape as subscribe, but subscription is optional and categoryIds is not accepted.

Public: Update Subscription Tags

POST /api/v1/public/subscription-tags

Body

{
"appId": "acme-news",
"userId": "browser-uuid-abc-123",
"tags": [
{ "key": "newsletter", "value": "daily" },
{ "key": "plan", "value": "pro" }
]
}

Upserts per (subscription, tag_key).


Internal Endpoints

These are used by OCM Pulse's own infrastructure (the Go delivery worker). Integrators normally don't need them.


Quick Start — End-to-end example

Send your first notification from the command line:

# 1. Mint a token in the dashboard with abilities:
# apps:read, deliveries:write, deliveries:read
export TOKEN="17|aBc1...XyZ"
export HOST="https://Pulse.example.com"

# 2. Find the identifier of the app you want to send to
curl -s -H "Authorization: Bearer $TOKEN" \
"$HOST/api/v1/client/apps" | jq '.data[].identifier'

# 3. Send a notification
curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
"$HOST/api/v1/client/apps/acme-news/deliveries" \
-d '{
"name": "Hello world",
"title": "Hello, subscribers!",
"body": "This is our first API-sent push.",
"url": "https://news.acme.com/welcome"
}' | jq

# 4. Poll status
curl -s -H "Authorization: Bearer $TOKEN" \
"$HOST/api/v1/client/apps/acme-news/deliveries/8821" | jq '.data.status'