Integration Guide

This guide explains how to integrate your Aquoris app with the centralized billing service.

Overview

The billing service provides:

  • Billing sessions — redirect users to manage their subscription
  • Eligibility checks — query whether a user has an active subscription
  • Customer sync — keep customer data up to date
  • Plan listing — dynamically fetch available plans

All server-to-server API calls use per-app API keys via Bearer token authentication.

Authentication

Each app registered in the admin panel has its own unique API key. Include it in the Authorization header:

curl -H "Authorization: Bearer YOUR_APP_API_KEY" \
  https://payment.aquoris.ai/api/v1/plans

API keys are generated automatically when an app is created in the admin panel (Admin → Apps). You can view, copy, and regenerate keys from the app's edit dialog.

Important: The API key identifies the calling app. When creating billing sessions, the appSlug in the request body must match the app that owns the API key — otherwise you'll get a 401.

Rate Limits

The API allows 100 requests per minute per app. When the limit is exceeded, the API returns 429 with the RATE_LIMITED error code.

There are no X-RateLimit-* headers — if you receive a 429, wait at least 60 seconds before retrying.

Creating a Billing Session

When a user wants to manage their subscription, create a billing session and redirect them:

const response = await fetch('https://payment.aquoris.ai/api/v1/sessions', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.PAYMENT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    externalUserId: user.id, // Your app's user ID (max 256 characters)
    appSlug: 'vega', // Your app slug (registered in admin panel)
    user: {
      email: user.email, // Optional, creates or updates the customer record
      name: user.name, // Optional, max 1024 characters
    },
  }),
})

const { url } = await response.json()
// Redirect the user's browser to `url`

Response:

{
  "url": "https://payment.aquoris.ai/session/eyJhbGciOiJIUzI1NiJ9..."
}

The response URL contains a signed JWT valid for 15 minutes. Once the user visits the URL, a session cookie is set that lasts 2 hours. Sessions cannot be reused after the URL token expires.

The user will be redirected to the billing service where they can:

  • Browse and subscribe to plans
  • View their current subscription status
  • Manage their subscription (upgrade, downgrade, cancel)
  • View invoice history

After they're done, they click the "Back to app" link to return.

Session Intent

The intent parameter controls where the user lands:

IntentLanding PageUse Case
"manage" (default)/billing — full dashboardUser clicks "Manage subscription" in your app
"upgrade"/billing/plans — focused plan selectionUser clicks "Upgrade" on a feature gate or paywall

When using "upgrade" intent, you can also pass planSlug to pre-select a specific plan:

body: JSON.stringify({
  externalUserId: user.id,
  appSlug: 'vega',
  intent: 'upgrade',
  planSlug: 'pro', // Pre-selects and highlights the PRO plan
})

After a successful upgrade checkout, the user sees a confirmation page and is automatically redirected back to your app.

Return URL

By default, the "Back to app" link uses the return URL registered with your app in the admin panel. You can override it per session:

body: JSON.stringify({
  externalUserId: user.id,
  appSlug: 'vega',
  returnUrl: 'https://vega.aquoris.ai/settings/billing',
})

Domain restriction: The returnUrl domain must match your app's registered domain, or be a subdomain of it. For example, if your app's registered URL is https://vega.aquoris.ai, then https://app.vega.aquoris.ai/settings is allowed but https://evil.com is rejected with a 422.

Session Errors

{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "API key does not belong to app \"other-app\""
  }
}
ScenarioCodeHTTP
Invalid or missing API keyUNAUTHORIZED401
API key does not match the requested appSlugUNAUTHORIZED401
Invalid body, bad returnUrl domainVALIDATION_ERROR422
Too many requestsRATE_LIMITED429

Checking Eligibility

To check if a user has an active subscription (e.g., for feature gating):

const response = await fetch(
  `https://payment.aquoris.ai/api/v1/customers/${user.id}/subscription`,
  { headers: { Authorization: `Bearer ${process.env.PAYMENT_API_KEY}` } },
)

const data = await response.json()

Response when user has an active subscription:

{
  "base": {
    "status": "ACTIVE",
    "plan": {
      "slug": "pro",
      "name": "Aquoris PRO",
      "interval": "month"
    },
    "currentPeriodEnd": "2026-05-06T00:00:00.000Z",
    "cancelAtPeriodEnd": false,
    "source": "CHECKOUT"
  },
  "addons": []
}

