ioniqx / Embed Widget
Embed Widget
Drop investor KYC, accreditation, and holdings into any website with two lines of JavaScript.
Overview
The ioniqx embed widget is a hosted iframe that your site loads via a single <script> tag —
modelled on Stripe.js. It handles the full investor verification lifecycle:
- Email lookup — check existing verification status
- Identity verification — Didit KYC (ID scan + liveness)
- Accreditation — investor self-certifies their basis (income, net worth, etc.)
- Holdings display — shows the investor their positions in your company's offerings
- Offerings list — your company's live offerings the investor may see
When the flow completes the widget posts a short-lived signed result token to your page via
window.postMessage. You verify this token server-side to confirm the investor's identity and
grant them access to your application.
Quick start
Three steps to get a working embed on your site:
1. Add the script tag
<script src="https://staging.ioniqx.io/widget.js"></script>
Place it anywhere in your HTML — the script is CDN-cached and has no dependencies.
2. Initialise the widget
SaasWidget.init({
apiKey: "pk_live_…",
onSuccess: function (token) {
// send `token` to your server to verify
fetch("/verify-investor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token })
}).then(function () {
// close the widget once your server has confirmed the investor
window.SaasWidget.close();
});
}
});
3. Open the widget
// Attach to a button, or set autoOpen: true in init()
document.getElementById("verify-btn").addEventListener("click", function () {
SaasWidget.open();
});
Get an API key
Every investment company on ioniqx gets a publishable API key that identifies them when
loading the embed widget. Keys are self-served — any company admin or manager
can find theirs in the portal under Portal → Developers
(/portal/developers). No request to ioniqx support is required.
Finding your key
- Sign in to the ioniqx portal and complete company KYC if you haven't already.
- Open the Portal menu in the top navigation bar and click Developers.
- Your publishable API key is displayed at the top of the page. Click Copy to copy it to the clipboard.
Key formats
Keys are prefixed to indicate the environment they belong to:
pk_live_…— production key issued on the live ioniqx platformpk_test_…— non-production key, used in development and staging environments
A key is auto-provisioned the first time you visit the Developers page — you do not need to create one manually.
Regenerating a key
If a key is compromised or you need to rotate it, click Regenerate on the Developers page. The old key is invalidated immediately and all embeds using it will stop loading until updated. Only company admins and managers can regenerate keys.
Allowed embed domains
The widget will refuse to load on any origin that is not listed in your allowed
domains. Configure the domains on the Developers page — one per line, without
a protocol prefix (e.g. example.com, not https://example.com).
Subdomains are automatically permitted: adding example.com also allows
app.example.com.
pk_… key is safe to embed in client-side JavaScript because it is a
publishable key — it only identifies your customer account and does not grant
server-side access. Keep your server-side credentials (webhook secret, API credentials)
out of the browser.
Installation
The widget script is served from /widget.js on the ioniqx app domain.
It sets up the global window.SaasWidget object and registers a
message listener for the SAAS_COMPLETE postMessage event.
<!-- Place before closing </body> or in <head> -->
<script src="https://staging.ioniqx.io/widget.js" defer></script>
The script is HTTP-cached for one hour. It has no external dependencies and does not read cookies or local storage.
SaasWidget.init(options)
Creates the overlay and iframe. Must be called before open().
Call it once on page load — calling init() a second time is a no-op.
pk_live_… or pk_test_…).true, the widget overlay opens immediately after init().
Default: false.
false, clicking the dimmed overlay backdrop will not close the widget.
Default: true.
Returns the SaasWidget object (chainable).
SaasWidget.open()
Shows the overlay. The iframe is already mounted — this just sets display:flex.
SaasWidget.open();
SaasWidget.close()
Hides the overlay without destroying the iframe. The iframe retains its state if the user re-opens the widget in the same page session.
SaasWidget.close();
The widget does not close automatically after verification completes — call
window.SaasWidget.close() from your onSuccess callback
once you are ready to dismiss it (e.g. after your server confirms the token).
If you want the investor to stay in the widget to review their holdings, simply
omit the call.
Verification flow overview
Every investor goes through up to three steps. The widget detects existing state and skips steps the investor has already completed.
GET /embed/lookup./embed/callback when done.SAAS_COMPLETE fires via postMessage. The investor's holdings are displayed. Your onSuccess callback receives the signed token.Step 1 — KYC
When the email lookup returns needs_kyc, the widget shows a "Start Verification"
button. Clicking it navigates the iframe to Didit's hosted identity verification flow.
The investor scans their government-issued ID and completes a liveness check without
leaving the overlay.
Didit redirects the iframe back to /embed/callback when the session ends.
The widget then polls GET /embed/poll every 3 seconds until the ioniqx webhook
processor receives the Didit result and updates the investor's kyc_status.
IssueQiAttestationJob to write the investor's qualified-investor
attestation on-chain. That attestation is what the token's transfer hook checks
before allowing the wallet to receive.
Step 2 — Accreditation
Accreditation is required when both of the following are true:
- The investor's KYC is approved
- The API customer is linked to an investment company and the investor does not
yet have an active
AccreditedCertificationfor that company
The investor selects the basis that applies to them:
| Basis | Description |
|---|---|
income_200k | Individual income over $200k in each of the past two years |
income_300k_joint | Joint income over $300k in each of the past two years |
net_worth_1m | Individual or joint net worth over $1M (excluding primary residence) |
licensed_broker | Holds a Series 7, 65, or 82 license in good standing |
entity_5m | Entity with assets over $5M not formed for this investment |
The self-certification is recorded as an AccreditedCertification with:
- Status:
approvedimmediately (investor is their own certifier under Reg D 506(c) self-certification) - Expiry: 12 months from the attestation date
/portal/investors/:id/accreditation.
On successful accreditation the widget fires an email to the investor (Accredited investor status confirmed) and proceeds to the Complete step.
Step 3 — Complete
When the investor is fully verified the widget:
- Shows a success card ("Verified!")
- Posts
{ type: "SAAS_COMPLETE", token: "…" }to the parent window viapostMessage - Fetches and renders the investor's holdings and your offerings (see below)
Your onSuccess callback fires with the signed result token. The widget does
not automatically close — the investor can review their holdings before
dismissing it.
SAAS_COMPLETE. That event means
verification finished, not that the investor is done. Everything interactive in the
holdings table — giving notice to redeem, in particular — lives on the card that renders
after it fires, so a host that calls SaasWidget.close() from its
onSuccess handler removes the redemption UI before anyone can reach it.
Treat SAAS_COMPLETE as "record the token"; leave dismissal to the investor,
or close on your own confirmation step rather than on this event.
Holdings display
After the verification completes, the widget fetches GET /embed/holdings?token=…
and renders a summary table if the investor has any cap table positions.
Holdings are scoped to the company linked to the current API key. The widget runs on your
site, so it shows your offerings and no other issuer's; an investor sees their positions
across every issuer on the ioniqx site. POST /embed/redeem is scoped the same
way and answers 404 for another issuer's offering.
| Field | Type | Description |
|---|---|---|
project_name | string | Title of the investment project |
token_amount | integer | Number of tokens held |
ownership_pct | string | Ownership percentage (up to 6 decimal places) |
cost_basis | string | Total cost basis in USDC |
distributions_received | string | Sum of all paid USDC distribution allocations |
acquired_at | ISO 8601 | Date the initial position was opened |
offering | string | Offering slug — the identifier POST /embed/redeem takes |
redeemable | boolean | Whether a redemption notice can be given against this position right now |
notice_period_days | integer | Days of notice the offering requires |
settles_on | ISO 8601 | The date a notice given today would settle |
pending_notice | object / null | { amount, scheduled_for, window_open } when a notice is already standing |
The holdings fetch is non-blocking — SAAS_COMPLETE fires before the fetch
resolves. If the fetch fails, the complete card still shows; the table is simply omitted.
Offerings list
The widget also fetches GET /embed/offerings?token=…: your company's live
offerings the investor may see. The investor must be KYC-approved and have an accreditation on
record, and an investor who has only self-certified sees only offerings that accept
self-certification. Each entry carries a status — qualified,
accreditation_required, or relationship_required.
A Rule 506(b) offering may not be generally solicited. To an investor with no existing
relationship with your company it appears as { company, status: "relationship_required" }
— no title, no terms — and several such offerings collapse into one entry.
Giving notice to redeem
Where a position is redeemable, the holdings table offers a Redeem
control. Redemption is not a market order: it is gated to a notice period plus the offering's
accounting cycle, so the widget states both before the investor commits — a holder who learns
the notice period after submitting has been told the important part too late.
The control posts to POST /embed/redeem with the result token, the offering slug
and an amount. An investor has one live notice per offering, so once one is standing the cell
reports it instead of offering another.
This is one of three ways ioniqx accepts a notice. An investor can also sign one directly from
the wallet holding the tokens (POST /redemptions, which needs no ioniqx session at
all), and an employee can record one that arrived by phone, email or signed instruction. All
three produce the same notice; they differ in what evidences it, and which channel was used is
stored on the record.
Token verification (server side)
The result token is a signed Rails message verifier payload — it is not a JWT. Verify it on your server by calling the ioniqx token verification endpoint.
Request
POST https://staging.ioniqx.io/embed/verify_token
Authorization: Bearer <your_server_secret>
Content-Type: application/json
{ "token": "<result_token_from_postMessage>" }
Response
{
"valid": true,
"user_id": 42,
"email": "investor@example.com",
"kyc_status": "approved",
"expires_at": "2026-06-18T14:30:00Z"
}
SAAS_COMPLETE event. Never store the raw token — store the
user_id or your own session identifier instead.
Webhooks
ioniqx sends webhook events to your configured endpoint when investor status changes. All events are signed with HMAC-SHA256 using your webhook secret.
| Event | Trigger |
|---|---|
investor.kyc_approved | KYC verification approved by Didit |
investor.kyc_rejected | KYC verification rejected |
investor.accreditation_granted | Accredited certification created |
investor.distribution_announced | Distribution allocation created for investor |
Signature verification
# Ruby example
expected = OpenSSL::HMAC.hexdigest(
"SHA256",
ENV["IONIQX_WEBHOOK_SECRET"],
request.body.read
)
raise "Invalid signature" unless
ActiveSupport::SecurityUtils.secure_compare(
expected,
request.headers["X-Ioniqx-Signature"]
)
postMessage events
The widget communicates with the host page exclusively via window.postMessage.
Always verify event.origin matches the ioniqx app domain before acting on any message.
| event.data.type | When fired | Payload |
|---|---|---|
SAAS_COMPLETE |
Investor has completed the full verification flow | { type, token } |
SaasWidget.init() already installs the message listener and calls
your onSuccess callback for you. Only set up your own listener if you need to
handle the event before the widget's built-in handler runs.
CSP & iframe security
The ioniqx embed uses a strict frame-ancestors Content Security Policy.
The widget iframe will only render on origins that are explicitly listed in the customer's
allowed-domains list. Any other origin receives an HTTP 403 response.
If your site uses a Content-Security-Policy header, you must allow the ioniqx
app domain as an iframe source:
Content-Security-Policy: frame-src https://staging.ioniqx.io
The widget iframe requests the identity-credentials-get permission policy,
which is required for some Didit verification flows. No other permissions are requested.
Troubleshooting
Widget does not open
Check the browser console for errors. The most common causes are:
- Invalid API key — the key is malformed or belongs to an inactive customer. The embed returns HTTP 401.
- Origin not in allowlist — your page's origin is not listed in the customer's allowed domains. The embed returns HTTP 403.
- Script not loaded —
SaasWidgetis undefined. Ensure the<script>tag loads before theinit()call, or use thedeferattribute and wrap your init call in aDOMContentLoadedlistener.
Investor email returns "not found"
The investor must have an account in ioniqx before using the embed.
Accounts are created when a portal employee adds the investor to a company and sends them
a KYC verification link via POST /portal/investors/:id/kyc.
Accreditation step not appearing
The accreditation step only appears when the API customer is associated with an investment company. Your API key is always scoped to your company — confirm at Portal → Developers that the Developers page loads without error (which confirms the company association is in place). If no company is linked to your customer record, contact ioniqx support. If a company is linked but the step still does not appear, the investor may already have an active accreditation for that company — the embed skips accreditation and goes directly to Complete in that case.
onSuccess token is expired
Result tokens have a 5-minute TTL. If there is a delay between the user completing verification and your server receiving the token (e.g. the user left the page open), the token will be rejected. Ask the investor to open the widget again — since they are already fully verified, the embed will complete immediately and issue a fresh token.
Holdings table not showing
Holdings only appear if the investor has at least one CapTableEntry record
in ioniqx. A newly onboarded investor will see no holdings until a company employee
issues tokens to them via the cap table management interface.