How it works

BoxOwl's PDaaS model uses a connection record, not OAuth2 bearer tokens. Three primitives:

  1. Your org API key (bxorg_*) — issued once, stored on your backend. Identifies your app.
  2. User handle / webDID — public identifier. Names which user you're asking about.
  3. Connection — a row at BoxOwl tying (your_app, user, granted_scopes). The user grants once via /connect; you read by handle thereafter. Authorization is a DB row, not a token claim.

For the full design rationale, see PDaaS overview and the canonical spec at wiki/concepts/pdaas-connect.md in the repo.

Step 1 — Register your app

App registration is currently founder-handled — send support@boxowl.me the following:

You'll receive a bxorg_* API key. Store it server-side only. The key never enters browser JS.

Scope vocabulary

Scopes are dot-notation with an optional :verb suffix. Bare scopes default to :read. Each row below is one registered vault-entity handler — adding a new scope on the backend is a single @Component, and this table is generated from that registry at build time.

The table is also exposed live at GET /api/v1/connect/registry/scopes for tooling.

Scope Pattern Operations Returns / accepts
identity.nameAread · writefirstName, lastName, preferredName, displayName
identity.emailAread · writePrimary email + verified flag. App writes land unverified.
identity.verifiedCreadBoolean: has any verified email. Derived; writes return 400 unwritable_scope.
contact.phoneAread · writePrimary phone (number, label, verified)
address.primaryAread · writePrimary postal address (atomic primary-row upsert)
address.shippingAreadList of shipping addresses. Writes via address.flags:write on a collection row.
address.list + address.flagsBread · write · delete · flags:writeFull address-book CRUD + flag patches
social.linksBread · write · deleteSocial account handles per platform
preferences.generalAread · writeColor, size, locale preferences
preferences.dietaryAread · writeRestrictions, allergies, cuisine, spice tolerance
work.historyBread · write · deleteEmployment history entries

Pattern key: A = singular entity (one row per user); B = collection (0..N rows); C = derived (read-only synthetic).

Step 2 — Send the user through /connect

Three delivery modes against the same backend:

Mode A: Full-page redirect

Simplest, most robust. Redirect the browser:

https://api.boxowl.me/connect?app=your-app&scopes=identity.name,identity.email,address.primary&return=https://your-app.com/auth/callback&state=RANDOM_NONCE&pkce_challenge=BASE64URL_SHA256_OF_VERIFIER&pkce_method=S256

On grant the user lands at return?code=...&state=... with a single-use 60-second code. On deny: ?error=access_denied&state=....

Mode B: Popup window

Same URL, but opened via window.open(...). The popup postMessages the grant code to the opener and closes itself. Useful for SPA flows where you don't want full navigation.

Mode C: Embedded iframe (recommended for SPAs)

<iframe src="https://api.boxowl.me/connect/embed?app=your-app&scopes=...&return=...&state=...&pkce_challenge=...&parent_origin=https://your-app.com"></iframe>

BoxOwl emits Content-Security-Policy: frame-ancestors matching your registered allowlist. On success/cancel, the iframe postMessages the parent:

// success
{ type: 'boxowl.connect', grantCode, state, scopes }

// user denied / closed
{ type: 'boxowl.connect.cancelled', state, reason }

// Safari ITP blocked the iframe — fall back to popup mode
{ type: 'boxowl.connect.fallback_required', reason: 'storage_access_denied' }

Always check event.origin === 'https://api.boxowl.me' on incoming messages and verify the state nonce matches what you generated.

Step 3 — Exchange the code

Your backend exchanges the grant code (server-to-server, with the API key):

curl -X POST https://api.boxowl.me/api/v1/connect/exchange \
  -H "Authorization: Bearer bxorg_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "ONE_TIME_CODE_FROM_REDIRECT",
    "codeVerifier": "PKCE_VERIFIER_YOUR_SPA_HELD_IN_MEMORY"
  }'

Response:

{
  "handle": "alice",
  "uid": "9Y2OLEBFV",
  "orgSlug": "your-app",
  "connectionId": "ocn_xxx",
  "scopes": ["identity.name", "identity.email", "address.primary"],
  "consentPurpose": "Order fulfillment + personalization",
  "consentVersion": "consent-ui-v1",
  "connectedAt": "2026-05-25T18:30:00Z"
}

Persist uid + connectionId tied to your user record — uid is the stable 9-char identifier (same as the SMRT JWT sub claim and the value behind did:web:boxowl.me/uid:{uid}). handle is mutable, so don't rely on it as a foreign key. Mint your own session for the SPA (cookie, app-side JWT — whatever you use). Never expose the API key or grant code to browser JS.

Step 4 — Fetch profile data

Your backend reads the user's consented data on demand:

curl https://api.boxowl.me/api/v1/connect/users/alice/profile \
  -H "Authorization: Bearer bxorg_xxxxx"

Optional narrowing — only fetch what you need this call:

curl 'https://api.boxowl.me/api/v1/connect/users/alice/profile?scopes=identity.name,address.primary' \
  -H "Authorization: Bearer bxorg_xxxxx"

Response (only granted scopes appear):

{
  "handle": "alice",
  "connectionId": "ocn_xxx",
  "scopesGranted": ["identity.name", "identity.email", "address.primary"],
  "scopesUsed": ["identity.name", "identity.email", "address.primary"],
  "identity": {
    "firstName": "Alice", "lastName": "Liddell", "displayName": "Alice",
    "primaryEmail": { "address": "alice@example.com", "verified": true }
  },
  "address": {
    "primary": {
      "label": "home", "street": "1212 Canyon Blvd",
      "cityTown": "Boulder", "stateProvince": "CO", "postalCode": "80302", "country": "US"
    }
  }
}

Error responses:

Every fetch is recorded in the user-visible access audit log at boxowl.me/account/connections. Be a good citizen — fetch only what you need, when you need it.

Step 5 — Writes

Most PDaaS apps don't just read user data — they write back too. A checkout that captures a shipping address, a profile editor that updates phone numbers, a calendar sync that adds events. PDaaS supports these via the same gateway, with explicit :write / :delete scopes that the user grants separately from reads.

Request the write scope at consent time

Add :write to any scope in your /connect URL. The consent screen renders read and write as separate checkboxes — the user can grant either, both, or neither:

https://api.boxowl.me/connect?app=your-app&scopes=address.primary,address.primary:write,...

Bare scopes (no :verb) are interpreted as :read for back-compat with pre-PDAAS-WRITE consents.

Three endpoint patterns

Every registered scope lives in exactly one of three URL-shape patterns. The pattern determines which HTTP verbs are mounted; you don't pick the URL, the framework does.

Pattern A — Singular entity

GET    /api/v1/connect/users/{handle}/{group}/{field}    — read
PUT    /api/v1/connect/users/{handle}/{group}/{field}    — write (upsert)

Used for entities that exist at most once per user: address.primary, identity.name, contact.phone, etc.

Pattern B — Collection

GET    /api/v1/connect/users/{handle}/{group}/{field}              — list
POST   /api/v1/connect/users/{handle}/{group}/{field}              — create
PUT    /api/v1/connect/users/{handle}/{group}/{field}/{id}         — update
DELETE /api/v1/connect/users/{handle}/{group}/{field}/{id}         — delete
PATCH  /api/v1/connect/users/{handle}/{group}/{field}/{id}/flags   — patch flags

Used for 0..N collections: address.list, social.links, work.history.

Pattern C — Derived

GET    /api/v1/connect/users/{handle}/{group}/{field}    — read

Read-only computed values. Writes against these return 400 unwritable_scope — the framework refuses to mount write routes at all.

Write example — primary address

curl -X PUT https://api.boxowl.me/api/v1/connect/users/alice/address/primary \
  -H "Authorization: Bearer bxorg_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "street": "7253 Park Lane Rd",
    "cityTown": "Gunbarrel",
    "stateProvince": "CO",
    "postalCode": "80301",
    "country": "USA",
    "label": "home"
  }'

Response: the canonical record after upsert (with assigned id). The body shape is identical to what the read returned, so you can write back exactly what you read.

Write error envelope

Idempotency

Pass an Idempotency-Key: <opaque> header on any write. If the same key is seen again within 24 hours, the cached response is returned instead of re-running the handler. Lets you safely retry a write that timed out:

curl -X PUT https://api.boxowl.me/api/v1/connect/users/alice/address/primary \
  -H "Authorization: Bearer bxorg_xxxxx" \
  -H "Idempotency-Key: 7d3a9e8f-..." \
  -H "Content-Type: application/json" \
  -d '{...}'

Re-consent flow when scopes widen

If your app requests address.primary:write for a user whose existing connection only has address.primary (read), the write returns 403 scope_missing. Surface a "Grant write access" link that sends the user to /connect with the wider scope set — the consent screen handles the diff. After grant, retry the write.

Choosing scopes — minimize the surface

Ask for the narrowest write scope that does the job. A checkout that only needs to repoint shipping should request address.flags:write (toggles flags on existing rows), not address.list:write (full CRUD). The principle of least authority applies to writes the same way it does to reads.

Step 6 — Revoke + webhooks

When a user signs out or deletes their account on your side, revoke the connection:

curl -X POST https://api.boxowl.me/api/v1/connect/connections/ocn_xxx/revoke \
  -H "Authorization: Bearer bxorg_xxxxx"

Subscribe to webhooks at your registered endpoint for the inverse:

Payloads are HMAC-signed with your shared secret. Verify before processing.

Silent customer matching with tags

