NetzonPay DOCS
NETZONPAY DOCUMENTATION

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.

Hosted checkoutKeep payment details out of your app.
Recurring billingRenew using FIUU-issued tokens.
Access statusGate features with one status call.

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 App Identity and URLs
2 Tiers Monthly and annual prices
3 Launch Key, IDs, and endpoints

1. Open the correct onboarding environment

2. Enter the app identity

FieldRequiredWhat to enter
App nameYesThe product customers are subscribing to.
SlugNoA unique URL-safe name. NetzonPay generates it when omitted.
DescriptionNoA short internal description of the integration.
Callback URLNoYour app’s HTTPS integration callback URL, when used.
Logo URLNoA public HTTPS logo used to identify the app.
CurrencyYesThree-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.

TIERStarter
MONTHLYPHP 299.00Starter Monthly
ANNUALPHP 2,990.00Starter Annual

4. Launch and save the result

After a successful launch, copy all integration values before leaving the screen.

APP IDIdentifies your app

Use as NETZONPAY_APP_ID.

RAW API KEYShown at creation

Store immediately in your backend secret manager.

PLAN IDsMonthly and annual

Map each product choice to its plan UUID.

ENDPOINTSPlans and sessions

Use the returned paths with the matching API base URL.

Copy the API key immediately.

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.

POST/api/v1/onboarding/appsOnboarding endpoint
Onboarding request
curl --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
      }
    ]
  }'
Confirm the environment before launch.

App slugs must be unique. Development, staging, and production create separate apps, API keys, Plan IDs, subscribers, and payment records.

Continue to developer setup →

Configure your backend

Use values from the onboarding success screen. Keep a separate set for development, staging, and production.

Backend environment
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
BACKEND ONLYAPI key and App ID

Use these when your trusted server calls NetzonPay.

PRODUCT MAPPINGYour price → Plan ID

Choose the Plan ID on the server. Do not accept arbitrary Plan IDs from an untrusted client.

Confirm the onboarded plans

GET/api/v1/integration/plansApp API key

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

1
Store onboarding output

Save the App ID, raw API key, and the Monthly/Annual Plan IDs created for each tier.

2
Create a session from your backend

Send the authenticated user and selected Plan ID to POST /api/v1/sessions.

3
Redirect to the payment URL

Use the returned checkout URL. Never collect FIUU card details inside your own application.

4
Check subscription access

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"
  }'
Keep the API key server-side.

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.

EnvironmentAPI base URLPortal URLFIUU
Developmenthttps://dev-api-pay.netzon.devhttps://dev-pay.netzon.devDedicated sandbox
Staginghttps://staging-api-pay.netzon.devhttps://staging-pay.netzon.devDedicated sandbox
Productionhttps://api-pay.netzon.devhttps://pay.netzon.devLive
Never cross callback hosts.

A development payment returned to staging cannot be found because its order and session exist in the development database.

Core concepts

01App

An integrating product. It owns plans, subscribers, and a secret API key.

02Plan

A price and billing cycle belonging to one app.

03Subscriber

Your user, identified by an external_user_id within an app.

04Session

A temporary hosted-checkout attempt tied to one subscriber and plan.

05Subscription

The access and billing lifecycle: pending, active, past due, cancelled, or expired.

06Payment method

FIUU-issued token metadata used for eligible recurring charges.

How a payment moves through the system

Your appCreates session
→
NetzonPayHosts checkout
→
FIUUProcesses payment
→
NetzonPayVerifies callback
→
Your appChecks access
  1. Your backend creates a session with its app API key.
  2. The customer opens the returned NetzonPay portal URL.
  3. NetzonPay creates the FIUU hosted-checkout payload.
  4. FIUU handles card entry, 3DS, and payment authorization.
  5. FIUU posts signed return/callback data to NetzonPay.
  6. NetzonPay verifies the signature and updates payment and subscription state idempotently.
  7. 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.

APP INTEGRATIONSX-Api-Key

