Developing a notification system for dApps via Push Protocol
Email notifications in DeFi don't work — users don't have email in the protocol, only a wallet address. Telegram bots require separate subscription. Push Protocol (formerly EPNS) solves this: a decentralized messaging layer where notifications are tied to a wallet address, users subscribe on-chain to channels, and receive notifications in the Push App, browser extension, or directly in the dApp via SDK.
How Push Protocol works
Channel — an address that sends notifications. Created once, requires staking 50 PUSH on Ethereum (or Polygon). The protocol is paid for channel creation, but notification distribution is free.
Subscriber — a user who opts in to a channel. Subscription is an on-chain transaction (or off-chain via a gasless mechanism on Polygon).
Notification — a JSON payload stored in IPFS, indexed by Push Protocol network nodes. Types: Broadcast (all subscribers), Targeted (specific address), Subset (address list).
Creating a channel and sending notifications
npm install @pushprotocol/restapi @pushprotocol/socket ethers
Channel creation happens through the Push dApp (app.push.org). Programmatic notification sending from a server:
import * as PushAPI from '@pushprotocol/restapi'
import { ethers } from 'ethers'
const CHANNEL_PRIVATE_KEY = process.env.PUSH_CHANNEL_PRIVATE_KEY!
const signer = new ethers.Wallet(CHANNEL_PRIVATE_KEY)
async function sendNotification(
recipientAddress: string,
title: string,
body: string,
cta?: string
) {
await PushAPI.payloads.sendNotification({
signer,
type: 3, // targeted
identityType: 2, // direct payload
notification: { title, body },
payload: {
title,
body,
cta: cta ?? '',
img: '',
},
recipients: `eip155:1:${recipientAddress}`,
channel: `eip155:1:${CHANNEL_ADDRESS}`,
env: 'prod',
})
}
Typical triggers for DeFi notifications
| Event | Notification type | Delay |
|---|---|---|
| Liquidation risk (health < 1.2) | Targeted, HIGH urgency | Real-time |
| Position liquidated | Targeted | Real-time |
| Yield harvest available | Targeted | Hourly |
| Governance proposal created | Broadcast | On-chain event |
| Voting deadline in 24h | Broadcast | Scheduled |
| Large price movement (>10%) | Broadcast | Price oracle |
For real-time triggers, you need an on-chain event listener:
import { createPublicClient, http, parseAbiItem } from 'viem'
const client = createPublicClient({ chain: mainnet, transport: http(RPC_URL) })
// Watch for liquidation events
client.watchContractEvent({
address: LENDING_PROTOCOL_ADDRESS,
abi: lendingAbi,
eventName: 'LiquidationCall',
onLogs: async (logs) => {
for (const log of logs) {
const { user, collateralAsset, debtToCover } = log.args
await sendNotification(
user,
'Position liquidated',
`Liquidated ${formatUnits(debtToCover, 18)} USDC. Check your positions.`,
`https://app.protocol.xyz/positions`
)
}
}
})
Displaying notifications in a dApp
Fetch notifications for a user
import * as PushAPI from '@pushprotocol/restapi'
import { useAccount } from 'wagmi'
import { useQuery } from '@tanstack/react-query'
export function useNotifications() {
const { address } = useAccount()
return useQuery({
queryKey: ['push-notifications', address],
queryFn: async () => {
const feeds = await PushAPI.user.getFeeds({
user: `eip155:1:${address}`,
limit: 20,
env: 'prod',
})
return feeds
},
enabled: !!address,
refetchInterval: 30_000, // poll every 30 seconds
})
}
Real-time via WebSocket
import { createSocketConnection, EVENTS } from '@pushprotocol/socket'
function usePushSocket(address: string | undefined) {
const [socket, setSocket] = useState<any>(null)
const queryClient = useQueryClient()
useEffect(() => {
if (!address) return
const sdkSocket = createSocketConnection({
user: `eip155:1:${address}`,
env: 'prod',
socketOptions: { autoConnect: true }
})
sdkSocket.on(EVENTS.CONNECT, () => console.log('Push socket connected'))
sdkSocket.on(EVENTS.USER_FEEDS, (feedItem: any) => {
// Invalidate cache on new notification
queryClient.invalidateQueries({ queryKey: ['push-notifications', address] })
// Show toast
showToast(feedItem.payload.notification.title)
})
setSocket(sdkSocket)
return () => sdkSocket?.disconnect()
}, [address])
}
Notification component
function NotificationBell() {
const { data: notifications = [], isLoading } = useNotifications()
const unread = notifications.filter(n => !n.epoch || n.epoch > lastRead)
return (
<Popover>
<PopoverTrigger>
<Bell className="h-5 w-5" />
{unread.length > 0 && (
<span className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-red-500 text-xs flex items-center justify-center">
{unread.length}
</span>
)}
</PopoverTrigger>
<PopoverContent className="w-80">
{notifications.map(n => (
<NotificationItem key={n.sid} notification={n} />
))}
</PopoverContent>
</Popover>
)
}
Checking subscription and opt-in
Before sending targeted notifications, check if the user is subscribed:
async function isSubscribed(userAddress: string): Promise<boolean> {
const subscriptions = await PushAPI.user.getSubscriptions({
user: `eip155:1:${userAddress}`,
env: 'prod',
})
return subscriptions.some(
(sub: any) => sub.channel.toLowerCase() === CHANNEL_ADDRESS.toLowerCase()
)
}
Gasless opt-in via Push SDK — users subscribe through an off-chain signature (EIP-712), without gas. Important for onboarding — requiring gas for notification subscription discourages users.
const user = await PushAPI.initialize(signer, { env: 'prod' })
await user.notification.subscribe(`eip155:1:${CHANNEL_ADDRESS}`) // gasless via delegated signing







