In-app
Web push
Mobile push
How it works
Allow your origin
Wallet authenticates
Client connects
You send pushes
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.
Keys and environments
pk_live_*matches origins registered asproductionpk_test_*matches origins registered asdevelopment
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.- 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.
- 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
pk_. The constructor throws otherwise./api/v1 suffix, e.g. https://api.onchainsuite.com.personal_sign via window.ethereum.window.ethereum.false to disable the built-in UI.false to suppress the built-in toast. actions provides report(type), click(), and dismiss()./onchainsuite-sw.js. Set to false to disable web push entirely. See Web push.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:
"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.Host the service worker
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" }.Prompt from a user action
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
Mobile push reaches a native iOS or Android app even when it’s closed. It needs a one-time setup, because each notification travels through Apple’s and Google’s own push services, and they have to trust your app first. What you need from your side:Set up push with Apple and Google
- iOS: an Apple Developer account and an APNs auth key (a
.p8file). - Android: a Firebase project with Firebase Cloud Messaging (FCM) enabled, and its service-account key.
Upload those credentials once, in the dashboard
Get the device token in your app, and hand it to the SDK
expo-notifications or @react-native-firebase/messaging) and pass it to registerDevice.start().
"ios" for Apple devices (APNs) and "android" for Android (FCM). Stop delivery to one device with unregisterDevice(token).
Choosing a channel is automatic
You don’t pick the channel, the platform routes each notification per recipient: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.
templateId.templateId.content.channel must be "inapp".{{ token }} placeholders. Max 50 entries.ctaUrl is also present.inapp_api.data:
Templates and merge variables
When you sendtemplateId, these variables are always available without you supplying them:
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
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.
{ 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
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
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
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
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