Sessions, plans for an integration, subscription status, and app-owned cancellation.

ADMIN OPERATIONSAuthorization: Bearer

Apps, 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.

POST/api/v1/sessionsApp API key
FieldRequiredPurpose
app_idYesYour NetzonPay app UUID.
external_user_idYesStable user identifier from your system.
emailYesSubscriber email used for billing records and invoices.
nameYesSubscriber or billing name.
phoneNoBilling contact number.
plan_idYesPlan UUID owned by the authenticated app.
quantityNoNumber of units on this plan — branches, seats, tranches, or whatever your app decides. Defaults to 1. The total charge is plan.amount × quantity.
return_urlYesYour application destination after the NetzonPay result screen.
Subscribing multiple units on one plan →

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.

PLANStarter Monthly
PER UNITPHP 299.00from plan.amount
quantity: 5PHP 1,495.00charged once, covers all 5

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

Session request with quantity
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"
  }'
Pricing the total before checkout.

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.

201 Created
{
  "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.

1Customer returnsYour application result page loads.
→
2Backend verifiesCall the subscription check endpoint.
→
3UI rendersShow active, pending, or retry guidance.
Next.js result route
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
  });
}
Never trust query parameters such as ?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.

GET/api/v1/subscriptions/checkApp API key
Server-side check
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", 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");
}
Use 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

200 OK
{
  "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.

POST/api/v1/subscriptions/{subscriptionId}/cancelApp API key
Request
curl --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

200 OK
{
  "success": true,
  "data": {
    "status": "cancelled",
    "access_until": "2026-07-05T00:00:00Z",
    "quantity": 3
  }
}
Show the access end date.

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.

POST/api/v1/subscriptions/{subscriptionId}/quantityApp API key
Request
curl --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

200 OK
{
  "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"
  }
}
ConditionResult
quantity missing, not an integer, or below 1400
Subscription belongs to another app404 (not found)
Subscription is cancelled, expired, or (for an upgrade specifically) not Active409
Upgrade: no payment method on file, or the prorated charge is declined409 — nothing on the subscription changes
Revised 2026-09-15 — upgrades now charge immediately and reset the renewal date.

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.

The invoice explains the change.

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.

POST/api/v1/subscriptions/{subscriptionId}/planApp API key
Request
curl --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

200 OK
{
  "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-..."
  }
}
ConditionResult
Already on this plan409
Target plan is not priced higher than the current one, or belongs to another app409 / 404
Subscription is not Active409
No payment method on file, or the prorated charge is declined409 — nothing on the subscription changes
Only upgrades — no downgrade path yet.

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 modeUse it when…
recurring (default)You charge the same amount on a repeating schedule.
one_time_tokenizationYou take one payment, then need to charge the same card on demand for a bounded period, then never again.

The flow

  1. Guest checks in — you call POST /api/v1/sessions with token_valid_until set to the end of the window (e.g. the check-out date). Guest pays through the normal hosted checkout. No subscription is created.
  2. 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.
  3. Window ends — you call the close endpoint to invalidate the card token. It can never be charged again.
Create a booking session
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.

Not used: FIUU's zero-charge tokenization API.

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 })
  });
}

Understand the subscription lifecycle

Pendingpayment successTrial / Activerenewal failsPastDuerecovery failsExpired
StatusAccessDeveloper behavior
TrialYesGrant access and show the trial end date.
ActiveYesGrant access through the current paid period.
PendingNoShow processing guidance and poll conservatively.
PastDueUntil the current period endsTrust the returned active value and show payment recovery guidance.
CancelledUntil the returned access endDo not start another renewal; show the end date.
ExpiredNoOffer a new checkout session.

Supported billing periods

Monthly 1 monthBimonthly 2 monthsQuarterly 3 monthsSemiannual 6 monthsAnnual 12 months

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.

Initial payment3DS / customer present
Token storedEncrypted at rest
Period dueAuto-charge worker
FIUU recurringServer to server
RenewedPeriod extended

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.
Simulation tokens are not FIUU tokens.

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.

