Docs

Send your first push in a minute.

One bearer token, one URL. POST a JSON payload and your phone lights up. Use pushr cloud, or run the open-source backend yourself.

Quickstart

Three steps on pushr cloud. Self-hosting? Jump to the self-hosting guide.

  1. 1
    Get the app

    Install pushr on your iPhone, sign up and allow notifications.

  2. 2
    Create a source app

    In the Apps tab, tap +, name it after what will send pushes, and copy the token. It is shown once.

  3. 3
    Send a request

    In the app's API & token sheet, tap Copy curl example: your notify URL is filled in. Run it anywhere.

bash
curl -X POST "$PUSHR_URL/notify" \
  -H "Authorization: Bearer $PUSHR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Hello","body":"from my terminal"}'
Try itPOST /notify
Idle
Priority

URL and token persist in this browser only. Nothing is logged server-side by the docs page.

Pending—

Fill in URL and token, then hit send. The actual HTTP response prints here: status, latency, body.

Effective curl
curl -X POST https://site-convex.pushr.sh/notify \
  -H "Authorization: Bearer $PUSHR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
         "title": "Hello from the docs",
         "body": "It works.",
         "priority": "normal"
       }'

Every endpoint lives at your Convex deployment's site URL — https://<your-deployment>.convex.site — and is referenced below as $PUSHR_URL. Every authenticated endpoint takes a source-app bearer token (pshr_…) created from the mobile app's Apps tab and referenced as $PUSHR_TOKEN.

Self-hosting

pushr's backend is open source. You can run it on your own Convex deployment and point the iPhone app at it, so your notifications never touch pushr's servers. Self-hosted backends have no quotas or plans: every feature is unlocked, for free.

What you need

1. Create your deployment

bash
git clone https://github.com/cpreston321/pushr-backend
cd pushr-backend
bun install
bun run dev

The first bun run dev signs you in to Convex, creates a deployment and pushes the schema and functions. It writes CONVEX_URL (ends in .convex.cloud) and CONVEX_SITE_URL (ends in .convex.site) to .env.local. You'll need both.

2. Set the server secrets

These live in the Convex deployment, not in .env.local:

bash
bunx convex env set BETTER_AUTH_SECRET "$(openssl rand -hex 32)"
bunx convex env set SITE_URL "https://<your-deployment>.convex.site"

3. Create your account

bash
bunx convex run seed:createAdmin '{"email":"you@example.com","password":"<a strong password>"}'

4. Connect the app

  1. In pushr, open Settings → Server.

  2. Under Custom Convex Deployment, paste your .convex.cloud URL and your .convex.site URL.

  3. Tap Test connection. It checks that /healthz answers from your deployment.

  4. Make a connection code on the computer you deploy from, and type it into the app:

    bash
    bunx convex run pairing:createCode
    # { "code": "P4QM-2B2V", "expiresInMinutes": 15 }
  5. Tap Save & sign out. The app switches to your server right away; sign in with the account from step 3.

Connection codes

A self-hosted server only accepts new accounts that its owner lets in, so it can't quietly turn into a public pushr. Only someone with access to the Convex deployment can run pairing:createCode. Each code:

  • lasts 15 minutes and works once;
  • lets one new account sign up, in the app or with Sign in with Apple, within the next hour;
  • isn't needed for accounts that already exist, or for ones you make with seed:createAdmin.

After 20 wrong codes in 15 minutes, /pair refuses every code until the window passes.

To go back, return to the same screen and tap Use pushr cloud.

If you change BETTER_AUTH_SECRET

The key that signs the app's Convex tokens is stored encrypted with the secret. After changing it, sign-in still works but the app can't load anything, and the logs show Failed to decrypt private key. Generate a new key with:

bash
bunx convex run maintenance:resetAuthKeys

5. Send a push

Create a source app in the Apps tab and copy its token, then:

bash
curl -X POST "https://<your-deployment>.convex.site/notify" \
  -H "Authorization: Bearer $PUSHR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Hello","body":"from my own server"}'

