Embedded Wallets & Privy
Learn how to embed the Herald User Portal inside your dApp using Privy or other app-scoped wallets.
Embedded Wallets & Privy
Many modern Solana dApps use embedded wallet providers like Privy, Dynamic, or Web3Auth to sign up users with their email or Google accounts.
Because these embedded wallets are scoped to a specific application, users cannot connect them to external websites like notify.useherald.xyz.
To solve this, Herald allows you to embed the User Portal directly inside your application via an iframe and delegate wallet connection and signatures to your host app over a secure postMessage protocol.
1. How It Works

2. postMessage Protocol Specification
When embedded, the Herald Portal communicates using window.parent.postMessage. All requests sent from the iframe have the following format:
interface HeraldRequest {
source: "HERALD_PORTAL_IFRAME";
requestId: string; // Unique ID to correlate requests and responses
action: "connect" | "disconnect" | "signTransaction" | "signMessage";
params: {
transaction?: string; // bs58-encoded transaction (only for signTransaction)
isVersioned?: boolean; // whether transaction is a VersionedTransaction
message?: string; // bs58-encoded message string (only for signMessage)
};
}Your host application must listen for these requests, perform the necessary action using the Privy wallet, and reply back to the iframe using:
interface HeraldResponse {
target: "HERALD_PORTAL_IFRAME";
requestId: string; // Matches the request's requestId
result?: any; // Action result (string or object)
error?: string; // Error message if the action failed/declined
}3. Integration Methods
You can integrate the Herald embedded iframe using either our official React SDK helper exports (recommended) or write a custom manual integration.
Option A: Using the React SDK (Recommended)
If you are using React or Next.js, our official SDK provides out-of-the-box components and hooks that handle the protocol communication, address validation, and wallet state updates automatically.
1. Install the SDK
npm install @herald-protocol/sdk2. Implement the Widget
Import HeraldIframe and the useHeraldIframeListener hook. Pass your host dApp's active wallet state (e.g. from Privy, Dynamic, or standard wallet adapters) into the listener.
import { useSolanaWallet } from "@privy-io/react-auth/solana"; // Privy's Solana hook
import { HeraldIframe, useHeraldIframeListener } from "@herald-protocol/sdk/react";
export function HeraldSettingsWidget({ protocolId }: { protocolId: string }) {
const { wallet } = useSolanaWallet();
// 1. Automatically handles all 'connect', 'signTransaction', and 'signMessage' iframe requests.
// It also automatically propagates wallet changes (switches or disconnects) from the host into the iframe.
useHeraldIframeListener({
wallet,
onRegistrationComplete: (txSignature, alreadyRegistered) => {
console.log(`Successfully subscribed! Tx: ${txSignature}. Already registered: ${alreadyRegistered}`);
// e.g., Close your settings modal, show success toasts, etc.
}
});
// 2. Render the iframe safely with optimal styling
return (
<div className="w-full h-[600px] border border-border rounded-2xl overflow-hidden bg-card">
<HeraldIframe protocolId={protocolId} />
</div>
);
}Option B: Manual Integration (Custom / Non-React Apps)
If you are not using React or need a custom implementation, you can write the event listener and iframe manually.
1. Create a Custom Message Listener Hook
Here is the manual hook implementation that includes robust address validations and watches for host-side wallet state changes to broadcast them into the iframe:
import { useEffect, useRef } from "react";
import { useSolanaWallet } from "@privy-io/react-auth/solana";
import { PublicKey } from "@solana/web3.js";
import bs58 from "bs58";
export function useHeraldIframeListener() {
const { wallet } = useSolanaWallet();
const walletRef = useRef(wallet);
walletRef.current = wallet;
const pubkey = wallet?.address ||
(typeof wallet?.publicKey === 'string' ? wallet?.publicKey : wallet?.publicKey?.toBase58());
// Broadcast host wallet switches/disconnects to the embedded iframe(s)
useEffect(() => {
const notifyIframesOfWalletChange = () => {
const iframes = document.querySelectorAll("iframe");
iframes.forEach((iframe) => {
try {
const iframeUrl = new URL(iframe.src);
// Match official Herald portal domain or localhost for testing
if (iframeUrl.origin === "https://notify.useherald.xyz" || iframeUrl.origin === "http://localhost:3000") {
if (pubkey) {
iframe.contentWindow?.postMessage(
{ source: "HERALD_HOST_APPLICATION", action: "hostWalletSwitch", params: { address: pubkey } },
iframeUrl.origin
);
} else {
iframe.contentWindow?.postMessage(
{ source: "HERALD_HOST_APPLICATION", action: "hostDisconnect" },
iframeUrl.origin
);
}
}
} catch {
// Ignore cross-origin / parsing errors
}
});
};
notifyIframesOfWalletChange();
}, [pubkey]);
useEffect(() => {
const handleMessage = async (event: MessageEvent) => {
const data = event.data;
if (!data || data.source !== "HERALD_PORTAL_IFRAME") return;
// Security: Validate message origin
if (event.origin !== "https://notify.useherald.xyz" && event.origin !== "http://localhost:3000") return;
const { action, params, requestId } = data;
const currentWallet = walletRef.current;
const reply = (result: any = null, error: string | null = null) => {
if (event.source) {
(event.source as Window).postMessage(
{ target: "HERALD_PORTAL_IFRAME", requestId, result, error },
event.origin
);
}
};
try {
if (action === "onRegistrationComplete") {
console.log("Subscription complete:", params.txSignature);
return;
}
if (!currentWallet) {
throw new Error("No wallet connected in the host application");
}
switch (action) {
case "connect": {
const address = currentWallet.address;
if (!address) throw new Error("Wallet public key is not available");
// Validate address is a Solana public key (cross-chain safeguard)
try {
new PublicKey(address);
} catch {
throw new Error(`Invalid Solana address: ${address}. Please switch to a Solana account.`);
}
reply(address);
break;
}
case "signTransaction": {
const { transaction: serializedBase58 } = params;
if (!serializedBase58) throw new Error("Missing transaction payload");
const txBytes = bs58.decode(serializedBase58);
const signedTxBytes = await currentWallet.signTransaction(txBytes);
reply({ signedTransaction: bs58.encode(signedTxBytes) });
break;
}
case "signMessage": {
const { message: messageBase58 } = params;
if (!messageBase58) throw new Error("Missing message payload");
const messageBytes = bs58.decode(messageBase58);
const signatureBytes = await currentWallet.signMessage(messageBytes);
reply({ signature: bs58.encode(signatureBytes) });
break;
}
case "disconnect": {
reply(true);
break;
}
default:
reply(null, `Unsupported action: ${action}`);
}
} catch (err: any) {
console.error("Herald iframe adapter error:", err);
reply(null, err.message || "Failed to process request");
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
}2. Render the Iframe
Render the iframe element with query parameters embed=true, protocolId, and parentOrigin. Be sure to set allow="clipboard-write" to enable secure copying functions inside the Portal.
export function HeraldSettingsWidget({ protocolId }: { protocolId: string }) {
// Activate Privy message listener
useHeraldIframeListener();
// Get the current origin dynamically in the browser
const hostOrigin = typeof window !== "undefined" ? window.location.origin : "";
return (
<div className="w-full h-[600px] border border-border rounded-2xl overflow-hidden bg-card">
<iframe
id="herald-portal-iframe"
src={`https://notify.useherald.xyz/register?embed=true&protocolId=${protocolId}&parentOrigin=${encodeURIComponent(hostOrigin)}`}
className="w-full h-full border-none"
title="Herald Notification Preferences"
allow="clipboard-write"
/>
</div>
);
}4. Registration Completion Event
When the user completes registration or signs in, the Herald Portal posts an unsolicited "onRegistrationComplete" action back to the host window:
interface RegistrationCompleteResponse {
source: "HERALD_PORTAL_IFRAME";
action: "onRegistrationComplete";
params: {
txSignature: string; // The Solana transaction signature (or "already-registered")
alreadyRegistered: boolean; // Whether the user was already registered
};
}You can listen for this event in your manual hook or pass onRegistrationComplete directly to the useHeraldIframeListener SDK hook.