Skip to main content
Deliver notifications to your users addressed by wallet address, no email, no phone number, no account system required. A user proves ownership of their wallet by signing a message, and from then on you can reach them wherever they are. One send reaches the user through whichever channel they’re reachable on:

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.
The SDK gives you all three plus wallet auth and a styled toast UI; a server-to-server API sends pushes from your backend with a secret key.
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.ethereum fallback. Signing uses EIP-191 personal_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.
The server verifies both. Which scheme runs is chosen from the address shape (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 as production
  • pk_test_* matches origins registered as development
The number-one integration failure is a key-to-origin mismatch. A pk_live_* key on an origin you registered as development fails even though the key is valid and the origin is saved, which reads as “the product is broken.” Register your production domain as production, and add http://localhost:3000 as development while you build. The allowlist is fail-closed: until an origin is saved, the SDK can’t connect at all.
The origins API also accepts staging, but no publishable key ever resolves to it. An origin registered as staging will never match any key, use production or development.
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). Add data-key and it constructs a client on window.onchainsuite; add data-autostart and it calls start() for you. Optional data-api sets the API base (defaults to https://api.onchainsuite.com). No import, no build step. Only ever put a pk_… key here, never sk_….
  • CDN (ESM). A raw esm.sh import can’t include the bundled real-time library, so load it first (the SDK finds it on window.io), or inject your own with the ioClient option.
The SDK is lightweight and adds only the parts you actually use to your site.
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

That’s the whole 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.
Render it once, for example inside your wallet provider:

Custom rendering

Turn the built-in toast off with display: false and render notifications with your own design system. The actions argument reports interactions for you, so analytics stay accurate.
The notification and its actions:
Reporting maps to analytics and webhooks: arrival is 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.
You can also construct the client with 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 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:
Point the SDK at a custom path with 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.
The SDK fetches the platform VAPID key, registers the subscription with the server, and re-syncs a granted subscription automatically on every 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 after start(). 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.
Use "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 by pk_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 of data.
Errors use a parallel shape:
Error codes by status: 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:
The challenge is valid for 5 minutes.

Step 2, Sign and verify

For an EVM wallet, sign the returned message with EIP-191 personal_sign:
For a Solana wallet, sign the UTF-8 bytes of the same message with ed25519 and send the signature base58-encoded:
The server picks the verification scheme from the address shape (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.
Both /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.
Pending notifications are replayed automatically on connect, before any live pushes arrive.

Socket events

Server → client, PUSH:
Client → server: type is one of delivered, viewed, dismissed, or clicked. Optional metadata is stored on the delivery record.
Reporting events for a wallet other than the one in your session token disconnects the socket immediately. There is no error event. It surfaces client-side as disconnect or connect_error.

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:
deliveredNow is a best-effort signal meaning “a socket for this wallet is connected to the node that handled your request.” A false does not mean the push failed; the user may be connected to another node, and offline users get the push replayed on reconnect. For authoritative delivery, read the delivered event in your analytics.

Templates and merge variables

When you send templateId, 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-inapp resolves recipients from the campaign’s segment and returns { campaignRunId, recipientCount, deliveredNowCount, skippedCount }. Optional body fields title, body, ctaLabel, ctaUrl override the campaign’s stored content.
  • Automations, add a send_inapp step and let the workflow resolve the audience.
To preview reach before sending, query your segment with ?reachable_on=inapp.
To trigger a push from something that happens in your product, a signup, a deposit, a status change, send a custom event and let an automation turn it into a push. See the end-to-end walkthrough.

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

  • The Origin header must match a registered origin exactly: scheme, host, and port.
  • Confirm the key environment lines up, pk_live_* only matches production origins, pk_test_* only matches development.
  • An origin saved as staging never matches anything. Re-register it.
Send the key as x-publishable-key or Authorization: Bearer. Check you haven’t shipped a test key to production.
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.
Challenges live 5 minutes and are single-use. Request a fresh one rather than retrying an old signature.
  • 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 402 or 429 means it was never queued.
  • Remember that deliveredNow: false is normal and not an error.
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.
Both ctaLabel and ctaUrl must be present. A label without a URL is dropped silently.
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.