PDaaS Connect — Developer Guide
Integrate with BoxOwl as a Personal-Data-as-a-Service provider. Users authorize your app with explicit, granular consent; you fetch profile data by handle with your org API key.
How it works
BoxOwl's PDaaS model uses a connection record, not OAuth2 bearer tokens. Three primitives:
- Your org API key (
bxorg_*) — issued once, stored on your backend. Identifies your app. - User handle / webDID — public identifier. Names which user you're asking about.
- 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:
- App slug (lowercase, hyphen-separated, e.g.
your-app) - Display name shown to users
- Allowed redirect URIs for
/connectcallbacks (e.g.https://your-app.com/auth/callback) - Allowed iframe parent origins for
/connect/embed(omit to disable iframe mode) - Scopes you'll request by default (see Scope vocabulary below)
- Webhook endpoint (HTTPS, accepts HMAC-signed JSON payloads)
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.name | A | read · write | firstName, lastName, preferredName, displayName |
identity.email | A | read · write | Primary email + verified flag. App writes land unverified. |
identity.verified | C | read | Boolean: has any verified email. Derived; writes return 400 unwritable_scope. |
contact.phone | A | read · write | Primary phone (number, label, verified) |
address.primary | A | read · write | Primary postal address (atomic primary-row upsert) |
address.shipping | A | read | List of shipping addresses. Writes via address.flags:write on a collection row. |
address.list + address.flags | B | read · write · delete · flags:write | Full address-book CRUD + flag patches |
social.links | B | read · write · delete | Social account handles per platform |
preferences.general | A | read · write | Color, size, locale preferences |
preferences.dietary | A | read · write | Restrictions, allergies, cuisine, spice tolerance |
work.history | B | read · write · delete | Employment 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:
404 user_not_found— the handle doesn't resolve.403 connection_missing— the user hasn't authorized your app.403 scope_missing— you requested a scope the user didn't grant.401 re_consent_required— your requested scopes have widened since the user's last consent. Redirect them back through/connectto refresh.
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
400 validation_failed— body doesn't match the handler's DTO or fails business validation.403 scope_missing— connection lacks{scope}:write(or:delete, etc.). Treat as a re-consent prompt: send the user back through/connectwith the write scope added.400 unwritable_scope— you targeted a Pattern C derived scope.409 conflict— write would violate an invariant (e.g., deleting the last primary row).429 rate_limited— burst limit hit (10/min) or sustained limit hit (100/hour), per(org, user, scope).
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:
customer.connection-established— user just granted accesscustomer.connection-revoked— user revoked via BoxOwl; delete your copycustomer.connection-updated— scopes changed (added or removed)customer.re-consent.required— your app's scope request widened; next data fetch will 401 until user re-consentscustomer.vault.updated— vault data changed by the user themselves (BoxOwl app, web, or extension); consider re-fetchingcustomer.vault.written-by-app— vault data changed by an app (any registered scope; the event payload includesscope,operation,entity,entityId). Distinguish "user changed this" from "another app changed this on the user's behalf."customer.vault.flags-changed— flag fields on a collection row changed (e.g.,is_primary,is_shipping) — fired separately fromwritten-by-appfor cleaner cross-app reconciliation.
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:
- An
OrgUserTagstore per connection — string key/value pairs you write at connect-time (your CRM customer-id, membership tier, anything). - A
tagsclaim on every identity JWT delivered to your domain — so your server readstags.customerIdon the first request and joins straight to your own customer record. No callback. - A
customer.vault.user-updatedwebhook (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:
- Batch size: max 100 orgUids per request. Paginate by chunking on your side.
- Scopes per orgUid must be a subset of what the user granted at connect-time. Scopes the user hasn't granted appear in
missingScopes, and the corresponding data is omitted fromdata. - Connections with re-consent pending are silently omitted from the result set — bounce the user back through
/connectbefore retrying. - OrgUids that don't belong to your org are silently 404'd in the result. No information leak.
- Rate-limited per API key.
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
- PDaaS API reference — full endpoint catalog
- PDaaS overview — pitch / positioning
- Silent customer matching overview — in-PDaaS feature positioning
- Embed SDK reference (in-repo):
web-commons/src/lib/boxowl-connect.ts— copy as a starting point for your client - Questions, feedback, or app registration: support@boxowl.me