POST/api/v1/fiuu/returnBrowser return
POST/api/v1/fiuu/notifyServer notification
POST/api/v1/fiuu/callbackPayment callback
Do not call these endpoints from your client app.

They are FIUU transport endpoints, not integration webhooks for merchants using NetzonPay.

Security practices for integrations

Do
  • 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.
Do not
  • 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

POST/api/v1/onboarding/apps

Creates one active app, generates its raw API key, and creates Monthly and Annual plans for every supplied tier.

Request rules

FieldRule
app_nameRequired; maximum 100 characters.
slugOptional; unique lowercase alphanumeric value with hyphens.
callback_urlOptional absolute URL; HTTPS is required outside development.
currencyRequired three-letter uppercase ISO currency code.
tiersBetween 1 and 20 tiers with unique names.
monthly_priceGreater than zero with at most two decimal places.
annual_priceGreater 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_plan and annual_plan, including Plan IDs.
  • The Plans and Sessions endpoint paths and the X-Api-Key header name.
201 App and plans created400 Validation failed409 Slug already exists422 Cannot process request

Create checkout session

POST/api/v1/sessions

Creates or reuses the subscriber record and opens a hosted payment session for a plan owned by the authenticated app.

Responses

201 Session created400 Invalid request401 Invalid API key404 Plan not found409 Conflicting subscription429 Rate limited

List integration plans

GET/api/v1/integration/plans

Returns plans available to the app authenticated by X-Api-Key. Use these IDs when building a server-controlled product-to-plan mapping.

Check subscription

GET/api/v1/subscriptions/check

Required query parameters are app_id and external_user_id. The supplied App ID must match the authenticated app.

Cancel subscription

POST/api/v1/subscriptions/{id}/cancel

Cancels an app-owned subscription. An optional JSON reason may be supplied for audit and support context.

Edit quantity

POST/api/v1/subscriptions/{id}/quantity

Sets 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

application/json
{
  "quantity": 5,
  "reason": "Customer upgraded to 5 units"
}

Responses

200 Quantity changed400 quantity missing or below 1404 Subscription not found409 Invalid state, or (upgrade only) no payment method / charge declined429 Rate limited
Revised 2026-09-15.

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

POST/api/v1/admin/subscriptions/{id}/quantity

Admin 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

application/json
{
  "quantity": 2,
  "reason": "Customer downgrade request"
}

Responses

200 Quantity changed401 Invalid or missing JWT404 Subscription not found409 Invalid state, or (upgrade only) no payment method / charge declined429 Rate limited

Change plan

POST/api/v1/subscriptions/{id}/plan

Switches 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

application/json
{
  "plan_id": "7a1c8890-...",
  "reason": "Customer upgraded to Premium"
}

Responses

200 Plan changed404 Subscription or plan not found409 Already on this plan, target not priced higher, not Active, no payment method, or charge declined429 Rate limited

Admin change plan

POST/api/v1/admin/subscriptions/{id}/plan

Admin 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

200 Plan changed401 Invalid or missing JWT404 Subscription or plan not found409 Already on this plan, target not priced higher, not Active, no payment method, or charge declined429 Rate limited

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.

POST/api/v1/integration/payment-methods/{paymentMethodId}/chargesApp API key
Request
curl --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" }'
200 Charged401 Invalid or missing API key404 Payment method not found409 Token expired, closed, or the charge was declined422 Not a one-time-tokenization token

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.

POST/api/v1/integration/payment-methods/{paymentMethodId}/closeApp API key
Request
curl --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'
200 Closed (or already closed)401 Invalid or missing API key404 Payment method not found422 Not a one-time-tokenization token
If you never call this, NetzonPay closes the token for you.

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

HTTPMeaningWhat to do
400Malformed or invalid inputFix fields before retrying.
401Missing or invalid authenticationCheck the server-side API key.
403Authenticated app does not own the resourceCheck App ID and environment.
404Resource not foundCheck IDs and environment boundaries.
409Resource state conflicts with the requestRead current subscription/session state.
422Request understood but cannot be processedShow a safe message and log the response.
429Too many requestsBack off and retry with jitter.
502Payment provider unavailableDo 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.

