MENU navbar-image

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:

  1. Upsert a subscriber when a user signs up: POST /api/v1/subscribers.
  2. Open the billing portal for self-service plan selection, changes and cancellation: POST /api/v1/portal-sessions → redirect the user to the returned url.
  3. Read entitlements to gate features and enforce limits: GET /api/v1/subscribers/{external_id}/entitlements.
  4. 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

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

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"
    }
}
 

Request      

GET api/v1/subscribers

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

type   string  optional    

Filter by subscriber type ("user" or "organization"). Example: organization

email   string  optional    

Filter by exact email address. Example: user@example.com

limit   integer  optional    

Page size, 1-100. Defaults to 100. Example: 25

starting_after   string  optional    

Cursor: the id of the last subscriber on the previous page. Example: sbr_01HXYZ

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"
    }
}
 

Request      

GET api/v1/subscribers/{external_id}

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

external_id   string     

The external ID of the subscriber. Example: architecto

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"
    }
}
 

Request      

POST api/v1/subscribers

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Idempotency-Key        

Example: idem_01JXY...

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

external_id   string     

Your own identifier for the user or tenant this subscriber represents — whatever primary id you already use in your application. Proxyon stores it as an opaque string with no required format or prefix (allowed characters: letters, digits and _ . - :). The user_12345 example is illustrative only; a bare 42 or a UUID work just as well. Example: user_12345

type   string     

Subscriber type: "user" or "organization". Example: user

email   string  optional    

Email address of the subscriber. Example: user@example.com

name   string  optional    

Display name of the subscriber. Example: John Doe

currency   string  optional    

ISO 4217 code of the currency this subscriber is billed in (must be one of the project's enabled currencies). The whitelabel portal prices every plan in this currency for the subscriber — the end user cannot switch it. Omit or send null to inherit the project default currency. Example: EUR

metadata   object  optional    

Arbitrary key-value metadata.

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"
    }
}
 

Request      

PATCH api/v1/subscribers/{external_id}

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Idempotency-Key        

Example: idem_01JXY...

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

external_id   string     

The external ID of the subscriber. Example: architecto

Body Parameters

type   string  optional    

Subscriber type: "user" or "organization". Example: organization

email   string  optional    

New email address. Example: newemail@example.com

name   string  optional    

New display name. Example: Jane Doe

currency   string  optional    

ISO 4217 billing currency code (one of the project's enabled currencies), or null to inherit the project default. Drives the portal pricing currency for this subscriber. Example: EUR

metadata   object  optional    

Updated metadata (merge).

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"
    }
}
 

Request      

DELETE api/v1/subscribers/{external_id}

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Idempotency-Key        

Example: idem_01JXY...

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

external_id   string     

The external ID of the subscriber. Example: architecto

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"
    }
}
 

Request      

GET api/v1/subscribers/{external_id}/subscriptions

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

external_id   string     

External identifier of the subscriber. Example: user_12345

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"
    }
}
 

Request      

GET api/v1/subscriptions/{id}

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   string     

Subscription public id (sub_*) or numeric id. Example: sub_01JXY...

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"
    }
}
 

Request      

GET api/v1/plans

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Content-Type        

Example: application/json

Accept        

Example: application/json

Query Parameters

currency   string  optional    

Filter plans by currency code (e.g. USD, EUR). Example: USD

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"
    }
}
 

Request      

GET api/v1/plans/{key}

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

key   string     

The key of the plan. Example: basic-monthly

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"
    }
}
 

Request      

POST api/v1/portal-sessions

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Idempotency-Key        

Example: idem_01JXY...

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

subscriber_external_id   string     

External identifier of the subscriber to authenticate into the portal. Example: user_12345

return_url   string     

URL the portal redirects the subscriber to when they leave. Example: https://app.example.com/account

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"
    }
}
 

Request      

GET api/v1/subscribers/{external_id}/entitlements

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

If-None-Match        

Example: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

external_id   string     

External identifier of the subscriber. Example: user_12345

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"
    }
}
 

Request      

GET api/v1/subscribers/{external_id}/usage

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

external_id   string     

External identifier of the subscriber. Example: user_12345

Query Parameters

meter_key   string     

Meter key to summarise usage for. Example: api_calls

period   string  optional    

Period to aggregate: "current" (default — the active billing period), "last" (the billing period immediately before the current one), or "last_30d" (rolling window over the last 30 days). Example: current

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"
    }
}
 

Request      

POST api/v1/usage

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Idempotency-Key        

Example: idem_01JXY...

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

subscriber_external_id   string     

External identifier of the subscriber whose subscription will be charged. Example: user_12345

meter_key   string     

Meter key to record usage against. Example: api_calls

quantity   integer     

Quantity to record. Non-zero integer. Positive values record consumption; negative values post a correction (only allowed on meters using sum aggregation). Example: 10

idempotency_key   string     