Updating

Pull the latest source and run bun run deploy to push it to your production deployment, or bun run push for your dev deployment. Schema changes deploy with the functions; there's no separate migration step.

Differences from pushr cloud

  • Live Activities are not available with the App Store app. Starting and updating them needs an APNs key from the team that publishes the app. Everything else (pushes, actions, ack-or-escalate, widgets, webhooks) works.
  • Delivery goes through Expo's push service, the same as pushr cloud. Your server only needs outbound HTTPS.
  • Plans don't apply. Pro features are on for every account on your deployment.
  • Sign in with Apple is off unless you set APPLE_SIGN_IN=enabled. Apple gives every server for the pushr app the same identifier for a person, so signing in to your server would link their Apple ID to their pushr cloud account. Deletion also can't revoke the Apple sign-in (that needs the publisher's key), and Hide My Email addresses only get email if your sending domain is registered with Apple.

Endpoints at a glance

MethodPathPurposeAuth
POST/notifySend a notificationAuthorization: Bearer $PUSHR_TOKEN
POST/hooks/githubGitHub webhook adapterBearer or ?token=
POST/hooks/sentrySentry webhook adapterBearer or ?token=
POST/hooks/grafanaGrafana webhook adapterBearer or ?token=
GET/healthzHealth check; selfHosted: true on your deploymentnone
POST/pairTrade a connection code for a sign-up grant (used by the app)none

POST /notify

Sends one notification to every device the source app's owner has registered (and to every accepted member's devices, if the app is shared).

Request

Plain
POST /notify
Authorization: Bearer pshr_…
Content-Type: application/json

Required fields

FieldTypeNotes
titlestringBanner title.
bodystringBanner body. Or message (Gotify-compatible alias).

Optional fields

FieldTypeNotes
prioritynumber 1–10 | string1–6 and "low"/"normal" deliver at default priority. 7–10 and "high" wake the device with a high-priority push. Defaults to normal.
urlstringTapping the banner opens this URL.
dataobjectArbitrary JSON delivered alongside the push (forwarded to the app's notification handler).
imagestringURL of an image to attach as a banner thumbnail.
actionobjectSingle action button (legacy). { label: string, url: string }. Replaced by actions if both are set.
actionsarray (max 4)Rich interactive actions — see Action buttons.
ackobjectAck-or-escalate alarm — see Ack-or-escalate.
liveActivityobjectDrive an iOS Live Activity — see Live Activities.
deliverAtnumber (ms-epoch)Schedule the push for a future time. Must be ≥ now − 60 s.

Response

json
{ "id": "j97...", "scheduledFor": null }

Status codes:

CodeBodyWhen
202{ id, scheduledFor }Accepted. scheduledFor is the deliverAt ms-epoch (or null).
200{ id, scheduledFor } plus an Idempotent-Replay: true headerA retry with an Idempotency-Key already used. Nothing is sent again.
400{ error: "<reason>" }Validation error (missing fields, bad shape).
401{ error: "Invalid token" } / "Missing bearer token"Bad or absent token.
403{ error: "Source app disabled" }Token is valid but the app was disabled.
409{ error, code: "IDEMPOTENCY_KEY_REUSED" }The Idempotency-Key was used with a different payload.
429{ error, code: "QUOTA_EXCEEDED", tier, count, limit }pushr cloud only: the monthly push limit is reached. See Rate limits and quotas.
500{ error: "<message>" }Unexpected server error.

Retries and Idempotency-Key

Send an Idempotency-Key header (any string up to 255 characters, such as a job id or UUID) to make retries safe:

bash
curl -X POST "$PUSHR_URL/notify" \
  -H "Authorization: Bearer $PUSHR_TOKEN" \
  -H "Idempotency-Key: deploy-482-finished" \
  -d '{"title":"Deploy #482 finished"}'
  • A retry with the same key and the same body returns the original id with 200 and Idempotent-Replay: true. It isn't pushed again and doesn't count toward your quota.
  • The same key with a different body is a 409, because that's a bug in the sender, not a retry.
  • Keys are per source app and are remembered for 24 hours.

Minimal example

bash
curl -X POST "$PUSHR_URL/notify" \
  -H "Authorization: Bearer $PUSHR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Build succeeded",
    "body":  "ci.example.com / main → staging"
  }'

