Quickstart
This walks the public /v1 API end to end against a local edge-api dev server
(pnpm dev, default http://localhost:8787). Every request below is copied from — or is a
direct curl-equivalent of — this repo’s own integration tests, not invented.
Examples use a curl cookie jar (-c cookies.txt -b cookies.txt) to carry the dashboard session
across steps, the same way a browser would with Set-Cookie/Cookie, and jq to pull ids out of
JSON responses into shell variables. Swap http://localhost:8787 for your deployment’s origin.
0. Two kinds of caller
Every /v1 request authenticates as exactly one of:
| kind | how | used for |
|---|---|---|
| dashboard | session cookie + an active organization | account/org/key management (steps 1–4 below) |
| apiKey | x-api-key: lp_live_… / lp_test_… header |
everything a POS integration or backend does (steps 5+) |
A request that shows up with the wrong kind gets 403 TOKEN_TYPE_NOT_ALLOWED — every route
declares explicitly which kinds it accepts.
1. Create a merchant (dashboard) account
curl -sS -c cookies.txt http://localhost:8787/api/auth/sign-up/email \
-H 'Content-Type: application/json' \
-H 'Origin: http://localhost:8787' \
-d '{
"email": "owner@adascafe.example",
"password": "correct horse battery staple",
"name": "Ada Owner"
}'
The
Originheader is not optional. Better Auth runs its own server-side CSRF check on POST requests — independent of, and stricter than, browser CORS (curl never enforces CORS itself, so this check is the only thing stopping a random POST from succeeding here). Once a request carries a session cookie, Better Auth rejects anyOriginthat isn’t in the server’sTRUSTED_ORIGINSlist. SendOriginon every auth POST in this guide, set to an origin your server actually trusts (local dev’s.dev.vars.exampletrustshttp://localhost:5173,http://localhost:8787) — the safest habit is to always send it, the same way a real browser always does, rather than relying on the narrower case where a cookie-less first request happens to be exempt.
A 200 response sets the session cookie (stored in cookies.txt) and returns the created user.
2. Create your organization
An organization is a tenant — tenant_id throughout the rest of the API is the organization’s
id.
curl -sS -c cookies.txt -b cookies.txt http://localhost:8787/api/auth/organization/create \
-H 'Content-Type: application/json' \
-H 'Origin: http://localhost:8787' \
-d '{ "name": "Adas Cafe", "slug": "adas-cafe" }' | tee /tmp/org.json | jq .
export ORG_ID=$(jq -r '.id // .organization.id' /tmp/org.json)
(The response shape’s exact envelope — a top-level id vs. a nested organization.id — has
varied across Better Auth versions; the // fallback above handles either.)
3. Activate the organization
curl -sS -c cookies.txt -b cookies.txt http://localhost:8787/api/auth/organization/set-active \
-H 'Content-Type: application/json' \
-H 'Origin: http://localhost:8787' \
-d "{ \"organizationId\": \"$ORG_ID\" }"
Do not skip this. organization/create does activate the new org on the session row
server-side, but its response never reissues the session cookie — and with the session cookie
cache enabled (5 minute TTL), your existing cookie keeps reporting the stale
activeOrganizationId: null for up to 5 minutes. Every /v1 call in that window fails with
403 NO_ACTIVE_ORGANIZATION. organization/set-active is the call that actually reissues the
cookie with the new active org baked in — it’s what Better Auth’s own dashboard client calls
after creating or switching an org, so this guide does the same.
4. Create a live API key
API keys are organization-owned (they outlive whichever user created them) and carry their
own permission grants — a key is only ever as powerful as the permissions you give it.
curl -sS -b cookies.txt http://localhost:8787/v1/api-keys \
-H 'Content-Type: application/json' \
-H 'Origin: http://localhost:8787' \
-d '{
"config_id": "live",
"name": "quickstart key",
"permissions": {
"program": ["read", "manage"],
"rewards": ["read", "manage"],
"members": ["read", "write"],
"transactions": ["read", "write"],
"redemptions": ["initiate", "commit", "cancel"]
}
}' | tee /tmp/key.json | jq .
export API_KEY=$(jq -r '.key' /tmp/key.json)
{ "id": "01J...", "key": "lp_live_9f2c...", "prefix": "lp_live_", "start": "lp_live_9f2c" }
The key field is shown exactly once. Store it now — every later call to
GET /v1/api-keys returns only prefix + start, never the full secret again. Keys are prefixed
lp_live_ or lp_test_ depending on config_id; use a test key for anything you don’t want
touching real balances.
permissions values must come from the platform’s permission catalog (e.g. transactions:write,
redemptions:initiate) — an unknown resource or action is a 422 VALIDATION_FAILED, not a
silent no-op.
5. Create a program
POST /v1/transactions (step 10 below) evaluates your organization’s earning rules against a
program — create one first:
curl -sS -X POST http://localhost:8787/v1/programs \
-H "x-api-key: $API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "Adas Cafe Rewards",
"type": "points",
"currency": "USD",
"points_rounding": "floor"
}' | tee /tmp/program.json | jq .
export PROGRAM_ID=$(jq -r '.id' /tmp/program.json)
{
"id": "01J...",
"name": "Adas Cafe Rewards",
"type": "points",
"currency": "USD",
"points_rounding": "floor",
"expiry_policy": null,
"status": "draft",
"created_at": "2026-07-07T09:00:00.000Z",
"updated_at": "2026-07-07T09:00:00.000Z"
}
Every new program starts in draft. type, currency, and points_rounding are permanent once
the program records ledger activity — see the
programs and rewards guide for the full lifecycle and the lock
guard behind that rule.
6. Add an earning rule
A program earns nothing until it has at least one active earning rule. This one pays 1 point
per whole currency unit spent — net below is minor units (cents), and a "spend" effect divides
by 100 internally, so 1500 minor units × 1 ÷ 100 = 15 points, matching step 10’s example:
curl -sS -X POST "http://localhost:8787/v1/programs/$PROGRAM_ID/rules" \
-H "x-api-key: $API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"conditions_json": {},
"effect_json": { "type": "spend", "points_per_currency_unit": 1 },
"status": "active"
}' | jq .
{
"id": "01J...",
"program_id": "01J...",
"version": 1,
"status": "active",
"priority": 0,
"conditions_json": {},
"effect_json": { "type": "spend", "points_per_currency_unit": 1 },
"valid_from": null,
"valid_to": null,
"created_at": "2026-07-07T09:00:05.000Z"
}
Rules are versioned, append-only: this call always inserts a new row (version is
server-assigned, never accepted from you) — there’s no in-place edit of conditions_json /
effect_json. See the programs and rewards guide for why, and
for the full condition/effect shapes.
7. Activate the program
curl -sS -X PATCH "http://localhost:8787/v1/programs/$PROGRAM_ID" \
-H "x-api-key: $API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "status": "active" }' | jq .
This would answer 422 VALIDATION_FAILED (“program needs an active earn rule”) if step 6 had been
skipped — a program can only go active once it has at least one active earning rule.
8. Create a reward
Rewards, like programs, always start in draft — activate it too, or quoting it later 404s:
curl -sS -X POST http://localhost:8787/v1/rewards \
-H "x-api-key: $API_KEY" \
-H 'Content-Type: application/json' \
-d "{
\"program_id\": \"$PROGRAM_ID\",
\"type\": \"discount\",
\"name\": \"\$5 off\",
\"cost_points\": 15
}" | tee /tmp/reward.json | jq .
export REWARD_ID=$(jq -r '.id' /tmp/reward.json)
curl -sS -X PATCH "http://localhost:8787/v1/rewards/$REWARD_ID" \
-H "x-api-key: $API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "status": "active" }' | jq .
Quoting a draft (or otherwise-not-active) reward fails 404 NOT_FOUND (“active reward
required”) — see the programs and rewards guide for the full
reward-type and status model.
9. Enroll a member
POST /v1/members resolves-or-creates: send one or more identifiers, and either an existing
member matching any of them comes back (200, created: false, nothing mutated), or a new one is
made (201, created: true). Phone identifiers must be E.164:
curl -sS -X POST http://localhost:8787/v1/members \
-H "x-api-key: $API_KEY" \
-H 'Content-Type: application/json' \
-d "{
\"program_id\": \"$PROGRAM_ID\",
\"identifiers\": [{ \"type\": \"phone\", \"value\": \"+15550002222\" }]
}" | tee /tmp/member.json | jq .
export MEMBER_ID=$(jq -r '.id' /tmp/member.json)
{
"id": "01J...",
"program_id": "01J...",
"status": "active",
"identifiers": [{ "type": "phone", "value_masked": "+15••••2222" }],
"created": true
}
Enrolling the exact same identifier again later replays the resolve path (200, created: false)
instead of creating a duplicate — there’s deliberately no Idempotency-Key here, since enrollment
never touches the ledger. If the identifiers you send resolve to more than one existing
member, the request fails 409 VALIDATION_FAILED rather than silently picking one; see the
programs and rewards guide for why.
Everything from here on is unchanged regardless of how you got here: record a sale, check the balance, quote and commit a redemption.
10. Record a sale (earn points)
Money is minor units (integer cents) + an ISO-4217 currency code. Every write to the ledger
requires an Idempotency-Key header: the same key + the same body replays the original response
byte-for-byte (Idempotency-Replay: true, stored 48h); the same key with a different body is a
409 IDEMPOTENCY_CONFLICT.
member takes exactly one of id or identifier — this example identifies the member by
phone, the same way a POS integration would:
curl -sS http://localhost:8787/v1/transactions \
-H "x-api-key: $API_KEY" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-4471' \
-d '{
"source_system": "till",
"external_id": "order-4471",
"occurred_at": "2026-07-06T12:00:00Z",
"currency": "USD",
"gross": 1500,
"net": 1500,
"member": { "identifier": { "type": "phone", "value": "+15550002222" } }
}' | tee /tmp/txn.json | jq .
export MEMBER_ID=$(jq -r '.member_id' /tmp/txn.json)
{
"transaction_id": "01J...",
"member_id": "01J...",
"earned": 15,
"balance": 15,
"rule_id": "01J...",
"replayed": false
}
program_id was omitted above — that’s only valid while your tenant has exactly one active
program; pass it explicitly once you have more than one (422 VALIDATION_FAILED tells you when
it’s required). If you already know the member’s internal id, "member": { "id": "..." } works
identically — the identifier form above just mirrors how a real till/connector call looks (see the
generic connector guide for the async, POS-webhook-shaped version of
the same thing).
Retry the exact same request (same Idempotency-Key, same body) and you’ll get the identical
response back with an Idempotency-Replay: true header — no double-earn.
11. Check the balance
curl -sS "http://localhost:8787/v1/members/$MEMBER_ID/balance" -H "x-api-key: $API_KEY" | jq .
{ "balance": 15, "expiring": [{ "points": 15, "expires_at": "2027-07-06T12:00:00.000Z" }] }
expiring lists earn buckets that have an expiry policy, soonest first — points redeem FIFO out
of the oldest bucket first, so this is also a preview of the order upcoming redemptions will draw
down.
12. Quote a redemption
Redemption is two-phase on purpose: quote prices the reward and checks the balance without
touching the ledger; commit re-verifies the balance inside the same DB transaction and
actually debits it. Quoting never holds or reserves points — only commit does. $REWARD_ID is
still the one you created and activated in step 8.
curl -sS -X POST http://localhost:8787/v1/redemptions/quote \
-H "x-api-key: $API_KEY" \
-H 'Content-Type: application/json' \
-d "{ \"member_id\": \"$MEMBER_ID\", \"reward_id\": \"$REWARD_ID\" }" \
| tee /tmp/quote.json | jq .
export REDEMPTION_ID=$(jq -r '.id' /tmp/quote.json)
{ "id": "01J...", "points": 15, "expires_at": "2026-07-06T12:05:00.000Z" }
Commit before expires_at or the quote is gone (409 QUOTE_EXPIRED).
13. Commit the redemption
Commits are money-moving, so they require Idempotency-Key too:
curl -sS -X POST "http://localhost:8787/v1/redemptions/$REDEMPTION_ID/commit" \
-H "x-api-key: $API_KEY" \
-H 'Idempotency-Key: redeem-order-4471' | jq .
{ "state": "committed", "ledger_entry_id": "01J...", "balance": 0 }
From here, POST /v1/redemptions/{id}/fulfill marks it handed over, and
POST /v1/redemptions/{id}/cancel reverses a committed-but-not-yet-fulfilled redemption’s ledger
debit. Committing beyond the current balance is a 409 INSUFFICIENT_BALANCE, not a partial
redemption — the check re-runs at commit time even if the quote was fine when it was issued.
Troubleshooting
Every error response is the same envelope: {code, message, details?, trace_id}.
| Status | Code | Usually means |
|---|---|---|
| 401 | UNAUTHORIZED |
Missing/garbage x-api-key, or no session cookie |
| 403 | NO_ACTIVE_ORGANIZATION |
Signed in, but never called organization/set-active (or the cookie cache hasn’t caught up — see step 3) |
| 403 | TOKEN_TYPE_NOT_ALLOWED |
Called a dashboard-only route with an API key, or vice versa |
| 403 | MISSING_PERMISSION |
Your API key doesn’t hold the permission the route requires |
| 404 | NOT_FOUND |
No active program / no member matches that identifier / reward missing or not active — see steps 5–9 |
| 409 | IDEMPOTENCY_CONFLICT |
Reused an Idempotency-Key with a different request body |
| 409 | VALIDATION_FAILED |
Enrollment’s identifiers matched more than one existing member (an unusual status/code pairing, but deliberate) — see step 9 |
| 409 | INSUFFICIENT_BALANCE / QUOTE_EXPIRED / INVALID_STATE |
Ledger-level rejection — re-quote, top up, or check the redemption’s current state |
| 422 | VALIDATION_FAILED |
Body failed schema validation — details.issues has the specifics |
Next steps
- Programs and rewards — the full program lifecycle, earning-rule versioning semantics, and reward types.
- Generic connector — the async, batch-oriented ingestion path a
real POS integration uses instead of
POST /v1/transactions. - Webhook verification — verify the outbound webhooks this platform sends you.
- API reference — every operation, schema, and error code.