Documentation
One script tag puts a real community inside your product. This is everything a platform team needs to evaluate that: the API, what it does when it fails, what we do with your users’ data, and what we do not have yet.
Quickstart
Paste this. There is nothing to install and no build step.
<div id="soul"></div>
<script src="https://www.soulverified.com/sdk.js"></script>
<script>
Soul.init({
partner: 'YOUR_SLUG',
key: 'YOUR_PUBLISHABLE_KEY',
user: currentUser.id,
city: 'New York',
});
Soul.Community({ el: '#soul' });
</script>Try it against the free sandbox first: an unregistered slug needs no key, reads are real and writes are inert, so nothing you do while evaluating reaches anyone.
That is the whole install. If you want the React, cURL or Python versions, the markup-only path for a CMS, the scaffold command or the one line to hand a coding agent, they are all on the SDK page, next to a sandbox you can drive.
How it works
An agent reads who your users are, programs real gatherings for them at free public walk-in places, fills them, and reads its own results to decide what to program next. Nothing waits for anyone on your side to have an idea.
Exactly how a tag is derived
Nothing here is a model deciding what somebody is like. It is thresholds, and they are worth reading, because a company handing us a data connection is entitled to know precisely what comes out the other side. Oura, as an example:
readiness avg >= 88 -> elite-recovery
>= 80 -> high-recovery
>= 70 -> recovered
< 60 -> depleted
sleep avg >= 80 -> good-sleeper
>= 70 -> rested
< 60 -> short-sleeper
wake hour avg < 6.0 -> dawn-riser (main sleeps only, naps excluded)
< 7.5 -> early-riser
>= 9.5 -> late-riser
steps avg >= 14000 -> very-active
>= 9000 -> active
>= 6000 -> movesEvery tag is namespaced to its source, so the stored value is oura:dawn-riser, never a bare word that could have come from anywhere. The number that produced it is not kept: the threshold is evaluated once, at ingest, and the reading is discarded. There is no row in our database with somebody’s readiness score in it.
Those namespaced tags are then read once into the closed vocabulary the matcher uses, which is where oura:dawn-riser and strava:early-riser become the same word and two people from different products can finally be compared.
Erasing somebody
One call, and it is a deletion rather than a flag. It removes the member record, the derived tags and traits, and every RSVP and arrival.
await Soul.forget(user.id)
Call it when a user revokes access, deletes their account, or asks you to erase them. Safe to retry: deleting somebody already gone returns removed: 0 rather than an error, because an erasure job that throws on a repeat is dangerous exactly where retries matter most. What survives is the going count on a gathering other people are still attending, which is a number with nobody behind it.
Inside a native app
Most companies worth doing this for are an iOS and Android app, not a website. There are two paths and they are genuinely different amounts of work, so pick deliberately rather than by default.
A WebView, which is a day. Point a WKWebView or an Android WebView at the embed with your key and your user id. It renders in your colours, handles its own scrolling, and you get every future improvement without shipping an app update, which is the part that usually decides it.
// iOS
let url = URL(string: "https://www.soulverified.com/embed"
+ "?partner=oura&key=(soulKey)&user=(user.id)&city=(user.city)")!
webView.load(URLRequest(url: url))
// Android
webView.settings.javaScriptEnabled = true
webView.loadUrl("https://www.soulverified.com/embed?partner=oura&key=$soulKey&user=${user.id}")Two things to set or it will feel wrong: allow JavaScript, and let the WebView open a new window for OAuth, or Connect will silently do nothing when a member taps it. The consent screen belongs to the platform being connected and has to be a real browser context.
Or headless, and render it yourself. The REST API returns the same board, ranked the same way, and you build the interface in Swift or Kotlin. More work, complete control of the look, and no WebView at all. This is what a large app usually ends up wanting.
GET /api/embed/events?partner=oura&key=...&user=<id>&city=New%20York
POST /api/embed/rsvp { partner, key, user, meetupId, going }
POST /api/embed/checkin { partner, key, user, meetupId }
POST /api/embed/connect { partner, key, user, tags: [...] }
POST /api/embed/forget { partner, key, user }Every response is JSON with no HTML in it, so nothing about the rendered tab is load bearing. Teams commonly start with the WebView to get it in front of users in a sprint, then move the surface native once they know it works.
The key never ships in the app. A mobile binary is readable, so treat it the way you treat any server secret: your backend holds it and hands the client either a signed embed URL or the board itself. The sandbox needs no key at all, which is what makes it safe to prototype against before any of this matters.
Where your calls meet the agent
Four of the fourteen methods change what the agent does. The rest read.
Soul.identify(user, { tags }) -> the agent reads this person into its
vocabulary, within seconds. Their board
reorders on the next read.
Soul.rsvp(id, true) -> counts toward what filled. Feeds the next
curation run for your whole board.
Soul.checkin(id) -> counts toward who ARRIVED, which outranks
every other signal, and teaches the agent
what this person turns up to.
Soul.host({ ... }) -> your own gathering, written by you, sits
alongside the agent's on your board.
(the veto, from your brief) -> an instruction, not a data point. That
shape is never written again.Everything else, events, Community, badge, open, is a read. It never changes what gets programmed.
When it runs
Curation runs on a schedule, every six hours, for every company, whether or not anyone calls anything. It is not triggered by your traffic and there is no endpoint to poke. Twelve gatherings a week per city is the target, and your own fill first: shared ones only ever top up what is left.
So the honest sequence on day one is: you install the tag, you call identify for your users, and within one curation cycle the board stops being generic and starts being about them. Nothing you call makes that happen faster, and nothing you forget to call stops it.
The parts that are not a model
Worth stating plainly, because it is the first thing a technical evaluator tries to work out. Deciding which gathering surfaces for which person is deterministic whole-word comparison over a fixed vocabulary of traits, plus a bias toward the hours someone actually keeps. It is inspectable and the same every time. Models write the gatherings and read each person into that vocabulary; they do not do the matching.
The vocabulary is closed on purpose. Free text would be fluent and useless, because “loves early mornings” and “morning person” do not match each other. A fixed set is the only way two people arriving from different products land on the same word. The agent, in full.
Five kinds, and who sees each
Anything Soul programs is open by default, because a room needs people in it. Anything you make is private by default, because your name is on it.
- hosted is yours. Your users only, so a gathering with your name on it never fills with somebody else’s community. This is your board: the agent programs it in full, every week, for your people.
- soul is programmed by the agent and open across the network. It is what fills a room in a city you launched in this week, and it is a backstop rather than your board: it appears only when your own cannot be filled.
- collab names another company and puts both sets of users in the room. One call, and the one thing an in-house team structurally cannot build.
- takeover is a Soul gathering already filling that you put your line on. Everyone still sees it.
- exclusive is Soul-run and capped. Everyone sees it, not everyone gets in.
Sandbox and live
- Sandbox is any unregistered slug. Free forever, no key, no card. Reads are real; writes are created inactive so nothing you make in a sandbox reaches a real person.
- Live is a registered slug plus your key. Writes are real, webhooks are available, and the board is programmed for your users specifically.
- Lapsed returns
402on reads and writes. A partner who stops paying is never quietly downgraded to the sandbox.
Authentication
Two keys, and they are not interchangeable. The publishable key (sk_live_…) is scoped to your slug and is safe in a browser, which is what the snippet above uses: it opens the board, RSVP, check-in, connect and host. The secret key (ssk_live_…) is server-to-server only and opens exactly two things: the cohort read and webhook registration. Both return or redirect facts about your own named users, so neither can be guarded by a credential that ships in your page. Never put the secret key in client code.
Soul.config() never returns a key. Ask us to set an origin allowlist and a leaked publishable key stops working from anywhere but your own hosts. Note that an allowlist is not a control on the secret key: a server-to-server call sends no Origin header at all, which is exactly why that key is separate. Both are shown once at/signup/done.
SDK reference
Full types at /soul.d.ts. Every call resolves to an object rather than rejecting, so our downtime cannot break your interface.
| Method | What it does |
|---|---|
Soul.init(options) | Set partner, key, user, city and brand once. Every other call inherits them. |
Soul.Community({ el }) | Mount the full community tab inline, in your brand. Resizes itself to its content and posts its height to the parent. |
Soul.badge({ el }) | A live count for your nav. Says "Nothing on yet" rather than inventing a number. |
Soul.open() / Soul.close() | The same tab as an overlay over your app. Escape and backdrop both close it. |
Soul.events({ city, limit }) | The board as JSON, ranked for this user, so you can build your own interface. Each gathering carries why it surfaced for them. If you build your own board, render e.sign. It is a place inside the venue somebody can walk to, and without it a person arrives on time and stands in a room of strangers. |
Soul.rsvp(eventId, going) | Commit or take it back. Returns the live going count. |
Soul.checkin(eventId) | Records that they turned up. Only succeeds while the gathering is happening; outside that you get a 409 carrying both ends of the window. Asks the device for a coordinate to confirm they are at the place, and records the arrival anyway if that is refused. Pass { geo: false } to never ask. Idempotent. Calling it twice returns the first timestamp. |
Soul.identify(user, { tags }) | Send your own signal server side, no OAuth. The realistic path for most products: your app already knows what your users like. |
Soul.connect(source) | Opens that platform’s real consent screen in a popup. Reports a blocked popup instead of hanging. |
Soul.host({ title, when, where, collab }) | Your company programs its own gathering, or names another company for a shared one. Never retried on your behalf, because a repeat after an unseen reply would duplicate a gathering on your board with no undo. Send an Idempotency-Key header on the REST call and a repeat returns the first one with duplicate:true instead of making a second. |
Soul.on(event, fn) | ready, join, rsvp, checkin, connect, open, close, badge. Fires for the rendered tab as well as headless calls, so you never poll. |
Soul.config() | The current configuration. Never includes the key. |
Soul.destroy() | Takes everything back off the page: every mounted tab, the overlay, and our listeners. Call it when a single page app leaves the route. A mounted tab also has its own .destroy(), so one component can clean up without touching the others. |
Building your own interface
const { events } = await Soul.events({ limit: 5 });
events.forEach(e => {
console.log(e.title, e.when, e.where, e.going, e.why);
// RENDER e.sign. There is no door at a public park and nobody holding a list at a
// coffee shop, so this line is how somebody who turned up knows which strangers
// they came for: "the long bench along the left wall as you walk in".
console.log(e.sign); // and e.crowd, who is in the room, not how many
// e.checkin === 'open' -> show your "I'm here" control
});REST reference
Everything the SDK does, over HTTP, with CORS. Use this if you would rather not run our script at all.
| Endpoint | Parameters | Returns | Limit | |
|---|---|---|---|---|
| GET | /api/embed/eventsThe board, ranked for one user. | partner, key, user, city, limit | { events[], people[], matched[], connected, memberCount, signal[] } | 60 / min |
| POST | /api/embed/rsvpJoin, or commit to a gathering. Omit meetupId to join only. | partner, key, user, meetupId, going, name, city | { ok, going, count } | 40 / min |
| GET | /api/embed/cohortYour own users’ attendance, so your analysts can run the retention maths themselves. SECRET key, server side only. | partner, secret, days (≤365), format=csv | { soulSide{}, yourSide, cohort[{ userId, attended, gatherings, weeks, firstAttended, lastAttended }] } | 20 / min |
| POST | /api/embed/checkinRecord an arrival. Only inside the gathering’s window. | partner, key, user, meetupId, lat, lng (both optional) | { ok, arrived, at, here, confirmed } | 30 / min |
| POST | /api/embed/connectSend your own tags for a user. Namespaced to you, never mixed with another company’s. | partner, key, user, tags[], name, city, source | { ok, affinities } | 20 / min |
| POST | /api/embed/hostCreate a gathering owned by you. Pass collab to share it with another company. | partner, key, title, when, where, collab[], brandNote | { ok, id, sandbox? } | 10 / min |
| POST | /api/embed/webhookSet or clear your server endpoint. Returns the signing secret once. | partner, secret, url | { ok, url, secret, events[] } | 10 / min |
| GET | /api/embed/webhookWhat is configured, plus your last twenty delivery attempts. Never returns the secret. | partner, key | { ok, url, events[], recent[] } | 10 / min |
| GET | /api/embed/providersWhich platforms currently reach a real consent screen, and which return sample signal. No key needed. | none | { ok, live[], simulated[], note } | none |
Your brief
There is no dashboard, on purpose. You are paying us so that nobody on your side has to run a community, and a dashboard is that job handed back to you in a nicer font. Instead you get one link, and it is always current.
It shows what the agent programmed, who actually turned up, what your users turned out to be into, and what the agent learned and is doing differently. It needs no login, so you can forward it to whoever renews the contract without buying them a seat. You get it on the page that hands you your key, and again in a recap email each month.
It carries exactly one control. Next to anything that has not happened yet there is Not this one: it comes off the board immediately, we send you a gathering.vetoed webhook carrying how many of your users had already committed so you can tell them, and the agent never writes anything of that shape again. Tell it why and that reason shapes what it writes next. It is reversible, and nothing is ever deleted.
Webhooks
So your backend learns what happened without polling us. Register one endpoint from your server.
curl -X POST https://www.soulverified.com/api/embed/webhook \
-H 'content-type: application/json' \
-d '{
"partner": "YOUR_SLUG",
"key": "YOUR_SECRET_KEY",
"url": "https://yourapp.com/hooks/soul"
}'
# -> { "ok": true, "url": "...", "secret": "whsec_...", "events": [...] }
# Store that secret now. It is never returned again.Store it when you set the endpoint. It is never readable back over the API, because an endpoint that hands a signing secret to whoever holds the API key turns one leaked key into forged events. Setting the URL again rotates it. Send "url": null to turn webhooks off.
Events
| Event | When |
|---|---|
member.joined | One of your users entered the community for the first time. Fires once per user, not on every visit. |
rsvp.created | They committed to a gathering. |
rsvp.cancelled | They took it back. |
member.arrived | They said they were there, from their own phone, inside the gathering’s window. Fires on the first arrival only. |
gathering.created | The agent programmed something new for your users. This is how the work appears inside your product without you asking for it. |
gathering.vetoed | Someone on your side took a gathering off the board. Carries committed, the number of your users who had already said they were going: we hold no address for them, so this delivery is how you tell them. |
What a delivery looks like
POST /hooks/soul
Soul-Signature: t=1785000251,v1=6f2a...c91d
Soul-Event: rsvp.created
Soul-Delivery: evt_9f2c4a1b8e6d
{
"id": "evt_9f2c4a1b8e6d",
"type": "rsvp.created",
"created": "2026-07-29T18:04:11.220Z",
"partner": "acme",
"data": {
"user": "u_123",
"meetupId": "cmrwfqtk2000377jf",
"title": "Sunrise run, the bridge",
"startsAt": "2026-08-02T10:30:00.000Z",
"city": "New York",
"going": 6
}
}Verify every delivery
Soul-Signature is t=<unix>,v1=<hex>, an HMAC-SHA256 over <t>.<raw body> using your secret. The timestamp is inside the signed payload, so a delivery captured today cannot be replayed at you tomorrow.
import crypto from 'crypto';
// rawBody must be the UNPARSED request body. A re-serialised object will not
// match, and it is the most common reason a first integration fails.
export function verifySoul(rawBody, header, secret) {
const t = /t=(\d+)/.exec(header)?.[1];
const v1 = /v1=([a-f0-9]{64})/.exec(header)?.[1];
if (!t || !v1) return false;
// Reject anything older than five minutes, or a delivery captured today can be
// replayed at you tomorrow.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(v1, 'hex'),
);
}import hmac, hashlib, time, re
def verify_soul(raw_body: bytes, header: str, secret: str) -> bool:
t = re.search(r"t=(\d+)", header)
v1 = re.search(r"v1=([a-f0-9]{64})", header)
if not t or not v1:
return False
if abs(time.time() - int(t.group(1))) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{t.group(1)}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1.group(1))Before any JSON parsing. A re-serialised object will not produce the same bytes and will not match, and that is the single most common reason a first integration fails.
Delivery contract
- Reply
2xx. Anything else is a failure. - Retried twice on a
5xx, a429or a timeout, backing off 2s then 8s. A4xxis treated as your answer and is not retried. - At least once. Dedupe on
id. We will not promise exactly once, because we could not keep it. - We time out after 5 seconds. Acknowledge first, do your work after.
- Redirects are not followed. Give us the final URL, on a public host, over HTTPS.
GET /api/embed/webhook(secret key) returns your last twenty attempts, so a broken integration is debuggable without reading your own logs.
Errors and limits
| Status | Code | Meaning |
|---|---|---|
400 | Bad request | A required field is missing or malformed. The body says which. |
401 | unauthorized | The key does not match the partner. Never retried by the SDK, because it will say the same thing next time. |
402 | subscription_required | Registered but not currently paid up. Returned on reads as well as writes. A lapsed partner is never silently downgraded to the sandbox. |
403 | sandbox | The action needs a registered partner. Webhooks and live hosting are not available to the keyless sandbox. |
404 | Not found | No such gathering, or the user is not a member of this community yet. |
409 | checkin_not_open / checkin_closed | Outside the check-in window. The body carries opensAt and closesAt so your UI can say which side of it they are on. |
429 | Rate limited | Plain body, with Retry-After. The SDK retries this twice with backoff. Every response also carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, so your client can slow down before it is rejected rather than discovering the limit in production. |
5xx | Ours | The SDK retries reads and idempotent writes twice. host() is never retried. |
Rate limits are per IP per minute and listed against each endpoint above. Requests abort after eight seconds. The SDK retries reads and idempotent writes twice with backoff; host() is never retried, because a repeat after a reply we did not see would put the same gathering on your board twice with no undo.
Data and privacy
| Question | Answer |
|---|---|
| What we read | Coarse tags only, either derived at ingest when a user connects an account themselves, or sent by you through identify. Examples: early-riser, techno, runner. |
| What we never store | Raw health records. No heart rates, no sleep records, no workouts, no listening history. They are reduced to coarse tags at the point of ingest and the payload is discarded in the same request: there is no column for it, so it cannot be kept by accident. No location history and no coordinate tied to a person. |
| Location at check-in | Optional, and refusing it still records the arrival. The device may offer a coordinate when someone taps that they are there; it is compared against where the gathering is and discarded in the same request. What is kept is one true or false on the arrival, plus a single coordinate per gathering rounded to about a hundred metres, set by the first person to arrive. That describes the park, not the person. |
| What the agent sees | Already-reduced tags and the titles of gatherings a person committed to. Nothing else. A prompt cannot leak what was never loaded. |
| Separation between companies | Every user is namespaced to your slug. Your board never shows another company’s private gathering, and one company never learns anything about another’s users. Shared gatherings are shared on purpose and are marked as such. |
| What you learn about goers | First names and how many of your own users are going. Never an email, never another company’s user. |
| Deletion | On request, by user or in full, including anything derived. Ask and it is done; there is no queue to wait behind. |
| Processing agreement | The DPA is published in full at /dpa, including the sub-processor list, the security measures and the breach notification window. Read it before the call rather than after. Send us yours instead if that is what your legal team needs. |
Security
| Area | Detail |
|---|---|
| Content Security Policy | script-src, frame-src and connect-src must allow https://www.soulverified.com. The SDK needs no unsafe-inline and no unsafe-eval. |
| Subresource Integrity | Every pinned build publishes its hash at /v/<version>/integrity.txt, generated from the exact bytes at build time rather than by hand. Add it as integrity="sha384-..." with crossorigin="anonymous" and the browser refuses anything that does not match, which is the real answer to "what stops you serving different code into our page tomorrow". |
| Accessibility | The overlay is a labelled role="dialog" with aria-modal, it keeps Tab inside itself rather than walking your page behind it, Escape closes it, and focus returns to whatever had it before. The inline tab is a titled iframe. |
| Seeing our failures | Soul.on('error', fn) fires on every failed call with where, message and a timestamp, so our problems land in your monitoring instead of only in a user's console. |
| Version pinning | Load /v/<version>/sdk.js to pin. Pinned files are immutable and cached for a year. The rolling /sdk.js has a five minute cache. A published version is never overwritten; the build fails instead. |
| Rotating a key | POST /api/partner/rotate-key with {partner, key} and you get a new one back. Your OLD key keeps working for 24 hours, so you deploy the new one on your own release schedule instead of taking your community tab down the moment you rotate. A rotation that causes an outage is one nobody performs, which is why the window exists. |
| Key handling | The publishable key is scoped to your slug and is safe in a browser. Your secret key is for server-to-server calls, including setting webhooks, and must not ship to a client. Soul.config() never returns a key. |
| Origin allowlist | A partner can be restricted to a list of hosts, so a leaked publishable key cannot be used from another site. Ask to have it set. |
| Your brief link | The link is the credential: a 32-byte random token, no login, because the person who renews a contract should not need a seat to read a report. It is a deliberate trade and here is the rest of it. The link is read-only except for one action, taking a gathering off your own board, which is reversible, is recorded against your company with a timestamp, cannot touch another company’s gatherings, and cannot alter anything that already happened. Ask and we will rotate the token, which kills every copy of the old link immediately. If you would rather it did not exist at all, we can issue you a read-only link with the control removed. |
| Failure behaviour | Every SDK call resolves to an object carrying .error rather than rejecting, so our downtime cannot break your interface. Requests abort at eight seconds. |
| What we do not have | No SOC 2 report and no penetration test on file yet, and we would rather say so than imply otherwise. There are no patents. What is registered is the company and the marks. |
Versioning
The rolling /sdk.js is cached for five minutes and always carries the current version. Pin with /v/<version>/sdk.js, which is immutable and cached for a year. A published version is never overwritten: the build fails rather than replace one, so a pinned customer cannot be changed underneath.
Fields are added to responses, never renamed or removed without a new major version. Webhook event names are added, never renamed, because a rename breaks every customer’s switch statement at once and silently. Everything we ship is listed on the changelog, generated from the commit history rather than written by hand.