Priority

bash
# Wake the device — banner + sound:
-d '{"title":"DB disk > 90%","body":"prod","priority":"high"}'

# Numeric (Gotify-style 1-10):
-d '{"title":"FYI","body":"nightly backups complete","priority":4}'

Priority also sets how hard the push may interrupt on iOS:

PriorityiOS interruption levelEffect
1–3, "low"passiveLands quietly in Notification Center, no screen wake
4–6, "normal" (default)activeA normal banner and sound
7–10, "high"time-sensitiveBreaks through Focus and the notification summary
any push with acktime-sensitiveSame, for every re-push until acknowledged

During a source app's quiet hours, pushes are delivered as passive unless they need acknowledgment.

Open URL on tap

bash
-d '{
  "title": "PR #42 merged",
  "body":  "from peptide",
  "url":   "https://github.com/owner/repo/pull/42"
}'

Image attachment

bash
-d '{
  "title": "New deploy",
  "body":  "Build #382 passed",
  "image": "https://ci.example.com/badges/build-382.png"
}'

Action buttons

Up to 4 actions. Three kinds:

jsonc
{
  "title": "Staging deploy ready",
  "body":  "Merge main → staging?",
  "actions": [
    {
      "kind": "callback",
      "id": "approve",
      "label": "Approve",
      "callbackUrl": "https://ci.example.com/deploy/42/approve"
    },
    {
      "kind": "callback",
      "id": "reject",
      "label": "Reject",
      "callbackUrl": "https://ci.example.com/deploy/42/reject",
      "destructive": true
    },
    {
      "kind": "open_url",
      "id": "logs",
      "label": "View logs",
      "url": "https://ci.example.com/deploy/42"
    },
    {
      "kind": "reply",
      "id": "comment",
      "label": "Reply",
      "callbackUrl": "https://ci.example.com/deploy/42/comment",
      "placeholder": "Add a note…"
    }
  ]
}
kindBehavior
open_urlOpens url when tapped. Recorded as an event server-side.
callbackPOSTs to callbackUrl. HTTP status stored on the action event. Optional authRequired to lock-gate.
replyiOS inline text reply. POSTs { reply, ... } to callbackUrl. Optional placeholder.

Callback request shape

When the user taps a callback or reply action, pushr POSTs to your callbackUrl:

Plain
POST /your/endpoint
Content-Type: application/json
User-Agent: pushr/1.0
X-Pushr-Source: pushr
X-Pushr-Notification: <notificationId>
X-Pushr-Action: <action.id>

{
  "notificationId": "...",
  "actionId":       "approve",
  "respondedAt":    1712160000000,
  "reply":          "LGTM"          ← only for kind=reply
}

callbackUrl must be a public https:// URL: localhost, private-network and link-local addresses are rejected, and redirects are not followed. pushr records only the response status, never the body. Viewers on a shared app can open links but can't run callbacks or replies.

