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 Origin header 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 any Origin that isn’t in the server’s TRUSTED_ORIGINS list. Send Origin on every auth POST in this guide, set to an origin your server actually trusts (local dev’s .dev.vars.example trusts http://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