SoulQuickstartHow it worksAuthenticationSDK referenceREST referenceYour briefWebhooksErrors and limitsData and privacySecurityVersioningTypeScript typesFor a coding agentLive sandbox

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.

html
<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  -> moves

Every 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 402 on 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.

MethodWhat 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

js
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.

EndpointParametersReturnsLimit
GET/api/embed/events
The board, ranked for one user.
partner, key, user, city, limit{ events[], people[], matched[], connected, memberCount, signal[] }60 / min
POST/api/embed/rsvp
Join, 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/cohort
Your 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/checkin
Record 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/connect
Send 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/host
Create 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/webhook
Set or clear your server endpoint. Returns the signing secret once.
partner, secret, url{ ok, url, secret, events[] }10 / min
GET/api/embed/webhook
What is configured, plus your last twenty delivery attempts. Never returns the secret.
partner, key{ ok, url, events[], recent[] }10 / min
GET/api/embed/providers
Which 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.

bash
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.
The secret is returned once.

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

EventWhen
member.joinedOne of your users entered the community for the first time. Fires once per user, not on every visit.
rsvp.createdThey committed to a gathering.
rsvp.cancelledThey took it back.
member.arrivedThey said they were there, from their own phone, inside the gathering’s window. Fires on the first arrival only.
gathering.createdThe agent programmed something new for your users. This is how the work appears inside your product without you asking for it.
gathering.vetoedSomeone 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

http
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.

node
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'),
  );
}
python
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))
Verify against the raw body.

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, a 429 or a timeout, backing off 2s then 8s. A 4xx is 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

StatusCodeMeaning
400Bad requestA required field is missing or malformed. The body says which.
401unauthorizedThe key does not match the partner. Never retried by the SDK, because it will say the same thing next time.
402subscription_requiredRegistered but not currently paid up. Returned on reads as well as writes. A lapsed partner is never silently downgraded to the sandbox.
403sandboxThe action needs a registered partner. Webhooks and live hosting are not available to the keyless sandbox.
404Not foundNo such gathering, or the user is not a member of this community yet.
409checkin_not_open / checkin_closedOutside the check-in window. The body carries opensAt and closesAt so your UI can say which side of it they are on.
429Rate limitedPlain 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.
5xxOursThe 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

QuestionAnswer
What we readCoarse 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 storeRaw 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-inOptional, 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 seesAlready-reduced tags and the titles of gatherings a person committed to. Nothing else. A prompt cannot leak what was never loaded.
Separation between companiesEvery 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 goersFirst names and how many of your own users are going. Never an email, never another company’s user.
DeletionOn request, by user or in full, including anything derived. Ask and it is done; there is no queue to wait behind.
Processing agreementThe 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

AreaDetail
Content Security Policyscript-src, frame-src and connect-src must allow https://www.soulverified.com. The SDK needs no unsafe-inline and no unsafe-eval.
Subresource IntegrityEvery 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".
AccessibilityThe 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 failuresSoul.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 pinningLoad /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 keyPOST /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 handlingThe 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 allowlistA 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 linkThe 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 behaviourEvery 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 haveNo 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.

© 2026 The Soul Company, Inc.
PricingDocsPrivacyTermsDPABook a demo