Subscription payments,
made clear.
Everything developers and QA engineers need to integrate, verify, and operate NetzonPay—from the first checkout session to automated FIUU renewals.
Create checkout sessions, protect access, and manage subscriptions.
→ FOR QA TEAMS Test with confidenceUse repeatable scenarios, expected results, and evidence checklists.
→Start in the onboarding console
Onboarding creates the NetzonPay app, generates its API key, and creates monthly and annual plans for every pricing tier. Complete this before writing checkout code.
1. Open the correct onboarding environment
dev-pay.netzon.dev/onboarding
Stagingstaging-pay.netzon.dev/onboarding
Productionpay.netzon.dev/onboarding
2. Enter the app identity
| Field | Required | What to enter |
|---|---|---|
App name | Yes | The product customers are subscribing to. |
Slug | No | A unique URL-safe name. NetzonPay generates it when omitted. |
Description | No | A short internal description of the integration. |
Callback URL | No | Your app’s HTTPS integration callback URL, when used. |
Logo URL | No | A public HTTPS logo used to identify the app. |
Currency | Yes | Three-letter currency code. Use PHP for Philippine peso plans. |
3. Build the pricing tiers
Every tier creates two active plans automatically: one Monthly plan and one Annual plan. For example, the Starter tier creates Starter Monthly and Starter Annual.
4. Launch and save the result
After a successful launch, copy all integration values before leaving the screen.
Use as NETZONPAY_APP_ID.
Store immediately in your backend secret manager.
Map each product choice to its plan UUID.
Use the returned paths with the matching API base URL.
NetzonPay stores only its hash. If the raw value is lost, regenerate the key from the app settings and update your backend secret; the previous key becomes invalid.
Optional: onboard programmatically
The onboarding console uses this API. It creates real app and plan records immediately in the selected environment.
/api/v1/onboarding/appsOnboarding endpointcurl --request POST \
--url https://dev-api-pay.netzon.dev/api/v1/onboarding/apps \
--header 'Content-Type: application/json' \
--data '{
"app_name": "Example SaaS",
"slug": "example-saas",
"description": "Subscription billing for Example SaaS",
"callback_url": "https://example.com/billing/callback",
"logo_url": "https://example.com/logo.svg",
"currency": "PHP",
"tiers": [
{
"name": "Starter",
"monthly_price": 299.00,
"annual_price": 2990.00
}
]
}'
App slugs must be unique. Development, staging, and production create separate apps, API keys, Plan IDs, subscribers, and payment records.
Configure your backend
Use values from the onboarding success screen. Keep a separate set for development, staging, and production.
NETZONPAY_API_URL=https://dev-api-pay.netzon.dev
NETZONPAY_PORTAL_URL=https://dev-pay.netzon.dev
NETZONPAY_APP_ID=YOUR_ONBOARDED_APP_ID
NETZONPAY_API_KEY=YOUR_ONBOARDING_API_KEY
NETZONPAY_PLAN_STARTER_MONTHLY=MONTHLY_PLAN_ID
NETZONPAY_PLAN_STARTER_ANNUAL=ANNUAL_PLAN_ID
APP_URL=https://your-app.example
Use these when your trusted server calls NetzonPay.
Choose the Plan ID on the server. Do not accept arbitrary Plan IDs from an untrusted client.
Confirm the onboarded plans
/api/v1/integration/plansApp API keyThis returns the active plans belonging to the authenticated app and is useful for confirming onboarding or powering a server-controlled pricing page.
Create your first session →Integrate the onboarded app
Once onboarding gives you the App ID, API key, and Plan IDs, connect them to your backend. Your server creates a short-lived session, the customer pays through hosted checkout, and your server verifies access afterward.
Save the App ID, raw API key, and the Monthly/Annual Plan IDs created for each tier.
Send the authenticated user and selected Plan ID to POST /api/v1/sessions.
Use the returned checkout URL. Never collect FIUU card details inside your own application.
After the customer returns, call the status endpoint from your backend and trust active as the access decision.
curl --request POST \
--url https://dev-api-pay.netzon.dev/api/v1/sessions \
--header 'Content-Type: application/json' \
--header 'X-Api-Key: YOUR_APP_API_KEY' \
--data '{
"app_id": "YOUR_APP_ID",
"external_user_id": "user-123",
"email": "customer@example.com",
"name": "Customer Name",
"phone": "09170000000",
"plan_id": "YOUR_PLAN_ID",
"return_url": "https://your-app.example/billing/result"
}'
const response = await fetch(
"https://dev-api-pay.netzon.dev/api/v1/sessions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-Key": process.env.NETZONPAY_API_KEY
},
body: JSON.stringify({
app_id: process.env.NETZONPAY_APP_ID,
external_user_id: user.id,
email: user.email,
name: user.name,
phone: user.phone,
plan_id: selectedPlanId,
return_url: `${process.env.APP_URL}/billing/result`
})
}
);
const { data } = await response.json();
return data.payment_url;
$response = Http::withHeaders([
'X-Api-Key' => env('NETZONPAY_API_KEY'),
])->post('https://dev-api-pay.netzon.dev/api/v1/sessions', [
'app_id' => env('NETZONPAY_APP_ID'),
'external_user_id' => (string) $user->id,
'email' => $user->email,
'name' => $user->name,
'phone' => $user->phone,
'plan_id' => $planId,
'return_url' => route('billing.result'),
]);
return redirect($response->json('data.payment_url'));
import os
import requests
response = requests.post(
"https://dev-api-pay.netzon.dev/api/v1/sessions",
headers={
"X-Api-Key": os.environ["NETZONPAY_API_KEY"],
"Content-Type": "application/json",
},
json={
"app_id": os.environ["NETZONPAY_APP_ID"],
"external_user_id": str(user.id),
"email": user.email,
"name": user.get_full_name(),
"phone": user.phone,
"plan_id": selected_plan_id,
"return_url": f"{os.environ['APP_URL']}/billing/result",
},
)
response.raise_for_status()
payment_url = response.json()["data"]["payment_url"]
Do not put X-Api-Key in browser JavaScript, mobile applications, screenshots, tickets, or source control.
Use the right environment end to end
API credentials, portal URLs, FIUU credentials, callbacks, databases, and test records must stay within the same environment.
| Environment | API base URL | Portal URL | FIUU |
|---|---|---|---|
| Development | https://dev-api-pay.netzon.dev | https://dev-pay.netzon.dev | Dedicated sandbox |
| Staging | https://staging-api-pay.netzon.dev | https://staging-pay.netzon.dev | Dedicated sandbox |
| Production | https://api-pay.netzon.dev | https://pay.netzon.dev | Live |
A development payment returned to staging cannot be found because its order and session exist in the development database.
Core concepts
An integrating product. It owns plans, subscribers, and a secret API key.
A price and billing cycle belonging to one app.
Your user, identified by an external_user_id within an app.
A temporary hosted-checkout attempt tied to one subscriber and plan.
The access and billing lifecycle: pending, active, past due, cancelled, or expired.
FIUU-issued token metadata used for eligible recurring charges.
How a payment moves through the system
- Your backend creates a session with its app API key.
- The customer opens the returned NetzonPay portal URL.
- NetzonPay creates the FIUU hosted-checkout payload.
- FIUU handles card entry, 3DS, and payment authorization.
- FIUU posts signed return/callback data to NetzonPay.
- NetzonPay verifies the signature and updates payment and subscription state idempotently.
- Your backend checks the subscription before enabling paid features.
Authentication
Integration endpoints use an app-scoped API key. Admin endpoints use an authenticated administrator session or bearer token.
X-Api-KeySessions, plans for an integration, subscription status, and app-owned cancellation.
Authorization: BearerApps, plans, reports, payments, subscribers, refunds, and operational actions.
Create a payment session
Sessions must be created from your trusted backend. NetzonPay verifies that the authenticated app owns both the supplied App ID and Plan ID.
/api/v1/sessionsApp API key| Field | Required | Purpose |
|---|---|---|
app_id | Yes | Your NetzonPay app UUID. |
external_user_id | Yes | Stable user identifier from your system. |
email | Yes | Subscriber email used for billing records and invoices. |
name | Yes | Subscriber or billing name. |
phone | No | Billing contact number. |
plan_id | Yes | Plan UUID owned by the authenticated app. |
quantity | No | Number of units on this plan — branches, seats, tranches, or whatever your app decides. Defaults to 1. The total charge is plan.amount × quantity. |
return_url | Yes | Your application destination after the NetzonPay result screen. |
Subscribe multiple units on one plan
A subscriber who needs several units of the same package — branches, seats, tranches, whatever your app calls them — can pay for all of them in one session instead of creating one subscription per unit. Send quantity when creating the session; NetzonPay scales the charge and keeps every recurring cycle in sync automatically. NetzonPay has no opinion on what a "unit" is — that's entirely your app's business logic.
plan.amountWhat quantity affects
- The initial charge for the session equals
plan.amount × quantity. - The subscription created after a successful payment stores the same
quantity, and every future auto-charge and retry-charge uses it — you don't need to resend it. - A retried session (after a failed payment) keeps the original
quantity; the subscriber cannot change it mid-retry. - Only one package per subscription. If different unit groups need different packages, create a separate session/subscription per package.
quantity is not echoed back by the status endpoints.GET /api/v1/subscriptions/check and GET /api/v1/integration/plans both return the plan's per-unit amount, not a scaled total, and neither returns quantity. If your app needs to show "N units × ₱X/mo" after checkout, record the quantity you sent when creating the session in your own database — NetzonPay does not hand it back to you outside the admin portal.
curl --request POST \
--url https://dev-api-pay.netzon.dev/api/v1/sessions \
--header 'Content-Type: application/json' \
--header 'X-Api-Key: YOUR_APP_API_KEY' \
--data '{
"app_id": "YOUR_APP_ID",
"external_user_id": "user-123",
"email": "customer@example.com",
"name": "Customer Name",
"plan_id": "YOUR_PLAN_ID",
"quantity": 5,
"return_url": "https://your-app.example/billing/result"
}'
GET /api/v1/integration/plans returns the per-unit amount. Multiply it by the quantity on your side to show the total before the subscriber commits — NetzonPay does not accept or return a pre-computed total.
Redirect to hosted checkout
The create-session response provides the session identifier and checkout/payment URL. Redirect the customer to that URL without modifying it.
{
"success": true,
"data": {
"session_id": "d4e5f6a7-b8c9-0123-4567-890abcdef123",
"payment_url": "https://dev-pay.netzon.dev/pay/d4e5f6a7-b8c9-0123-4567-890abcdef123"
}
}
Handle the customer’s return safely
The configured return_url is only a navigation destination. It does not carry a trusted payment decision. When the customer returns, your backend must check the subscription using the authenticated user’s own external_user_id.
export async function GET(request) {
const user = await requireAuthenticatedUser(request);
const url = new URL(
"https://dev-api-pay.netzon.dev/api/v1/subscriptions/check"
);
url.searchParams.set("app_id", process.env.NETZONPAY_APP_ID);
url.searchParams.set("external_user_id", String(user.id));
const response = await fetch(url, {
headers: { "X-Api-Key": process.env.NETZONPAY_API_KEY },
cache: "no-store"
});
const { data } = await response.json();
return Response.json({
active: data.active,
status: data.status,
plan_name: data.plan_name
});
}
?status=success.The browser redirect can be edited or replayed. Only the server-side subscription check should control paid access.
Gate access with the subscription check
Do not grant paid access from a browser redirect or query parameter. Your backend should ask NetzonPay for the subscriber’s current access decision.
/api/v1/subscriptions/checkApp API keyconst url = new URL(
"https://dev-api-pay.netzon.dev/api/v1/subscriptions/check"
);
url.searchParams.set("app_id", process.env.NETZONPAY_APP_ID);
url.searchParams.set("external_user_id", user.id);
const response = await fetch(url, {
headers: { "X-Api-Key": process.env.NETZONPAY_API_KEY }
});
const { data } = await response.json();
if (!data.active) {
return redirect("/subscribe");
}
active for authorization.The detailed status is useful for UI and support, while active is the normalized access decision.
amount below is the per-unit price, not a total.For a multi-unit subscription, this endpoint does not return quantity or a scaled total. See Subscribe multiple units on one plan for how to track and display the total on your side.
Subscription response
{
"success": true,
"data": {
"app_id": "YOUR_APP_ID",
"external_user_id": "user-123",
"active": true,
"subscription_id": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"plan_id": "YOUR_PLAN_ID",
"status": "active",
"current_period_end": "2026-08-30T00:00:00Z",
"trial_end": null,
"plan_name": "Starter Monthly",
"amount": 299.00,
"payment_method": {
"type": "card",
"brand": "VISA",
"last4": "4242"
}
}
}
Cache intentionally
The NetzonPay endpoint has a five-second response cache. Your backend may cache the normalized result for 30–60 seconds for ordinary page access, but should re-check before sensitive account or billing operations.
Cancel a subscription
An app can cancel only its own subscription. Use full cancellation when the subscriber wants to stop renewal entirely, and use edit-quantity when they only want to upgrade or downgrade the unit count.
/api/v1/subscriptions/{subscriptionId}/cancelApp API keycurl --request POST \
--url https://dev-api-pay.netzon.dev/api/v1/subscriptions/SUBSCRIPTION_ID/cancel \
--header 'Content-Type: application/json' \
--header 'X-Api-Key: YOUR_APP_API_KEY' \
--data '{ "reason": "Requested by customer" }'
Full cancellation response
{
"success": true,
"data": {
"status": "cancelled",
"access_until": "2026-07-05T00:00:00Z",
"quantity": 3
}
}
After cancellation, present the returned access_until value so the customer understands when their already-paid access ends and that the subscription will not renew.
Edit quantity — upgrade or downgrade a multi-unit subscription
Send the target quantity, not a delta — it can be higher (upgrade) or lower (downgrade) than the subscription's current quantity, while keeping the subscription status active, trial, or past due. This endpoint does not fully cancel the subscription. Sending the same quantity the subscription already has is a harmless no-op.
/api/v1/subscriptions/{subscriptionId}/quantityApp API keycurl --request POST \
--url https://dev-api-pay.netzon.dev/api/v1/subscriptions/SUBSCRIPTION_ID/quantity \
--header 'Content-Type: application/json' \
--header 'X-Api-Key: YOUR_APP_API_KEY' \
--data '{ "quantity": 8, "reason": "Customer upgraded to 8 units" }'
Edit-quantity response
{
"success": true,
"data": {
"status": "active",
"quantity": 8,
"previous_quantity": 5,
"current_period_start": "2026-09-15T00:00:00Z",
"current_period_end": "2026-10-15T00:00:00Z",
"amount_charged": 550.00,
"credit_applied": 250.00,
"payment_id": "8f2c1a3e-...",
"updated_at": "2026-09-15T00:00:00Z"
}
}
| Condition | Result |
|---|---|
quantity missing, not an integer, or below 1 | 400 |
| Subscription belongs to another app | 404 (not found) |
Subscription is cancelled, expired, or (for an upgrade specifically) not Active | 409 |
| Upgrade: no payment method on file, or the prorated charge is declined | 409 — nothing on the subscription changes |
Downgrades are unchanged: applied immediately, no charge, renewal date untouched. An upgrade now charges the stored card immediately for a fresh full cycle at the new quantity — plan.amount × new_quantity — credited for whatever's unused of the current period at the old quantity. The charge must succeed (or be legitimately zero) before anything changes; a decline leaves the quantity and period exactly as they were. On success, current_period_start/current_period_end reset to today plus one billing cycle — the old access_until field is gone, since "access until" now moves on an upgrade instead of staying fixed.
Every successful upgrade gets its own invoice showing the old quantity, the credit, the new quantity's full-cycle price, the net charge, and the new renewal date. Downgrades still use the older mechanism — no dedicated invoice, but the next recurring invoice carries an explanatory note, e.g. "Quantity changed from 5 to 3 on 20 Jun 2026 — this invoice bills the new quantity."
Change plan — switch to a higher-priced plan
A different action from Edit Quantity above — this changes Subscription.PlanId itself (e.g. Basic → Premium) rather than the quantity on the same plan — but it runs the identical billing mechanism: a fresh full cycle charged immediately, credited for unused time, gated on a successful charge, renewal date reset to today. Only upgrading to a strictly higher-priced plan on the same app is supported.
/api/v1/subscriptions/{subscriptionId}/planApp API keycurl --request POST \
--url https://dev-api-pay.netzon.dev/api/v1/subscriptions/SUBSCRIPTION_ID/plan \
--header 'Content-Type: application/json' \
--header 'X-Api-Key: YOUR_APP_API_KEY' \
--data '{ "plan_id": "PREMIUM_PLAN_ID", "reason": "Customer upgraded to Premium" }'
Change-plan response
{
"success": true,
"data": {
"status": "active",
"plan_id": "7a1c8890-...",
"plan_name": "Premium",
"current_period_start": "2026-09-20T00:00:00Z",
"current_period_end": "2026-10-20T00:00:00Z",
"amount_charged": 799.33,
"credit_applied": 99.67,
"payment_id": "3d9e2b1a-..."
}
}
| Condition | Result |
|---|---|
| Already on this plan | 409 |
| Target plan is not priced higher than the current one, or belongs to another app | 409 / 404 |
Subscription is not Active | 409 |
| No payment method on file, or the prorated charge is declined | 409 — nothing on the subscription changes |
newPlan.amount must be strictly greater than the current plan's amount. Switching to a cheaper or equal-priced plan is rejected outright rather than silently reinterpreted.
One-time tokenization — booking-style apps
Everything above this section is about Subscription-billed apps — recurring plans, auto-renewal, quantity upgrade/downgrade. NetzonPay also supports a second, separate billing mode for apps whose business is "one payment, then a bounded window of on-demand charges" — a hotel booking, an equipment rental, an event pass with add-ons. Billing mode is chosen once, when the app is created, and can't be changed afterward.
| Billing mode | Use it when… |
|---|---|
recurring (default) | You charge the same amount on a repeating schedule. |
one_time_tokenization | You take one payment, then need to charge the same card on demand for a bounded period, then never again. |
The flow
- Guest checks in — you call
POST /api/v1/sessionswithtoken_valid_untilset to the end of the window (e.g. the check-out date). Guest pays through the normal hosted checkout. No subscription is created. - During the window — you call the charge endpoint any time you need to bill an extra, against the same card, with no further card entry.
- Window ends — you call the close endpoint to invalidate the card token. It can never be charged again.
curl --request POST \
--url https://dev-api-pay.netzon.dev/api/v1/sessions \
--header 'Content-Type: application/json' \
--header 'X-Api-Key: YOUR_APP_API_KEY' \
--data '{
"app_id": "YOUR_APP_ID",
"external_user_id": "guest-12345",
"email": "guest@example.com",
"name": "Jane Guest",
"plan_id": "YOUR_ROOM_RATE_PLAN_ID",
"return_url": "https://yourapp.com/booking/confirm",
"token_valid_until": "2026-09-15T12:00:00Z"
}'
token_valid_until is required for one_time_tokenization apps, and rejected for recurring apps.Everything else about session creation and checkout is identical to a normal one-time payment — the only difference is that no Subscription is created, and the resulting stored card carries the expiry you provided.
FIUU's Token API has an ADD_TOKEN action that tokenizes a raw card with no charge, but it requires a full PCI-DSS Attestation of Compliance — a large compliance undertaking, not a simple toggle. This feature deliberately keeps tokenization on the existing hosted-checkout flow (card data never touches our servers) and only uses FIUU's token-deletion action to end a token's life.
A complete backend integration
Keep NetzonPay calls behind a small server-side module. This makes authentication, error handling, environment changes, and tests consistent across subscribe, status, and cancellation routes.
const API_URL = process.env.NETZONPAY_API_URL;
const API_KEY = process.env.NETZONPAY_API_KEY;
const APP_ID = process.env.NETZONPAY_APP_ID;
async function netzonPay(path, options = {}) {
const response = await fetch(`${API_URL}${path}`, {
...options,
headers: {
"X-Api-Key": API_KEY,
"Content-Type": "application/json",
...options.headers
}
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(body.detail || `NetzonPay error ${response.status}`);
error.status = response.status;
error.traceId = body.traceId;
throw error;
}
return body.data;
}
export function createPaymentSession(user, planId, returnUrl, quantity = 1) {
return netzonPay("/api/v1/sessions", {
method: "POST",
body: JSON.stringify({
app_id: APP_ID,
external_user_id: String(user.id),
email: user.email,
name: user.name,
phone: user.phone,
plan_id: planId,
quantity: quantity,
return_url: returnUrl
})
});
}
export function checkSubscription(userId) {
const query = new URLSearchParams({
app_id: APP_ID,
external_user_id: String(userId)
});
return netzonPay(`/api/v1/subscriptions/check?${query}`, {
headers: { "Content-Type": undefined }
});
}
export function cancelSubscription(subscriptionId, reason) {
return netzonPay(`/api/v1/subscriptions/${subscriptionId}/cancel`, {
method: "POST",
body: JSON.stringify({ reason })
});
}
export function setQuantity(subscriptionId, quantity, reason) {
return netzonPay(`/api/v1/subscriptions/${subscriptionId}/quantity`, {
method: "POST",
body: JSON.stringify({ quantity, reason })
});
}
router.post("/subscribe", requireAuth, async (req, res, next) => {
try {
const session = await createPaymentSession(
req.user,
req.body.plan_id,
`${process.env.APP_URL}/subscription/result`
);
res.redirect(303, session.payment_url);
} catch (error) {
next(error);
}
});
router.get("/subscription/status", requireAuth, async (req, res, next) => {
try {
res.json(await checkSubscription(req.user.id));
} catch (error) {
next(error);
}
});
router.post("/subscription/cancel", requireAuth, async (req, res, next) => {
try {
const subscription = await checkSubscription(req.user.id);
if (!subscription.subscription_id) {
return res.status(400).json({ error: "No subscription to cancel" });
}
res.json(await cancelSubscription(
subscription.subscription_id,
req.body.reason || "Customer requested cancellation"
));
} catch (error) {
next(error);
}
});
router.post("/subscription/quantity", requireAuth, async (req, res, next) => {
try {
const subscription = await checkSubscription(req.user.id);
if (!subscription.subscription_id) {
return res.status(400).json({ error: "No subscription to update" });
}
res.json(await setQuantity(
subscription.subscription_id,
req.body.quantity,
req.body.reason || "Customer requested quantity change"
));
} catch (error) {
next(error);
}
});
Understand the subscription lifecycle
| Status | Access | Developer behavior |
|---|---|---|
| Trial | Yes | Grant access and show the trial end date. |
| Active | Yes | Grant access through the current paid period. |
| Pending | No | Show processing guidance and poll conservatively. |
| PastDue | Until the current period ends | Trust the returned active value and show payment recovery guidance. |
| Cancelled | Until the returned access end | Do not start another renewal; show the end date. |
| Expired | No | Offer a new checkout session. |
Supported billing periods
How automatic renewal works
The initial payment is customer-present. Successful FIUU tokenization stores a protected payment token. When an active subscription becomes due, NetzonPay submits a server-to-server recurring request and waits for FIUU’s verified result.
Eligibility requirements
- Subscription status is active and the current period has ended.
- A default payment method exists with a real FIUU token, consent, or mandate identifier.
- The payment method is a card or has a supported FIUU recurring record type.
- No recurring payment for the same subscription is already pending.
- Auto-charge is enabled and FIUU recurring credentials belong to the same environment.
Use a real successful sandbox initial payment when validating FIUU recurring end to end.
FIUU return, notify, and callback endpoints
These endpoints are public because FIUU calls them. NetzonPay validates required fields, verifies skey, applies idempotency, and maps the FIUU order to an existing payment.
/api/v1/fiuu/returnBrowser return/api/v1/fiuu/notifyServer notification/api/v1/fiuu/callbackPayment callbackThey are FIUU transport endpoints, not integration webhooks for merchants using NetzonPay.
Security practices for integrations
- Store API keys in a server-side secret manager or environment variable.
- Use a different key and App ID for every environment.
- Call NetzonPay only from authenticated backend routes.
- Verify access on the server and re-check sensitive operations.
- Log the HTTP status and trace ID without logging credentials.
- Expose keys in browser bundles or mobile applications.
- Trust return URLs, client state, or query parameters as payment proof.
- Mix development, staging, or production identifiers.
- Log FIUU tokens, JWTs, cookies, or full payment details.
- Retry non-idempotent requests indefinitely.
Frequently asked questions
What happens when a subscription renews?
NetzonPay charges the eligible stored FIUU token when the billing period becomes due. A successful result advances the period automatically; your application keeps using the subscription check endpoint.
What happens when a renewal fails?
The subscription enters recovery according to NetzonPay’s retry policy and may become PastDue or Expired. Your application should use the latest active decision and show payment recovery guidance.
Can the same person subscribe to multiple plans?
Subscription conflicts are enforced within the current app and lifecycle rules. Treat a 409 response as a state decision and retrieve the current subscription rather than creating duplicate sessions.
How should upgrades and downgrades work?
Do not replace Plan IDs in client code. Coordinate the intended plan-change policy with NetzonPay administration before implementing proration or immediate switching.
How do I test without live payments?
Use development or staging with its dedicated FIUU sandbox credentials. Internal simulation is useful for UI testing, but real recurring validation requires a real sandbox initial payment and FIUU-issued token.
What should I use for external_user_id?
Use your stable internal user identifier. It should not change when the user updates their email or display name.
Can a mobile application integrate directly?
The mobile app may open the hosted payment URL, but your backend must create sessions and check subscriptions because the NetzonPay API key cannot be safely embedded in a mobile binary.
Onboard an app and its tiers
/api/v1/onboarding/appsCreates one active app, generates its raw API key, and creates Monthly and Annual plans for every supplied tier.
Request rules
| Field | Rule |
|---|---|
app_name | Required; maximum 100 characters. |
slug | Optional; unique lowercase alphanumeric value with hyphens. |
callback_url | Optional absolute URL; HTTPS is required outside development. |
currency | Required three-letter uppercase ISO currency code. |
tiers | Between 1 and 20 tiers with unique names. |
monthly_price | Greater than zero with at most two decimal places. |
annual_price | Greater than zero with at most two decimal places. |
201 response includes
- The created app, including its App ID, slug, and configured URLs.
- The one-time raw
api_key. - Every tier’s
monthly_planandannual_plan, including Plan IDs. - The Plans and Sessions endpoint paths and the
X-Api-Keyheader name.
Create checkout session
/api/v1/sessionsCreates or reuses the subscriber record and opens a hosted payment session for a plan owned by the authenticated app.
Responses
List integration plans
/api/v1/integration/plansReturns plans available to the app authenticated by X-Api-Key. Use these IDs when building a server-controlled product-to-plan mapping.
Check subscription
/api/v1/subscriptions/checkRequired query parameters are app_id and external_user_id. The supplied App ID must match the authenticated app.
Cancel subscription
/api/v1/subscriptions/{id}/cancelCancels an app-owned subscription. An optional JSON reason may be supplied for audit and support context.
Edit quantity
/api/v1/subscriptions/{id}/quantitySets an app-owned subscription's quantity to the given target value — higher (upgrade) or lower (downgrade) than the current value. If the subscription is owned by another app, this endpoint returns 404.
Request body
{
"quantity": 5,
"reason": "Customer upgraded to 5 units"
}
Responses
An upgrade response now also includes current_period_start, current_period_end, amount_charged, credit_applied, and payment_id — see Edit quantity above for the full response shape and billing rules.
Admin edit quantity
/api/v1/admin/subscriptions/{id}/quantityAdmin variant of edit-quantity. Requires JWT bearer authentication and a valid admin_user claim. Admin endpoints can act on any subscription and perform quantity changes even when the subscription is owned by another app.
Request body
{
"quantity": 2,
"reason": "Customer downgrade request"
}
Responses
Change plan
/api/v1/subscriptions/{id}/planSwitches an app-owned Active subscription to a different, higher-priced plan on the same app. Shares the exact billing mechanism as an Edit Quantity upgrade — see Change plan above.
Request body
{
"plan_id": "7a1c8890-...",
"reason": "Customer upgraded to Premium"
}
Responses
Admin change plan
/api/v1/admin/subscriptions/{id}/planAdmin variant of change-plan. Requires JWT bearer authentication and a valid admin_user claim. Request/response body is identical to the API key endpoint.
Responses
Charge a one-time-tokenization token
Charges an arbitrary amount against an active one_time_tokenization card token — used for on-demand extras during the booking window. Only works on tokens with purpose OneTime; recurring subscription tokens are never chargeable through this endpoint.
/api/v1/integration/payment-methods/{paymentMethodId}/chargesApp API keycurl --request POST \
--url https://dev-api-pay.netzon.dev/api/v1/integration/payment-methods/PAYMENT_METHOD_ID/charges \
--header 'Content-Type: application/json' \
--header 'X-Api-Key: YOUR_APP_API_KEY' \
--data '{ "amount": 350.00, "description": "Spa treatment — Sept 12" }'
Close a one-time-tokenization token
Ends a token's life early — call this the moment your own business event fires (guest checks out, rental returned) rather than waiting for token_valid_until to pass. Idempotent: closing an already-closed token just returns its current state.
/api/v1/integration/payment-methods/{paymentMethodId}/closeApp API keycurl --request POST \
--url https://dev-api-pay.netzon.dev/api/v1/integration/payment-methods/PAYMENT_METHOD_ID/close \
--header 'X-Api-Key: YOUR_APP_API_KEY'
A background sweep automatically deletes the token once token_valid_until passes. Calling the close endpoint as soon as you know the window is over is still better practice — it narrows the window during which the card could still be charged.
Errors and rate limits
| HTTP | Meaning | What to do |
|---|---|---|
400 | Malformed or invalid input | Fix fields before retrying. |
401 | Missing or invalid authentication | Check the server-side API key. |
403 | Authenticated app does not own the resource | Check App ID and environment. |
404 | Resource not found | Check IDs and environment boundaries. |
409 | Resource state conflicts with the request | Read current subscription/session state. |
422 | Request understood but cannot be processed | Show a safe message and log the response. |
429 | Too many requests | Back off and retry with jitter. |
502 | Payment provider unavailable | Do not assume payment failure; reconcile later. |
A practical test strategy
Validate each environment in layers. Start with reachability and configuration, then cover the customer journey, payment state, subscription state, recurring behavior, and failure recovery.
Health, portal, login, and API availability.
Apps, plans, sessions, checkout, payment, and access.
Recurring, retry, cancellation, expiry, and refunds.
Invalid auth, replayed callbacks, cross-app IDs, and rate limits.
Record the environment, App ID, Plan ID, external user ID, Session ID, NetzonPay order ID, and FIUU transaction ID for every payment test.
Preflight checklist
- Open the environment API
/healthz. - Open its portal domain and sign in.
- Confirm Swagger is available only in dev/staging.
- Confirm FIUU callback URLs use the same environment hostname.
- Confirm internal testing tools appear only when enabled outside production.
- Create or select a QA app and active plan.
- Copy the raw API key securely.
- Use a unique external user ID and email for the run.
Initial payment scenario
- Create a session for a new external user and an active plan.
- Open the returned NetzonPay payment URL.
- Initiate FIUU and complete the approved sandbox flow.
- Wait for the FIUU callback and refresh the NetzonPay status.
- Open the subscriber and payment records in the admin portal.
Recurring payment scenario
- Start with an active subscription created by a real successful FIUU initial payment.
- Confirm the subscriber has a default FIUU payment method.
- Use Set Due Date in the internal recurring tool.
- Confirm the subscription is eligible, then choose Run Recurring.
- Observe API logs and refresh the subscriber record.
Submitting FIUU recurring request
formKey 0 · fieldCount 12 · checksumLength 32
FIUU recurring charge submission completed
Recurring payment renewed subscription
High-value negative scenarios
Session creation returns 401 and creates no records.
Request is rejected; no session is created.
Checkout cannot be initiated and clearly explains expiration.
Webhook is rejected and payment state is unchanged.
Processing is idempotent; no duplicate renewal or invoice.
Charge is skipped safely and subscriber data explains ineligibility.
No false success; the subscription is not penalized for transport failure.
API returns 429 and recovers after the retry window.
Capture evidence that developers can act on
- Environment, timestamp with timezone, browser, and build/commit.
- App ID, Plan ID, external user ID, Session ID, subscription ID, and payment ID.
- NetzonPay order ID and FIUU transaction ID when available.
- Exact steps, expected result, and actual result.
- Network request URL, method, HTTP status, and redacted response.
- Relevant API logs using the request trace ID.
- Screenshot or short video with secrets and payment data hidden.
Never attach API keys, JWTs, FIUU tokens, verify keys, secret keys, full card details, cookies, or SMTP credentials.
Testing tools and setup
The three feature guides below — card replacement, quantity reduction, and quantity per session — run entirely from the browser: the onboarding console, the admin portal, the internal test tool, and Swagger's interactive forms. No terminal or curl commands are required to execute them.
Create a test app, plan, and API key at /onboarding.
/internal/demo-app creates sessions and drives checkout with one-click buttons. Available outside production.
Edit quantity (upgrade or downgrade), generate card-replacement links, and run the per-subscriber Recurring Test panel.
Covers the few requests with no dedicated screen, such as setting quantity on session creation.
Sandbox test card
| Field | Value |
|---|---|
| Card number | 4012 0000 0000 0007 |
| Expiry | Any future date |
| CVV | 123 |
Internal test tool buttons
- Create Session — calls session creation for the selected app, plan, and test identity.
- Initiate FIUU / Open Portal — opens real hosted checkout in a new tab for the sandbox card.
- Simulate Success / Simulate Failure — skips real checkout for backend-only test cases.
- Set Due Date / Run Recurring — forces a subscription's period to look due and fires one auto-charge pass immediately.
- Generate Card Replacement Link, Edit Quantity, Cancel Subscription — buttons on each subscriber's detail page.
Open /swagger, click Authorize, paste the app's raw API key into the ApiKey field and Bearer <token> into the Bearer field, then use Try it out → Execute on any endpoint's browser form.
Card details: retry and replacement
Covers subscriber-facing retry after a failed payment, card replacement via a signed link, admin card visibility, and worker fallback to an alternate stored card.
- In the internal test tool, create a session and click Simulate Failure.
- Open
/pay/<sessionId>/failedin the browser. - Click Retry.
- Reopen the original session's checkout page.
- Find a subscriber with status Past Due.
- Create a fresh checkout for the same external user ID and complete it (real card or Simulate Success).
- On a subscriber's detail page, click Generate Card Replacement Link and copy the URL.
- Open it in a private browser window; confirm the current card and expiry are shown.
- Click Replace Card. The checkout page shows a small verification charge (₱1.00 by default, not the plan's real price) and a "Verify & Save Card" button — expected, see the callout below. Complete FIUU checkout with the sandbox card.
- Reload the subscriber detail page.
/payments and settles to Refunded within seconds.- Edit a valid link's
expiresquery parameter to a past timestamp and open it.
- Change one character in a valid link's
tokenquery parameter and open it.
- Complete TC-CARD-003.
- Generate a new replacement link for the same subscriber and replace again with the identical card.
- Note a subscriber's card status badge (Active).
- Ask a developer to set that card's expiry to a past date directly in the database — there is no UI control for this, since expiry normally comes from real card data.
- Reload the subscriber detail page.
- Get a subscriber with two stored cards (initial checkout, then one card replacement).
- Ask a developer to invalidate the current default's token in the database, simulating a bank-side token failure.
- On the subscriber page, use Set Due Date then Run Recurring.
Stored card tokens are encrypted with a fresh random value every time, so the system's "is this card already on file?" check essentially never matches — even for an identical token. TC-CARD-006 accounts for this; treat a duplicate row as expected until this is fixed with a proper lookup column.
FIUU has no documented zero-amount tokenization mode — a real, non-zero, completed transaction is required to mint a card token. Replacement charges a small configurable nominal amount (CardReplacement:VerificationAmount, default ₱1.00), then auto-refunds it the moment the charge succeeds. If the automatic refund fails, the payment stays in Success status and is refundable manually via the existing "Refund Last Payment" admin button — check server logs for "Automatic refund of card-replacement verification charge failed" to catch this.
Whether refunds settle back to the consumer instantly, and whether FIUU has a dedicated tokenization-only API that would remove the need for this workaround entirely, are unconfirmed — no FIUU documentation available to us covers refund settlement timing. An email is out to FIUU support; until they answer, treat "subscriber pays nothing net" as expected but not verified.
Multi-unit handling: edit quantity (upgrade & downgrade)
Covers upgrading and downgrading quantity to an arbitrary target value, cancelling the whole subscription, the multi-subscription admin view, client-app ownership isolation, billing timing, and the invoice explanation for a quantity change. (Formerly decrement-only "reduce quantity by one," itself formerly "per-branch cancellation" — generalized 2026-09-02 to support upgrades, not just downgrades; billing model revised again 2026-09-15 so upgrades charge immediately and reset the renewal date — see TC-CXL-002 and TC-CXL-012–014.)
- Create a session with
quantity: 3via Swagger and complete checkout. - Open the subscriber detail page; confirm Quantity: 3.
- Click Edit Quantity, enter
2, confirm the downgrade.
- Note the current Period End, then on the same subscriber (Quantity: 2), click Edit Quantity and enter
5.
/payments.- Click Edit Quantity, leave the field at the current value, click Save.
- Click Cancel Subscription and confirm.
- Open a Cancelled subscription's card.
- Create a second subscription for the same subscriber on a different plan.
- Open the subscriber detail page.
- In Swagger, Authorize with the owning app's API key and call the quantity endpoint with a higher value (upgrade), then a lower one (downgrade), on its own subscription.
- Authorize with a different app's key and repeat a call against the same subscription.
- Call the quantity endpoint with
{"quantity": 0}.
- Downgrade a
quantity: 3subscription to 2, then use Set Due Date + Run Recurring — confirms downgrades still flow to the next cycle unchanged. - Upgrade the same subscriber from 2 to 6 (this immediately creates a Prorated Upgrade payment and resets the renewal date, per TC-CXL-002), then use Set Due Date + Run Recurring again against the new period end.
- Using the downgrade from TC-CXL-009 (quantity changed from 3 to 2), open that recurring payment's invoice from
/payments. - Separately, open the invoice for a payment made before any quantity change.
- Open the Edit Quantity and Cancel Subscription dialogs and dismiss each without confirming.
- Note the current quantity and Period End. Have a developer force the next charge attempt to decline.
- Attempt an upgrade via Edit Quantity.
/payments for admin follow-up.- Attempt to upgrade quantity on a Trial or Past Due subscription.
- Open the invoice for the TC-CXL-002 upgrade payment.
Change plan (New 2026-09-15)
A different action from Edit Quantity above, but it runs the identical billing mechanism — a fresh full cycle charged immediately, credited for unused time, gated on a successful charge, renewal date reset to today.
- Open an Active subscriber on a cheaper plan (create a second, pricier plan on the app first if needed). Note the current Period End.
- Click Change Plan, select the higher-priced plan, and confirm.
/payments.- Confirm the current plan and any cheaper plan never appear in the picker.
- Via Swagger, call the admin change-plan endpoint directly with a cheaper
plan_id, then against a Trial/Past Due/Cancelled subscription.
- Note the current plan and Period End. Force the next charge to decline.
- Attempt Change Plan → a higher-priced plan → confirm.
/payments.Multi-unit handling: quantity per session
Covers session-level quantity, amount scaling for initial and recurring charges, retry preservation, and the interaction between quantity reduction and the next charge cycle. (Formerly "branch quantity per session" — generalized 2026-09-02.)
- In Swagger, create a session with
quantity: 5against a plan priced at ₱100. - Open the returned
payment_urland complete checkout.
- Create a session with
quantity: 0. - Create a session with
quantityomitted.
- On the TC-QTY-001 subscription, use Set Due Date then Run Recurring.
- Fail a
quantity: 4session, then click Retry from the failed page. - Check the breakdown on the new checkout page.
- Ask a developer to send one correctly signed webhook with an intentionally wrong amount for a
quantity: 3session.
- Compare a quantity = 5 subscriber against a quantity = 1 subscriber.
- On a quantity = 5, ₱100 subscriber, trigger the renewal reminder path (developer-assisted for the scheduler run).
- Open the reminder's renewal URL.
- Create a quantity = 4, ₱100 subscription.
- Edit Quantity down to 3.
- Immediately run Set Due Date + Run Recurring.
One-time tokenization (booking apps)
A separate billing mode from the multi-unit subscription features above — covers creating a One-Time Tokenization app, a booking session that produces no subscription, on-demand charges against the token, closing the token, and the automatic expiry sweep.
- Go to Apps → Create App, and for Billing Mode select "One-Time Tokenization (Booking)".
- Via Swagger, create a session for the TC-OTT-001 app with
token_valid_untila few minutes in the future, and complete checkout. - Open the subscriber's detail page, then
/card-tokens.
token_valid_until required / not applicableFunctional- Repeat session creation without
token_valid_untilon a One-Time Tokenization app, then with it on a Recurring app, then with a past timestamp.
- In Swagger, call the charge endpoint for the TC-OTT-002 payment method with an arbitrary amount.
- On
/card-tokens, click Close Token on the TC-OTT-002 row, then repeat the TC-OTT-004 charge call.
- Create a booking session with
token_valid_until~1 minute out, complete checkout, then wait for it to pass plus one worker poll interval.
Troubleshooting map
502 from Caddy
Test the published host port, inspect container port bindings, and ensure containerized Caddy connects to the host bridge address—not its own 127.0.0.1.
Payment not found after FIUU return
Verify that the FIUU dashboard return/callback URLs point to the same environment that created the order. Compare the returned orderid with the payment record.
Recurring reports missing OrderID
Confirm the outgoing form uses top-level key 0, has 12 card-token fields, and logs orderIdLength 32 and checksumLength 32.
Payment succeeds but access is inactive
Inspect payment, subscription status, current period end, and callback processing logs. Use the subscription check response as the source of truth.
Developer examples call localhost
Rebuild the web image after changing NEXT_PUBLIC_API_URL; public Next.js values are embedded during build.