Response when user has no subscription (or doesn't exist):

{
  "base": null,
  "addons": []
}

Field Reference

FieldTypeDescription
baseobject | nullThe primary subscription, or null if none
base.statusstringACTIVE, PAST_DUE, CANCELED, INCOMPLETE, TRIALING, UNPAID, PAUSED, or EXPIRED
base.plan.slugstringPlan identifier (e.g. "pro")
base.plan.namestringDisplay name (e.g. "Aquoris PRO")
base.plan.intervalstring"month" or "year"
base.currentPeriodEndstringISO 8601 timestamp — end of current billing period
base.cancelAtPeriodEndbooleantrue if the subscription will not renew
base.sourcestringHow it was created: CHECKOUT (Stripe), MANUAL (admin-granted), or TRIAL
addonsarrayAdd-on subscriptions (currently unused, always [])

Feature Gating Examples

Basic access check:

const hasAccess = data.base?.status === 'ACTIVE' || data.base?.status === 'TRIALING'

Plan-specific gating:

const hasPro = hasAccess && data.base?.plan.slug === 'pro'

Graceful cancellation handling:

if (data.base?.cancelAtPeriodEnd) {
  // Still has access, but subscription won't renew
  // Show "Your plan expires on {currentPeriodEnd}" instead of blocking
}

Important notes:

  • This endpoint always returns 200 — even if the customer doesn't exist. A non-existent customer returns { "base": null, "addons": [] }, not 404.
  • Only ACTIVE, TRIALING, and PAST_DUE subscriptions are returned. CANCELED and EXPIRED subscriptions are excluded from the response.
  • When cancelAtPeriodEnd is true, the subscription is still active but will not renew. Grant access until currentPeriodEnd.

Polling Guidance

For feature gating on each request, call this endpoint server-side and cache the result for 1–5 minutes using your framework's caching layer. The subscription state changes infrequently (checkout, cancel, period end), so aggressive polling is unnecessary.

// Next.js example — cache for 60 seconds
const data = await fetch(url, {
  headers: { Authorization: `Bearer ${secret}` },
  next: { revalidate: 60 },
}).then((r) => r.json())

Do not call this endpoint from the browser — the API key would be exposed. Always call from server-side code.

Listing Plans

To display pricing information in your app:

const response = await fetch('https://payment.aquoris.ai/api/v1/plans', {
  headers: { Authorization: `Bearer ${process.env.PAYMENT_API_KEY}` },
})

const { plans } = await response.json()

Response:

{
  "plans": [
    {
      "slug": "free",
      "name": "Aquoris Free",
      "priceAmount": 0,
      "currency": "thb",
      "interval": "month",
      "trialDays": 0
    },
    {
      "slug": "pro",
      "name": "Aquoris PRO",
      "priceAmount": 29900,
      "currency": "thb",
      "interval": "month",
      "trialDays": 0
    }
  ]
}

Field Reference

FieldTypeDescription
slugstringStable plan identifier — use this for comparisons, not name
namestringDisplay name for the UI
priceAmountintegerPrice in smallest currency unit (e.g. 29900 = THB 299.00). Divide by 100 for display.
currencystringISO 4217 code: "thb" or "usd"
intervalstring"month" or "year"
trialDaysintegerFree trial days (0 if no trial)

Only active plans are returned. Inactive plans (disabled by admin) are excluded.

Syncing Customer Data

When a user updates their profile in your app, sync the changes:

const response = await fetch(`https://payment.aquoris.ai/api/v1/customers/${user.id}`, {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${process.env.PAYMENT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: user.newEmail,
    name: user.newName, // max 1024 characters
  }),
})

const customer = await response.json()

Response:

{
  "id": "cm5abc123...",
  "externalUserId": "user_123",
  "email": "alice@example.com",
  "name": "Alice Smith"
}

Behavior:

  • Upsert: If the customer doesn't exist, they are created. If they do exist, the provided fields are updated. This is idempotent — calling twice with the same data is safe.
  • Partial update: You can send just email or just name — the other field is preserved. But the body cannot be empty (at least one field is required, otherwise you get 422).
  • Stripe sync: If the customer has a linked Stripe account, the changes are automatically synced to Stripe. If the Stripe sync fails, the DB update still succeeds (non-blocking).
  • Validation: Email must be a valid format. Name must be 1024 characters or less. Invalid values return 422.

This is important for future email sending — stale emails mean undelivered billing notifications.

Webhooks

You do not need to receive webhooks yourself. The billing service receives webhooks from Stripe and updates subscription state internally. Your app should use the eligibility endpoint to check subscription status.

