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:
| Intent | Landing Page | Use Case |
|---|---|---|
"manage" (default) | /billing — full dashboard | User clicks "Manage subscription" in your app |
"upgrade" | /billing/plans — focused plan selection | User 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\""
}
}
| Scenario | Code | HTTP |
|---|---|---|
| Invalid or missing API key | UNAUTHORIZED | 401 |
API key does not match the requested appSlug | UNAUTHORIZED | 401 |
| Invalid body, bad returnUrl domain | VALIDATION_ERROR | 422 |
| Too many requests | RATE_LIMITED | 429 |
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
| Field | Type | Description |
|---|---|---|
base | object | null | The primary subscription, or null if none |
base.status | string | ACTIVE, PAST_DUE, CANCELED, INCOMPLETE, TRIALING, UNPAID, PAUSED, or EXPIRED |
base.plan.slug | string | Plan identifier (e.g. "pro") |
base.plan.name | string | Display name (e.g. "Aquoris PRO") |
base.plan.interval | string | "month" or "year" |
base.currentPeriodEnd | string | ISO 8601 timestamp — end of current billing period |
base.cancelAtPeriodEnd | boolean | true if the subscription will not renew |
base.source | string | How it was created: CHECKOUT (Stripe), MANUAL (admin-granted), or TRIAL |
addons | array | Add-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": [] }, not404. - Only
ACTIVE,TRIALING, andPAST_DUEsubscriptions are returned.CANCELEDandEXPIREDsubscriptions are excluded from the response. - When
cancelAtPeriodEndistrue, the subscription is still active but will not renew. Grant access untilcurrentPeriodEnd.
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
| Field | Type | Description |
|---|---|---|
slug | string | Stable plan identifier — use this for comparisons, not name |
name | string | Display name for the UI |
priceAmount | integer | Price in smallest currency unit (e.g. 29900 = THB 299.00). Divide by 100 for display. |
currency | string | ISO 4217 code: "thb" or "usd" |
interval | string | "month" or "year" |
trialDays | integer | Free 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
emailor justname— the other field is preserved. But the body cannot be empty (at least one field is required, otherwise you get422). - 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 Event | What Happens |
|---|---|
checkout.session.completed | Creates the subscription and first invoice in the database |
customer.subscription.updated | Syncs status changes, plan changes, and cancellation state |
customer.subscription.deleted | Marks the subscription as CANCELED |
invoice.paid | Creates or updates the invoice record as PAID |
invoice.payment_failed | Creates or updates the invoice record as OPEN |
charge.refunded | Records 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.updatedandsubscription.deletedevents.
Error Handling
| Response | Meaning |
|---|---|
200 | Event processed (or ignored if unhandled type) |
400 | Invalid signature or missing header — Stripe will not retry |
500 | Handler 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
| Code | HTTP Status | Description | Retry? |
|---|---|---|---|
UNAUTHORIZED | 401 | Invalid/missing API key, or key doesn't match appSlug | No — check your API key and app |
CUSTOMER_NOT_FOUND | 404 | Customer does not exist | No |
PLAN_NOT_FOUND | 404 | Plan does not exist | No |
APP_NOT_FOUND | 404 | App does not exist | No |
RATE_LIMITED | 429 | Too many requests | Yes — wait 60s |
VALIDATION_ERROR | 422 | Invalid request body | No — fix the request |
PROVIDER_ERROR | 502 | Payment provider error | Yes — retry with backoff |
INTERNAL_ERROR | 500 | Internal server error | Yes — retry with backoff |
Retry Strategy
For retryable errors (429, 502, 500), use exponential backoff:
- Wait 1 second, retry
- Wait 2 seconds, retry
- Wait 4 seconds, retry
- 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 Status | Meaning |
|---|---|
| 200 | All checks passed |
| 503 | One 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:
| Variable | Description |
|---|---|
DATABASE_URL | PostgreSQL connection string |
SESSION_SECRET | Secret for signing billing session JWTs |
ADMIN_SESSION_SECRET | Secret for signing admin session JWTs |
Required in production (optional in development/test — Stripe features fail gracefully at runtime):
| Variable | Description |
|---|---|
STRIPE_SECRET_KEY | Stripe API key (sk_test_... or sk_live_...) |
STRIPE_WEBHOOK_SECRET | Stripe webhook signing secret (whsec_...) |
Optional:
| Variable | Default | Description |
|---|---|---|
NEXT_PUBLIC_APP_URL | http://localhost:3000 | Public URL for session redirect links |
NEXT_PUBLIC_SENTRY_DSN | (unset) | Sentry DSN for error tracking |
NEXT_PUBLIC_SENTRY_ENVIRONMENT | NODE_ENV | Sentry 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.