Base URL

https://api.boxowl.me/api/v1

Authentication — Org API Key

PDaaS uses bearer-token API key authentication. Keys are created per organization and presented in the standard Authorization header.

Authorization: Bearer bxorg_01h455vb4pex5vsknk084sn02q_abc123def456

API keys are prefixed with bxorg_. Keys can be created, rotated, and revoked from your organization dashboard or via the API.

Key scopes

Each key is created with a set of scopes describing intended use. Endpoints today check for an owning org or a valid API-key principal rather than fine-grained scope enforcement; track the scopes you grant so you can tighten them as scope-level checks roll out.

ScopeDescription
vault:readRead connected customer vault data
connections:readList connected customer accounts
connections:writeExchange grant codes and revoke connections
webhooks:writeSubscribe and unsubscribe webhooks

Endpoints

Connect flow (user authorizes your app)

GET
https://api.boxowl.me/connect?app={slug}&scopes=identity.name,identity.email,address.primary&return=https://your-app.com/callback&state=...&pkce_challenge=...&pkce_method=S256
Connect entry URL. Redirect the user here from your "Sign in with BoxOwl" button. Validates the request, shows the consent screen, on grant redirects to return?code=...&state=... with a single-use 60-second code; on deny, ?error=access_denied&state=.... scopes uses the canonical dot-notation vocabulary (identity.name, identity.email, contact.phone, address.primary, address.shipping, social.links, preferences.general, preferences.dietary, work.history). return must match the app's registered redirect allowlist. PKCE (S256 only) is required for SPA-initiated flows.
GET
/connect/embed?app={slug}&scopes=...&return=...&parent_origin=...&state=...&pkce_challenge=...
Iframe-embeddable variant of the consent flow. Emits per-app Content-Security-Policy: frame-ancestors; only origins in the app's registered allowlist can embed. On grant/cancel, postMessages the parent window — payload { type: 'boxowl.connect', grantCode, state, scopes } on success or { type: 'boxowl.connect.cancelled', state, reason } on cancel. Storage Access API probe handles Safari ITP with a fallback_required message for SDK-driven popup fallback.
POST
/api/v1/connect/exchange
Exchange the grant code for a connection record. Body: { "code": "...", "codeVerifier": "..." } (verifier required if the code was issued with PKCE). Returns { handle, userId, orgId, orgSlug, connectionId, scopes, consentPurpose, consentVersion, connectedAt }. Auth: org API key (bxorg_*). Legacy POST /api/v1/org-connections/exchange-code stays for one release and accepts the same body but returns the legacy grantedCategories/acknowledgeVersion field shape.

Read connected customer data

GET
/api/v1/connect/users/{handle}/profile?scopes=identity.name,address.primary
Read scope-filtered profile data for a connected user. Response is keyed by scope namespace (identity, contact, address, social, preferences, work) — absent groups (scopes the user didn't grant) are dropped from the response entirely. Optional ?scopes= narrows to a subset; requesting a scope the user didn't grant returns 403 scope_missing. 403 connection_missing if the user hasn't authorized this app. 401 re_consent_required when the app's request widened beyond what the user previously granted. Auth: org API key.
GET
/api/v1/org-connections
List the calling user's connections (user JWT auth). Used by the "Apps connected to my account" UI in BoxOwl + the Android sharing settings.
DELETE
/api/v1/connect/connections/{connectionId}
User-side revoke (user JWT auth). Deletes the connection and fires customer.connection-revoked webhook to the app. App stops being able to fetch this user's data immediately.
POST
/api/v1/connect/connections/{connectionId}/revoke
App-side revoke (org API key auth). Useful when the app deletes a user on its side. Idempotent — revoking an already-gone connection returns 204. Same connection-revoked webhook fires.
DELETE
/api/v1/orgs/{slug}/connections/{personId}
Legacy form of app-side revoke (by person, not connection id). Still works; prefer POST /api/v1/connect/connections/{id}/revoke for new integrations. Auth: connections:write

Org & admin

POST
/orgs/{slug}/api-keys
Create a scoped API key for the organization. Auth: owner
GET
/orgs/{slug}/api-keys
List API keys (key values redacted). Auth: owner
DELETE
/orgs/{slug}/api-keys/{keyId}
Revoke an API key immediately. Auth: owner
GET
/orgs/{slug}
Get organization details. Auth: public
GET
/orgs/{slug}/members
List organization members and their roles. Auth: owner
GET
/orgs/{slug}/audit-log
Read audit log entries. Auth: owner

Webhooks

POST
/orgs/{slug}/webhooks
Register a webhook endpoint. Returns { id, secret } — keep the secret to verify HMAC signatures. Auth: webhooks:write
GET
/orgs/{slug}/webhooks
List active webhook subscriptions. Auth: webhooks:write
DELETE
/orgs/{slug}/webhooks/{webhookId}
Unregister a webhook. Auth: webhooks:write

Ownership boundary

The user owns their personal data. An organization cannot delete a user's vault entries or BoxOwl account. An organization can only:

When a user deletes their BoxOwl account from their app, every connected org receives the 30-day deletion lifecycle webhooks (customer.vault.deletion-pending, then ...deletion-confirmed or ...deletion-cancelled) so they can keep their own records in sync. There is no org-callable "erase this user" endpoint, and there will not be one.

Webhook Events

When you subscribe, BoxOwl pushes HTTP POST payloads to your endpoint. Each event includes a type, timestamp, organizationId, and data object.

Event typeDescription
customer.connection-establishedA user granted your org access to their vault
customer.connection-revokedThe connection was revoked (by user or org). Stop reading their vault immediately
customer.vault.updatedA connected user's vault was modified
customer.vault.deletion-pendingUser has initiated account deletion. 30-day grace period; hide local copies
customer.vault.deletion-cancelledUser cancelled the deletion during the grace period
customer.vault.deletion-confirmed30-day grace expired; the user's BoxOwl account is gone. Hard-delete your copies
customer.vault.exportedUser issued a DSAR export against the connection
customer.re-consent.requiredRe-consent is needed (e.g. new categories requested)
customer.re-consent.completedRe-consent was granted
customer.vault.written-by-appAn org wrote to the user's vault via the PDaaS framework (write-back path). Distinguishes "user-self" from "another app on the user's behalf."
customer.vault.flags-changedCollection-row flags (e.g. is_primary) changed via the PDaaS framework.
customer.vault.user-updatedPDaaS True only. A user-self vault change. Fires to every connected PDaaS True org with scope for the changed field (excluding the org that drove the write, if any). See the PDaaS True section below.

Payload signature

Each webhook request includes a X-BoxOwl-Signature header of the form sha256=<hex>, computed as HMAC-SHA256 of the raw JSON body using the secret returned when you registered the webhook (not your API key's secret). Two additional headers identify the delivery: X-BoxOwl-Event (event type) and X-BoxOwl-Delivery (unique ID, useful for idempotency).

The body BoxOwl signs and POSTs is:

{
  "event": "customer.connection-established",
  "timestamp": "2026-05-23T14:22:08Z",
  "deliveryId": "owd_01h...",
  "data": { ... event-specific payload ... }
}

Verify the signature against the raw bytes BoxOwl posted (do not re-serialize) and compare in constant time:

Node.js (Express)

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.BOXOWL_WEBHOOK_SECRET;

// Capture the raw body so we sign the exact bytes BoxOwl sent.
app.post('/webhooks/boxowl',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sigHeader = req.header('X-BoxOwl-Signature') || '';
    const expected = 'sha256=' + crypto
      .createHmac('sha256', SECRET)
      .update(req.body)
      .digest('hex');
    const a = Buffer.from(sigHeader);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send('bad signature');
    }
    const payload = JSON.parse(req.body.toString('utf8'));
    // ... handle payload.event ...
    res.status(204).end();
  });

