← Pushoney

Documentation

Everything you need to ship push notifications, integrate programmatically, and start earning.

On this page

Quickstart

  1. Sign up at /signup. Verify your email.
  2. Create your first hub — onboarding asks for a slug; subscribers will live at <slug>.pushoney.com.
  3. Drop the embed snippet on your landing page. Copy it from your hub overview.
  4. Open your landing page, accept the push prompt — congrats, you're subscriber #1.
  5. Send a test campaign from Campaigns → New. Or call the API (POST /v1/campaigns) with an API key minted at Account → API keys.
  6. (Optional) Enable monetisation from Account → Monetisation. Earnings start accruing immediately.

The embed snippet

Each hub generates an embed snippet visible from its overview page. Drop it before your closing </body>:

<script
  src="https://<your-hub>.pushoney.com/embed.js"
  data-prompt="auto"
  defer>
</script>

Attributes:

AttrValuesDefaultEffect
data-prompt auto · manual auto auto: show the soft-prompt 5 seconds after page load. manual: only show when you call Pushoney.prompt().
data-vertical any string none Tag this subscribe with a vertical for later campaign segmentation (e.g. finance, gaming).
data-utm-default-source any string none Default UTM source if the URL doesn't carry one. Per-hub branding can override.

Public API (v1) — interactive reference

Pushoney ships a versioned REST API at https://<your-hub>.pushoney.com/v1/. Full interactive reference with try-it-out forms is at:

Open Pushoney API v1 reference → (or fetch the spec directly: /v1/openapi.json · /v1/openapi.yaml)

Resources covered: hubs, subscribers (with paginated list, filters, per-subscriber send history), campaigns (full CRUD + send + clone + preview), conversions, api_keys (mint / revoke), segments (saved filter trees), exports (async bulk dumps), webhooks (HMAC-signed event push).

API authentication

Mint an API key at Portal → Account → API keys (or via POST /v1/api_keys). Pass it as a Bearer token:

curl https://<your-hub>.pushoney.com/v1/hubs \
  -H "Authorization: Bearer pk_...."

Keys are bound to a single hub. Each key carries scopes (read and/or write); writes 403 if the scope is missing. Keys can be revoked via DELETE /v1/api_keys/<id>; once revoked they cannot be un-revoked — mint a fresh one.

The legacy x-api-key: pk_... header is still accepted for backwards compatibility; new integrations should use Bearer.

Pagination

All list endpoints use opaque cursor pagination (Stripe-shape). Pass ?limit=50&starting_after=<next_cursor> to walk pages.

{
  "data": [...],
  "next_cursor": "eyJ2IjoxLCJpIjoi...",
  "has_more": true
}

Default limit is 50; max 200. Treat next_cursor as opaque — encoding details may change without notice.

Errors

Every 4xx/5xx returns RFC 7807 problem+json:

{
  "type": "https://docs.pushoney.com/errors/invalid_request",
  "title": "Invalid request",
  "status": 400,
  "detail": "One or more fields failed validation.",
  "errors": [
    { "path": "destination_url", "message": "must use https://" }
  ]
}

Idempotency

All POST endpoints that create resources accept an Idempotency-Key header. The first request creates the resource; replays within 24 hours return the stored response verbatim with an Idempotent-Replayed: true response header — the handler does not re-execute. Use this in retry loops to prevent double-creates.

curl -X POST https://<hub>.pushoney.com/v1/campaigns \
  -H "Authorization: Bearer pk_..." \
  -H "Idempotency-Key: drop-2026-05-10" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Drop", ... }'

Rate limits

Per-API-key buckets. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. On exhaustion the API returns 429 with a Retry-After header.

Endpoint groupLimit
Reads (GET *)1000 / minute
Writes (POST/PATCH/DELETE *)300 / minute
POST /v1/campaigns/:id/sends60 / minute
POST /v1/api_keys5 / hour
DELETE /v1/api_keys/:id60 / hour
POST /v1/exports10 / hour
POST /v1/webhooks60 / hour

Webhooks (signed event push)

Subscribe to platform events by registering a webhook:

curl -X POST https://<hub>.pushoney.com/v1/webhooks \
  -H "Authorization: Bearer pk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/pushoney-webhook",
    "events": ["subscriber.created", "click.recorded", "conversion.recorded"]
  }'

Delivery URLs MUST be https://. Plaintext http:// targets are rejected at create / update time (subscriber data crosses the wire — a TLS upgrade is not optional). Private + loopback IPs are also rejected at create AND at delivery time as SSRF defense.

The response includes a signing_secret shown ONCE — store it securely. We sign every event POST with HMAC-SHA256 over <timestamp>.<raw_body> and ship it in the X-Pushoney-Signature header (Stripe shape):

X-Pushoney-Signature: t=1715347200,v1=5257a869e7ec...,v2=8a3f1b22cc...
X-Pushoney-Event: subscriber.created
X-Pushoney-Delivery: ckxxx...