Silent customer matching is a PDaaS feature for orgs that already have a customer database — it ships as part of standard PDaaS, no separate tier. It bundles three capabilities on top of everything documented above:

  1. An OrgUserTag store per connection — string key/value pairs you write at connect-time (your CRM customer-id, membership tier, anything).
  2. A tags claim on every identity JWT delivered to your domain — so your server reads tags.customerId on the first request and joins straight to your own customer record. No callback.
  3. A customer.vault.user-updated webhook (real-time push) and a batch sync API (for reconciliation) that keep your local copy of consented fields current.

1. Tag a connection at connect-time

Immediately after exchanging the grant code (Step 4 above), call the tag write endpoint with whatever annotations you want to live on the connection:

curl -X POST https://api.boxowl.me/api/v1/orgs/your-shop/connections/$ORG_UID/tags \
  -H "Authorization: Bearer bxorg_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "customerId": "C123", "memberTier": "gold", "loyaltyPoints": "4200" }'

The orgUid you pass is the pairwise orgUid BoxOwl minted for this user on your org. Pull it from the exchange response (userId field for connections established prior to SMRT-014; the orgUid field going forward).

Tag values are strings only (serialize JSON if you need richer values). Keys are org-defined; BoxOwl doesn't interpret them. Don't put PII in tags — store your own internal references and look up the rest in your DB.

2. Read tags from the identity JWT

Once tags are set, every identity JWT minted for this user on your domain carries them under the tags claim:

{
  "sub":      "ouid-alice-yourshop-1",  // pairwise orgUid
  "name":     "Alice Smith",
  "verified": true,
  // value set is {smrt, pdaas} pending PRICE-NEW-006 confirmation
  "tier":     "pdaas",
  "org":      "your-shop",
  "tags":     {
    "customerId": "C123",
    "memberTier": "gold",
    "loyaltyPoints": "4200"
  },
  "smrt":     { "ageBand": "25-34", "region": "CO", ... },
  "iss":      "boxowl.me",
  "exp":      1748392200
}

Your server verifies the JWT exactly as it does without tags — same JWKS, same org claim check. Read tags.customerId, look it up in your DB, render the full customer context. Zero callbacks to BoxOwl.

3. Handle the propagation webhook

When a user updates a consented vault field anywhere in BoxOwl (the app, the web SPA, the extension), a webhook fires to every connected PDaaS org that has scope for the changed field:

POST <your webhook URL>
X-BoxOwl-Signature: <hmac-sha256 of body, hex>
Content-Type: application/json

{
  "event":     "customer.vault.user-updated",
  "orgUid":    "ouid-alice-yourshop-1",
  "changes":   { "address.primary": { "line1": "123 New St", "city": "Denver", "state": "CO", "zip": "80203", "country": "US" } },
  "updatedAt": "2026-05-28T14:30:00Z"
}

Verify the X-BoxOwl-Signature HMAC against your registered endpoint's shared secret, then apply the change to your local copy keyed by orgUid. Standard retry-with-exponential-backoff applies; the source of truth survives at BoxOwl regardless of delivery outcome.

4. Reconcile via batch sync

For initial population, post-downtime reconciliation, or pre-campaign audits, call the batch sync endpoint:

curl -X POST https://api.boxowl.me/api/v1/pdaas/true/sync \
  -H "Authorization: Bearer bxorg_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "orgUids": ["ouid-alice-...", "ouid-bob-..."], "scopes": ["address.primary", "identity.name"] }'

Response shape:

{
  "results": [
    {
      "orgUid":   "ouid-alice-...",
      "data":     { "address.primary": { ... }, "identity.name": { ... } },
      "fetchedAt": "2026-05-28T14:30:00Z",
      "missingScopes": []
    },
    { "orgUid": "ouid-bob-...", "data": { ... }, "fetchedAt": "...", "missingScopes": [] }
  ]
}

Notes:

5. Missed-events recovery

If your webhook endpoint was unreachable for a stretch, recover events with the cursor endpoint:

curl -X GET 'https://api.boxowl.me/api/v1/pdaas/true/events?since=2026-05-28T10:00:00Z' \
  -H "Authorization: Bearer bxorg_xxxxx"

Returns up to 500 customer.vault.user-updated events strictly newer than the cursor, oldest first. Advance your high-water mark by the last entry's createdAt and re-call until you get an empty page.

{
  "events": [
    {
      "event":     "customer.vault.user-updated",
      "orgUid":    "ouid-alice-...",
      "payload":   { "orgUid": "ouid-alice-...", "changes": { ... }, "updatedAt": "..." },
      "createdAt": "2026-05-28T10:05:00Z"
    },
    ...
  ]
}

Retention: 30 days. Older events drop off the index — design your reconciliation cadence to fit.

Metered usage

All four endpoints (/tags, /pdaas/true/sync, /pdaas/true/events) and the tags JWT claim are available on every PDaaS org. Active orgUids, propagation webhooks, and sync calls are metered against the included PDaaS quotas (5K orgUids · 50K webhooks · 1K sync calls per month at the $99 base), with per-unit overage above. See pricing for the meter table.

Next steps