Better Auth quick start
Create a user, register a personal endpoint through authenticated routes, and list it. One file, SQLite, no PostgreSQL.
One script runs the plugin end to end against a SQLite file. No server, no PostgreSQL, and no webhook is sent:
- No sessionListing endpoints without signing in returns
401. - CreateA signed-in user creates an endpoint and gets its secret once.
- ListThe endpoint is listed, without its secret.
- Other scopeAsking for application scope returns
403.
You need Node.js 24 and npm.
Create a project
mkdir good-webhooks-auth-quickstart && cd good-webhooks-auth-quickstart
npm init -ynpm install good-webhooks@beta better-auth@1.7.7Generate an auth secret for this shell session:
export BETTER_AUTH_SECRET="$(node -e 'console.log(require("node:crypto").randomBytes(32).toString("base64"))')"Write the script
Save as quick-start.mjs. It applies Better Auth's schema programmatically because this is a disposable SQLite database. A real application uses its normal schema workflow.
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
import { betterAuth } from 'better-auth'
import { getMigrations } from 'better-auth/db/migration'
import { goodWebhooks } from 'good-webhooks/better-auth'
const origin = 'http://localhost:3000'
const options = {
database: new DatabaseSync('./quick-start.sqlite'),
baseURL: origin,
secret: process.env.BETTER_AUTH_SECRET,
emailAndPassword: { enabled: true },
plugins: [goodWebhooks({ eventTypes: ['invoice.created', 'invoice.paid'] })],
}
await (await getMigrations(options)).runMigrations()
const auth = betterAuth(options)
// Call a plugin route the way a browser would, with a JSON body and cookies.
async function call(operation, body = {}, cookie = '') {
return auth.handler(
new Request(`${origin}/api/auth/good-webhooks/${operation}`, {
method: 'POST',
headers: { origin, 'content-type': 'application/json', cookie },
body: JSON.stringify(body),
}),
)
}
// Sign up and keep the session cookie.
const signup = await auth.api.signUpEmail({
body: { name: 'Quick start', email: `${randomUUID()}@example.com`, password: randomUUID() },
asResponse: true,
})
const cookie = signup.headers
.getSetCookie()
.map((value) => value.split(';')[0])
.join('; ')
// No session: rejected.
assert.equal((await call('list')).status, 401)
console.log('Anonymous list rejected with 401')
// Create a personal endpoint.
const created = await (
await call(
'create',
{ url: 'https://receiver.example/webhooks', eventTypes: ['invoice.created'] },
cookie,
)
).json()
assert.match(created.secret, /^whsec_/)
console.log('Created endpoint', created.endpoint.id, 'with a one-time secret')
// List it. Secrets never appear in reads.
const endpoints = await (await call('list', {}, cookie)).json()
assert.equal(endpoints.length, 1)
assert.equal('secret' in endpoints[0], false)
console.log('Listed 1 endpoint without its secret')
// Application scope needs an explicit policy.
assert.equal((await call('list', { scope: null }, cookie)).status, 403)
console.log('Application scope rejected with 403')
await call('remove', { id: created.endpoint.id }, cookie)
options.database.close()Run it
node quick-start.mjsAnonymous list rejected with 401
Created endpoint <id> with a one-time secret
Listed 1 endpoint without its secret
Application scope rejected with 403auth.handler() receives real Request objects, so there is no HTTP server to start. On repeated runs Better Auth's SQLite inspector may warn that array fields use TEXT storage. That is expected.
Clean up
Delete quick-start.sqlite when you are done.