01Smoke

Health, portal, login, and API availability.

02Functional

Apps, plans, sessions, checkout, payment, and access.

03Lifecycle

Recurring, retry, cancellation, expiry, and refunds.

04Hardening

Invalid auth, replayed callbacks, cross-app IDs, and rate limits.

Test one environment at a time.

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

TC-ENV-001Environment is readySmoke
  1. Open the environment API /healthz.
  2. Open its portal domain and sign in.
  3. Confirm Swagger is available only in dev/staging.
  4. Confirm FIUU callback URLs use the same environment hostname.
  5. Confirm internal testing tools appear only when enabled outside production.
ExpectedHealth is 200, the portal loads without console errors, and no URL crosses into another environment.
TC-ENV-002Test data can be tracedData
  1. Create or select a QA app and active plan.
  2. Copy the raw API key securely.
  3. Use a unique external user ID and email for the run.
ExpectedThe App ID, Plan ID, API key owner, and generated subscriber are all from the same environment.

Initial payment scenario

Recurring payment scenario

Useful log signals Submitting FIUU recurring request formKey 0 · fieldCount 12 · checksumLength 32 FIUU recurring charge submission completed Recurring payment renewed subscription

High-value negative scenarios

TC-NEG-001Invalid API key

Session creation returns 401 and creates no records.

TC-NEG-002Cross-app Plan ID

Request is rejected; no session is created.

TC-NEG-003Expired session

Checkout cannot be initiated and clearly explains expiration.

TC-NEG-004Invalid FIUU signature

Webhook is rejected and payment state is unchanged.

TC-NEG-005Duplicate callback

Processing is idempotent; no duplicate renewal or invoice.

TC-NEG-006Missing recurring token

Charge is skipped safely and subscriber data explains ineligibility.

TC-NEG-007FIUU unavailable

No false success; the subscription is not penalized for transport failure.

TC-NEG-008Rate limit

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.
Redact before sharing.

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.

01Onboarding console

Create a test app, plan, and API key at /onboarding.

02Internal test tool

/internal/demo-app creates sessions and drives checkout with one-click buttons. Available outside production.

03Subscriber detail page

Edit quantity (upgrade or downgrade), generate card-replacement links, and run the per-subscriber Recurring Test panel.

04Swagger

Covers the few requests with no dedicated screen, such as setting quantity on session creation.

Sandbox test card

FieldValue
Card number4012 0000 0000 0007
ExpiryAny future date
CVV123

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.
Using Swagger without curl.

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.

TC-CARD-002Successful retry reactivates a PastDue subscriptionLifecycle
  1. Find a subscriber with status Past Due.
  2. Create a fresh checkout for the same external user ID and complete it (real card or Simulate Success).
ExpectedThe subscription status returns to Active.
TC-CARD-003Replace card via a valid link (happy path)Critical
  1. On a subscriber's detail page, click Generate Card Replacement Link and copy the URL.
  2. Open it in a private browser window; confirm the current card and expiry are shown.
  3. 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.
  4. Reload the subscriber detail page.
ExpectedLands on a "Card updated" confirmation; the new card is Default, the old card remains on file but no longer Default; a payment row for the nominal verification amount appears on /payments and settles to Refunded within seconds.
TC-CARD-004Expired replacement link is rejectedSecurity
  1. Edit a valid link's expires query parameter to a past timestamp and open it.
ExpectedAn expired-link error page is shown; no card details are displayed.
TC-CARD-005Tampered token is rejectedSecurity
  1. Change one character in a valid link's token query parameter and open it.
ExpectedAn invalid-link error page is shown; no subscriber details are leaked.
TC-CARD-006Replacing with the same card twice always leaves one defaultRegression
  1. Complete TC-CARD-003.
  2. Generate a new replacement link for the same subscriber and replace again with the identical card.
