CapyDB Docs
GuidesIntegrations

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

  1. Pick a signing secret (openssl rand -hex 32).

  2. 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_auth schema and users table and stores the secret encrypted. The receiver URL (also returned in config.webhook_receiver):

    https://capydb.dev/api/capydb/v1/integrations/better-auth/webhook/{projectID}
  3. Send events from your Better Auth setup — database hooks on the user model 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 (or type) is user.created, user.updated, or user.deleted.
  • user (or data) is the Better Auth core user object. For deletions, only the id is required.
  • Session and account events are acknowledged and ignored — the integration mirrors users only.

Normalization

capydb_auth.users columnFrom Better Auth
idid (required)
emailemail
first_name / last_namename split on whitespace (first token / remainder)
usernameusername (when the username plugin is in use)
image_urlimage
created_at / updated_atcreatedAt / updatedAt (RFC 3339)
last_sign_in_atnot provided by the Better Auth user model; stays NULL
deleted_atset on user.deleted (soft delete; rows are kept)
rawthe 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.