How It Works

When a user completes checkout or a subscription changes, Stripe sends webhook events to POST /api/v1/webhooks/stripe. The billing service handles six event types:

Stripe EventWhat Happens
checkout.session.completedCreates the subscription and first invoice in the database
customer.subscription.updatedSyncs status changes, plan changes, and cancellation state
customer.subscription.deletedMarks the subscription as CANCELED
invoice.paidCreates or updates the invoice record as PAID
invoice.payment_failedCreates or updates the invoice record as OPEN
charge.refundedRecords the refund

Idempotency and Deduplication

All webhook handlers are idempotent. Stripe may retry delivery, and processing the same event twice produces the same result:

  • Event-level deduplication: Each Stripe event has a unique event.id. The billing service records this ID in audit logs and skips events that have already been processed.
  • Data-level idempotency: Subscription and invoice records use upserts, so duplicate events don't create duplicate data.
  • Race protection: Webhook handlers run inside database transactions to prevent races between concurrent subscription.updated and subscription.deleted events.

Error Handling

ResponseMeaning
200Event processed (or ignored if unhandled type)
400Invalid signature or missing header — Stripe will not retry
500Handler failed — Stripe will retry with exponential backoff

If the billing service returns 500, Stripe retries the webhook up to ~16 times over 3 days. The deduplication logic ensures that when the retry eventually succeeds, no duplicate records are created.

Timing

After a user completes Stripe Checkout, there is typically a 1–5 second delay before the webhook fires and the subscription appears in the eligibility endpoint. The billing UI handles this with polling, but if your app checks eligibility immediately after redirect, it may see base: null briefly. Use a short retry with backoff if needed.

Error Handling

All error responses follow a consistent format:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "At least one field (email or name) must be provided"
  }
}

Error Codes

CodeHTTP StatusDescriptionRetry?
UNAUTHORIZED401Invalid/missing API key, or key doesn't match appSlugNo — check your API key and app
CUSTOMER_NOT_FOUND404Customer does not existNo
PLAN_NOT_FOUND404Plan does not existNo
APP_NOT_FOUND404App does not existNo
RATE_LIMITED429Too many requestsYes — wait 60s
VALIDATION_ERROR422Invalid request bodyNo — fix the request
PROVIDER_ERROR502Payment provider errorYes — retry with backoff
INTERNAL_ERROR500Internal server errorYes — retry with backoff

Retry Strategy

For retryable errors (429, 502, 500), use exponential backoff:

  1. Wait 1 second, retry
  2. Wait 2 seconds, retry
  3. Wait 4 seconds, retry
  4. Give up after 3 attempts

For 429 specifically, wait at least 60 seconds before retrying (the rate limit window resets every minute).

Health Check

The billing service exposes a health check endpoint that does not require authentication:

curl https://payment.aquoris.ai/api/health

Response (healthy):

{
  "status": "ok",
  "timestamp": "2026-04-07T12:00:00.000Z",
  "checks": {
    "database": "ok"
  }
}

Response (degraded — database unreachable):

{
  "status": "degraded",
  "timestamp": "2026-04-07T12:00:00.000Z",
  "checks": {
    "database": "error"
  }
}
HTTP StatusMeaning
200All checks passed
503One or more checks failed

Use this endpoint for uptime monitoring (e.g., UptimeRobot, Better Stack). The Docker containers also use it internally for health-based restarts.

Required Environment Variables

The billing service validates environment variables at startup and fails fast if required ones are missing.

Always required:

VariableDescription
DATABASE_URLPostgreSQL connection string
SESSION_SECRETSecret for signing billing session JWTs
ADMIN_SESSION_SECRETSecret for signing admin session JWTs

Required in production (optional in development/test — Stripe features fail gracefully at runtime):

VariableDescription
STRIPE_SECRET_KEYStripe API key (sk_test_... or sk_live_...)
STRIPE_WEBHOOK_SECRETStripe webhook signing secret (whsec_...)

Optional:

VariableDefaultDescription
NEXT_PUBLIC_APP_URLhttp://localhost:3000Public URL for session redirect links
NEXT_PUBLIC_SENTRY_DSN(unset)Sentry DSN for error tracking
NEXT_PUBLIC_SENTRY_ENVIRONMENTNODE_ENVSentry environment label (staging or production)
ENABLE_DEV_TOOLS(unset)Set to true to enable admin dev tools page

Full API Reference

See the interactive API documentation at /docs/api for complete request/response schemas with field descriptions.