ExpectedExactly one stored method is marked Default afterward — never zero. It is acceptable for a second row to appear for the same card rather than the old row being reused (see the known-issue note below).
TC-CARD-007Admin card status badgesDev-assisted
  1. Note a subscriber's card status badge (Active).
  2. 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.
  3. Reload the subscriber detail page.
ExpectedBadge changes to Expired. A subscriber with zero payment methods shows "No payment method on file" with a button to generate a replacement link.
TC-CARD-008Recurring charge falls back to an alternate cardDev-assisted · Critical
  1. Get a subscriber with two stored cards (initial checkout, then one card replacement).
  2. Ask a developer to invalidate the current default's token in the database, simulating a bank-side token failure.
  3. On the subscriber page, use Set Due Date then Run Recurring.
ExpectedThe subscription is not immediately pushed to PastDue; the alternate card becomes Default; a new Success payment appears against it; the retry count increases by at most one for the cycle, not once per card attempted.
Known issue: token lookup does not detect duplicates.

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.

Why replacement charges ₱1.00 instead of nothing.

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.

Parked pending FIUU confirmation (as of 2026-09-02).

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

TC-CXL-003Setting the same quantity is a no-opEdge
  1. Click Edit Quantity, leave the field at the current value, click Save.
ExpectedToast reads "Quantity unchanged"; no audit entry is written and nothing else changes.
TC-CXL-004Cancel the whole subscription (full cancellation)Critical
  1. Click Cancel Subscription and confirm.
ExpectedDialog states access continues until the current period ends; status becomes Cancelled and both Edit Quantity and Cancel Subscription buttons on the card are then disabled.
TC-CXL-005Edit quantity on an already-cancelled subscriptionEdge
  1. Open a Cancelled subscription's card.
ExpectedBoth Edit Quantity and Cancel Subscription buttons are disabled — the UI gives no way to trigger an invalid change; the backend also rejects it with 409 if attempted directly via Swagger, in either direction.
TC-CXL-006Multi-subscription admin viewFunctional
  1. Create a second subscription for the same subscriber on a different plan.
  2. Open the subscriber detail page.
ExpectedBoth subscriptions render as separate cards, each with its own status, quantity, and correctly enabled or disabled actions.
TC-CXL-007Client-app quantity endpoint respects ownership, both directionsSecurity
  1. 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.
  2. Authorize with a different app's key and repeat a call against the same subscription.
ExpectedThe owning app's calls succeed in both directions; the other app's call returns 404, not 403, avoiding any information leak.
TC-CXL-008quantity validationNegative
  1. Call the quantity endpoint with {"quantity": 0}.
Expected400 response referencing "quantity must be at least 1"; subscription's quantity is unchanged.
TC-CXL-009The cycle following an upgrade reflects the new quantity normallyCritical
  1. Downgrade a quantity: 3 subscription to 2, then use Set Due Date + Run Recurring — confirms downgrades still flow to the next cycle unchanged.
  2. 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.
ExpectedBoth recurring payment records show amount = plan amount × the (then-)current quantity, type Recurring — confirming the cycle after an upgrade bills normally with no leftover proration logic.
TC-CXL-011Confirmation dialogs can be dismissed safelyFunctional
  1. Open the Edit Quantity and Cancel Subscription dialogs and dismiss each without confirming.
ExpectedNo change is made in either case; quantity and status are unchanged on reload.
TC-CXL-013Trial/Past Due upgrades are rejected outrightNegative
  1. Attempt to upgrade quantity on a Trial or Past Due subscription.
Expected409 response; quantity and period unchanged. Downgrading from the same status is unaffected and still works.
TC-CXL-014The upgrade invoice shows the full credit/charge/renewal breakdownCritical
  1. Open the invoice for the TC-CXL-002 upgrade payment.
ExpectedLine item shows quantity 1 and the actual amount charged (not a misleading per-unit rate). The note states the old and new quantities, the credit, the new quantity's full-cycle price, the total charged, and the new renewal date.

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.

