Add PushTower to your website
Give your users an Add PushTower button. They scan a QR code with the PushTower app, or tap a button on their phone, and approve. Your backend receives a channel link for that user and can send them notifications and Live Activity updates from then on.
Your users need no PushTower account; the app works without sign-up. You need no API key and no registration.
How it works
- The user clicks Add PushTower on your site.
- Your backend starts a connect session with your user’s id and a callback
URL, and keeps the
pollToken. - Your page opens PushTower’s widget with
connect.js. It shows a QR code on desktop and an Open PushTower button on phones. - The user approves in the app. The app shows your requester name and callback
host as unverified. The link goes to whoever holds the session’s
pollToken; the callback host is not proof of that recipient’s identity. - PushTower calls your callback. Your backend polls with its
pollTokenand receives the link, exactly once. - You store the link for that user and send a test notification.
The pollToken and the link never touch the browser.
1. Start a session (backend)
import { PushTowerConnect } from '@pushtower/client';
const connect = new PushTowerConnect();
app.post('/pushtower/start', requireUser, async (req, res) => {
const session = await connect.start({
clientName: 'mypage.com',
channelName: 'mypage.com',
externalRef: req.user.id,
callbackUrl: 'https://mypage.com/pushtower/callback',
});
// Server-side only: whoever has the pollToken gets the link
await db.pushtowerSessions.insert({
sessionId: session.sessionId,
pollToken: session.pollToken,
userId: req.user.id,
});
res.json({
sessionId: session.sessionId,
pairingUrl: session.pairingUrl,
userCode: session.userCode,
});
});
Or over HTTP:
curl -X POST https://api.pushtower.com/api/connect/sessions \
-H "Content-Type: application/json" \
-d '{
"clientName": "mypage.com",
"channelName": "mypage.com",
"externalRef": "user-42",
"callbackUrl": "https://mypage.com/pushtower/callback"
}'
| Field | Notes |
|---|---|
clientName |
Shown to the user as who is asking (max 60) |
channelName |
Suggested name for the channel created in their app (max 100) |
externalRef |
Your user id; echoed back in the callback and poll result. Not a secret. |
callbackUrl |
Public HTTPS endpoint of yours. Its host is shown on the consent screen. |
Sessions expire after 10 minutes. Start a new one each time the user clicks.
2. Show the widget (frontend)
<script src="https://pushtower.com/connect.js"></script>
<button id="add-pushtower">Add PushTower</button>
<script>
document.getElementById('add-pushtower').onclick = async () => {
const session = await fetch('/pushtower/start', { method: 'POST' }).then(
(r) => r.json(),
);
PushTowerConnect.open({
...session,
onApproved: () => location.reload(), // or refresh your settings UI
onExpired: () => alert('The code expired. Please try again.'),
});
};
</script>
PushTowerConnect.open shows a modal with the hosted widget in an iframe and
returns { close }. Callbacks: onApproved, onDenied, onExpired and
onClose. onApproved means the user said yes. Your backend still collects
the link itself (step 3).
Full-page alternative (strict Content Security Policy, no JavaScript):
send the browser to PushTowerConnect.redirectUrl(session, returnUrl), or
build it yourself:
https://pushtower.com/connect/widget#session=<sessionId>&pair=<code from pairingUrl>&code=<userCode>&return=<returnUrl>
After an answer the widget returns to returnUrl with
?pushtower_connect=approved|denied|expired&pushtower_session=<id>. It only
does this when returnUrl has the same origin as your callbackUrl.
3. Collect the link (backend)
PushTower POSTs to your callbackUrl as soon as the user answers:
{
"event": "connect.approved",
"sessionId": "4f0c…",
"externalRef": "user-42"
}
The body carries no credential and isn’t signed. Treat it as a hint and poll
to get the real result, which only your pollToken can do:
app.post('/pushtower/callback', express.json(), async (req, res) => {
res.sendStatus(204);
const row = await db.pushtowerSessions.find(req.body.sessionId);
if (!row) return;
const result = await connect.poll(row); // { sessionId, pollToken }
// An unsigned hint can arrive before the user answers. Keep the poll token.
if (result.status === 'pending') return;
if (result.status === 'approved' && result.link) {
await db.users.update(row.userId, {
pushtowerLink: encrypt(result.link.url),
pushtowerLinkId: result.link.id,
});
await notify(
row.userId,
'PushTower connected',
"You'll get mypage.com notifications here.",
);
}
await db.pushtowerSessions.delete(row.sessionId);
});
A callback received while the session is still pending must preserve the
session and its poll token so a later approval can still be collected.
No public callback (local development, or you’d rather not)? Poll from your
backend when the page reports onApproved, or every 2 seconds while the widget
is open. connect.waitForLink(session) does the loop for you.
The link is returned once. Later polls return claimed.
4. Send notifications
async function notify(userId: string, title: string, message: string) {
const user = await db.users.find(userId);
if (!user.pushtowerLink) return;
const res = await fetch(decrypt(user.pushtowerLink), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, message }),
signal: AbortSignal.timeout(5000),
});
if (res.status === 401) {
// The user disconnected you in the app
await db.users.update(userId, { pushtowerLink: null });
}
}
See Channel Links for all fields and for Live Activities (order tracking, deliveries, long-running jobs).
Disconnecting
Users can disconnect you at any time in the app under Settings → Connected
services. From then on the link returns 401. Clear it and show the
Add PushTower button again. To disconnect from your side, delete the link
you stored.
Sends through the link count toward the user’s own monthly notification quota. Anonymous and free accounts have small quotas, so send only what users would miss.
Checklist
- The
pollTokenand the link stay on your server. Store the link encrypted, like any credential. callbackUrlis public HTTPS. Private and local addresses are rejected.- Look up the session by
sessionIdin your own database; don’t trust the callback’sexternalRefalone. - Send one test notification right after connecting so the user sees it work.
- Handle
401(disconnected) and429(rate or quota limit) when sending.