In-app
A live toast in your dApp while the page is open, over a secure real-time connection.
Web push
An OS notification when the tab is closed, via a service worker.
Mobile push
APNs and FCM delivery to your iOS and Android apps.
In plain terms. You add a small script (the SDK) to your dApp. It asks a visitor’s wallet to sign a one-time message to prove the wallet is theirs, then keeps a live connection open so your notifications can pop up on their screen. You don’t build any of the wallet or connection plumbing, the SDK handles it. The rest of this page is the setup, from a two-line copy-paste to full control over how each notification looks.
How it works
1
Allow your origin
Register your frontend’s origin in the dashboard. Requests from anywhere else are rejected.
2
Wallet authenticates
The browser requests a challenge with your publishable key, the wallet signs it, and the server returns a short-lived session token. The wallet can be EVM or Solana, the server verifies both.
3
Client connects
The client opens a secure real-time connection with that token, scoped to its own org and wallet. Optionally it also registers for web or mobile push.
4
You send pushes
From your backend (secret key), a campaign, or an automation. The platform routes each one to a live socket if the user is connected, otherwise to web or mobile push, otherwise it’s held and replayed on reconnect.
Wallets it works with
The SDK auto-discovers the wallets installed in the browser and authenticates whichever the user picks:- EVM wallets are discovered via EIP-6963 multi-wallet announcement, with a
window.ethereumfallback. Signing uses EIP-191personal_sign. - Solana wallets (Phantom, Solflare, Backpack, Glow, Coinbase) are discovered by scanning their standard injection points. Signing uses ed25519, and the signature is sent base58-encoded.
0x… → EVM, otherwise Solana), so you don’t configure a chain. To take over signing entirely, for either chain, pass a signMessage override.
The platform never sends a live in-app toast and an OS push for the same notification. If the wallet has an open socket, only the toast fires. When the socket is closed, web and mobile push take over. Cross-channel duplicates are collapsed by delivery ID, so tapping an OS push and then opening your dApp won’t show the same notification twice.
Keys and environments
Your publishable key’s environment decides which allowed origins apply:
pk_live_*matches origins registered asproductionpk_test_*matches origins registered asdevelopment
The in-app allowlist is separate from the API’s CORS allowlist. Your origin must be present in both for browser requests to succeed. If you get a CORS error in the console rather than an
Origin not allowed response body, it’s the CORS allowlist that’s missing your domain.Quickstart with the SDK
The fastest path is the official browser SDK. It handles the challenge/verify handshake, the socket lifecycle, reconnection, delivery reporting, and an optional toast UI.Three ways in, pick one:
- npm / bundler.
import { OnchainSuite } from "@onchainsuite/sdk". The real-time connection library is bundled in automatically, nothing else to install. - CDN loader (
inapp.js). A single self-contained script (the real-time library included). Adddata-keyand it constructs a client onwindow.onchainsuite; adddata-autostartand it callsstart()for you. Optionaldata-apisets the API base (defaults tohttps://api.onchainsuite.com). No import, no build step. Only ever put apk_…key here, neversk_…. - CDN (ESM). A raw
esm.shimport can’t include the bundled real-time library, so load it first (the SDK finds it onwindow.io), or inject your own with theioClientoption.
Which snippet do I use?
- Just trying it out, or your app doesn’t manage the wallet connection itself, use Simplest integration.
- Your app already connects the wallet (wagmi, viem, ethers, or a Solana adapter), use Bring your own signer so the user isn’t asked to sign twice.
- You’re on React or Next.js, use React.
- You want notifications to match your own design instead of the built-in toast, use Custom rendering.
Simplest integration
start() finds the wallet the visitor has installed (both EVM and Solana wallets are detected automatically), asks it to sign the one-time login message, opens the live connection, and starts showing notifications. Pass an address to start(walletAddress) to pin a specific account.
Bring your own signer
If your app already manages wallet connection, pass a signer and the address explicitly so the user isn’t prompted twice.signMessage is the universal override: (message, walletAddress) => Promise<string>. When you supply it, the SDK never touches the wallet itself, so it works for any wallet on either chain, an EVM signer from wagmi/viem/ethers, or a Solana wallet’s signMessage. Return the signature your wallet produces (hex for EVM, base58 for Solana). Without the override, the SDK signs with the discovered wallet automatically.
React
Mount one component high in the tree, keyed on the connected wallet.Custom rendering
Turn the built-in toast off withdisplay: false and render notifications with your own design system. The actions argument reports interactions for you, so analytics stay accurate.
message.delivered, report("viewed") is message.viewed, and click() is message.clicked.
SDK reference
string
required
Must start with
pk_. The constructor throws otherwise.string
API origin without the
/api/v1 suffix, e.g. https://api.onchainsuite.com.(message: string, walletAddress: string) => Promise<string>
Custom signer, receiving the challenge message and the wallet address. Defaults to
personal_sign via window.ethereum.EIP1193Provider
Explicit wallet provider instead of
window.ethereum.DisplayOptions | false
Toast configuration, or
false to disable the built-in UI.(n, actions) => boolean | void
Called per notification. Return
false to suppress the built-in toast. actions provides report(type), click(), and dismiss().io factory
Inject your own real-time client instead of the bundled one, useful on the CDN path or to pin a version.
false | { serviceWorkerPath?: string }
Web push configuration. Defaults to enabled with the service worker at
/onchainsuite-sw.js. Set to false to disable web push entirely. See Web push.boolean
Verbose console logging.
createClient(publishableKey, options), a functional alias for new OnchainSuite(...).
Display defaults: position: "bottom-right", accent: "#6d5efc", background: "#111318", foreground: "#f5f6f8", duration: 8000 (use 0 for sticky), maxVisible: 3, zIndex: 2147483000.
Methods:
Events:
"notification", "connected", "disconnected", "error".
Behaviour worth knowing: delivered is reported automatically on receipt; the socket reconnects indefinitely with backoff from 1s to 15s; toast content is rendered with textContent, so notification text can’t inject HTML; and start() resolves after 8 seconds even if the socket hasn’t connected yet, so it never hangs your boot sequence.
Web push
Web push delivers an OS-level notification when your dApp’s tab is closed, the same notifications, reaching users who’ve navigated away. It needs a service worker and the user’s permission.1
Host the service worker
The SDK ships one at Point the SDK at a custom path with
node_modules/@onchainsuite/sdk/public/onchainsuite-sw.js. Copy it to your site root so it’s served at /onchainsuite-sw.js.Already have a service worker? Import ours into it instead:push: { serviceWorkerPath: "/my-sw.js" }.2
Prompt from a user action
Call
enablePush() in response to a click, not on load, so the permission prompt has context. Gate it on pushStatus() so you don’t re-prompt someone who already decided.start(). There’s no VAPID key for you to configure. Turn it off per user with disablePush(), or disable the feature entirely with push: false.
Mobile push
For a native iOS or Android app, register the device’s push token afterstart(). The SDK doesn’t fetch the token, obtain it with your own push library (expo-notifications, @react-native-firebase/messaging, etc.) and hand it over.
"ios" for APNs. Stop delivery to one device with unregisterDevice(token).
Mobile push requires your Apple (APNs
.p8) or Firebase (FCM service account) credentials on file, an OWNER or ADMIN uploads them once in the dashboard under organization push credentials. Without them, device tokens register but nothing is delivered.Choosing a channel is automatic
You don’t pick the channel, the platform routes each notification per recipient:
A wallet with both a browser subscription and a phone can receive both. What never happens is a live toast and an OS push for the same notification.
deliveredNow: false on a send is still not an error. It means the user wasn’t socket-connected on the node that handled the request, and OS push or replay takes over.
Dashboard setup
Everything above runs on two things you set once in the dashboard: your keys and your allowed origins. You manage both under the in-app integration settings, no code required.- Allowed origins. Add the web addresses your dApp runs on, one for production (matched by your
pk_live_…key) and one for local development (matched bypk_test_…). A request from any address you haven’t listed is rejected. Owner or Admin can add or remove origins. - Your keys. See your publishable keys (safe to ship in the browser) and the details of your secret keys, the name, when each was last used, and whether it’s been revoked. More in API keys.
- Live usage. Watch how many wallets are connected right now and how much of your daily in-app allowance you’ve used.
- Send a test push. Fire a one-off notification to a wallet to check your setup end to end. Owner, Admin, or Editor can send one. Test pushes don’t count against your plan’s message allowance, but they do count toward the daily in-app quota.
Response format
Every successful response is wrapped in a standard envelope. The examples below show the contents ofdata.
400 BAD_REQUEST, 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND, 409 CONFLICT, 422 VALIDATION_ERROR, 429 RATE_LIMITED, 500 INTERNAL_ERROR.
Manual browser integration
Use this if you’re not on JavaScript or need to control the handshake yourself. Otherwise prefer the SDK above.Step 1, Request a challenge
POST /api/v1/inapp/challenge
Send x-publishable-key: pk_live_… (or Authorization: Bearer pk_live_…) and an Origin header matching an allowed origin.
data:
Step 2, Sign and verify
For an EVM wallet, sign the returnedmessage with EIP-191 personal_sign:
message with ed25519 and send the signature base58-encoded:
0x… → EVM, otherwise Solana), so send the address and signature exactly as your wallet produced them.
POST /api/v1/inapp/verify with the same headers:
data:
wsUrl is built from the incoming request’s host, so always use the returned value rather than hardcoding it./inapp/challenge and /inapp/verify are rate limited to 3 requests per 10 seconds.
Step 3, Connect the socket
Open the real-time connection at path/api/v1/inapp/register. The token can go in handshake.auth.token, an Authorization: Bearer header, or a ?t= query param.
Socket events
Server → client,PUSH:
type is one of delivered, viewed, dismissed, or clicked. Optional metadata is stored on the delivery record.
Sending pushes from your backend
POST /api/v1/inapp/push with Authorization: Bearer sk_… (or x-secret-key: sk_…). The organization is derived from the key.
string
required
Recipient wallet.
string
Required unless supplied by
templateId.string
Required unless supplied by
templateId.string
An in-app template to render. Its
content.channel must be "inapp".Record<string, string>
Merge values for
{{ token }} placeholders. Max 50 entries.string
Button label. Only renders when
ctaUrl is also present.string
Button destination.
string
Free-form tag for attribution. Defaults to
inapp_api.data:
Templates and merge variables
When you sendtemplateId, these variables are always available without you supplying them:
Unknown placeholders are left in the text as-is rather than blanked, which makes typos visible during testing.
Sending to many wallets
There is no bulk endpoint on the secret-key API, loop/inapp/push per wallet. For audience-scale sends, use one of these instead:
- Campaign to a segment,
POST /api/v1/campaigns/{id}/send-inappresolves recipients from the campaign’s segment and returns{ campaignRunId, recipientCount, deliveredNowCount, skippedCount }. Optional body fieldstitle,body,ctaLabel,ctaUrloverride the campaign’s stored content. - Automations, add a
send_inappstep and let the workflow resolve the audience.
?reachable_on=inapp.
Limits and quotas
Expired, dismissed, and clicked notifications are pruned and never replayed.
In-app pushes have their own monthly allowance, separate from email. Exceeding it returns
402 PLAN_LIMIT_EXCEEDED; warnings fire at 75% and 95% of the allowance. Exceeding the daily cap returns 429 Daily in-app push quota exceeded. See Plans and billing for the per-plan numbers.
Rate-limit (429) responses from the throttler return a flat body,
{ statusCode, message, error, retryAfter, limit, remaining, resetAt, path, timestamp }, rather than the standard error envelope, along with X-RateLimit-Limit and X-RateLimit-Remaining headers.Troubleshooting
401 Origin not allowed
401 Origin not allowed
- The
Originheader must match a registered origin exactly: scheme, host, and port. - Confirm the key environment lines up,
pk_live_*only matchesproductionorigins,pk_test_*only matchesdevelopment. - An origin saved as
stagingnever matches anything. Re-register it.
401 Invalid publishable key / Missing publishable key
401 Invalid publishable key / Missing publishable key
Send the key as
x-publishable-key or Authorization: Bearer. Check you haven’t shipped a test key to production.401 Signature does not match wallet
401 Signature does not match wallet
Sign the
message string exactly as returned, no trimming or re-encoding, using EIP-191 personal_sign. Also confirm walletAddress in the verify call matches the signing account.401 Challenge not found or expired
401 Challenge not found or expired
Challenges live 5 minutes and are single-use. Request a fresh one rather than retrying an old signature.
Notifications never arrive
Notifications never arrive
- Confirm the socket is connected and listening for
PUSH(not a custom event name). - Check the path is exactly
/api/v1/inapp/register. - Check the send response, a
402or429means it was never queued. - Remember that
deliveredNow: falseis normal and not an error.
Socket disconnects immediately
Socket disconnects immediately
Almost always a wallet mismatch: the wallet in
REGISTER or EVENT differs from the one in the session token. Re-run the handshake after a wallet switch.Real-time works locally but not in production
Real-time works locally but not in production
Live delivery depends on the wallet holding an open real-time connection. If something in your production stack closes idle connections (an aggressive proxy or load balancer, for example), toasts fall back to web or mobile push, and anything missed is replayed on the next reconnect, so it’s delayed rather than lost. Make sure your infrastructure allows long-lived WebSocket connections.