Python (Flask)

import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['BOXOWL_WEBHOOK_SECRET'].encode()

@app.post('/webhooks/boxowl')
def handle():
    sig = request.headers.get('X-BoxOwl-Signature', '')
    expected = 'sha256=' + hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        abort(401)
    payload = request.get_json()
    # ... handle payload['event'] ...
    return '', 204

Idempotency: BoxOwl retries delivery up to 5 times with exponential backoff. Persist the deliveryId on first successful handling and short-circuit replays so retries are safe.

PDaaS True endpoints

Three additional endpoints unlock for orgs on the PDaaS True subscription tier. Standard PDaaS orgs that hit them get 403 upgrade_required. Full integrator narrative on the developer docs page.

Identity JWT — tags claim

PDaaS True orgs get a tags claim on every X-BoxOwl-Identity JWT delivered to their domain, alongside the standard tier: "pdaas_true" marker. The claim is a flat { key: stringValue } map sourced from OrgUserTag rows on the connection.

{
  "sub":      "ouid-alice-yourshop-1",
  "name":     "Alice Smith",
  "verified": true,
  "tier":     "pdaas_true",
  "org":      "your-shop",
  "tags":     { "customerId": "C123", "memberTier": "gold" },
  "smrt":     { "ageBand": "25-34", "region": "CO" },
  "iss":      "boxowl.me",
  "exp":      1748392200
}

Tag write — POST /api/v1/orgs/{slug}/connections/{orgUid}/tags

Org API key auth. Writes (or upserts) tags on the connection. Path uses the pairwise orgUid as the per-user identifier.

Request body:

{ "customerId": "C123", "memberTier": "gold", "loyaltyPoints": "4200" }

Values are strings only. Don't put PII in tags — they're meant for your internal references.

Batch sync — POST /api/v1/pdaas/true/sync

Org API key auth. Returns the latest snapshot of consented fields for a batch of orgUids.

Request body:

{
  "orgUids": ["ouid-alice-...", "ouid-bob-..."],
  "scopes":  ["address.primary", "identity.name"]
}

Response:

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

Missed-events recovery — GET /api/v1/pdaas/true/events?since=<ISO>

Org API key auth. Returns persisted customer.vault.user-updated events created strictly after the since cursor, oldest first.

Response:

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

True-up webhook payload — customer.vault.user-updated

The webhook side of the recovery endpoint. Fires the moment a user-self vault change lands at BoxOwl, scoped to every connected PDaaS True org that has scope for the changed field.

POST <your webhook URL>
X-BoxOwl-Signature: sha256=<hex>
Content-Type: application/json

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

Same HMAC signature scheme as the standard webhooks above. The orgUid in the payload matches the sub claim from the identity JWT for the same user.

Errors

Error Codes

HTTPTypical meaning
401Missing or invalid API key
403API key valid but not authorised for this org / connection; or category not in the user's sharedCategories
404Organisation, connection, or resource not found
410Grant code already exchanged or expired
429Rate limit exceeded

← Back to API Reference