TC-PLN-002Cheaper plan, same plan, or non-Active subscription are all rejectedNegative
  1. Confirm the current plan and any cheaper plan never appear in the picker.
  2. Via Swagger, call the admin change-plan endpoint directly with a cheaper plan_id, then against a Trial/Past Due/Cancelled subscription.
Expected409 in every case; nothing changes on the subscription.

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

TC-QTY-002quantity validationNegative
  1. Create a session with quantity: 0.
  2. Create a session with quantity omitted.
Expected0 is rejected with a 400 validation error; omitting it defaults to 1 for backward compatibility.
TC-QTY-003Recurring charge uses the subscription's quantityCritical
  1. On the TC-QTY-001 subscription, use Set Due Date then Run Recurring.
ExpectedNew payment record: amount ₱500, quantity 5.
TC-QTY-004Retry preserves the original quantityRegression
  1. Fail a quantity: 4 session, then click Retry from the failed page.
  2. Check the breakdown on the new checkout page.
ExpectedThe retry session still shows × 4 at the same per-unit total — the subscriber cannot change quantity mid-retry.
TC-QTY-005Webhook amount mismatch is still detected with scalingDev-assisted · Security
  1. Ask a developer to send one correctly signed webhook with an intentionally wrong amount for a quantity: 3 session.
ExpectedThe payment is marked Failed for amount mismatch, not Success.
TC-QTY-006Admin view shows quantity per subscriptionFunctional
  1. Compare a quantity = 5 subscriber against a quantity = 1 subscriber.
ExpectedThe quantity-5 card shows the count and the per-unit × quantity = total breakdown; the quantity-1 card shows plain "1" with no special formatting.
TC-QTY-007Renewal reminder charges the correct scaled amountRegression · Critical
  1. On a quantity = 5, ₱100 subscriber, trigger the renewal reminder path (developer-assisted for the scheduler run).
  2. Open the reminder's renewal URL.
ExpectedCheckout shows ₱100 × 5 = ₱500, not ₱100; completing checkout charges ₱500.
TC-QTY-008Quantity edit and recurring charge compose correctlyCritical
  1. Create a quantity = 4, ₱100 subscription.
  2. Edit Quantity down to 3.
  3. Immediately run Set Due Date + Run Recurring.
ExpectedThe charge amount is ₱300 — the new quantity is picked up immediately by the next cycle, with no stale value carried over — and that payment's invoice includes a "quantity changed from 4 to 3" note (see TC-CXL-010).

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.

TC-OTT-003token_valid_until required / not applicableFunctional
  1. Repeat session creation without token_valid_until on a One-Time Tokenization app, then with it on a Recurring app, then with a past timestamp.
Expected422 "required for one-time-tokenization apps", 422 "not applicable for subscription apps", and 400 "must be in the future", respectively.
TC-OTT-004Charge an incidental against the tokenCritical
  1. In Swagger, call the charge endpoint for the TC-OTT-002 payment method with an arbitrary amount.
Expected200 success response; a new Manual-type payment row with no subscription linked appears in Payments.
TC-OTT-005Close the token — further charges are rejectedCritical
  1. On /card-tokens, click Close Token on the TC-OTT-002 row, then repeat the TC-OTT-004 charge call.
ExpectedStatus flips to "Removed" and the button disables; the repeated charge call returns 409 "no longer chargeable". Closing an already-closed token again is a harmless no-op.
TC-OTT-006Expiry sweep closes an unclosed token automaticallyNeeds dev assist
  1. Create a booking session with token_valid_until ~1 minute out, complete checkout, then wait for it to pass plus one worker poll interval.
ExpectedThe token's status moves to "Removed" (or "Expired" if the FIUU delete call itself failed) without anyone calling the close endpoint.

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.

Go-live checklist

NetzonPay

Built for developers and QA teams shipping reliable subscription payments.

Documentation v1.0 · July 2026