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>If your platform lets you paste HTML but not write JavaScript, the markup path does the same thing:
<script src="https://www.soulverified.com/sdk.js"></script>
<div data-soul partner="YOUR_SLUG" data-user="123" data-city="New York"></div>Or scaffold a real component with curl soulverified.com/install | sh. Try any of it against the free sandbox first: an unregistered slug needs no key, reads are real, and writes are inert.
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.
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.
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. The publishable key is scoped to your slug and is safe in a browser, which is what the snippet above uses. The secret key is for server-to-server calls, including registering a webhook, and must never ship to a client.
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.
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. |
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. 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. A repeat after an unseen reply would duplicate a gathering on your board with no undo. |
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. |
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);
// 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 |
| POST | /api/embed/checkinRecord an arrival. Only inside the gathering’s window. | partner, key, user, meetupId | { ok, arrived, at, here } | 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, key, 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 |
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. |
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/webhookreturns 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. The SDK retries this twice with backoff. |
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, no locations. They are reduced to tags at the point of ingest and the raw payload is never persisted. |
| 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 | A DPA is available for every paying partner. Ask on the demo call. |
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. |
| 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. |
| 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. |
| 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.