Outbound action callbacks are unsigned. To authenticate the request, embed a bearer token in the callbackUrl itself (e.g. https://api.example.com/hook?key=…) or terminate the URL on a server that already trusts pushr's egress IP / origin.

Inbound webhook signing (provider → pushr) is unrelated and configured per provider — see Webhook adapters → Signing secrets below.

Caveat: lockscreen labels

iOS requires notification categories to be pre-registered, so the lockscreen banner shows generic labels (Action 1, Action 2, Reply). The mobile feed renders the real labels you sent and is where users normally respond.

Ack-or-escalate

Turn a notification into an on-call-style alarm that re-pushes at high priority — ignoring quiet hours and source-app mutes — until the user taps it.

jsonc
{
  "title":    "DB disk > 90%",
  "body":     "homelab/prod",
  "priority": "high",
  "ack": {
    "timeoutSec":  60,        // 10–86400, seconds between re-pushes
    "maxAttempts": 5          // 1–20, total re-pushes after the initial send
  }
}

Tapping the notification (or opening its url) acknowledges it and stops the loop. Un-acked notifications are listed at the top of the feed with an "ack needed" badge.

Scheduled delivery

jsonc
{
  "title":     "Standup",
  "body":      "Time to gather",
  "deliverAt": 1714435200000   // ms-epoch
}

Returns 202 { id, scheduledFor } immediately; delivery happens at the target time via the Convex scheduler.

Live Activities

Drive iOS lockscreen / Dynamic Island progress indicators. Three actions: start / update / end, all keyed by your own activityId.

jsonc
{
  "title": "Deploy #42",
  "body":  "Building",
  "liveActivity": {
    "action":     "start",                    // start | update | end
    "activityId": "deploy-42",                // caller-chosen, reused for update/end
    "attributes": { "name": "ci.example.com" }, // immutable, only on start
    "state": {                                  // mutable ContentState
      "title":    "Deploy #42",
      "status":   "Running tests",
      "progress": 0.35,                         // 0..1, omit for indeterminate
      "icon":     "hammer.fill"                 // SF Symbol name
    },
    "staleDate":      1712200000000,            // ms-epoch (optional)
    "relevanceScore": 0.8                       // 0..1 (optional)
  }
}

Update:

json
{
  "title": "Deploy #42",
  "body":  "Deploying to staging",
  "liveActivity": {
    "action":     "update",
    "activityId": "deploy-42",
    "state":      { "title": "Deploy #42", "status": "Deploying", "progress": 0.85 }
  }
}

End:

json
{
  "title": "Deploy #42",
  "body":  "Shipped 🎉",
  "liveActivity": {
    "action":     "end",
    "activityId": "deploy-42",
    "state":      { "title": "Deploy #42", "status": "Complete", "progress": 1.0 }
  }
}

start requires the device to have registered a push-to-start token (the mobile client does this on first launch). update/end can fire even if the app is terminated. Set the APNS_* environment variables on your Convex deployment to enable this end-to-end (see .env.example).

If APNS_AUTH_KEY isn't configured, the liveActivity field is silently ignored and the regular push still delivers normally.

Gotify-style fallback

If you're migrating from Gotify, pushr accepts the alternate field names without code changes:

bash
curl -X POST "$PUSHR_URL/notify" \
  -H "Authorization: Bearer $PUSHR_TOKEN" \
  -d '{
    "title":    "Backup failed",
    "message":  "exit 2",
    "priority": 8,
    "extras":   { "client::notification": { "click": { "url": "https://homelab.lan" } } }
  }'

message maps to body; extras["client::notification"].click.url maps to url.


Webhook adapters

Forward provider webhooks straight to pushr without writing glue code. Adapters normalize the provider payload into the same internal shape and flow through the same delivery / quota / ack plumbing.

Plain
POST /hooks/github?token=$PUSHR_TOKEN
POST /hooks/sentry?token=$PUSHR_TOKEN
POST /hooks/grafana?token=$PUSHR_TOKEN

Auth: either Authorization: Bearer $PUSHR_TOKEN (when the provider lets you customize headers) or ?token=$PUSHR_TOKEN in the query string.

GitHub

Paste $PUSHR_URL/hooks/github?token=$PUSHR_TOKEN into your repo or org webhook settings. Content type application/json. Subscribe to events you care about:

EventTitlePriority
push"{repo} — N commits to {branch}"normal
pull_request"PR #N: {title}"normal
issues"Issue #N: {title}"normal
release"{repo} released {tag_name}"normal
workflow_run"{name} {conclusion}"high if conclusion == "failure"
check_runsimilar to workflow_runhigh on failure
deployment_status"{environment} {state}"high on failure

