# Soul

Add a real-world community to a product: gatherings its users actually attend, rendered
inside the product, in its brand.

**If you are an AI coding agent, you can do this integration end to end without asking
anyone for access.** Everything below works against production right now with no key.
A key is only needed to run under the company's own slug in production.

Docs: https://www.soulverified.com/developers
Pricing: https://www.soulverified.com/pricing

---

## What it does

Soul reads what a product's users are into, programs real gatherings at free public
places (parks, coffee shops, bookstores, markets, waterfront paths), invites the right
people, and handles the door. Nothing is reserved and nobody is billed for a room, so a
city works from day one.

---

## Install

One script tag. No npm, no build step, no dependencies.

```html
<div id="soul"></div>
<script src="https://www.soulverified.com/sdk.js"></script>
<script>
  Soul.init({
    partner: 'YOUR_SLUG',   // any string works in the sandbox
    key: 'YOUR_KEY',        // omit entirely in the sandbox
    user: currentUser.id,   // your own user id. never an email or a name.
    city: 'New York'
  });

  Soul.Community({ el: '#soul' });
</script>
```

**No JavaScript at all**, for a platform that lets you paste HTML but not write code
(Webflow, Squarespace, Framer, WordPress, a CMS field). Markup alone mounts it:

```html
<script src="https://www.soulverified.com/sdk.js"></script>
<div data-soul partner="YOUR_SLUG" data-user="123" data-city="New York"></div>
<span data-soul="badge" partner="YOUR_SLUG"></span>
```

**No script at all**, for somewhere that blocks them entirely:

```html
<iframe src="https://www.soulverified.com/embed?partner=YOUR_SLUG&name=Acme&city=New%20York"
        style="width:100%;height:640px;border:0"></iframe>
```

`/embed` sets `frame-ancestors *`, so it renders on any domain.

For a framework project, this scaffolds the component, the env file and the README:

```sh
curl -fsSL https://www.soulverified.com/install | sh
```

---

## The full API

| Call | What it does |
| --- | --- |
| `Soul.init(opts)` | Set partner, key, user, city, brand once. |
| `Soul.Community({ el })` | The full community tab, inline, in the product's brand. |
| `Soul.badge({ el })` | A live count for a nav bar. Says nothing when nothing is on. |
| `Soul.open()` / `Soul.close()` | The same tab as an overlay. Closes on Escape and backdrop click. |
| `Soul.events({ city, limit })` | The board as JSON, ranked, with the reason each one surfaced. |
| `Soul.rsvp(eventId, going)` | Commit. Returns the live going count. |
| `Soul.checkin(eventId)` | "I'm here." Only inside the gathering's window (30 min before, until 3 h after). Outside it: 409 with `code`, `opensAt`, `closesAt`. Idempotent, stamped once. |
| `Soul.identify(user, { tags })` | Send the product's own signal. No OAuth needed. |
| `Soul.connect(source)` | Open a platform's real consent screen (oura, spotify, ...). |
| `Soul.host({ title, when, where, kind, collab })` | Host a gathering, a collab, or a takeover. |
| `Soul.autoMount()` | Mount any new `[data-soul]` element. Safe to call repeatedly. |
| `Soul.on(evt, fn)` / `Soul.off` | `ready` `join` `rsvp` `checkin` `connect` `open` `close` `badge`. |

TypeScript definitions: https://www.soulverified.com/soul.d.ts

`Soul.config()` never returns the key. Network failures resolve to `{ error: 'network' }`
rather than rejecting, so a partner's UI cannot break on Soul downtime.

---

## Options for `init`

| Option | Notes |
| --- | --- |
| `partner` | The slug. Any unregistered string runs in the free sandbox. |
| `key` | Only needed for a registered slug in production. |
| `user` | The product's own user id. Scoped per partner, never linked across partners. |
| `city` | Where this user is. |
| `name` | Display name of the product, shown inside the tab. |
| `accent` | Brand colour, e.g. `#7fd0c0`. |
| `theme` | `light` or `dark`. |
| `skin` | Optional preset skin, independent of the slug. |

---

## HTTP API

Every SDK call maps to an endpoint, so a server-side or non-JS integration is the same
product. CORS is open; auth is the key.

```
GET  /api/embed/events?partner=SLUG&key=KEY&user=UID&city=New%20York&limit=8
POST /api/embed/rsvp      { partner, key, user, meetupId, going }
POST /api/embed/checkin   { partner, key, user, meetupId }
POST /api/embed/webhook   { partner, key, url }   set your server endpoint
GET  /api/embed/webhook?partner=&key=             what is configured + recent deliveries
POST /api/embed/connect   { partner, key, user, tags: ["early riser","coffee"] }
POST /api/embed/host      { partner, key, title, where, when, note, collab: ["notion"] }
GET  /api/embed/providers      -> which platforms currently reach a real consent screen
```

Try it with no credentials at all:

```sh
curl "https://www.soulverified.com/api/embed/events?partner=try-demo&limit=3"
```

---

## The five kinds of gathering, and who sees each

| kind | Who sees it |
| --- | --- |
| `soul` | Everyone. Programmed by the agent. Shared across the network, which is why a room fills in a city a company just launched in. Most of the board. |
| `hosted` | That company's users only. The default for anything a partner creates. |
| `collab` | The host plus every partner named in `collab`. Both communities in the room. |
| `takeover` | Everyone, with the host's line on the card. The room stays open. |
| `exclusive` | Everyone sees it, not everyone gets in. Run by Soul. |

