Introduction
ProxyOn provides a single REST API to manage subscribers, subscriptions, plans, entitlements, metered usage and invoices on top of Stripe. The subscriber portal is hosted by ProxyOn and the actual payment method is collected by Stripe. Authenticate with a project API key and call the v1 endpoints below.
Welcome to the ProxyOn API documentation. ProxyOn orchestrates Stripe Billing so your application can offer subscription management under its own brand. Card data is collected and updated by Stripe; ProxyOn hosts the rest of the billing experience.
Quick Start
Integrate ProxyOn in four base calls — subscriptions are managed entirely inside the ProxyOn-hosted whitelabel billing portal, so you never build payment or plan-management UI yourself:
- Upsert a subscriber when a user signs up:
POST /api/v1/subscribers. - Open the billing portal for self-service plan selection, changes and cancellation:
POST /api/v1/portal-sessions→ redirect the user to the returnedurl. - Read entitlements to gate features and enforce limits:
GET /api/v1/subscribers/{external_id}/entitlements. - Listen for webhooks (
subscription.*,entitlement.updated,invoice.*) to keep your app in sync.
Report metered usage with POST /api/v1/usage when you bill by consumption.
Base URL
- Production:
https://your-domain.com/api/v1 - Development:
https://proxyon.test/api/v1
Authentication
All API requests require authentication using your Project API Key (pxn_test_* or pxn_live_*). Include it in the Authorization header as a Bearer token.
Rate Limits
- Read operations: 600 requests/minute
- Write operations: 120 requests/minute
- Usage reporting (
proxyon-usagebucket): 1200 requests/minute - Portal sessions (
proxyon-portalbucket): 60 requests/minute
Idempotency
Use Idempotency-Key header for safe retries on write operations. Successful (2xx) and client-error (4xx) responses are cached for 24 hours and replayed on retry; only server errors (5xx) bypass the cache.
Authenticating requests
To authenticate requests, include an Authorization header with the value "Bearer {YOUR_AUTH_KEY}".
All authenticated endpoints are marked with a requires authentication badge in the documentation below.
Send the project API key as Bearer pxntest… or Bearer pxnlive…, or use the X-Project-Api-Key header with the same value.
Subscribers
Manage subscribers in your project
List subscribers
requires authentication
Returns the most recent subscribers in your project, newest first, with
Stripe-style cursor pagination: pass the last item's id as
starting_after to fetch the next page, until has_more is false.
Use the email filter to look one up by address, or type to narrow
the list to users or organizations.
Example request:
curl --request GET \
--get "https://proxyon.teknoza.be/api/v1/subscribers?type=organization&email=user%40example.com&limit=25&starting_after=sbr_01HXYZ" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/subscribers"
);
const params = {
"type": "organization",
"email": "user@example.com",
"limit": "25",
"starting_after": "sbr_01HXYZ",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, List wrapper. `data[]` items follow the Subscriber resource shape; `has_more` signals another page behind the `starting_after` cursor.):
{
"object": "list",
"has_more": false,
"data": [
{
"object": "subscriber",
"id": "sbr_01HXYZ",
"external_id": "user_12345",
"type": "user",
"email": "user@example.com",
"name": "John Doe",
"metadata": [],
"created_at": "2026-05-20T10:00:00+00:00",
"updated_at": "2026-05-20T10:00:00+00:00"
}
]
}
Example response (403, Forbidden — API key lacks the required scope `subscribers:read`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: subscribers:read",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Retrieve a subscriber
requires authentication
Returns the subscriber identified by your application's external_id.
Useful to verify Proxyon has the latest profile information or to fetch
the linked Stripe customer id.
Example request:
curl --request GET \
--get "https://proxyon.teknoza.be/api/v1/subscribers/architecto" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/subscribers/architecto"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200):
{
"object": "subscriber",
"id": "sbr_rx1mhn08f3kxumjnit9w",
"external_id": "usr_ujagszu3nqpx",
"type": "user",
"email": "price.amber@example.org",
"name": "Miss Jazlyn Keebler III",
"metadata": {},
"created_at": "2026-08-11T13:51:37+00:00",
"updated_at": "2026-08-11T13:51:37+00:00"
}
Example response (403, Forbidden — API key lacks the required scope `subscribers:read`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: subscribers:read",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Not Found — the referenced resource does not exist for this project.):
{
"error": {
"type": "not_found",
"message": "The requested resource was not found.",
"doc_url": "https://proxyon.teknoza.be/docs#not_found"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create or update a subscriber
requires authentication
Idempotently upserts a subscriber by external_id. Call this right after
a customer signs up in your application — Proxyon will create the
subscriber on Stripe if needed and mirror the identity locally. Pass an
Idempotency-Key header to safely retry on network errors.
Example request:
curl --request POST \
"https://proxyon.teknoza.be/api/v1/subscribers" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Idempotency-Key: idem_01JXY..." \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"external_id\": \"user_12345\",
\"type\": \"user\",
\"email\": \"user@example.com\",
\"name\": \"John Doe\",
\"currency\": \"EUR\",
\"metadata\": {
\"plan\": \"premium\"
}
}"
const url = new URL(
"https://proxyon.teknoza.be/api/v1/subscribers"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Idempotency-Key": "idem_01JXY...",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"external_id": "user_12345",
"type": "user",
"email": "user@example.com",
"name": "John Doe",
"currency": "EUR",
"metadata": {
"plan": "premium"
}
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200):
{
"object": "subscriber",
"id": "sbr_1rbq6dehld2s0owrkkpe",
"external_id": "usr_4g2uspo1lkmj",
"type": "user",
"email": "wleuschke@example.net",
"name": "Mina Bauch",
"metadata": {},
"created_at": "2026-08-11T13:51:38+00:00",
"updated_at": "2026-08-11T13:51:38+00:00"
}
Example response (403, Forbidden — API key lacks the required scope `subscribers:write`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: subscribers:write",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update a subscriber
requires authentication
Partially updates a subscriber. Only the fields you send are changed; existing email, type, name and metadata are preserved. Stripe is reconciled automatically.
Example request:
curl --request PATCH \
"https://proxyon.teknoza.be/api/v1/subscribers/architecto" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Idempotency-Key: idem_01JXY..." \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"type\": \"organization\",
\"email\": \"newemail@example.com\",
\"name\": \"Jane Doe\",
\"currency\": \"EUR\",
\"metadata\": {
\"plan\": \"basic\"
}
}"
const url = new URL(
"https://proxyon.teknoza.be/api/v1/subscribers/architecto"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Idempotency-Key": "idem_01JXY...",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"type": "organization",
"email": "newemail@example.com",
"name": "Jane Doe",
"currency": "EUR",
"metadata": {
"plan": "basic"
}
};
fetch(url, {
method: "PATCH",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200):
{
"object": "subscriber",
"id": "sbr_gkt4rjckw0zby5nt83cn",
"external_id": "usr_u6v0mp5kgyhv",
"type": "user",
"email": "ferne52@example.com",
"name": "Mr. Carey Smitham",
"metadata": {},
"created_at": "2026-08-11T13:51:38+00:00",
"updated_at": "2026-08-11T13:51:38+00:00"
}
Example response (403, Forbidden — API key lacks the required scope `subscribers:write`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: subscribers:write",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Not Found — the referenced resource does not exist for this project.):
{
"error": {
"type": "not_found",
"message": "The requested resource was not found.",
"doc_url": "https://proxyon.teknoza.be/docs#not_found"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete a subscriber
requires authentication
Soft-deletes a subscriber. Active subscriptions are cancelled at the period end by Stripe; the local row is kept for historic invoice access.
Example request:
curl --request DELETE \
"https://proxyon.teknoza.be/api/v1/subscribers/architecto" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Idempotency-Key: idem_01JXY..." \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/subscribers/architecto"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Idempotency-Key": "idem_01JXY...",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());Example response (204, Subscriber soft-deleted.):
Empty response
Example response (403, Forbidden — API key lacks the required scope `subscribers:write`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: subscribers:write",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Not Found — the referenced resource does not exist for this project.):
{
"error": {
"type": "not_found",
"message": "The requested resource was not found.",
"doc_url": "https://proxyon.teknoza.be/docs#not_found"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Subscriptions
Subscription lifecycle and listing
List a subscriber's subscriptions
requires authentication
Returns every subscription for the subscriber, active ones first. Use this to display a billing history page or to find the subscription id to cancel, swap or resume.
Example request:
curl --request GET \
--get "https://proxyon.teknoza.be/api/v1/subscribers/user_12345/subscriptions" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/subscribers/user_12345/subscriptions"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, List of the subscriber's subscriptions, active subscriptions first. `data[]` items follow the Subscription resource shape.):
{
"object": "list",
"data": [
{
"object": "subscription",
"id": "sub_01HXYZ",
"project_id": "1",
"subscriber_id": "1",
"plan_id": "1",
"status": "active",
"current_period_start": "2026-05-01T00:00:00+00:00",
"current_period_end": "2026-06-01T00:00:00+00:00",
"trial_ends_at": null,
"cancel_at": null,
"canceled_at": null,
"metadata": [],
"created_at": "2026-05-01T00:00:00+00:00",
"updated_at": "2026-05-01T00:00:00+00:00"
}
]
}
Example response (403, Forbidden — API key lacks the required scope `subscriptions:read`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: subscriptions:read",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Not Found — the referenced resource does not exist for this project.):
{
"error": {
"type": "not_found",
"message": "The requested resource was not found.",
"doc_url": "https://proxyon.teknoza.be/docs#not_found"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Retrieve a subscription
requires authentication
Returns the subscription identified by its public id (sub_*) or numeric
id. Includes the resolved plan, prices and current billing period bounds.
Example request:
curl --request GET \
--get "https://proxyon.teknoza.be/api/v1/subscriptions/sub_01JXY..." \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/subscriptions/sub_01JXY..."
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200):
{
"object": "subscription",
"id": "sub_rf29b606bhup8zr9rujq",
"project_id": "51",
"subscriber_id": "21",
"plan_id": "16",
"status": "active",
"current_period_start": "2026-08-01T00:00:00+00:00",
"current_period_end": "2026-09-01T00:00:00+00:00",
"trial_ends_at": null,
"cancel_at": null,
"canceled_at": null,
"metadata": {},
"created_at": "2026-08-11T13:51:37+00:00",
"updated_at": "2026-08-11T13:51:37+00:00"
}
Example response (403, Forbidden — API key lacks the required scope `subscriptions:read`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: subscriptions:read",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Not Found — the referenced resource does not exist for this project.):
{
"error": {
"type": "not_found",
"message": "The requested resource was not found.",
"doc_url": "https://proxyon.teknoza.be/docs#not_found"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Plans
List and retrieve plans in your project
List plans
requires authentication
Returns the active plans in your project, each with its active prices
and the currency they are billed in. Use this to render a pricing page in
your own UI; subscribers pick and pay for a plan inside the ProxyOn
billing portal (POST /api/v1/portal-sessions).
Example request:
curl --request GET \
--get "https://proxyon.teknoza.be/api/v1/plans?currency=USD" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/plans"
);
const params = {
"currency": "USD",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, List of active plans. `data[]` items follow the Plan resource shape, each with its catalog `features[]` and active `prices[]`. Use `features[]` to render what a plan includes without creating a subscriber, and `is_default` to resolve the project's default/free tier — never infer it from `key`.):
{
"object": "list",
"data": [
{
"object": "plan",
"id": "1",
"key": "pro_monthly",
"locale": "en",
"name": "Pro Monthly",
"description": "Professional plan billed monthly.",
"status": "active",
"is_default": false,
"metadata": {
"highlight": "true"
},
"created_at": "2026-05-01T00:00:00+00:00",
"updated_at": "2026-05-01T00:00:00+00:00",
"features": [
{
"key": "projects",
"name": "Projects",
"type": "quota",
"value": 10
},
{
"key": "sso",
"name": "SSO",
"type": "boolean",
"value": true
},
{
"key": "ai_tokens",
"name": "AI tokens",
"type": "metered",
"value": 100000
}
],
"prices": []
}
]
}
Example response (403, Forbidden — API key lacks the required scope `plans:read`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: plans:read",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Retrieve a plan by key
requires authentication
Returns a single plan and its active prices, identified by the
human-readable plan key (e.g. pro, basic-monthly). Prefer this over
numeric ids when wiring up your pricing UI.
Example request:
curl --request GET \
--get "https://proxyon.teknoza.be/api/v1/plans/basic-monthly" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/plans/basic-monthly"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, A single plan with its catalog `features[]` and active `prices[]`.):
{
"object": "plan",
"id": "1",
"key": "pro_monthly",
"locale": "en",
"name": "Pro Monthly",
"description": "Professional plan billed monthly.",
"status": "active",
"is_default": false,
"metadata": {
"highlight": "true"
},
"created_at": "2026-05-01T00:00:00+00:00",
"updated_at": "2026-05-01T00:00:00+00:00",
"features": [
{
"key": "projects",
"name": "Projects",
"type": "quota",
"value": 10
},
{
"key": "sso",
"name": "SSO",
"type": "boolean",
"value": true
}
],
"prices": []
}
Example response (403, Forbidden — API key lacks the required scope `plans:read`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: plans:read",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Not Found — the referenced resource does not exist for this project.):
{
"error": {
"type": "not_found",
"message": "The requested resource was not found.",
"doc_url": "https://proxyon.teknoza.be/docs#not_found"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Portal
Whitelabel billing portal sessions
Create a billing portal session
requires authentication
Issues a single-use magic link to the project's whitelabel ProxyOn
billing portal so the subscriber can choose or change a plan, update their
payment method, view invoices and cancel on their own. Redirect them to
the returned url; when they leave the portal they are sent back to
return_url.
Treat url as opaque: its host is your verified custom domain when one is
configured, otherwise the ProxyOn shared host. Do not parse or rebuild it.
Example request:
curl --request POST \
"https://proxyon.teknoza.be/api/v1/portal-sessions" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Idempotency-Key: idem_01JXY..." \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"subscriber_external_id\": \"user_12345\",
\"return_url\": \"https:\\/\\/app.example.com\\/account\"
}"
const url = new URL(
"https://proxyon.teknoza.be/api/v1/portal-sessions"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Idempotency-Key": "idem_01JXY...",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"subscriber_external_id": "user_12345",
"return_url": "https:\/\/app.example.com\/account"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200, Portal session created. Redirect the subscriber to the returned URL.):
{
"url": "https://billing.example.com/portal/abcdef0123456789",
"expires_at": "2026-01-15T10:15:00+00:00"
}
Example response (403, Forbidden — API key lacks the required scope `portal:write`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: portal:write",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Domain error: `subscriber_not_found`.):
{
"error": {
"type": "subscriber_not_found",
"message": "Subscriber not found for this project.",
"doc_url": "https://proxyon.teknoza.be/docs#subscriber_not_found"
}
}
Example response (409, Domain error: `stripe_not_connected`.):
{
"error": {
"type": "stripe_not_connected",
"message": "Connect and verify your Stripe account before using checkout or billing portal.",
"doc_url": "https://proxyon.teknoza.be/docs#stripe_not_connected"
}
}
Example response (422, Domain error: `subscriber_wrong_project`.):
{
"error": {
"type": "subscriber_wrong_project",
"message": "Subscriber does not belong to the authenticated project.",
"doc_url": "https://proxyon.teknoza.be/docs#subscriber_wrong_project"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Entitlements
Resolve subscriber feature entitlements
Resolve a subscriber's entitlements
requires authentication
Returns the resolved feature flags and limits the subscriber currently
has access to, based on their active subscription's plan. Cache this on
the client for 60 seconds — Proxyon returns a strong ETag so you can
revalidate with If-None-Match for a cheap 304 Not Modified.
An entry states what the plan grants, never what is left: Proxyon does
not know how many devices or seats you have created. Count usage in your
own application and compare it against value (-1 means unlimited).
subscription is null for a subscriber with no live subscription, and
plan.is_default marks the project's free tier — never infer the free
plan from plan.key, which each project chooses for itself.
Example request:
curl --request GET \
--get "https://proxyon.teknoza.be/api/v1/subscribers/user_12345/entitlements" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "If-None-Match: \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/subscribers/user_12345/entitlements"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"If-None-Match": ""e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Returned with `Cache-Control: private, max-age=60` and a strong `ETag` of the JSON body. Send that ETag back as `If-None-Match` for a cheap revalidation.):
{
"object": "entitlements",
"data": {
"subscriber_id": 42,
"project_id": 7,
"subscription_id": 311,
"plan": {
"key": "pro",
"name": "Pro",
"description": "Professional plan.",
"is_default": false,
"interval_unit": "month",
"interval_count": 1
},
"seats": 5,
"subscription": {
"status": "active",
"cancel_at": null,
"current_period_end": "2026-07-01T00:00:00+00:00",
"trial_ends_at": null
},
"entries": [
{
"key": "projects",
"type": "quota",
"value": 5,
"period_start": "2026-06-01T00:00:00+00:00",
"period_end": "2026-07-01T00:00:00+00:00",
"meter_key": "project_events"
},
{
"key": "sso",
"type": "boolean",
"value": true,
"period_start": "2026-06-01T00:00:00+00:00",
"period_end": "2026-07-01T00:00:00+00:00",
"meter_key": null
}
],
"generated_at": "2026-06-10T12:00:00+00:00"
}
}
Example response (304, Entitlement set unchanged since the ETag in `If-None-Match`.):
Example response (403, Forbidden — API key lacks the required scope `entitlements:read`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: entitlements:read",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Not Found — the referenced resource does not exist for this project.):
{
"error": {
"type": "not_found",
"message": "The requested resource was not found.",
"doc_url": "https://proxyon.teknoza.be/docs#not_found"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Usage
Metered usage records and summaries
Get a subscriber's usage summary
requires authentication
Aggregates the subscriber's recorded usage for a single meter within the
current billing period. Returns 404 no_active_subscription when the
subscriber has no active subscription to report against.
Example request:
curl --request GET \
--get "https://proxyon.teknoza.be/api/v1/subscribers/user_12345/usage?meter_key=api_calls&period=current" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/subscribers/user_12345/usage"
);
const params = {
"meter_key": "api_calls",
"period": "current",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Aggregated usage for the subscriber's active subscription, scoped to the current billing period.):
{
"object": "usage_summary",
"meter_key": "api_calls",
"quantity": 1284,
"period_start": "2026-05-01T00:00:00+00:00",
"period_end": "2026-06-01T00:00:00+00:00",
"subscription_public_id": "sub_01JXY..."
}
Example response (403, Forbidden — API key lacks the required scope `usage:read`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: usage:read",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Subscriber has no active subscription to summarise usage against.):
{
"error": {
"type": "no_active_subscription",
"message": "Subscriber has no active subscription.",
"doc_url": "/docs#no_active_subscription"
}
}
Example response (404, Not Found — the referenced resource does not exist for this project.):
{
"error": {
"type": "not_found",
"message": "The requested resource was not found.",
"doc_url": "https://proxyon.teknoza.be/docs#not_found"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Record metered usage
requires authentication
Records a usage event against the subscriber's active metered subscription item. Use this whenever the subscriber consumes a billable feature (API call, transcoded video, sent email, …).
Pass a stable idempotency_key per logical event — the same key always
returns the same record, so it is safe to retry on network errors. The
recorded quantity is forwarded to Stripe and reflected in the next
invoice for the subscriber's current billing period.
Example request:
curl --request POST \
"https://proxyon.teknoza.be/api/v1/usage" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Idempotency-Key: idem_01JXY..." \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"subscriber_external_id\": \"user_12345\",
\"meter_key\": \"api_calls\",
\"quantity\": 10,
\"idempotency_key\": \"01JXY7QGJ8KZ...\",
\"recorded_at\": \"2026-01-15T10:00:00Z\"
}"
const url = new URL(
"https://proxyon.teknoza.be/api/v1/usage"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Idempotency-Key": "idem_01JXY...",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"subscriber_external_id": "user_12345",
"meter_key": "api_calls",
"quantity": 10,
"idempotency_key": "01JXY7QGJ8KZ...",
"recorded_at": "2026-01-15T10:00:00Z"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200):
{
"object": "usage_record",
"id": "4",
"meter_id": "5",
"quantity": 81,
"recorded_at": "2026-08-11T13:51:38+00:00",
"idempotency_key": "usage_70ceuelwr4wf5rsdtv4cbfab",
"created_at": "2026-08-11T13:51:38+00:00",
"updated_at": "2026-08-11T13:51:38+00:00"
}
Example response (403, Forbidden — API key lacks the required scope `usage:write`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: usage:write",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Domain error: `no_active_subscription`.):
{
"error": {
"type": "no_active_subscription",
"message": "This subscriber has no active subscription.",
"doc_url": "https://proxyon.teknoza.be/docs#no_active_subscription"
}
}
Example response (422, Domain error: `usage_invalid_quantity`.):
{
"error": {
"type": "usage_invalid_quantity",
"message": "Usage quantity must be a positive integer.",
"doc_url": "https://proxyon.teknoza.be/docs#usage_invalid_quantity"
}
}
Example response (422, Domain error: `usage_correction_requires_sum_aggregation`.):
{
"error": {
"type": "usage_correction_requires_sum_aggregation",
"message": "Usage corrections require sum aggregation.",
"doc_url": "https://proxyon.teknoza.be/docs#usage_correction_requires_sum_aggregation"
}
}
Example response (422, Domain error: `usage_recorded_at_in_future`.):
{
"error": {
"type": "usage_recorded_at_in_future",
"message": "recorded_at must be at or before the current server time.",
"doc_url": "https://proxyon.teknoza.be/docs#usage_recorded_at_in_future"
}
}
Example response (422, Domain error: `usage_recorded_at_too_old`.):
{
"error": {
"type": "usage_recorded_at_too_old",
"message": "recorded_at is older than the accepted backdating window.",
"doc_url": "https://proxyon.teknoza.be/docs#usage_recorded_at_too_old"
}
}
Example response (422, Domain error: `usage_subscription_not_active`.):
{
"error": {
"type": "usage_subscription_not_active",
"message": "Subscription is not active; usage cannot be recorded.",
"doc_url": "https://proxyon.teknoza.be/docs#usage_subscription_not_active"
}
}
Example response (422, Domain error: `usage_no_metered_item_for_meter`.):
{
"error": {
"type": "usage_no_metered_item_for_meter",
"message": "No metered subscription item is bound to this meter.",
"doc_url": "https://proxyon.teknoza.be/docs#usage_no_metered_item_for_meter"
}
}
Example response (422, Domain error: `plan_cross_project_resource`.):
{
"error": {
"type": "plan_cross_project_resource",
"message": "Plan resource belongs to a different project.",
"doc_url": "https://proxyon.teknoza.be/docs#plan_cross_project_resource"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Invoices
List and retrieve invoices
List a subscriber's invoices
requires authentication
Returns invoices for the subscriber in reverse chronological order, including hosted invoice URLs and PDF links generated by Stripe. Ideal for a "Billing history" tab in your application.
Example request:
curl --request GET \
--get "https://proxyon.teknoza.be/api/v1/subscribers/user_12345/invoices" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/subscribers/user_12345/invoices"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, List of invoices for the subscriber, newest first. `data[]` items follow the Invoice resource shape.):
{
"object": "list",
"data": [
{
"object": "invoice",
"id": "inv_01HXYZ",
"subscription_id": "1",
"currency_id": "1",
"currency_code": "USD",
"status": "paid",
"amount_due": 2900,
"amount_paid": 2900,
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_xxx/inv_xxx",
"pdf_url": "https://pay.stripe.com/invoice/xxx/pdf",
"period_start": "2026-05-01T00:00:00+00:00",
"period_end": "2026-06-01T00:00:00+00:00",
"paid_at": "2026-05-02T10:00:00+00:00",
"created_at": "2026-05-01T00:00:00+00:00",
"updated_at": "2026-05-02T10:00:00+00:00"
}
]
}
Example response (403, Forbidden — API key lacks the required scope `invoices:read`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: invoices:read",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Not Found — the referenced resource does not exist for this project.):
{
"error": {
"type": "not_found",
"message": "The requested resource was not found.",
"doc_url": "https://proxyon.teknoza.be/docs#not_found"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Retrieve an invoice
requires authentication
Returns a single invoice by its public id (in_*) or numeric id,
including the hosted Stripe URL and PDF download link.
Example request:
curl --request GET \
--get "https://proxyon.teknoza.be/api/v1/invoices/in_01JXY..." \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/invoices/in_01JXY..."
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200):
{
"object": "invoice",
"id": "in_biz1pbpsve1sud0mf1fh",
"subscription_id": "13",
"currency_id": "1",
"status": "open",
"amount_due": 49055,
"amount_paid": 0,
"hosted_invoice_url": null,
"pdf_url": null,
"period_start": "2026-08-01T00:00:00+00:00",
"period_end": "2026-09-01T00:00:00+00:00",
"paid_at": null,
"created_at": "2026-08-11T13:51:37+00:00",
"updated_at": "2026-08-11T13:51:37+00:00"
}
Example response (403, Forbidden — API key lacks the required scope `invoices:read`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: invoices:read",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Not Found — the referenced resource does not exist for this project.):
{
"error": {
"type": "not_found",
"message": "The requested resource was not found.",
"doc_url": "https://proxyon.teknoza.be/docs#not_found"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Webhook Endpoints
Manage outbound webhook endpoints and discover subscribable events
List subscribable webhook event types
requires authentication
Machine-readable catalog of every event type an endpoint can subscribe to. Use it to render a subscription picker or to validate your stored event list against the current platform catalog instead of hardcoding event names.
Example request:
curl --request GET \
--get "https://proxyon.teknoza.be/api/v1/webhook-events" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/webhook-events"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Every subscribable event type with its human-readable label.):
{
"data": [
{
"type": "subscriber.created",
"label": "Subscriber created"
},
{
"type": "subscription.created",
"label": "Subscription created"
},
{
"type": "entitlement.updated",
"label": "Entitlements updated"
}
]
}
Example response (403, Forbidden — API key lacks the required scope `webhooks:read`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: webhooks:read",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List webhook endpoints
requires authentication
Returns every webhook endpoint configured for your project, with their current status and which event types they subscribe to. Use this to render a webhooks settings page in your dashboard.
Example request:
curl --request GET \
--get "https://proxyon.teknoza.be/api/v1/webhook-endpoints" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/webhook-endpoints"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, List of webhook endpoints. `data[]` items follow the WebhookEndpoint resource shape.):
{
"object": "list",
"data": [
{
"object": "webhook_endpoint",
"id": 1,
"url": "https://example.test/webhooks/proxyon",
"description": "Production webhook",
"status": "active",
"event_types": [
"subscription.created",
"invoice.paid"
],
"consecutive_failures": 0,
"last_success_at": "2026-05-20T09:00:00+00:00",
"last_failure_at": null,
"signing_secret_grace_ends_at": null,
"created_at": "2026-05-01T00:00:00+00:00",
"updated_at": "2026-05-20T09:00:00+00:00"
}
]
}
Example response (403, Forbidden — API key lacks the required scope `webhooks:read`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: webhooks:read",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Retrieve a webhook endpoint
requires authentication
Returns a single webhook endpoint, including its current delivery status and last-success / failure counters.
Example request:
curl --request GET \
--get "https://proxyon.teknoza.be/api/v1/webhook-endpoints/16" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/webhook-endpoints/16"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200):
{
"object": "webhook_endpoint",
"id": 8,
"url": "https://example.com/webhooks/bfc53181-d647-36b2-9080-f9c2b76006f4",
"description": "Qui commodi incidunt iure odit.",
"status": "active",
"event_types": [],
"consecutive_failures": 0,
"last_success_at": null,
"last_failure_at": null,
"signing_secret_grace_ends_at": null,
"created_at": "2026-08-11T13:51:38+00:00",
"updated_at": "2026-08-11T13:51:38+00:00"
}
Example response (403, Forbidden — API key lacks the required scope `webhooks:read`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: webhooks:read",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Not Found — the referenced resource does not exist for this project.):
{
"error": {
"type": "not_found",
"message": "The requested resource was not found.",
"doc_url": "https://proxyon.teknoza.be/docs#not_found"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create a webhook endpoint
requires authentication
Registers a new HTTPS URL to receive Proxyon webhook deliveries. The
response contains a one-time-visible signing secret — store it
securely on your server and use it to verify the Proxyon-Signature
header on every delivery.
Example request:
curl --request POST \
"https://proxyon.teknoza.be/api/v1/webhook-endpoints" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Idempotency-Key: idem_01JXY..." \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"url\": \"https:\\/\\/app.example.com\\/webhooks\\/proxyon\",
\"event_types\": [
\"subscription.created\",
\"invoice.paid\"
],
\"description\": \"Production receiver\"
}"
const url = new URL(
"https://proxyon.teknoza.be/api/v1/webhook-endpoints"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Idempotency-Key": "idem_01JXY...",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"url": "https:\/\/app.example.com\/webhooks\/proxyon",
"event_types": [
"subscription.created",
"invoice.paid"
],
"description": "Production receiver"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (201, Webhook endpoint created. `signing_secret` is exposed **once** — store it securely; subsequent reads of this endpoint will omit it.):
{
"object": "webhook_endpoint",
"id": 1,
"url": "https://app.example.com/webhooks/proxyon",
"description": "Production receiver",
"status": "active",
"event_types": [
"subscription.created",
"invoice.paid"
],
"consecutive_failures": 0,
"last_success_at": null,
"last_failure_at": null,
"signing_secret": "whsec_01JXY7Z3K2M5N6P8Q9R0S1T2U3",
"signing_secret_grace_ends_at": null,
"created_at": "2026-05-20T10:00:00+00:00",
"updated_at": "2026-05-20T10:00:00+00:00"
}
Example response (403, Forbidden — API key lacks the required scope `webhooks:write`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: webhooks:write",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update a webhook endpoint
requires authentication
Updates the URL, description, subscribed events or active status of a
webhook endpoint. Setting status="disabled" pauses deliveries without
losing the signing secret.
Example request:
curl --request PATCH \
"https://proxyon.teknoza.be/api/v1/webhook-endpoints/16" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Idempotency-Key: idem_01JXY..." \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"url\": \"http:\\/\\/www.bailey.biz\\/quos-velit-et-fugiat-sunt-nihil-accusantium-harum.html\",
\"event_types\": [
\"architecto\"
],
\"description\": \"Eius et animi quos velit et.\",
\"status\": \"architecto\"
}"
const url = new URL(
"https://proxyon.teknoza.be/api/v1/webhook-endpoints/16"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Idempotency-Key": "idem_01JXY...",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"url": "http:\/\/www.bailey.biz\/quos-velit-et-fugiat-sunt-nihil-accusantium-harum.html",
"event_types": [
"architecto"
],
"description": "Eius et animi quos velit et.",
"status": "architecto"
};
fetch(url, {
method: "PATCH",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200):
{
"object": "webhook_endpoint",
"id": 9,
"url": "https://example.com/webhooks/a4855dc5-0acb-33c3-b921-f4291f719ca0",
"description": null,
"status": "active",
"event_types": [],
"consecutive_failures": 0,
"last_success_at": null,
"last_failure_at": null,
"signing_secret_grace_ends_at": null,
"created_at": "2026-08-11T13:51:38+00:00",
"updated_at": "2026-08-11T13:51:38+00:00"
}
Example response (403, Forbidden — API key lacks the required scope `webhooks:write`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: webhooks:write",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Not Found — the referenced resource does not exist for this project.):
{
"error": {
"type": "not_found",
"message": "The requested resource was not found.",
"doc_url": "https://proxyon.teknoza.be/docs#not_found"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete a webhook endpoint
requires authentication
Permanently removes a webhook endpoint. In-flight deliveries are aborted; pending retries are discarded.
Example request:
curl --request DELETE \
"https://proxyon.teknoza.be/api/v1/webhook-endpoints/16" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Idempotency-Key: idem_01JXY..." \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/webhook-endpoints/16"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Idempotency-Key": "idem_01JXY...",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());Example response (204, Webhook endpoint deleted.):
Empty response
Example response (403, Forbidden — API key lacks the required scope `webhooks:write`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: webhooks:write",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Not Found — the referenced resource does not exist for this project.):
{
"error": {
"type": "not_found",
"message": "The requested resource was not found.",
"doc_url": "https://proxyon.teknoza.be/docs#not_found"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Rotate the signing secret
requires authentication
Generates a new signing secret for the webhook endpoint and returns it
in the response (one-time view). The previous secret continues to sign
deliveries for a 24-hour grace period so you can roll out the new one
without dropping events.
Example request:
curl --request POST \
"https://proxyon.teknoza.be/api/v1/webhook-endpoints/16/rotate" \
--header "Authorization: Bearer {YOUR_AUTH_KEY}" \
--header "Idempotency-Key: idem_01JXY..." \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/webhook-endpoints/16/rotate"
);
const headers = {
"Authorization": "Bearer {YOUR_AUTH_KEY}",
"Idempotency-Key": "idem_01JXY...",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response => response.json());Example response (200, Rotated. The new `signing_secret` is returned **once** in plaintext. The previous secret remains valid for a grace window (see `signing_secret_grace_ends_at`).):
{
"object": "webhook_endpoint",
"id": 1,
"url": "https://app.example.com/webhooks/proxyon",
"description": "Production receiver",
"status": "active",
"event_types": [
"subscription.created",
"invoice.paid"
],
"consecutive_failures": 0,
"last_success_at": "2026-05-20T09:00:00+00:00",
"last_failure_at": null,
"signing_secret": "whsec_NEW01JXY7Z3K2M5N6P8Q9R0S1T2",
"signing_secret_grace_ends_at": "2026-05-21T10:00:00+00:00",
"created_at": "2026-05-01T00:00:00+00:00",
"updated_at": "2026-05-20T10:00:00+00:00"
}
Example response (403, Forbidden — API key lacks the required scope `webhooks:write`.):
{
"error": {
"type": "insufficient_scope",
"message": "API key lacks required scope: webhooks:write",
"doc_url": "https://proxyon.teknoza.be/docs#insufficient_scope"
}
}
Example response (404, Not Found — the referenced resource does not exist for this project.):
{
"error": {
"type": "not_found",
"message": "The requested resource was not found.",
"doc_url": "https://proxyon.teknoza.be/docs#not_found"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Health
Liveness probe for the public API
Liveness probe
Returns 200 {"status":"ok"} whenever the API process is up and serving
traffic. It requires no authentication and touches no datastore, so it
lets you tell apart two failure modes that otherwise look identical: a
200 here next to a 5xx on an authenticated call means the outage is
downstream (database / cache), not your API key; no response at all means
the service itself is unreachable.
Example request:
curl --request GET \
--get "https://proxyon.teknoza.be/api/v1/health" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://proxyon.teknoza.be/api/v1/health"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200):
{
"status": "ok"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Integration
This guide walks you through a complete Proxyon integration — what you do in the Proxyon admin panel and what you do in your own application — so a brand-new customer can sign up, pick a plan, pay through Stripe, consume a metered feature and cancel through the hosted billing portal.
Architecture overview
Proxyon is a shared subscription, entitlement and billing layer that sits between your application and Stripe. Your app stays focused on its product logic; Proxyon owns the plan catalog, quota enforcement, hosted billing portal, webhook fan-out and the Stripe sync lifecycle.
There are four actors:
- Project owner (you) — owns a Proxyon
projectand a project-scoped API key. - Subscriber — your tenant or user, identified by an
external_idyou supply: simply the primary id of whatever you bind it to in your own app (your user id, tenant/org id, …). Proxyon stores it as an opaque string — there is no required format or prefix. Example values likeuser_12345andorg_987in these docs are illustrative only; a bare42or a UUID work just as well — send whatever id your application already uses. - Stripe — holds the
stripe_customer_id,subscription_idand payment method. - Proxyon — links them together, exposes a single REST API and pushes webhooks.
Your App Proxyon API Stripe
| | |
|-- POST /api/v1/subscribers ------>| |
| (upsert by external_id) | |
| | |
|-- POST /api/v1/portal-sessions -->| |
|<--------- portal url -------------| |
| (redirect customer to the hosted ProxyOn billing portal, where |
| they pick a plan; ProxyOn drives Stripe Checkout) |
| |--- Stripe Checkout ------------->|
| |<-- customer.subscription.created |
|<-- subscription.created ----------| |
|<-- entitlement.updated -----------| |
| (verify signature, flush cache) |
| | |
|-- GET /api/v1/subscribers/{id}/entitlements |
|<-- features + limits (ETag) ------| |
| |
|-- POST /api/v1/usage ------------>|---- Stripe usage record -------->|
| (per metered event) |
Before you start
Decide a few things up front; they shape the rest of the integration:
- Subscription model. Subscriptions attach to whatever
external_idyou choose. Pick the granularity once:external_id = tenant.id(organization-billed) orexternal_id = user.id(user-billed). - Stack. All Proxyon calls are server-to-server. Pick where you call from (Laravel backend, Next.js route handlers / server actions, Node service, …). Never put
pxn_*API keys in a public browser bundle. - Sync strategy. Cache
GET /api/v1/subscribers/{external_id}/entitlementsfor ~60s withETag+If-None-Matchrevalidation. Listen to theentitlement.updatedwebhook to invalidate the cache immediately on plan changes. - Currency, trial and catalog shape. Decide which features are boolean flags, which are quotas and which are text; declare any consumption dimensions you bill on as meters. Pick a default currency per plan and whether new subscriptions get a trial.
- Stripe readiness. Have a Stripe account ready. You connect it to Proxyon over Stripe Connect (OAuth) — there are no secret keys to copy. Proxyon stores only the connected account id (
acct_...) and calls Stripe on your behalf.
Part 1 — Setting up Proxyon (the panel side)
Do these once per project. The result is: a project, an API key, a connected Stripe account, a plan catalog and a webhook endpoint pointing at your app.
- Create the project. In the Proxyon admin panel, go to Projects → New project, give it a name, and note the
projectslug. Everything below is scoped to this project. - Generate a Project API key. Go to API Keys → New key, select the scopes you need (use
["*"]while developing), and copy thepxn_test_*(orpxn_live_*) value. The key is shown only once — store it in your secret manager immediately. - Connect a Stripe account. Open Stripe settings inside the project and click Connect with Stripe. You are redirected to Stripe's OAuth consent screen; approve it and you return to Proxyon with the account linked. Proxyon stores only the connected account id (
acct_...) — never your secret keys — and marks the credentialVerifiedonce Stripe reports the account can accept charges. Inbound Stripe events (subscription, invoice, account updates) are delivered automatically to Proxyon's platform Connect webhook; there is no per-project signing secret to paste. - Build the plan catalog. Under Features, declare each entitlement (
device_countas a quota,pro_brandingas a boolean flag, etc. — a feature is boolean, quota or text). Under Meters, declare each consumption dimension you bill on (api_calls,ai_credits, …) with its aggregation. Under Plans, build packages (free,pro, …), attach feature limits and add prices — a metered price binds exactly one meter. Plans, meters and prices sync to the connected Stripe account automatically when you save (a manual Sync to Stripe action is also available). - Register an outbound webhook endpoint. Under Webhook Endpoints → New endpoint, point Proxyon at the HTTPS URL in your app that will receive events (e.g.
https://app.example.com/webhooks/proxyon). Subscribe at minimum tosubscription.*,invoice.*andentitlement.*. Copy the returnedwhsec_pxn_*signing secret — shown only once. This is the secret your app uses to verify outbound deliveries (Part 2). - (Optional) Rotate the signing secret. Call
POST /api/v1/webhook-endpoints/{id}/rotate(or use the panel button) when you need to roll the secret. The old secret keeps signing deliveries for a 24-hour grace period so you can deploy the new one with zero downtime.
Part 2 — Setting up your application
Now wire your app to the API. All snippets below assume the env vars from step 1.
1. Environment variables
PROXYON_BASE_URL=https://proxyon.test/api/v1
PROXYON_API_KEY=pxn_test_xxxxxxxxxxxxxxxxxxxx
PROXYON_WEBHOOK_SECRET=whsec_pxn_xxxxxxxx
2. HTTP client
Laravel — register a macro once, then call Http::proxyon() everywhere:
use Illuminate\Support\Facades\Http;
Http::macro('proxyon', fn () => Http::baseUrl(config('services.proxyon.base_url'))
->withToken(config('services.proxyon.api_key'))
->acceptJson()
->timeout(10)
->retry(2, 200, throw: false));
Next.js / Node — a tiny fetch wrapper that injects auth + base URL:
export async function proxyon(path: string, init: RequestInit = {}) {
const res = await fetch(`${process.env.PROXYON_BASE_URL}${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.PROXYON_API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json',
...init.headers,
},
});
if (!res.ok) throw new Error(`Proxyon ${res.status} ${await res.text()}`);
return res.json();
}
3. Upsert a subscriber on sign-up
Mirror every new tenant/user to Proxyon right after they register in your app. The call is idempotent on external_id; pass an Idempotency-Key header to make retries safe on network blips.
Http::proxyon()
->withHeaders(['Idempotency-Key' => "subscriber-create-{$tenant->id}"])
->post('/subscribers', [
'external_id' => (string) $tenant->id,
'type' => 'organization',
'name' => $tenant->name,
'email' => $tenant->owner_email,
])->throw();
4. Entitlement cache with ETag revalidation
GET /api/v1/subscribers/{external_id}/entitlements returns the resolved features for the subscriber's active plan and an ETag. Cache for ~60s and revalidate with If-None-Match to get a cheap 304 Not Modified.
public function entitlementsFor(string $externalId): array
{
return Cache::remember("proxyon:entitlements:{$externalId}", 60, function () use ($externalId) {
$etag = Cache::get("proxyon:entitlements:{$externalId}:etag");
$res = Http::proxyon()
->withHeaders(array_filter(['If-None-Match' => $etag]))
->get("/subscribers/{$externalId}/entitlements");
if ($res->status() === 304) {
return Cache::get("proxyon:entitlements:{$externalId}:body");
}
Cache::put("proxyon:entitlements:{$externalId}:etag", $res->header('ETag'), 3600);
Cache::put("proxyon:entitlements:{$externalId}:body", $res->json('data'), 3600);
return $res->json('data');
});
}
The response shape is:
{
"object": "entitlements",
"data": {
"subscriber_id": 42,
"project_id": 7,
"subscription_id": 311,
"plan": { "key": "pro", "name": "Pro", "description": "Professional plan.", "is_default": false, "interval_unit": "month", "interval_count": 1 },
"seats": 5,
"subscription": { "status": "active", "cancel_at": null, "current_period_end": "2026-07-01T00:00:00+00:00", "trial_ends_at": null },
"entries": [
{ "key": "device_count", "type": "quota", "value": 25, "period_start": "2026-06-01T00:00:00+00:00", "period_end": "2026-07-01T00:00:00+00:00", "meter_key": "device_events" },
{ "key": "pro_branding", "type": "boolean", "value": true, "period_start": "2026-06-01T00:00:00+00:00", "period_end": "2026-07-01T00:00:00+00:00", "meter_key": null }
],
"generated_at": "2026-06-10T12:00:00+00:00"
}
}
Read data.entries[]. Each entry states what the plan grants, never what is
left. Proxyon does not know how many devices, seats or bytes you have created —
count that yourself and compare it against value. (Metered consumption Proxyon
does track is served by GET /api/v1/subscribers/{external_id}/usage, below.
When a quota feature is linked to a meter, its entry carries that meter's key in
meter_key — use it to join the entitlement with the usage summary instead of
guessing at key conventions; it is null when no meter is linked.)
type |
value |
|---|---|
quota |
the per-period limit; -1 means unlimited |
boolean |
true / false |
text |
the configured string |
period_start / period_end come from the active subscription's billing cycle.
Three fields decide most of your billing UI:
plan.is_defaultmarks the project's free tier. Never infer "this is the free plan" fromplan.key— every project names its plans itself.subscriptionis the lifecycle summary (statusis one oftrialing,active,past_due,paused,canceled,unpaid,incomplete,incomplete_expired). It isnullonly when the subscriber has no live subscription at all. Use it for banners; do not gate on it — Proxyon already decides what apast_dueortrialingsubscriber is entitled to and reflects that inentries.entriesis the whole enforcement surface.
A subscriber always has a plan. When you upsert a subscriber, Proxyon opens a
subscription on the project's default plan; when a paid subscription lapses, it
reopens that default plan. So entries: [] with plan: null means one thing
only: the project has not marked any plan as default. Fix that in the panel
rather than special-casing it in your code.
Discovering features without a subscriber. GET /api/v1/plans and GET /api/v1/plans/{key} return each plan's features[] with the same key / type / value shape used above, plus the plan-level is_default flag. Use this to render pricing pages ("what does this plan include") and to resolve the default/free plan's limits before any subscriber exists — resolve it via is_default, never by matching plan.key strings.
5. Quota gate + metered usage
Resolve entitlements once per request, then enforce limits before doing the billable action. For metered consumption, record usage after the action succeeds.
The enforcement model maps to the entry type: boolean → gate on value, quota → count locally and compare against value, text → read the configured string. Metering is a separate axis: it is not an entitlement entry but a meter declared on the plan's metered price — you report consumption against its meter_key (below), independent of the entitlement entries.
$entries = collect($this->entitlementsFor($tenant->id)['entries']);
// quota → compare your own local count against the entry's `value` (the limit)
$deviceLimit = $entries->firstWhere('key', 'device_count')['value'] ?? 0;
abort_if($tenant->devices()->count() >= $deviceLimit, 403, 'Device quota exhausted.');
// run the billable action ...
$device = $tenant->devices()->create([...]);
// then report metered consumption — usage is recorded against a *meter*
// (declared on the plan's metered price), identified by its `meter_key`
Http::proxyon()
->withHeaders(['Idempotency-Key' => "usage-device-{$device->id}"])
->post('/usage', [
'subscriber_external_id' => (string) $tenant->id,
'meter_key' => 'api_calls',
'quantity' => 1,
'idempotency_key' => "usage-device-{$device->id}",
])->throw();
The idempotency_key field is mandatory on POST /api/v1/usage — pass a value that is unique per logical event (e.g. the database id of the thing you just created). Replaying the same key returns the original record instead of double-billing.
6. Billing portal redirect
Subscriptions are managed entirely inside the ProxyOn-hosted whitelabel billing portal — your only billing UI is a single "Manage billing" (or "Upgrade") button. The portal lets the subscriber pick or change a plan, update their payment method (handled by Stripe) and cancel. You never build a pricing page checkout or collect card details yourself.
The subscriber must already exist (see step 2), then:
// "Manage billing" / "Upgrade" button → ProxyOn billing portal
$res = Http::proxyon()->post('/portal-sessions', [
'subscriber_external_id' => (string) $tenant->id,
'return_url' => route('billing.index'),
])->throw();
return redirect($res->json('url'));
The returned url is a single-use magic link that expires in 15 minutes. When the subscriber leaves the portal they are redirected back to your return_url. The real subscription state always arrives on your webhook endpoint (subscription.*) — never trust the redirect alone.
7. Inbound webhook receiver
Proxyon signs every delivery with Proxyon-Signature: t=<unix>,v1=<hex hmac_sha256("{t}.{body}", secret)>. Reject anything with a stale timestamp (more than 300s old) or a bad signature.
The header may carry more than one
v1. While a signing secret is being rotated, Proxyon signs the body with both the new and the old secret and sends both signatures. Your verifier must accept the body if anyv1matches — reading only the first or the last one turns the grace window into an outage.
Laravel route + verifier:
Route::post('/webhooks/proxyon', function (Request $request) {
$timestamp = null;
$signatures = [];
foreach (explode(',', $request->header('Proxyon-Signature', '')) as $segment) {
[$key, $value] = array_pad(explode('=', $segment, 2), 2, '');
match (trim($key)) {
't' => $timestamp = (int) trim($value),
'v1' => $signatures[] = trim($value),
default => null,
};
}
abort_if($timestamp === null || abs(time() - $timestamp) > 300, 401, 'Stale signature');
$expected = hash_hmac('sha256', "{$timestamp}.".$request->getContent(), config('services.proxyon.webhook_secret'));
$verified = array_reduce($signatures, fn (bool $ok, string $s): bool => $ok || hash_equals($expected, $s), false);
abort_unless($verified, 401, 'Invalid signature');
$event = $request->json()->all();
// Idempotency: drop replays. Proxyon-Delivery-Id is unique per attempt,
// the envelope id is unique per logical event — dedupe on the latter.
if (! Cache::add("proxyon:event:{$event['id']}", 1, now()->addDay())) {
return response()->noContent();
}
ProcessProxyonEvent::dispatch($event);
return response()->noContent();
});
Next.js route handler:
import crypto from 'node:crypto';
export async function POST(req: Request) {
const body = await req.text();
const parts = (req.headers.get('proxyon-signature') ?? '').split(',').map(p => p.split('=', 2));
const t = parts.find(([k]) => k.trim() === 't')?.[1];
const signatures = parts.filter(([k]) => k.trim() === 'v1').map(([, v]) => v.trim());
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return new Response('stale', { status: 401 });
const expected = crypto.createHmac('sha256', process.env.PROXYON_WEBHOOK_SECRET!)
.update(`${t}.${body}`).digest('hex');
const verified = signatures.some(
s => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(s)),
);
if (!verified) return new Response('bad sig', { status: 401 });
const event = JSON.parse(body);
// dedupe on event.id, fan out to your queue, then:
return new Response(null, { status: 204 });
}
In the handler, dispatch a queued job and return 2xx within a few seconds.
Anything that is not a 2xx, 5xx, 429 or 408 is treated as a permanent
rejection and never retried (see Retry Policy) — so a 401 from
a wrong secret silently drops the event and, after 100 of them, disables your
endpoint. Watch your delivery success-rate.
Flush your local entitlement cache on entitlement.updated:
$externalId = $event['data']['object']['subscriber']['external_id'];
Cache::forget("proxyon:entitlements:{$externalId}");
Cache::forget("proxyon:entitlements:{$externalId}:etag");
End-to-end flow (sequence)
A walkthrough of one full customer lifecycle:
- Tenant signs up in your app. You create the local row, then call
POST /api/v1/subscriberswithexternal_id = tenant.id. If the project has a default plan marked, Proxyon opens a subscription on it right away; otherwise the subscriber starts with no subscription (entries: [],plan: null). - Customer opens "Billing". Your app calls
GET /api/v1/subscribers/{external_id}/entitlements(cached 60s) and renders the current plan + feature limits. - Customer clicks "Upgrade". You call
POST /api/v1/portal-sessionsand redirect to the returned portal URL. The customer picks theproplan inside the hosted ProxyOn billing portal, which drives Stripe Checkout for payment. - Customer pays. Stripe fires
customer.subscription.createdto Proxyon's Stripe-webhook listener. - Proxyon updates state and fans out. It links the Stripe subscription to your subscriber, recomputes entitlements and POSTs
subscription.created+entitlement.updatedto your webhook endpoint. - Your webhook receiver verifies the signature, dedupes on
event.id, flushes the entitlement cache and (optionally) pushes a "Plan upgraded" notification through your realtime channel. - Customer consumes a metered feature. Your service handles the action, then calls
POST /api/v1/usagewithmeter_key=ai_credits,quantity=1and a stableidempotency_key. Proxyon forwards the usage to Stripe. - Customer cancels via the portal. You generate a portal session with
POST /api/v1/portal-sessions; the customer cancels inside the hosted ProxyOn billing portal. Proxyon → your webhook deliverssubscription.canceled, your gate now denies the metered action on the next request.
Testing & rollout
Run these scenarios before flipping to live keys:
- Subscriber upsert is idempotent. Call
POST /api/v1/subscriberstwice with the sameexternal_idand the sameIdempotency-Key. Assert only one row exists and both responses match. - Usage records dedupe. Replay
POST /api/v1/usagewith the sameidempotency_key. Stripe should be charged once, not twice. - Webhook signature failure. Mutate the body of a delivery and assert your receiver returns
401. Mutate the timestamp by > 300s and assert401. - Event replay. POST the same webhook payload twice to your receiver. Assert the second one is dropped (the
Cache::addidempotency guard returns false). - ETag revalidation. Hit
GET /api/v1/subscribers/{id}/entitlementstwice in 30s withIf-None-Match. The second call should be a304 Not Modifiedwith no body. - Quota gate. Push a tenant past its
device_countlimit and assert your gate throws403before any local DB write. - Expired subscription. Cancel a subscription, flush the cache, attempt a metered action — your gate should refuse it.
Going to production
- Rotate to a
pxn_live_*API key with the minimum scopes you actually use. - Rotate the webhook signing secret with
POST /api/v1/webhook-endpoints/{id}/rotate; deploy the new secret within the 24h grace window. - Serve the webhook receiver over HTTPS only. Reject signatures older than 300s.
- Treat every Proxyon write as needing an
Idempotency-Key. Pick keys derived from your own primary keys, not random UUIDs you forget about. - Monitor: webhook failure rate, average end-to-end latency from Stripe event → cache flush, and
5xxrate on Proxyon calls. Alert on any sustained webhook failures — Stripe state can drift if your receiver is down for hours.Error Responses
ProxyOn returns conventional HTTP status codes. Every non-2xx response — including 422 validation failures — uses the same JSON error envelope (Content-Type: application/json) shown below. Switch on error.type; the human-readable error.message is localized and may change.
HTTP Status Codes
| Code | Meaning |
|---|---|
| 200 | OK — request succeeded |
| 201 | Created — resource created |
| 204 | No Content — request succeeded, no body |
| 304 | Not Modified — If-None-Match ETag matched (entitlements) |
| 400 | Bad Request — malformed payload or unsupported value |
| 401 | Unauthorized — invalid, missing or expired API key |
| 403 | Forbidden — API key lacks the required scope or project is suspended |
| 404 | Not Found — resource does not exist in this project |
| 409 | Conflict — Stripe not connected, idempotency-key payload mismatch, etc. |
| 422 | Unprocessable Entity — validation failure |
| 429 | Too Many Requests — rate limit exceeded |
| 500 | Internal Server Error — please retry, contact support if persistent |
Health Check & Telling Outages Apart from Auth Errors
GET /api/v1/health is an unauthenticated liveness probe. It needs no API key, touches no datastore, and returns 200 {"status":"ok"} whenever the API process is serving traffic.
Use it to disambiguate two failure modes that otherwise look the same:
- Authentication problems fail fast with
401before any business logic runs — a missing, malformed, unknown, revoked or expired key returns401directly, never500. - A
5xxon an authenticated call whileGET /api/v1/healthstill returns200means the outage is downstream (database / cache), not your credentials. Retry with backoff; the request was not your fault. - No response from
GET /api/v1/healthat all means the service itself is unreachable — fail over or alert, don't rotate keys.
curl -s https://your-domain.com/api/v1/health
# {"status":"ok"}
How Your Application Must React To Each Failure
This is the single most important table in this document, and the easiest thing to get wrong. Billing is on the critical path of your product: if reading entitlements throws, something still has to happen when a customer clicks "Add device".
The tempting shortcut is to wrap every Proxyon call in one catch and let the
action through — "never block a paying customer for our billing provider's
downtime". That is correct for an outage and catastrophic for a
misconfiguration. A revoked API key and a database failure are not the same
event. ProxyOn separates them for you: authentication and authorization always
fail with 401/403 before any business logic runs, and never with 5xx. So
if you collapse both into "Proxyon is down → grant everything", the day someone
rotates the API key without redeploying, every user on your platform silently
receives an unlimited plan. Nothing crashes. Nothing pages you. You find out on
the invoice.
| What happened | Status / error.type |
What your application should do |
|---|---|---|
Proxyon is unreachable, times out, or returns 5xx |
5xx, connection error |
Fail open. Serve the last entitlements you cached; if you have none, let the action through. This is a genuine outage: GET /api/v1/health also fails or the process is down. |
| Rate limit exhausted | 429 rate_limit_exceeded |
Back off for Retry-After seconds, then retry. Treat as a temporary outage in the meantime — never as "no entitlements". |
| API key missing, malformed, unknown, revoked, expired, wrong environment | 401 unauthenticated |
Never fail open. This is your misconfiguration and it will not heal on its own. Serve the last known entitlements, otherwise fall back to your project's default (free) plan. Raise a critical alert. |
| Project suspended | 403 project_suspended |
Same as above. Someone must act; granting unlimited access hides that. |
| API key lacks a scope | 403 insufficient_scope |
Same as above. Fix the key's scopes. |
| Subscriber does not exist yet | 404 subscriber_not_found |
Expected on a user's first entitlement read. Upsert the subscriber (POST /api/v1/subscribers) and retry once. Do not retry on any other 404. |
| Subscriber has no active subscription | 404 no_active_subscription (usage endpoints only) |
The subscriber has no metered subscription to report against. Entitlements never return this — a subscriber always has a plan. |
Two rules follow from the table, and they are what separate a correct integration from a plausible one:
- Fail open on
5xx/timeouts. Fail safe on401/403. "Fail safe" means the default plan, not unlimited. - Keep a long-lived fallback copy of each subscriber's last successful entitlement response (24 hours is a reasonable floor). It converts almost every failure above into a non-event, and it is the only thing standing between a Redis restart during an incident and a platform-wide free tier.
Error Envelope Shape
{
"error": {
"type": "subscriber_not_found",
"message": "Subscriber not found in this project.",
"doc_url": "https://your-domain.com/docs#subscriber_not_found",
"request_id": "req_01JXYZ..."
}
}
The request_id is also echoed back as the X-Request-Id response header for log correlation. You may pre-populate it by sending your own X-Request-Id request header.
422 — Validation Errors
Validation failures use the same error envelope with type: "validation_error". The param field names the first field that failed:
{
"error": {
"type": "validation_error",
"message": "The email field is required.",
"param": "email"
}
}
Common Error Types
error.type |
Status | Trigger |
|---|---|---|
unauthenticated |
401 | API key missing, malformed or unknown |
project_suspended |
403 | Project is suspended by a ProxyOn admin |
insufficient_scope |
403 | API key lacks the scope for this route (e.g. subscribers:write) |
subscriber_not_found |
404 | external_id does not match any subscriber in the project |
no_active_subscription |
404 | Subscriber has no active subscription (usage / summary endpoints) |
subscription_no_items |
422 | Subscription has no items to attribute usage to |
not_found |
404 | Generic resource lookup failed (plan price, invoice, etc.) |
stripe_not_connected |
409 | Project has no verified Stripe credentials |
idempotency_key_in_use |
400 | Same Idempotency-Key reused with a different payload within the 24h window |
rate_limit_exceeded |
429 | Per-project rate-limit bucket exhausted (see Rate Limits) |
internal_error |
500 | Unhandled exception. Contact support with request_id |
Domain Error Types
Business-rule failures use the same envelope. Each endpoint's documentation below lists the domain errors it can return; the full set is:
error.type |
Status | Endpoint(s) |
|---|---|---|
subscriber_wrong_project |
422 | POST /api/v1/portal-sessions |
usage_invalid_quantity |
422 | POST /api/v1/usage |
usage_correction_requires_sum_aggregation |
422 | POST /api/v1/usage |
usage_subscription_not_active |
422 | POST /api/v1/usage |
usage_no_metered_item_for_meter |
422 | POST /api/v1/usage |
usage_recorded_at_in_future |
422 | POST /api/v1/usage |
usage_recorded_at_too_old |
422 | POST /api/v1/usage |
Plan-selection and checkout errors (e.g. already subscribed, plan not active) are handled inside the ProxyOn-hosted billing portal UI — your integration never needs to handle them via the API.
Webhook Error Handling
For outbound webhooks ProxyOn delivers to your endpoints, see Webhooks. Transient failures (5xx, 429, 408, network errors) retry with exponential backoff; any other 4xx is a permanent rejection and is not retried. Endpoints auto-disable after 100 consecutive failures.
Webhooks
ProxyOn delivers outbound webhook events to your configured endpoints whenever subscriber, subscription, invoice, entitlement or plan state changes.
Configuring Endpoints
Configure endpoints in Customer Dashboard → Settings → Webhooks.
- Multiple endpoints per project supported.
- Each endpoint has its own encrypted signing secret (revealed only once at creation/rotation).
- Secret rotation supports a 24-hour grace window — both the new and previous secrets are accepted by the verifier during the window.
- Failed deliveries are retried with exponential backoff.
- Endpoints auto-disable after 100 consecutive failures (a
database+mailnotification is sent). - Replying with HTTP 410 Gone disables the endpoint immediately.
Event Types
The full catalog of dispatchable events. Subscribe to specific event types per endpoint.
| Event Type | Carries external_id |
Description |
|---|---|---|
subscriber.created |
✅ | New subscriber created |
subscriber.updated |
✅ | Subscriber details changed |
subscriber.deleted |
✅ | Subscriber removed |
subscriber.restored |
✅ | Soft-deleted subscriber restored |
subscription.created |
✅ | Subscription started |
subscription.updated |
✅ | Subscription changed (plan swap, period roll, etc.) |
subscription.canceled |
✅ | Subscription canceled (immediate or end-of-period) |
subscription.trial_will_end |
✅ | Trial ends within 3 days |
invoice.paid |
✅ | Invoice successfully paid |
invoice.payment_failed |
✅ | Invoice payment failed |
invoice.payment_action_required |
✅ | Payment needs customer action (3DS) |
entitlement.updated |
✅ | Subscriber entitlements recalculated |
plan.created |
— | Plan published |
plan.updated |
— | Plan attributes changed |
plan.feature_changed |
— | A plan's feature values changed |
plan.archived |
— | Plan archived |
Subscribing to entitlement.updated alone is enough to keep an entitlement
cache correct: Proxyon emits it for every change that alters what a subscriber
is entitled to, including plan edits made in the panel. The subscription.* and
invoice.* events exist for the UX you build around billing (banners, receipts,
dunning), not for gating.
Request Headers
Every webhook POST includes these headers:
| Header | Description |
|---|---|
Content-Type |
application/json |
User-Agent |
Proxyon/2.0 |
Proxyon-Signature |
t=<unix_timestamp>,v1=<hex_sha256> (see Signature Verification) |
Proxyon-Event-Id |
UUID matching the body's id field |
Proxyon-Event-Type |
Same value as body's event_type |
Proxyon-Delivery-Id |
Unique per delivery attempt; use for idempotency |
Payload Schema
These shapes are generated from the
contracts/v1/webhooks/*.jsonfixtures in the Proxyon repository and asserted bytests/Feature/Contract/WebhookContractTest.php. If a shape below ever disagrees with a real delivery, that is a bug in Proxyon.
All events share the same envelope:
{
"id": "9f8c2c8a-3a4d-4f1c-9b0e-2c5f7e1a9d20",
"event_type": "subscription.created",
"created_at": "2026-05-03T10:30:00+00:00",
"livemode": true,
"project": {
"slug": "acme"
},
"data": {
"object": {
"id": "sub_01HX9...",
"status": "active",
"stripe_subscription_id": "sub_1NyZ...",
"subscriber": { "id": "sbr_01HX9...", "external_id": "user_123" },
"plan": { "key": "pro-monthly" },
"current_period_start": "2026-05-03T10:30:00+00:00",
"current_period_end": "2026-06-03T10:30:00+00:00",
"trial_ends_at": null,
"cancel_at": null,
"canceled_at": null,
"metadata": {},
"previous_attributes": null
}
}
}
Every subscriber-scoped event carries data.object.subscriber.external_id — the
id you supplied. That is the only field you need to route an event to a local
user; never store or look up Proxyon's internal sbr_... id.
data.object shape varies per event_type:
subscriber.*
The subscriber is the object here, so external_id sits at the top level
rather than under a nested subscriber key.
{
"id": "sbr_01HX9...",
"external_id": "user_123",
"email": "alice@example.com",
"name": "Alice",
"stripe_customer_id": "cus_NyZ...",
"metadata": {}
}
subscription.*
See envelope example above. subscription.canceled adds canceled_immediately: bool. subscription.updated populates previous_attributes when fields change.
invoice.paid / invoice.payment_failed / invoice.payment_action_required
{
"id": "inv_01HX9...",
"status": "paid",
"stripe_invoice_id": "in_1NyZ...",
"subscription": { "id": "sub_01HX9..." },
"subscriber": { "id": "sbr_01HX9...", "external_id": "user_123" },
"amount_due": 2900,
"amount_paid": 2900,
"hosted_invoice_url": "https://invoice.stripe.com/...",
"period_start": "2026-05-03T10:30:00+00:00",
"period_end": "2026-06-03T10:30:00+00:00",
"paid_at": "2026-05-03T10:31:12+00:00"
}
invoice.payment_failed additionally includes failure_reason: string|null.
Invoices without a subscription are never dispatched.
entitlement.updated
Carries the full recomputed entitlement set, so a receiver can either drop its cache or write the snapshot straight through.
{
"subscriber": { "id": "sbr_01HX9...", "external_id": "user_123" },
"subscription_id": 311,
"reason": "subscription.created",
"entries": [
{
"key": "device",
"type": "quota",
"value": 5,
"period_start": "2026-05-03T10:30:00+00:00",
"period_end": "2026-06-03T10:30:00+00:00"
}
],
"generated_at": "2026-05-03T10:30:00+00:00"
}
reason is the change that triggered the recomputation: subscription.created,
subscription.updated, subscription.canceled, plan.updated or
plan.feature_changed.
plan.*
plan.* events describe the catalog, not a subscriber, so they carry no
external_id and you cannot use them for cache invalidation. You do not need
to: editing a plan fans out one entitlement.updated per affected subscriber.
{
"key": "pro-monthly",
"name": "Pro (Monthly)",
"status": "active",
"interval_unit": "month",
"interval_count": 1,
"trial_days": 14,
"sort_order": 10,
"metadata": {}
}
plan.updated adds previous_attributes: object|null.
Signature Verification
ProxyOn signs the exact request body bytes with HMAC-SHA256 keyed on the endpoint's signing secret.
- Header:
Proxyon-Signature - Format:
t=<unix_timestamp>,v1=<hex_sha256> - Signed string:
"{timestamp}.{body}"(the literal string<ts>.<raw_body>) - Tolerance:
±300 secondsbetween headertand your server clock (replay protection).
⚠️ The signature covers
"{ts}.{body}", not the body alone. Signing only the body will always fail verification.
PHP
<?php
declare(strict_types=1);
$secret = 'whsec_...'; // your endpoint signing secret
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_PROXYON_SIGNATURE'] ?? '';
// A rotation grace window sends one `v1` per accepted secret — collect them all.
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $segment) {
[$k, $v] = array_pad(explode('=', $segment, 2), 2, '');
match (trim($k)) {
't' => $timestamp = (int) trim($v),
'v1' => $signatures[] = trim($v),
default => null,
};
}
if ($timestamp === null || $signatures === []) {
http_response_code(400);
exit('missing signature parts');
}
if (abs(time() - $timestamp) > 300) {
http_response_code(400);
exit('timestamp out of tolerance');
}
$expected = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);
$verified = false;
foreach ($signatures as $signature) {
$verified = hash_equals($expected, $signature) || $verified;
}
if (! $verified) {
http_response_code(401);
exit('invalid signature');
}
// Idempotency: dedupe on Proxyon-Delivery-Id (or body.id).
$deliveryId = $_SERVER['HTTP_PROXYON_DELIVERY_ID'] ?? null;
// Process webhook…
http_response_code(200);
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
// IMPORTANT: capture raw body bytes for signature verification.
app.use(express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const secret = process.env.PROXYON_SIGNING_SECRET;
const header = req.headers['proxyon-signature'] || '';
const rawBody = req.body.toString('utf8');
// A rotation grace window sends one `v1` per accepted secret — check them all.
const parts = header.split(',').map((s) => s.split('=', 2).map((p) => p.trim()));
const t = parts.find(([k]) => k === 't')?.[1];
const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || signatures.length === 0) {
return res.status(400).send('missing signature parts');
}
const timestamp = parseInt(t, 10);
if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
return res.status(400).send('timestamp out of tolerance');
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const ok = signatures.some(
(s) =>
expected.length === s.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(s)),
);
if (!ok) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(rawBody);
// Idempotency: dedupe on req.headers['proxyon-delivery-id'] or event.id
res.status(200).end();
});
Source IP Allowlisting
ProxyOn delivers outbound webhooks from a fixed set of source IPs (the deployment's NAT / egress gateway). If your endpoint sits behind a firewall, you can allowlist these IPs so only ProxyOn can reach it.
- The current source IPs for your project are shown in Customer Dashboard → Settings → Webhooks → (endpoint) → Source IP allowlist, ready to copy.
- Allowlisting is defense-in-depth, not a replacement for HMAC signature verification — always verify the
Proxyon-Signature(see below). An attacker who spoofs a source IP still cannot forge a valid signature. - Source IPs are deployment-specific and may change when infrastructure is migrated; subscribe to operator change announcements before hard-pinning them.
If the allowlist section is empty, the deployment has not published static egress IPs (e.g. it delivers from a dynamic IP pool) — rely on signature verification instead.
Retry Policy
Network errors, timeouts and responses ProxyOn reads as "try again later" are retried automatically:
| Attempt | Delay before retry |
|---|---|
| 1 | (initial) |
| 2 | 5 seconds |
| 3 | 30 seconds |
| 4 | 5 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 6 hours |
- Maximum 7 attempts total (1 initial + 6 retries), spanning a ~8.5-hour delivery window.
- HTTP request timeout: 10 seconds.
Which responses are retried:
| Your response | ProxyOn's reaction |
|---|---|
2xx |
Success. Consecutive-failure counter resets. |
5xx, 429, 408, network error, timeout |
Retried on the backoff schedule above. |
any other 4xx (400, 401, 403, 404, …) |
Rejected — not retried. The delivery fails permanently on the first attempt. |
410 Gone |
Endpoint disabled immediately. |
A rejected envelope is a verdict, not a hiccup: a bad signature or a missing route will be rejected identically on every attempt. Retrying it seven times would only burn your error budget — so ProxyOn stops at one.
This makes a misconfigured receiver fail loudly and quickly. A wrong
PROXYON_WEBHOOK_SECRET in production means every delivery returns 401 and
counts as a failure; after 100 consecutive failures the endpoint is
auto-disabled and the project owner is notified. Alert on the delivery
success-rate in Customer Dashboard → Settings → Webhooks → Deliveries rather
than discovering it when a plan change never lands.
Idempotency
Each delivery includes a Proxyon-Delivery-Id header (unique per attempt) and an envelope id (unique per dispatched event).
- Use
Proxyon-Delivery-Idto deduplicate retries of the same delivery attempt. - Use envelope
idto deduplicate distinct deliveries of the same logical event (e.g. across endpoints or replays).
ProxyOn enforces an internal uniqueness guarantee on (endpoint_id, source_event_id) so the same upstream domain event is only enqueued once per endpoint.
Replay & Test Delivery
Failed deliveries can be replayed from Customer Dashboard → Settings → Webhooks → Deliveries → Replay. Replays create a new delivery row with a fresh Proxyon-Delivery-Id.
Local Testing
Use https://webhook.site or any tunnel (ngrok, Expose) to capture payloads, then verify the signature using the snippets above before wiring real handlers.
Rate Limits
ProxyOn API implements rate limiting to ensure fair usage and stability.
Rate Limit Buckets
Rate limits are applied per project (via API key) using the following buckets:
| Bucket | Default Limit | Window | Applies To |
|---|---|---|---|
proxyon-read |
600 requests | 1 minute | GET, HEAD requests |
proxyon-write |
120 requests | 1 minute | POST, PUT, PATCH, DELETE requests (except portal & usage) |
proxyon-portal |
60 requests | 1 minute | POST /api/v1/portal-sessions |
proxyon-usage |
1200 requests | 1 minute | POST /api/v1/usage (metered usage records) |
Limits can be tuned per environment via PROXYON_API_READ_LIMIT, PROXYON_API_WRITE_LIMIT, PROXYON_API_PORTAL_LIMIT, and PROXYON_API_USAGE_LIMIT.
Failed-Authentication Limit
In addition to the per-project buckets, requests that fail authentication
(invalid, expired, or revoked API key — any 401/403 where no project was
resolved) are limited per IP address: 300 failures per minute by default
(PROXYON_API_AUTH_LIMIT). Successfully authenticated requests never count
toward this bucket, so your real throughput is governed only by the
per-project buckets above. If an IP exhausts this bucket, all its requests
receive 429 until the window resets — check your key configuration rather
than retrying in a tight loop.
Rate Limit Headers
Each API response includes rate limit information in headers:
| Header | Description |
|---|---|
X-RateLimit-Limit |
Maximum requests allowed in window |
X-RateLimit-Remaining |
Remaining requests in current window |
X-RateLimit-Reset |
Unix timestamp when window resets |
Retry-After |
(only on 429) seconds until next allowed request |
Example response headers:
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 599
X-RateLimit-Reset: 1714742400
Rate Limit Exceeded (429)
When the limit is exceeded, the API returns 429 Too Many Requests with the standard JSON error envelope (see Errors):
{
"error": {
"type": "rate_limit_exceeded",
"message": "Too many requests.",
"doc_url": "https://your-domain.com/docs#rate_limit_exceeded",
"request_id": "req_..."
}
}
Retry after the Retry-After header value (or when X-RateLimit-Reset elapses).
Best Practices
- Batch operations when possible (use metadata for multiple operations)
- Cache read responses to reduce API calls
- Handle 429 gracefully with exponential backoff in your client
- Monitor rate limit headers to anticipate throttling
Idempotency Keys
For write operations, use the Idempotency-Key header to safely retry requests without duplicate side effects:
curl -X POST "https://proxyon.test/api/v1/subscribers" \
-H "Authorization: Bearer pxn_test_..." \
-H "Idempotency-Key: unique_key_123" \
-H "Content-Type: application/json" \
-d '{"external_id": "user_123", "type": "user", "email": "user@example.com"}'
Sending the same idempotency key with the same payload returns the cached response (24h window).