Better Auth sync
Mirror your Better Auth users into the capydb_auth.users table in your own database, authenticated with an HMAC-SHA256 signature.
What it does
Same as the Clerk and Auth0 syncs: CapyDB maintains a capydb_auth.users table inside your project database, fed by user lifecycle webhooks from your Better Auth instance. Identical table shape across providers.
Like Auth0, this sync is webhook-only — no backfill. Since Better Auth runs in your own app, the usual pattern is to send events from database hooks, so the table fills as users sign up and update.
Setup
-
Pick a signing secret (
openssl rand -hex 32). -
Configure the integration:
curl -X PUT https://capydb.dev/api/capydb/v1/projects/{projectID}/integrations/better_auth \ -H "Authorization: Bearer capy_live_..." \ -H "Content-Type: application/json" \ -d '{"credentials": {"webhook_signing_secret": "<your signing secret>"}}'This creates the
capydb_authschema anduserstable and stores the secret encrypted. The receiver URL (also returned inconfig.webhook_receiver):https://capydb.dev/api/capydb/v1/integrations/better-auth/webhook/{projectID} -
Send events from your Better Auth setup — database hooks on the
usermodel are the natural place:notify-capydb.ts import { createHmac } from 'node:crypto' export async function notifyCapyDB( event: 'user.created' | 'user.updated' | 'user.deleted', user: unknown ): Promise<void> { const body = JSON.stringify({ event, user }) const signature = createHmac('sha256', process.env.CAPYDB_AUTH_SYNC_SECRET!) .update(body) .digest('hex') const response = await fetch( `https://capydb.dev/api/capydb/v1/integrations/better-auth/webhook/${process.env.CAPYDB_PROJECT_ID}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Better-Auth-Signature': signature, }, body, } ) if (!response.ok) { throw new Error(`capydb auth sync failed: HTTP ${response.status}`) } }
Verification mechanics
Each request must carry the X-Better-Auth-Signature header: the hex-encoded HMAC-SHA256 of the raw request body, keyed with the signing secret you configured. A sha256= prefix is tolerated. The comparison is constant-time; missing, malformed, or mismatched signatures are rejected.
Sign the exact bytes you send. Serializing the body once, signing that string, and sending the same string (as in the snippet above) avoids the classic re-serialization mismatch.
Expected payload
{
"event": "user.updated",
"user": { "id": "usr_abc", "email": "a@example.com", "name": "Ada Lovelace", "...": "..." }
}event(ortype) isuser.created,user.updated, oruser.deleted.user(ordata) is the Better Auth core user object. For deletions, only theidis required.- Session and account events are acknowledged and ignored — the integration mirrors users only.
Normalization
capydb_auth.users column | From Better Auth |
|---|---|
id | id (required) |
email | email |
first_name / last_name | name split on whitespace (first token / remainder) |
username | username (when the username plugin is in use) |
image_url | image |
created_at / updated_at | createdAt / updatedAt (RFC 3339) |
last_sign_in_at | not provided by the Better Auth user model; stays NULL |
deleted_at | set on user.deleted (soft delete; rows are kept) |
raw | the full user payload, verbatim JSONB |
Same ground rules as every auth sync: the table is read-only from your side, filter with WHERE deleted_at IS NULL, and use raw for plugin fields the typed columns do not cover.