Open the source app in the iOS app → API & token → Webhook integrations → tap GitHub and paste the same secret you configured in GitHub. Pushr verifies X-Hub-Signature-256 on every delivery; mismatches return 401.

Sentry

Add an Internal Integration (or legacy plugin webhook) at /hooks/sentry?token=…. Severity maps to pushr priority:

Sentry levelpushr priority
debug2
info4
warning6
error8
fatal9

To verify Sentry signatures, set the same client secret in API & token → Webhook integrations → Sentry. Pushr verifies Sentry-Hook-Signature (bare hex HMAC-SHA256 of the raw body) on every delivery.

Grafana

Contact point → Webhook → URL $PUSHR_URL/hooks/grafana?token=…. The adapter collapses the alert batch into one push titled <ruleName> (<n> firing). commonLabels.severity maps to priority the same way Sentry levels do. Grafana doesn't HMAC-sign webhook payloads, so the bearer token is the only authenticator — keep the URL private.

Signing secrets

A single source app can be wired to multiple providers — each with its own signing secret. Configure them independently in API & token → Webhook integrations.

ProviderHeaderFormatNotes
GitHubX-Hub-Signature-256sha256=<hex>Required when set
SentrySentry-Hook-Signaturebare <hex>Required when set
Grafana——Bearer-only (no signing)

When a secret is set for a provider that supports signing, every inbound delivery to /hooks/{provider} must carry a valid signature or pushr returns 401 { error: "Invalid signature" }. Clearing the secret falls back to bearer-only auth.

Adapter response

CodeBodyWhen
202{ id, scheduledFor: null }Adapter normalized the event and the push was queued.
200{ ignored: true, provider }The adapter chose to ignore this event (e.g. GitHub ping).
401{ error: "Invalid signature" }HMAC verification failed for a provider with a configured signing secret.
401 / 400 / 429 / 500(same as /notify)

GET /healthz

Plain
GET /healthz
json
{ "ok": true }

Use this from a monitor to check that your deployment is reachable.


Source-app token format

Tokens always start with pshr_ followed by a base64url payload. They're shown once at creation time in the mobile app's Apps tab — the server only stores the sha256(token) hash. Revoking an app rotates the hash and immediately invalidates the token.

tokenPrefix (e.g. pshr_abcd1234) is safe to display in logs and CI config; the full token is not.


Rate limits and quotas

Self-hosted: there are no plans or quotas. Every account on your deployment gets everything, and the only ceiling is Convex's own function limits.

pushr cloud:

FreePro
Pushes per month10010,000
Source apps1Unlimited
History7 days90 days
People per shared app1Unlimited
Quiet hours, Slack and Discord forwardingNoYes

Past the monthly limit, /notify answers 429:

json
{
  "error": "Monthly quota exceeded",
  "code": "QUOTA_EXCEEDED",
  "tier": "free",
  "count": 100,
  "limit": 100
}

The count resets at the start of each month (UTC). Replays with an Idempotency-Key don't count.

That's the whole API. Go send one.

Paste a token into the console at the top and watch it land on your phone.

Wire it up tonight.

Free to start. 1,000 pushes a month and one source app are on us.

Coming soon to the App Store

iPhone app

Feed, Live Activities, widgets and per-app sounds.

Coming sooniOS 16.2+

CLI

Send from the shell, pipe stdin, drive Live Activities.

$brew install cpreston321/tap/pushrsh

SDK

Typed notify() and liveActivity() for any fetch runtime.

$bun add @pushrsh/sdk
pushr
Ledger2m
Payment received
$4,200.00 from Acme, Inc.
CA
ci.acme14m
Deploy #482 succeeded
main → production · 1m 04s
Beacon1h
api is back up
Resolved after 38s
↓Keep scrolling to send yourself a push