Onboarding
Connect a customer's WhatsApp number from inside your own product, using your own button, with no redirect and no PingMate UI.
The Onboarding SDK runs WhatsApp Business signup from a page you control. Your customer clicks your button, completes the WhatsApp connection flow in a popup, and lands back on your page — they never leave your product and never see ours.
It ships in the same embed.js loader as every other surface, but it isn't a render call. It has its own object, because it doesn't mount a panel — it drives a flow.
Why it needs a button of yours
The connection step opens a popup, and browsers only allow a popup when the window opening it has user activation — a real, recent click. A cross-origin frame only gets that if the user clicks the frame itself.
So the SDK pins an invisible frame exactly over an element you supply. Your button keeps your markup, your styles, and your layout; the click lands in the frame. You don't have to do anything about this beyond pointing attach() at an element — but it explains the two things that follow: your button's onclick never fires, and its :hover never triggers. Both are handled below.
Load the script
Serve the loader once per page. Use defer so it never blocks rendering.
<script src="https://pingmate.app/embed.js" defer></script>The loader defines the OnboardingSDK global (also available as Embed.Onboarding).
Quick start
<button id="connect" class="my-button">Connect WhatsApp</button>
<script src="https://pingmate.app/embed.js" defer></script>
<script>
window.addEventListener("load", function () {
var onboarding = new OnboardingSDK({
apiKey: "pm_xxx",
wabaId: "wb_123",
});
onboarding.on("ready", function () {
// The button is armed. Until now it was showing pm-onboarding-loading.
});
onboarding.on("submitted", function (r) {
console.log("connecting", r.wabaId);
});
onboarding.on("success", function (r) {
console.log("connected", r.phoneNumberId);
});
onboarding.on("error", function (e) {
console.error(e.code, e.message);
});
onboarding.on("cancel", function () {
// Closed the popup without finishing.
});
onboarding.attach("#connect", { platform: "whatsapp" });
});
</script>That's the whole integration. No redirect, no callback URL, no server step.
Pick the right platform
platform: "whatsapp" onboards a number already running on the WhatsApp
Business app. platform: "facebook" onboards through Meta Business, which
suits teams and API-first setups. It defaults to "whatsapp".
Styling hooks
Because the pointer is over the SDK's frame, your element's own :hover and :focus never fire. The SDK mirrors those states onto your element as classes, so you style them the same way you'd style any other state:
.my-button.pm-onboarding-loading { opacity: .6; cursor: progress; }
.my-button.pm-onboarding-hover { background: #1d4ed8; }
.my-button.pm-onboarding-focus { outline: 2px solid #1d4ed8; outline-offset: 2px; }| Class | When it's on |
|---|---|
pm-onboarding-loading | From attach() until ready. Clicks are swallowed during this window. |
pm-onboarding-hover | The pointer is over the button. |
pm-onboarding-focus | The connect control has keyboard focus. |
Keyboard access works without any effort on your part: tabbing reaches the connect control and Enter starts the flow. The SDK sets tabindex="-1" on your element while attached — otherwise it would be a focus stop that does nothing — and restores it on detach().
Without a button: start()
If you have nothing to overlay, call start(). It opens a small modal with its own button and returns a promise.
var onboarding = new OnboardingSDK({ apiKey: "pm_xxx", wabaId: "wb_123" });
document.querySelector("#connect").onclick = function () {
onboarding
.start({ platform: "whatsapp" })
.then(function (r) { console.log("connected", r.wabaId); })
.catch(function (e) { console.error(e.code, e.message); });
};Events fire for start() too, so you can mix both styles. Prefer attach() when you have a button — it costs the user one click instead of two.
React
Always destroy() on unmount: the SDK holds a message listener, an iframe, and a position tracker.
import { useEffect, useRef } from "react";
function ConnectButton({ apiKey, wabaId, onConnected }) {
const ref = useRef(null);
useEffect(() => {
const onboarding = new window.OnboardingSDK({ apiKey, wabaId });
onboarding.on("success", onConnected);
onboarding.attach(ref.current, { platform: "whatsapp" });
return () => onboarding.destroy();
}, [apiKey, wabaId, onConnected]);
return <button ref={ref}>Connect WhatsApp</button>;
}Make sure embed.js has loaded before the component runs — a <script> in your HTML shell, or load it dynamically and await window.OnboardingSDK.
Options
| Option | Type | Required | Description |
|---|---|---|---|
apiKey | string | yes | API key (pm_…). |
wabaId | string | yes | The number to connect. |
platform | "whatsapp" | "facebook" | no | Default for attach() / start(). Defaults to "whatsapp". |
theme | "light" | "dark" | "system" | no | Colour theme for the start() modal. |
baseUrl | string | no | Override the API origin. Does not move the onboarding frame's origin, which is fixed. |
waitTimeoutMs | number | no | How long to wait for setup to finish after the exchange. Default 180000. 0 skips waiting. |
on | object | no | Map of event name → handler, wired at construction. |
Methods
| Method | Description |
|---|---|
attach(target, opts?) | Overlay the click target on your element. target is a CSS selector or an Element. Attaching a second element detaches the first. |
detach() | Remove the overlay, restore tabindex, drop the added classes. |
start(opts?) | Open the modal variant. Returns Promise<OnboardingResult>. |
on / off / once | Event subscription. Chainable. |
ready | A promise resolving when the flow is armed. |
destroy() | detach(), close any modal, drop all listeners. Further calls are no-ops. |
Events
| Event | Payload | Meaning |
|---|---|---|
ready | {} | The flow is armed and the button accepts clicks. |
submitted | { wabaId, wabaAppId, phoneNumberId } | The connection was accepted and setup has started. |
success | { wabaId, wabaAppId, phoneNumberId } | The number is connected and ready to send. |
cancel | {} | The popup was closed without finishing. |
error | { code, message, cause } | Something failed. See the codes below. |
submitted is not success
Finishing the popup only starts the setup
When your customer finishes the popup, the number still has to be provisioned
— that takes seconds to a couple of minutes. submitted fires as soon as the
connection is accepted; the SDK then waits until the number is genuinely live
and fires success.
Set waitTimeoutMs: 0 if you'd rather treat acceptance as done and track the
rest yourself. And note that a timeout error does not mean setup failed
— it means it's still running in the background and outlasted the wait.
Error codes
| Code | Meaning | What to do |
|---|---|---|
config_failed | Couldn't load the onboarding configuration. | Check apiKey and wabaId; the key must own the number. |
sdk_load_failed | The connection script couldn't load. | Usually a network block or an ad blocker. Ask the user to retry. |
already_running | start() was called while a flow was open. | Wait for the open flow to settle. |
cancelled | The user closed the popup. | Not an error. Let them try again. |
exchange_failed | The connection couldn't be completed. | Retry; if it persists, the number may already be connected elsewhere. |
provisioning_failed | Setup ran and did not complete. | Ask the user to connect again. |
timeout | Setup outlasted waitTimeoutMs. | It is still running. Check the number's status later rather than retrying immediately. |
destroyed | The instance was destroyed mid-flow. | A lifecycle bug on your side — usually unmounting without cancelling. |
Authentication
Don't ship a raw API key to the browser
An apiKey (pm_…) is a long-lived secret that can spend wallet balance.
Anything in a page's HTML is public. This surface takes an apiKey only, so
treat the page it runs on accordingly: serve it to signed-in users, keep it
off public marketing pages, and issue a separate key per integration so you
can revoke one without disturbing the rest.
The SDK passes your page's origin to the frame, and the frame only ever posts events back to that exact origin — never to * — so event payloads can't leak to another frame.
How is this guide?