Unique key to deduplicate this usage record (max 100 chars — forwarded to Stripe as the meter event identifier). Same key returns the same record. Example: 01JXY7QGJ8KZ...

recorded_at   string  optional    

ISO-8601 timestamp when usage was observed. Defaults to the server time. Example: 2026-01-15T10:00:00Z

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"
    }
}
 

Request      

GET api/v1/subscribers/{external_id}/invoices

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

external_id   string     

External identifier of the subscriber. Example: user_12345

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"
    }
}
 

Request      

GET api/v1/invoices/{id}

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   string     

Invoice public id (in_*) or numeric id. Example: in_01JXY...

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"
    }
}
 

Request      

GET api/v1/webhook-events

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Content-Type        

Example: application/json

Accept        

Example: application/json

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"
    }
}
 

Request      

GET api/v1/webhook-endpoints

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Content-Type        

Example: application/json

Accept        

Example: application/json

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"
    }
}
 

Request      

GET api/v1/webhook-endpoints/{id}

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The webhook endpoint id. Example: 16

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"
    }
}
 

Request      

POST api/v1/webhook-endpoints

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Idempotency-Key        

Example: idem_01JXY...

Content-Type        

Example: application/json

Accept        

Example: application/json

Body Parameters

url   string     

HTTPS URL to receive webhook deliveries. Example: https://app.example.com/webhooks/proxyon

event_types   string[]     

List of webhook event types to subscribe to.

description   string  optional    

Optional description for this endpoint. Example: Production receiver

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"
    }
}
 

Request      

PATCH api/v1/webhook-endpoints/{id}

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Idempotency-Key        

Example: idem_01JXY...

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The webhook endpoint id. Example: 16

Body Parameters

url   string  optional    

New HTTPS URL. Example: http://www.bailey.biz/quos-velit-et-fugiat-sunt-nihil-accusantium-harum.html

event_types   string[]  optional    

Replace the full set of subscribed event types.

description   string  optional    

New description. Example: Eius et animi quos velit et.

status   string  optional    

New status: "active" or "disabled". Example: architecto

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"
    }
}
 

Request      

DELETE api/v1/webhook-endpoints/{id}

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Idempotency-Key        

Example: idem_01JXY...

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The webhook endpoint id. Example: 16

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"
    }
}
 

Request      

POST api/v1/webhook-endpoints/{id}/rotate

Headers

Authorization        

Example: Bearer {YOUR_AUTH_KEY}

Idempotency-Key        

Example: idem_01JXY...

Content-Type        

Example: application/json

Accept        

Example: application/json

URL Parameters

id   integer     

The webhook endpoint id. Example: 16

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"
}
 

Request      

GET api/v1/health

Headers

Content-Type        

Example: application/json

Accept        

Example: application/json

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:

  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:

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.

  1. Create the project. In the Proxyon admin panel, go to Projects → New project, give it a name, and note the project slug. Everything below is scoped to this project.
  2. Generate a Project API key. Go to API Keys → New key, select the scopes you need (use ["*"] while developing), and copy the pxn_test_* (or pxn_live_*) value. The key is shown only once — store it in your secret manager immediately.
  3. 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 credential Verified once 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.
  4. Build the plan catalog. Under Features, declare each entitlement (device_count as a quota, pro_branding as 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).
  5. 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 to subscription.*, invoice.* and entitlement.*. Copy the returned whsec_pxn_* signing secret — shown only once. This is the secret your app uses to verify outbound deliveries (Part 2).
  6. (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:

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 any v1 matches — 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:

  1. Tenant signs up in your app. You create the local row, then call POST /api/v1/subscribers with external_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).
  2. Customer opens "Billing". Your app calls GET /api/v1/subscribers/{external_id}/entitlements (cached 60s) and renders the current plan + feature limits.
  3. Customer clicks "Upgrade". You call POST /api/v1/portal-sessions and redirect to the returned portal URL. The customer picks the pro plan inside the hosted ProxyOn billing portal, which drives Stripe Checkout for payment.
  4. Customer pays. Stripe fires customer.subscription.created to Proxyon's Stripe-webhook listener.
  5. Proxyon updates state and fans out. It links the Stripe subscription to your subscriber, recomputes entitlements and POSTs subscription.created + entitlement.updated to your webhook endpoint.
  6. 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.
  7. Customer consumes a metered feature. Your service handles the action, then calls POST /api/v1/usage with meter_key=ai_credits, quantity=1 and a stable idempotency_key. Proxyon forwards the usage to Stripe.
  8. 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 delivers subscription.canceled, your gate now denies the metered action on the next request.

Testing & rollout

Run these scenarios before flipping to live keys:

Going to production

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:

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:

  1. Fail open on 5xx/timeouts. Fail safe on 401/403. "Fail safe" means the default plan, not unlimited.
  2. 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.

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/*.json fixtures in the Proxyon repository and asserted by tests/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.

⚠️ 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.

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

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).

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

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).