The JSON body envelope includes delivery_id — a fresh UUID per delivery. Dedup on it: a captured signature cannot be replayed because each delivery's signed body differs even when the event payload is logically identical.

{
  "event": "subscriber.created",
  "hub_id": "cmom56ina00003ejj5j0eoeol",
  "occurred_at": "2026-05-12T17:55:01.234Z",
  "delivery_id": "1b2c3d4e-5f60-7081-92a3-b4c5d6e7f809",
  "data": { /* event-specific */ }
}

Signature versions

Two HMAC tokens ship in every signature header. Use v2 when verifying — it's replay-safe by including the delivery_id in the signed string:

Verify in Node.js (preferred: v2 + delivery_id):

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret, rawBody, sigHeader, deliveryId) {
  // Parse the header — there may be multiple v1=/v2= tokens
  // during a secret rotation grace window.
  let t = null;
  const v2s = [];
  for (const part of sigHeader.split(',')) {
    const [k, v] = part.split('=', 2);
    if (k === 't') t = Number.parseInt(v, 10);
    if (k === 'v2') v2s.push(v);
  }
  if (t === null || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac('sha256', secret)
    .update(`${t}.${deliveryId}.${rawBody}`, 'utf8').digest('hex');
  return v2s.some((v) =>
    v.length === expected.length &&
    timingSafeEqual(Buffer.from(v), Buffer.from(expected))
  );
}

Verify in Python (preferred: v2 + delivery_id):

import hmac, hashlib, time

def verify(secret, raw_body, sig_header, delivery_id):
    t = None
    v2s = []
    for part in sig_header.split(','):
        k, _, v = part.partition('=')
        if k == 't': t = int(v)
        elif k == 'v2': v2s.append(v)
    if t is None or abs(time.time() - t) > 300:
        return False
    expected = hmac.new(
        secret.encode(),
        f"{t}.{delivery_id}.{raw_body}".encode(),
        hashlib.sha256,
    ).hexdigest()
    return any(hmac.compare_digest(expected, v) for v in v2s)

Working starter recipes for Express, Next.js (App Router), Fastify, and Flask — including the raw-body capture trick each framework needs — at /docs/webhooks/recipes. Local-test instructions with ngrok included.

Events:

Retry policy: we retry on 5xx / 408 / 429 with exponential backoff at 1m, 5m, 30m, 2h, 24h (5 attempts total). On any other 4xx we mark the delivery permanently failed. On 410 Gone we auto-disable the parent webhook (you signalled the endpoint is gone for good).

Inspect recent delivery attempts at GET /v1/webhooks/:id/deliveries. Rotate the signing secret with POST /v1/webhooks/:id/rotate_secret (returns the new secret ONCE).

Conversion postbacks

Wire your offer network's S2S postback to:

https://<your-hub>.pushoney.com/pb/{tracking-id}?payout={payout}&external_id={click-id}

Both payout (decimal dollars OR payout_cents integer) and external_id are optional but recommended. The (send_id, external_id) pair is unique-indexed — duplicate postbacks are silently deduplicated, so multi-tier networks that fire repeatedly don't double-count.

Enabling monetisation

From Account → Monetisation:

  1. Add your PayPal email for monthly payouts.
  2. Accept the disclosure ToS. One-time checkbox; we then surface the standard "may receive promotional offers" copy on your hub's subscribe prompt.
  3. Per-hub: flip the toggle, set drops/day (0–4), exclude any verticals you don't want.

Earnings show up immediately as clicks come in. See /earn for the full revenue model.

Bring your own domain

From a hub's settings, add a domain like push.yourbrand.com. We give you a TXT record to add to your DNS; once it propagates, click Verify. We auto-issue a Let's Encrypt cert and route the domain to your hub. End users see your brand, not ours.

FAQ

Is Pushoney really free?

Yes. No trial, no credit card, no monthly fee. We earn when you opt your subscribers into monetisation; you keep 60% of what we earn from them. If you never enable monetisation, you use the platform for free indefinitely within reasonable abuse-protection caps.

What are the per-account abuse caps?

Default is 1 hub, 10,000 active subscribers, 100,000 sends/day. These exist to prevent spam-account abuse, not as upgrade pressure. Email [email protected] if you need more.

Is my subscriber list portable?

Yes. Two paths: Account → Download my data in the portal exports every row tied to your account as JSON (GDPR Article 15). For programmatic exports, hit POST /v1/exports { "resource": "subscribers" } and poll GET /v1/exports/<id> for the download URL.

Where is the data stored?

European hosting. See the Privacy Policy for the full list of sub-processors.

How do I close my account?

Account → Delete account. Cascades all your data; audit-log rows survive with the actor pointer nulled.

Something missing? Email [email protected].