Documentation
Everything you need to ship push notifications, integrate programmatically, and start earning.
Quickstart
- Sign up at /signup. Verify your email.
- Create your first hub — onboarding asks
for a slug; subscribers will live at
<slug>.pushoney.com. - Drop the embed snippet on your landing page. Copy it from your hub overview.
- Open your landing page, accept the push prompt — congrats, you're subscriber #1.
- Send a test campaign from
Campaigns → New. Or call the API
(
POST /v1/campaigns) with an API key minted at Account → API keys. - (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:
| Attr | Values | Default | Effect |
|---|---|---|---|
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 group | Limit |
|---|---|
Reads (GET *) | 1000 / minute |
Writes (POST/PATCH/DELETE *) | 300 / minute |
POST /v1/campaigns/:id/sends | 60 / minute |
POST /v1/api_keys | 5 / hour |
DELETE /v1/api_keys/:id | 60 / hour |
POST /v1/exports | 10 / hour |
POST /v1/webhooks | 60 / 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:
v1 = HMAC-SHA256(secret, <t>.<body>)— original Stripe-shape signature. Kept for back-compat with receivers written before v2 shipped.v2 = HMAC-SHA256(secret, <t>.<delivery_id>.<body>)— replay-safe. An attacker who captures one delivery's signature cannot reuse it for a different delivery because the delivery_id (unique per delivery) is part of what was signed.
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:
subscriber.createdsubscriber.unsubscribedsubscriber.gone(push provider returned 410)campaign.queued·campaign.completedsend.delivered·send.failedclick.recordedconversion.recorded
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:
- Add your PayPal email for monthly payouts.
- Accept the disclosure ToS. One-time checkbox; we then surface the standard "may receive promotional offers" copy on your hub's subscribe prompt.
- 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].