Embeds
3 widgets for your page, each an iframe from our origin placed by 2 lines you paste. Readers stay on your page.
Drawing: the sample campaign
Flashing the controller
<div class="campaign-embed"
data-widget="join"
data-campaign="YOUR_CAMPAIGN_ID"
data-mode="light"
data-radius="8"
data-density="regular"></div>
<script src="https://campaign.udao.org/embed/v1/loader.js" async></script>Replace YOUR_CAMPAIGN_ID with the id from your campaign’s page address.
What an embed is
- It shows what is already publicA campaign’s public facts, a certificate its holder publishes, and one person’s outcome under a code your server mints.
- It carries our state wordsThe state table is the same one your dashboard reads. Simulated on a trial campaign is not themeable and cannot be switched off.
- It is never a sessionThe frame sets no cookie and shares nothing with a signed-in reader. Sign in, your dashboard, the queue, evidence decisions, consent, settings and anything that moves money are never embeddable.
- It never takes the joinThe Join button opens our join page at the top level, with ?via=partner and a return address of yours. A join is never taken inside a frame.
- It renders no text of yoursNothing a partner sends is drawn in the frame. The values you set are choices, listed in the reference.
The join card
The campaign’s public facts and a Join button. No API key, no server, no website on the critical path: the id is all it needs. Choose Join card above for its snippet.
The certificate badge
One certificate, by its document number, through the public verify route and the holder’s own visibility. No API key and no server. Where the holder has not published it, the frame draws nothing, at zero height. It answers a number never issued the same way.
The status card
One person’s own outcome, read-only. This is the one widget with a server behind it. Your server mints a single-use load code, the loader posts it into the frame, and the frame draws from that code alone. The code never appears in a URL and the frame never holds a session.
curl https://campaign.udao.org/api/v1/embed-codes \
-H "Authorization: Bearer camp_sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"campaign_id": "8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
"email": "ada@example.com",
"origin": "https://learn.example.ch"
}'It answers 200 with a code for every well-formed address, whether or not that person holds a place. The frame decides which card to draw and draws both at one height, so the call never tells you who joined. Each code is a read of your door. It spends one of your 600 reads a minute and writes one row on your Integrations page.
| Field | What it is |
|---|---|
campaign_id | The campaign the card is about. It has to be one of yours, and the code is refused in any other campaign’s frame. |
email | The address you know the person by. Matched the way the outcomes door matches it. |
email_hash | HMAC-SHA256 of the lowercased, trimmed address under your API key’s email salt (its menu, Show the email salt), hex encoded, where you hold a hash and not an address. Send email or email_hash, never both, exactly as the outcomes door takes them. |
origin | The exact origin of the page the frame will stand on: scheme and host, https and port 443, no path. It has to be on your Allowed websites list, and it is what the frame’s frame-ancestors is set to. |
{ "code": "emb_load_...", "expires_in": 600 }The join card and the badge each have a bare iframe; this widget has none. Its code never travels in a URL, so a script on the page posts it into the frame. An iframe address carrying a load code would put it in your server’s logs, in a referrer and in a reader’s history.
The mint refuses a request that carries a browser Origin. The API key belongs on your server: an API key in a bundle is an API key you have published. A partner with no server cannot ship this widget; the join card and the badge need none.
The origin you name has to be on your Allowed websites list, under Organization, then Integrations. An origin that is not there is answered 422 with the code origin_not_allowed and the list itself. The fix is one press in your dashboard, not a message to us. An API key for trial campaigns only may also name http://localhost with a port of yours, which is the one development address this door takes.
A retry mints a second code rather than replaying the first. The code is never stored, only a digest of it, so there is nothing for an Idempotency-Key to hand back. A code nobody loads is a row that expires in 10 minutes and costs you one read. Send the header if your client sends it everywhere: this endpoint accepts it and does not branch on it.
Single-page apps
The loader adds one global, CampaignEmbed, holding version ('1') and refresh(). It draws the containers on the page once, when it loads; CampaignEmbed.refresh() draws the ones your app has added since.
A container is drawn once: the loader marks it and never reads it again. To show another campaign, layout or code, remove the container and add a new one. The loader empties a container before it draws into it, so put nothing of your app’s inside.
A status container takes a code your server minted for that mount. A second mount of the same code within 60 seconds draws the same card; after that, mint a new one.
var box = document.createElement('div');
box.className = 'campaign-embed';
box.setAttribute('data-widget', 'join');
box.setAttribute('data-campaign', 'YOUR_CAMPAIGN_ID');
document.getElementById('your-sidebar').appendChild(box);
window.CampaignEmbed && window.CampaignEmbed.refresh();Reference
| Attribute | Values | Default | Loader build |
|---|---|---|---|
data-widget | join, certificate, status | Required | Works in every buildEvery build |
data-campaign | The campaign id | Required for join and status | Works in every buildEvery build |
data-credential | The document number | Required for certificate | Works in every buildEvery build |
data-code | One load code from the mint | Required for status | Works in every buildEvery build |
data-mode | light, dark, inherit | Default inheritinherit | Works in every buildEvery build |
data-accent | One colour, for the one button or link | Default oursOurs | Works in every buildEvery build |
data-radius | 0, 8, 12 | Default 88 | Works in every buildEvery build |
data-density | compact (Row), regular (Card) | Default regularregular | Works in every buildEvery build |
data-reward | hidden hides the amount; the card reads Paid reward | Default shownShown | Works in this build and laterThis build and later |
data-seats | hidden hides the places left | Default shownShown | Works in this build and laterThis build and later |
data-closes | hidden hides the deadline | Default shownShown | Works in this build and laterThis build and later |
The width is the container’s and is not a setting. There is no attribute for:
- the face
- free text
- a background
- the border
- transparency
- custom styles
- hiding a state word
Inherit is the reader’s setting, not your page’s
A frame is its own document, so inherit resolves to the operating system’s light or dark and cannot see a theme your stylesheet forces. A page that draws itself dark sets data-mode="dark". Reduced motion needs nothing from you: the frame reads it from the reader’s own system.
The height
The frame posts { source: 'campaign-embed', id, height } to the page on load, on resize and on a change of state. The loader sets the height and nothing else. It ignores a message whose origin is not ours or whose id it did not create, and it clamps the height at 1200px. Each layout carries a minimum height so the first paint does not jump: 96px for Row and 180px for Card. Without the loader the frame is a plain iframe and you own the height: its tab prints one. A bare iframe posts the same message and you may listen for it yourself.
The certificate badge’s iframe prints 182px for Row, its tallest reading, at 320 where its first line wraps. It settles at 156px from 400 up, and for Card it prints 226px. A badge that draws nothing is taken to zero by the loader, and under a height of your own it leaves a blank rectangle.
The colour you choose
The frame measures your accent against our surface on each ground it draws. On a ground where it falls under 4.5:1 for text or 3:1 for a control, ours stands in on that ground, silently. Only the configurator above and your dashboard say so, with the nearest shade that passes. Match the reader judges the light and the dark ground each on its own. The label on a filled button is chosen for your colour, white or our charcoal, whichever clears 4.5:1 on it. The mark’s own dot, the focus ring, every state colour and the type are never themeable.
The loader itself
One file at 2 addresses. The first is mutable, so a correction reaches every page carrying it. The second is the same bytes at an address that cannot change under them, for a policy of yours that pins what it loads. This build is 3050 bytes.
The address to pasteloader.js
https://campaign.udao.org/embed/v1/loader.js
This buildloader.813f8b6a71ee7089.js
https://campaign.udao.org/embed/v1/loader.813f8b6a71ee7089.js
One file, one global, and our own build checks refuse any version of it that carries fetch, XMLHttpRequest, a cookie or any storage at all.
The load code: 10 minutes, and what it binds
A load code lives 10 minutes from the moment your server mints it and is spent on first use. It binds 4 things, and is refused where any of them disagree with the frame presenting it:
- The origin you minted it for, which has to be on your Allowed websites list
- The campaign
- The one person you named by email or email_hash
- The API key that minted it, with which it dies
It is never an API key of yours, and it carries no session. A code that has expired, been spent or arrived from another origin draws the join card rather than an error.
Spent on first use means spent once, with one exception that costs nothing. For 60 seconds after the first load, a second mount of the same frame is answered with the same card. It is the same card and not a second reading, so a state that changed in between draws the join card instead. After those 60 seconds the code is finished, whatever else is true of it.
Where a widget may load
The status frame loads only from the exact origins on your Allowed websites list, under Organization, then Integrations. Each is https, port 443, the host alone, no wildcard and no path. The one exception is localhost, taken only under an API key for trial campaigns only. It lets you exercise the one widget with a server behind it in development. The card drawn there carries Simulated like every other trial. It is one list, shared with the rule that decides where a join link may return a person. Only an owner may change it, under a signature from the last 10 minutes. The join card and the certificate badge load anywhere.
What the frame does when it cannot draw
| When | What the frame does |
|---|---|
| A campaign that is unlisted, draft, cancelled, unknown, or whose organization is suspended | Keeps the card’s own size and draws the mark, This campaign isn’t open, and the foot line. The cases are answered alike, and your page never loses the card’s place. |
| A value that is not an id, such as YOUR_CAMPAIGN_ID | Draws the setup line: This card is not connected to a campaign yet. |
| A certificate that is unlisted, hidden or unknown | Draws nothing, at zero height. The 3 cases are answered alike, and the configurator above is the only place that says so. |
| A certificate its organization revoked | Draws the badge with the revoked stamp and its date where Verified stands. |
| A person holding no place | Draws the join card, at the status card’s height. |
| A code that is spent, expired or from another origin | Draws the join card, the same as no place. |
| An origin that is not on the list | The browser refuses the frame and the page keeps the space. Your call to the mint already answered 422 and named the list. |
What we set on every embed route
No cookie, no session, no store. noindex, so a widget of yours never competes with your own page in a search index, and Referrer-Policy: no-referrer. A 60-second edge cache, and one title we write naming the widget, because a frame with no title is announced as "iframe". Inside, one group with a label and no heading of its own, so your page keeps its outline.
Every other page of ours refuses to be framed at all. The default on this deployment is frame-ancestors none, and the widget routes are its only exception. The sample the configurator draws, and the preview of an organization’s own campaign before it is public, are framable by our own pages alone.
If your page sets a Content Security Policy
Admit our origin for the loader and for the frame it creates:
Content-Security-Policy: script-src 'self' https://campaign.udao.org; frame-src https://campaign.udao.org