Anything Soul programs is open by default. Anything a partner makes is private by
default. A partner cannot create a `soul` or `exclusive` gathering; asking for one
returns `hosted`. Naming anyone in `collab` makes it a `collab` whatever was requested.

---

## Sandbox versus production

- **Unregistered slug**: free forever, no key. Reads are real. Writes are accepted and
  validated but held inactive, so nothing an anonymous caller creates reaches a member.
  Responses carry `"sandbox": true`. Prefix a slug with `try-` for anything throwaway.
- **Registered slug + key**: the real thing, under the company's own brand and users.
  Get one at https://www.soulverified.com/signup (14 days free, card up front).
- **Lapsed subscription**: every endpoint returns `402` with
  `{ "code": "subscription_required" }`. It never silently degrades to the sandbox.

---

## Data rules, to state accurately to a customer

- The product sends a user id. Never an email, never a name.
- Anything richer arrives only because that user connected their own account and
  consented to specific scopes, or because the product sent its own coarse tags.
- Soul stores derived tags such as `oura:early-riser`, never raw records, routes, health
  data or listening history.
- Users are scoped per partner. The same human in two products is two rows, and neither
  product learns about the other.
- Nothing is trained on anyone. Matching is arithmetic (weighted set overlap), not a
  learned model.

---

## Integration checklist for an agent

1. Add the script tag and a mount element where the community tab belongs.
2. Call `Soul.init` with the app's real user id and the user's city.
3. If the app has signal of its own (interests, activity, category preferences), call
   `Soul.identify(userId, { tags: [...] })`. This is the realistic path for most
   products, and it needs no OAuth.
4. Optionally add `Soul.badge({ el })` to the nav.
5. Optionally wire `Soul.on('rsvp', fn)` to the app's own analytics.
6. Leave `key` out until the company has subscribed. Everything above works without it.

Do not invent endpoints, options or claims that are not in this file. If something is
needed that is not documented here, say so rather than guessing.

## Version pinning

Two urls, and the difference matters in production.

```
https://www.soulverified.com/sdk.js            rolling. always the latest. cached 5 minutes.
https://www.soulverified.com/v/2.2.0/sdk.js    pinned. never changes. cached a year, immutable.
```

Use the rolling url to get fixes automatically. Use a pinned url when you need to know that
nothing changes in your product until you change it, which is the usual answer for a large
deployment or anything that goes through a release process. A published version is never
overwritten: the build refuses it. To move, change the version in the url.

## Content Security Policy

If your site sends a CSP, Soul needs three directives. Nothing else, and no `unsafe-inline`
on our account.

```
script-src  https://www.soulverified.com    the sdk file
frame-src   https://www.soulverified.com    the community tab renders in an iframe
connect-src https://www.soulverified.com    the api calls the sdk makes
```

You do not need to allow framing OF your site by us, and we never ask for `unsafe-eval`.
The tab requests camera only on the member verification screen and works without it.

## What happens when the network fails

No call ever rejects. Every method resolves to an object, and a failure looks like
`{ error: 'network' }`, `{ error: 'timeout' }` or the API's own error body, so your UI
cannot break on our downtime.

Every gathering carries `checkin` (`early` | `open` | `closed`), and `here` once its
window has opened. Show the check-in action only while `checkin` is `open`: the window is
what makes an arrival mean more than an RSVP, and calling outside it is answered, never
recorded.

Requests time out after 8 seconds. Reads and idempotent writes (`events`, `rsvp`,
`identify`) retry twice with backoff on a dropped connection, a 5xx or a 429. A 4xx is
never retried, because it is an answer. `host()` is never retried, because a repeat after
an unseen reply would put the same gathering on your board twice.


## Webhooks: your backend, without polling

Set an endpoint once. We POST JSON to it when something happens to one of your users.

```
POST https://www.soulverified.com/api/embed/webhook
{ "partner": "acme", "key": "sk_live_...", "url": "https://acme.com/hooks/soul" }
```

The response carries a `secret` starting `whsec_`. **Store it then. It is never returned
again**, and setting the url again rotates it. Send `"url": null` to turn webhooks off.
Registered partners only: the sandbox is public and keyless, so it cannot register one.

### Events

| Event | When |
| --- | --- |
| `member.joined` | one of your users entered the community for the first time |
| `rsvp.created` | they committed to a gathering |
| `rsvp.cancelled` | they took it back |
| `member.arrived` | they said they were there, inside the gathering's window |
| `gathering.created` | the agent programmed something new for your users |

### The delivery

```json
{
  "id": "evt_9f2c...",
  "type": "rsvp.created",
  "created": "2026-07-29T18:04:11.220Z",
  "partner": "acme",
  "data": { "user": "u_123", "meetupId": "cm...", "title": "Sunrise run", "going": 6 }
}
```

Headers: `Soul-Signature`, `Soul-Event`, `Soul-Delivery`.

### Verify every delivery

`Soul-Signature` is `t=<unix>,v1=<hex>`, an HMAC-SHA256 over `<t>.<raw body>` with your
secret. Compare in constant time, and reject anything older than five minutes so a
captured delivery cannot be replayed at you later.

```js
import crypto from 'crypto';

function verify(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;
  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'));
}
```

Verify against the **raw** body, before any JSON parsing. A re-serialised body will not
match, and that is the single most common reason a first integration fails.

### Delivery contract

- Reply `2xx`. Anything else is a failure.
- We retry 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 cannot
  keep it.
- We time out after 5 seconds, so acknowledge first and do your work after.
- Redirects are not followed. Give us the final URL.
- `GET /api/embed/webhook` returns your last 20 delivery attempts, so a broken integration
  is debuggable without reading your own logs.
