Subscriptions API
Overview
The Subscriptions API allows authenticated users to retrieve their
subscription information including active plans, billing details,
payment methods, and subscription features. This replaces the legacy
subscription.php endpoint with standard JWT
authentication.
The getSubscriptions endpoint requires a valid JWT
token. The user must be logged in, and the developer key used at
login must have subscription access enabled.
Contact the VPT team to enable this permission on your developer
key.
Subscription access is controlled by the
subscription permission on your developer key. If
your key does not have this permission, you will receive an
ACCESS_DENIED error. VPT's own app keys have this
enabled by default.
Mutating endpoints (v2)
The endpoints below let an authenticated user manage their own
subscription end-to-end. They are dispatched through
/api using the query field, exactly like
the read endpoints.
Access tiers
Two tiers gate the subscription surface, in addition to the user's JWT and ownership check:
-
READ — driven by the
api_dev_keys.subscriptioncolumn. AllowsgetSubscriptions,getSubscriptionDetails,getPaymentHistory,getManageSubscriptionUrl. -
FULL — additional allowlist in
api/Subscriptions/access_config.php(per dev key). AllowscancelSubscription,pauseSubscription,resumeSubscription,setAutoRenewal,applyDiscountCode,getCheckoutUrl.
Calls outside the caller's tier return HTTP 403 with
SUBSCRIPTION_READ_REQUIRED or
SUBSCRIPTION_FULL_REQUIRED.
Endpoint summary
| query | Tier | Purpose |
|---|---|---|
getSubscriptions |
READ | Lists every subscription owned by the caller. |
getSubscriptionDetails |
READ |
Returns a single subscription in the same shape as
getSubscriptions. Use to refresh one subscription
after a mutation.
|
getPaymentHistory |
READ | Merged payment ledger across 2Checkout, PayPal and Apple. |
getManageSubscriptionUrl |
READ |
Gateway management URL (e.g. Apple's
apps.apple.com/account/subscriptions).
|
cancelSubscription |
FULL |
Cancels server-side. Apple subscriptions return
APPLE_NOT_SUPPORTED.
|
pauseSubscription |
FULL |
Pauses with a mandatory resumeDate
(YYYY-MM-DD).
|
resumeSubscription |
FULL | Resumes a paused subscription. |
setAutoRenewal |
FULL | Toggles auto-renewal (enabled: bool). |
applyDiscountCode |
FULL | Validates a code and returns a checkout URL with the discount pre-applied. |
getCheckoutUrl |
FULL | Builds a fresh checkout URL for a given product/promo. |
changePlan |
FULL | 2CO: change to any plan in the catalogue — prorated or deferred depending on direction. Pending implementation. |
paypal2checkoutMigration |
FULL | Transfer remaining days from a PayPal subscription to a new 2CO subscription, with tier-aware bonus days. |
Rate limiting
FULL-tier endpoints use the existing
subscription_api_logs table for rate limiting. A user
may perform at most
5 state-changing actions per hour; requests beyond
that limit return HTTP 429 with RATE_LIMITED.
Webhook URLs
The new architecture exposes one public entry per provider-event. These URLs are what you paste into the gateway's control panel.
| Provider | Event | URL |
|---|---|---|
| 2Checkout | Instant Payment Notification |
/api/webhooks/2checkout/ipn
|
| 2Checkout | License Change Notification |
/api/webhooks/2checkout/lcn
|
| PayPal | Instant Payment Notification |
/api/webhooks/paypal/ipn
|
| Apple | App Store Server Notifications V2 |
/api/webhooks/apple/notifications
|
| Apple | Mobile-app nudge (StoreKit success) |
/api/webhooks/apple/mobile
|
Re-pointing webhook URLs is done in each gateway's control panel by VPT staff. The legacy entry points keep working until you swap them out — switching is reversible at any time without code changes.
Get User Subscriptions
Returns all subscriptions for the authenticated user, including active, expired (within 30 days), paused, and cancelled plans. Each subscription includes billing details, payment method information, and available features.
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
query |
string |
Required | Must be "getSubscriptions" |
Request Body
{
"query": "getSubscriptions"
}
cURL Example
curl -X POST "https://api.virtualperformancetool.com/api" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "X-API-Version: 1.3.6" \
-d '{
"query": "getSubscriptions"
}'
Response
{
"apiVersion": "1.3.6",
"success": true,
"subscriptions": [
{
"subscriptionId": 1234,
"subscriptionReference": "ABC123DEF456",
"title": "VPT 737NG Pro Yearly",
"groupPackage": "VPT_GLOBAL_Pro",
"paymentMethod": "2checkout",
"status": "ACTIVE",
"isPaused": false,
"pauseUntil": null,
"pauseReason": null,
"autoRenew": true,
"billing": {
"frequency": "yearly",
"interval": "Y",
"pricePaid": 49.99,
"currency": "USD",
"nextBillingDate": "2027-04-15",
"nextBillingAmount": 49.99,
"lastPaymentDate": "2026-04-15",
"cyclesCompleted": null
},
"subscriber": {
"email": null,
"name": null,
"cardLast4": "1111"
},
"dates": {
"start": "2026-04-15",
"end": "2027-04-15",
"daysRemaining": 274,
"isActive": true,
"isExpired": false
},
"image": {
"filename": "VPT_GLOBAL_Pro.jpg",
"url": "https://api.virtualperformancetool.com/assets/images/subscriptions/VPT_GLOBAL_Pro.jpg"
},
"actions": {
"canPause": true,
"canCancel": true,
"canUpgrade": true,
"canRenew": true,
"renewalUrl": "https://secure.2checkout.com/renewal/?LICENSE=ABC123DEF456",
"appleManageUrl": null
},
"pricing": {
"catalogPrice": 79.99,
"discountPercent": 18,
"promoCode": "VA-ALLIANCE-18",
"discountRecurring": true,
"discountMaxCycles": 3,
"durationDays": 365,
"durationLabel": "1 year"
},
"features": {
"level": 3,
"maxCalculation": 999,
"isNotam": true,
"isNnc": true,
"isMel": true,
"isCdl": true,
"isMostFav": true,
"isBalkedLanding": true,
"isAllIntersections": true
}
}
],
"totalCount": 1
}
Response Fields
| Field | Type | Description |
|---|---|---|
success |
boolean |
Whether the request was successful |
subscriptions |
array |
Array of subscription objects (see below). Empty array if the user has no subscriptions. |
totalCount |
integer |
Total number of subscriptions returned |
Subscription Object
Each subscription in the subscriptions array contains:
Core Fields
| Field | Type | Description |
|---|---|---|
subscriptionId |
integer |
Unique subscription identifier |
subscriptionReference |
string|null |
External payment provider subscription reference |
title |
string |
Product display name |
groupPackage |
string|null |
Product group identifier |
paymentMethod |
string |
Payment method type:
paypal, 2checkout,
applepay, commercial,
unknown
|
status |
string |
Semantic subscription status:
ACTIVE — current and not paused;
ACTIVE_PAUSED — current but renewal is
paused (user retains full access until end date);
PAUSED — renewal paused and end date has
passed (gap period, no access); EXPIRED —
lapsed normally; CANCELLED — explicitly
cancelled.
|
isPaused |
boolean |
Whether the subscription renewal is currently paused (status
is ACTIVE_PAUSED or PAUSED)
|
pauseUntil |
string|null |
ISO 8601 date when billing is scheduled to resume.
null when the subscription is not paused.
|
pauseReason |
string|null |
Reason provided when the subscription was paused, or null |
autoRenew |
boolean |
Whether auto-renewal is active |
Billing Object
| Field | Type | Description |
|---|---|---|
billing.frequency |
string |
monthly, yearly, or
unknown
|
billing.interval |
string |
Raw interval code: Y (yearly) or
M (monthly)
|
billing.pricePaid |
float |
Original purchase price for the current period (rounded to 2 d.p.) |
billing.currency |
string|null |
ISO 4217 currency code (e.g. EUR,
USD). Populated live from 2Checkout;
null for PayPal, Apple Pay, and commercial
subscriptions.
|
billing.nextBillingDate |
string|null |
ISO 8601 next billing date. Populated live from 2Checkout;
null for non-2CO subscriptions.
|
billing.nextBillingAmount |
float|null |
Gross renewal amount including VAT. Populated live from
2Checkout;
null for non-2CO subscriptions.
|
billing.lastPaymentDate |
string|null |
Date of the most recent payment, or null if
unavailable
|
billing.cyclesCompleted |
integer|null |
Number of billing cycles completed, or null if
unavailable
|
Dates Object
| Field | Type | Description |
|---|---|---|
dates.start |
string |
Subscription start date |
dates.end |
string |
Subscription end date |
dates.daysRemaining |
integer |
Days remaining until expiration |
dates.isActive |
boolean |
Whether the subscription is within its active date range |
dates.isExpired |
boolean |
Whether the subscription end date has passed |
Features Object
| Field | Type | Description |
|---|---|---|
features.level |
integer |
Subscription level (determines feature access) |
features.maxCalculation |
integer |
Maximum calculations allowed per billing period |
features.isNotam |
boolean |
Whether NOTAM data access is included |
features.isNnc |
boolean |
Whether NNC (Non-Normal Checklist) access is included |
features.isMel |
boolean |
Whether MEL (Minimum Equipment List) access is included |
features.isCdl |
boolean |
Whether CDL (Configuration Deviation List) access is included |
features.isMostFav |
boolean |
Whether the most-favourable runway/condition optimisation is included (VPT Global Pro) |
features.isBalkedLanding |
boolean |
Whether the balked-landing (go-around) procedure calculation is included (VPT Global Pro) |
features.isAllIntersections |
boolean |
Whether the all-intersections takeoff calculation is included (VPT Global Pro) |
Actions Object
| Field | Type | Description |
|---|---|---|
actions.canPause |
boolean |
Whether the subscription can be paused (2CO and PayPal only) |
actions.canCancel |
boolean |
Whether the subscription can be cancelled |
actions.canUpgrade |
boolean |
Whether a plan change is available (use
changePlan endpoint)
|
actions.canRenew |
boolean |
Whether the subscription can be manually renewed |
actions.renewalUrl |
string|null |
Direct 2CO manual renewal URL (null for non-2CO
subscriptions). Open in WebView — no VPT API endpoint for
renewal.
|
actions.appleManageUrl |
string|null |
Apple Pay subscriptions only: deep-link to
apple.com/account/subscriptions.
null for all other payment methods. All other
actions (cancel, pause, upgrade, manage billing) go through
VPT API endpoints — no embedded URLs.
|
pricing.catalogPrice |
number|null |
Full list price from the product catalog, before any discount. Use as the strikethrough price in the UI (e.g. ~~€79.99~~) when a discount was applied. |
pricing.discountPercent |
integer|null |
Percentage discount applied at the time of purchase.
null if no discount code was used or the code is
not tracked in the discount_codes table.
|
pricing.promoCode |
string|null |
The discount / promo code used at purchase.
null if none was used.
|
pricing.discountRecurring |
boolean|null |
true — discount applies to renewal cycles (apply_recurring = ALL).false — discount was first payment only (apply_recurring = NONE).null — no discount code was used.
|
pricing.discountMaxCycles |
integer|null |
Number of renewal cycles the discount is limited to (maps to
2CO RecurringChargesNumber).null — unlimited renewals at the discounted price
(or no discount / first-payment-only).N — discount applies to the first
N renewals only. Only meaningful when
discountRecurring === true.
|
pricing.durationDays |
integer|null |
Subscription length in days as defined in the product catalog. |
pricing.durationLabel |
string|null |
Human-readable subscription duration, e.g.
"1 year", "1 month",
"2 years". Based on
paypal_interval and
paypal_interval_count from the product catalog.
|
Flutter / Mobile API (subscription.php)
The VPT Flutter app uses a dedicated
HMAC-signed endpoint
(subscription.php) that is separate from the JWT-based
/api endpoint documented above. All actions in this
section are dispatched through this endpoint.
Base URL
GET https://api.virtualperformancetool.com/subscription.php?action=ACTION
POST https://api.virtualperformancetool.com/subscription.php?action=ACTION
Request Headers
| Header | Required | Description |
|---|---|---|
X-API-Key |
Required | The user's API key. |
X-Timestamp |
Required | Unix timestamp in seconds. Must be within ±5 minutes of server time. |
X-Signature |
Required | HMAC-SHA256 request signature (see algorithm below). |
Content-Type |
POST only | application/json |
Signature Algorithm
$secret = hash('sha256', $userId . SAFETY_CODE);
$message = $apiKey . $timestamp . $action;
$signature = hash_hmac('sha256', $message, $secret);
Confirmation Tokens (State-Changing Actions)
Every POST action that modifies subscription state requires a
confirmation_token in the JSON body. The token is
generated client-side and is valid for
10 minutes.
// Build a confirmation token (requires SAFETY_CODE)
$timestamp = time();
$data = $action . '_' . ($subscriptionId ?? 'general') . '_' . $timestamp . '_' . $userId;
$hash = hash_hmac('sha256', $data, SAFETY_CODE);
$token = base64_encode(json_encode([
'action' => $action,
'subscription_id' => $subscriptionId,
'timestamp' => $timestamp,
'user_id' => $userId,
'hash' => $hash,
]));
State-changing actions are limited to
5 per user per hour. Exceeding the limit returns
HTTP 429 with RATE_LIMITED.
Action Summary
| action | Method | Description |
|---|---|---|
user_subscriptions |
GET | List all user subscriptions. |
subscription_details |
GET | Single subscription with live gateway status. |
payment_history |
GET | Paginated payment ledger. |
pause_subscription |
POST ★ | Pause (PayPal & 2CO; Apple → 422). |
resume_subscription |
POST ★ | Resume a paused subscription. |
cancel_subscription |
POST ★ | Cancel immediately. |
change_plan |
POST ★ | 2CO: change plan — upgrades prorated by gateway, downgrades re-prorated server-side (extends date_end). |
change_payment_method |
POST ★ | 2CO: get hosted payment-update URL. |
request_refund |
POST ★ | 2CO: initiate refund (currently admin-only). |
migration_preview |
GET | Check PayPal → 2CO migration eligibility. |
migration_start |
POST ★ | Create migration intent + checkout URL. |
migration_status |
GET | Poll migration completion. |
migration_cancel |
POST ★ | Discard a pending migration intent. |
★ = state-changing; requires confirmation_token in
body; counts against the 5/h rate limit.
Gateway Capabilities
Not every payment gateway supports server-side management. When an
unsupported operation is attempted the API returns
HTTP 422 with error code
OPERATION_NOT_SUPPORTED and a manage_url
field pointing to the provider's self-service portal.
| Operation | PayPal | 2Checkout | Apple IAP |
|---|---|---|---|
| Pause | ✅ NVP / REST fallback | ✅ | ❌ → App Store |
| Resume | ✅ NVP / REST fallback | ✅ | ❌ → App Store |
| Cancel | ✅ NVP / REST fallback | ✅ | ❌ → App Store |
| Upgrade | ❌ → PayPal | ✅ prorated immediately | ❌ → App Store |
| Downgrade | ❌ → PayPal | ✅ effective next renewal | ❌ → App Store |
| Refund | ❌ (webhook-inbound only) | ✅ | ❌ (webhook-inbound only) |
| Change Payment Method | ❌ → PayPal | ✅ hosted URL | ❌ → App Store |
Responses for unsupported operations include a
manage_url string that the app should open in a
webview so the user can manage the subscription directly with the
provider.
Get Payment History
Returns the paginated payment ledger for the authenticated user.
When filtering by subscription_id for a 2Checkout
subscription, live order data is fetched directly from the 2CO
API and falls back to the local IPN log table if the gateway
call fails.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
integer | 50 | Records per page (1–100). |
offset |
integer | 0 | Zero-based page offset for pagination. |
subscription_id |
integer | — | Filter results to a single subscription. |
from |
string | — | Start date filter YYYY-MM-DD. |
to |
string | — | End date filter YYYY-MM-DD. |
Response
{
"success": true,
"message": "Payment history retrieved successfully",
"user_id": 42,
"payments": [
{
"id": 101,
"subscription_id": 7,
"amount": 49.99,
"currency": "EUR",
"payment_method": "2checkout",
"payment_date": "2026-04-15 10:23:00",
"status": "complete",
"transaction_id": "104815628",
"gateway": "2checkout"
}
],
"total_count": 6,
"limit": 50,
"offset": 0,
"has_more": false
}
Pause / Resume / Cancel
All three actions update the local subscription status
and synchronise the change to the payment gateway where
supported (see
Gateway Capabilities). For
PayPal subscriptions the NVP API is tried first and the REST API is
used as a fallback. A gateway_warning field is included
in the response if the gateway sync fails — the local status is
still updated in that case.
Pause Subscription
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
confirmation_token |
string | Required | Action-scoped HMAC token. |
subscription_id |
integer | Required | Subscription to pause. |
resume_date |
string | Optional |
2CO only: scheduled resume date
YYYY-MM-DD HH:MM:SS. Defaults to 30 days from
now.
|
Response
{ "success": true, "subscription_id": 7, "previous_status": "Running", "new_status": "paused" }
Resume Subscription
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
confirmation_token |
string | Required | |
subscription_id |
integer | Required |
Response
{ "success": true, "subscription_id": 7, "previous_status": "paused", "new_status": "Running" }
Cancel Subscription
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
confirmation_token |
string | Required | |
subscription_id |
integer | Required | |
reason |
string | Optional | Human-readable cancellation reason (logged internally). |
Response
{ "success": true, "subscription_id": 7, "previous_status": "Running", "new_status": "Cancelled" }
Change Plan (2Checkout only)
Change the product on an active 2Checkout subscription. Moves to any plan in the catalogue — upgrades are prorated at the gateway (price difference charged immediately) while downgrades are re-prorated server-side so the unused value carries over as extra days at the new daily rate (see Plan Change Server Behaviour). Validate the target plan against the tier hierarchy below.
The changePlan query on the
/api endpoint is not yet available. Plan changes are
currently handled through the subscription.php Mobile
API (change_plan
action documented below).
Product Tier Hierarchy
| Rank | Product Code | Plan |
|---|---|---|
| 1 | VPT_73NG_LITE_M |
Lite — Monthly |
| 2 | VPT_73NG_LITE_Y |
Lite — Yearly |
| 3 | VPT_73NG_STANDARD_M |
Standard — Monthly |
| 4 | VPT_73NG_STANDARD_Y |
Standard — Yearly |
| 5 | VPT_73NG_PRO_M |
Pro — Monthly |
| 6 | VPT_73NG_PRO_Y |
Pro — Yearly |
Change Plan (Mobile API)
Changes the product on an active 2CO subscription. Upgrades to a higher-ranked product are prorated and charged immediately. Downgrades to a lower-ranked product are not charged but the remaining paid value is re-prorated and added as extra days at the new (cheaper) daily rate — see Plan Change Server Behaviour.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
confirmation_token |
string | Required | Action-scoped HMAC token. |
subscription_id |
integer | Required | 2CO subscription to change. |
target_pcode |
string | Required |
Target 2CO product code (e.g.
VPT_73NG_PRO_Y). Must differ from current
plan.
|
Response
// Upgrade (prorated charge applied immediately):
{
"success": true,
"subscription_id": 7,
"previous_pcode": "VPT_73NG_STANDARD_M",
"new_pcode": "VPT_73NG_PRO_Y",
"note": "Price difference has been prorated and will be charged immediately."
}
// Downgrade (no charge, date_end extended by re-prorating remaining value):
{
"success": true,
"subscription_id": 7,
"previous_pcode": "VPT_73NG_PRO_Y",
"new_pcode": "VPT_73NG_STANDARD_Y",
"note": "Downgrade applied. Remaining paid value re-prorated at the new daily rate; date_end has been extended."
}
Plan Change Server Behaviour
Independently of which client triggers a plan change (Mobile API,
2Checkout Customer Area, or admin tooling), the server-side IPN/LCN
dispatcher enforces the following rules so the
subscriptions table stays consistent:
-
One active row per user. Whenever a CREATE or
plan-change event lands a row for user X, every other row
of X with status
RunningorPausedis automatically moved toSuperseded. This fixes the historical bug where an upgrade from Lite → Pro would leave the old Lite row active. -
Upgrades trust the gateway date. 2Checkout
charges the price difference at the moment of the upgrade and
supplies a new
LICENSE_EXP_DATE; we copy it verbatim onto the (kept) subscription row. -
Downgrades are re-prorated server-side.
2Checkout does NOT refund the unused portion of the more expensive
plan. To honour the value already paid we extend
date_endusing:Worked example — user on Pro-Y (100 €, 365 d) downgrades to Lite-Y (50 €, 365 d) with 339 days remaining:remainingDays = max(0, oldDateEnd - today) remainingValue = remainingDays / oldDurationDays × oldPricePaid newDailyRate = newPrice / newDurationDays extendedDays = floor(remainingValue / newDailyRate) // capped at 1095 d newDateEnd = today + extendedDays
remainingValue = 339/365 × 100 ≈ 92.88 €,newDailyRate = 50/365 ≈ 0.137 €/d,extendedDays ≈ 678 d⇒ roughly two extra years on Lite, as expected. - Lateral changes (same tier, different billing cycle) transfer the remaining days 1:1.
-
LCN plan detection. 2Checkout downgrades
typically arrive only as an LCN with
STATUS=ACTIVEand a changedNEXT_RENEWAL_PRICE— the LCN payload does not carry the new PCODE. The dispatcher detects the price change, callsGET /subscriptions/{ref}/on 2Checkout to read the current PCODE, resolves it to a localproducts.id, and reroutes the event through the plan-change handler.
2Checkout Control Panel prerequisites
In Setup → Products → Upgrade Options, configure ALL tier
pairs in BOTH directions (Lite↔Standard, Lite↔Pro, Standard↔Pro).
Without these entries the Customer Area hides the downgrade
buttons and Mobile-API change_plan calls fail with
"Product is not a configured upgrade target".
- Enable BOTH webhooks (Setup → System Settings → Notifications): IPN (instant payment notifications) AND LCN (license change notifications). LCN is what carries the downgrade signal.
-
Webhook secrets must match the values stored in
config.php(TWOCHECKOUT_*_SECRET). -
The native 2Checkout proration setting can stay at its default —
the server re-computes
date_endfor downgrades regardless, so flipping it does not change the user-visible outcome.
Change Payment Method (2Checkout only)
Returns a 2Checkout hosted URL where the user can update their
saved payment card. Open this URL in an in-app webview. For
PayPal and Apple IAP subscriptions the response is HTTP 422 with
a manage_url pointing to the provider's portal.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
confirmation_token |
string | Required | |
subscription_id |
integer | Required |
Response
{
"success": true,
"subscription_id": 7,
"checkout_url": "https://secure.2checkout.com/myaccount/payment-update/I-XXXXXXXXXXXX",
"instructions": "Open checkout_url in a webview for the user to update their payment details."
}
Request Refund (2Checkout only)
User self-serve refunds are currently disabled (REFUND_USER_SELF_SERVE = false). This endpoint always returns HTTP 403 for regular users.
Direct refund requests to customer support. Self-serve can be
enabled server-side by toggling the constant.
When enabled, initiates a refund for the most-recent 2Checkout
order on the subscription. Refunds are processed asynchronously
— the gateway fires a REFUND_ISSUED IPN webhook
that sets the subscription status to Refunded.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
confirmation_token |
string | Required | |
subscription_id |
integer | Required | |
reason |
string | Required |
One of: FRAUD, DUPLICATE,
OTHER, CUSTOMER_REQUEST,
QUALITY_ISSUE.
|
amount |
float | Optional |
Amount to refund. Omit or pass 0 for a full
refund.
|
comment |
string | Optional | Internal note visible in the 2CO dashboard. |
Response
{
"success": true,
"subscription_id": 7,
"order_ref": "104815628",
"refund_status": "pending",
"message": "Refund initiated. Status will be confirmed via webhook."
}
PayPal → 2Checkout Migration
Allows a user with an active PayPal subscription to migrate to a 2Checkout subscription, carrying over remaining days with a tier-aware day bonus. Upgrading yields a higher bonus; downgrading transfers days 1:1 (no bonus). The flow is non-destructive: the PayPal subscription is only cancelled once the new 2CO subscription is confirmed by the payment gateway IPN.
Migration Day Bonus by Direction
| Direction | Example | Bonus |
|---|---|---|
| Same tier | PayPal Pro → 2CO Pro | +10 % |
| Upgrade +1 tier | PayPal Standard → 2CO Pro | +20 % |
| Upgrade +2 tiers | PayPal Lite → 2CO Pro | +30 % |
| Downgrade | PayPal Pro → 2CO Lite | 0 % (1:1) |
Option A — Direct API (Recommended)
Use the paypal2checkoutMigration query on the standard
/api endpoint (FULL tier, JWT auth). This is a two-step
process:
-
Call
getCheckoutUrlto generate a purchase URL for the target 2CO plan. Open it in a webview. -
After the webview closes, poll
getSubscriptionsuntil the new 2CO subscription row appears, then callpaypal2checkoutMigrationwith both IDs.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
paypalSubscriptionId |
integer | Required | DB id of the existing PayPal subscription row. |
twocheckoutSubscriptionId |
integer | Required | DB id of the newly-purchased 2CO subscription row. |
Response
{
"success": true,
"alreadyMigrated": false,
"remainingDays": 147,
"bonusDays": 161,
"newExpiryDate": "2025-09-10 00:00:00",
"paypalCancelStatus": "ok", // "ok" | "failed"
"warning": null
}
The operation is idempotent — repeat calls
return alreadyMigrated: true without re-extending.
If paypalCancelStatus is "failed", the
days were transferred but the PayPal recurring profile could not
be cancelled automatically; instruct the user to cancel it
manually at
paypal.com/myaccount/autopay.
Option B — Mobile API (Legacy Flow)
The subscription.php Mobile API offers a more granular
4-step intent-based flow for apps that need polling.
Flow Overview
-
Call
migration_preview(GET) — check eligibility and receive the checkout URL. -
Call
migration_start(POST) — create a migration intent; receiveintent_id. -
Open
checkout_urlin an in-app webview — user completes the 2CO purchase. -
Poll
migration_status(GET) withintent_id— wait for"status": "completed". -
If user cancels before paying: Call
migration_cancel(POST) to discard the intent.
When the 2CO IPN fires after purchase the server automatically
runs
MigrationService — it cancels the PayPal
subscription, credits bonus days, and marks the intent
completed. If the IPN processes the migration before
the app polls, migration_status will still return
completed with the new subscription details.
Step 1 — Migration Preview
Read-only. Returns eligibility information and the pre-built 2CO checkout URL. No state is changed.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
target_pcode |
string | Optional |
Override the default same-tier 2CO product code. Accepts
any value from MIGRATION_PCODE_MAP (e.g.
VPT_73NG_PRO_Y). Omit to use the same-tier
default. The available_targets array in the
response lists all valid options.
|
Response — eligible (same-tier, default)
{
"success": true,
"eligible": true,
"paypal_subscription": {
"id": 3,
"date_end": "2026-08-12",
"product_title": "VPT Standard Yearly"
},
"source_product_code": "VPT_73NG_STANDARD_Y",
"target_product_code": "VPT_73NG_STANDARD_Y",
"migration_direction": "same",
"remaining_days": 85,
"bonus_pct": 10,
"bonus_extra_days": 9,
"bonus_days": 94,
"bonus_description": "Your remaining 85 day(s) will carry over with a 10% bonus (9 extra day(s) added to your new 2Checkout subscription).",
"checkout_url": "https://secure.2checkout.com/checkout/buy?merchant=XXXX&prod=VPT_73NG_STANDARD_Y&...",
"available_targets": [
{ "pcode": "VPT_73NG_PRO_Y", "plan_label": "Pro-Y", "direction": "upgrade", "bonus_pct": 20, "bonus_extra_days": 17, "total_days": 102, "is_default": false },
{ "pcode": "VPT_73NG_PRO_M", "plan_label": "Pro-M", "direction": "upgrade", "bonus_pct": 20, "bonus_extra_days": 17, "total_days": 102, "is_default": false },
{ "pcode": "VPT_73NG_STANDARD_Y", "plan_label": "Standard-Y", "direction": "same", "bonus_pct": 10, "bonus_extra_days": 9, "total_days": 94, "is_default": true },
{ "pcode": "VPT_73NG_STANDARD_M", "plan_label": "Standard-M", "direction": "same", "bonus_pct": 10, "bonus_extra_days": 9, "total_days": 94, "is_default": false },
{ "pcode": "VPT_73NG_LITE_Y", "plan_label": "Lite-Y", "direction": "downgrade", "bonus_pct": 0, "bonus_extra_days": 0, "total_days": 85, "is_default": false },
{ "pcode": "VPT_73NG_LITE_M", "plan_label": "Lite-M", "direction": "downgrade", "bonus_pct": 0, "bonus_extra_days": 0, "total_days": 85, "is_default": false }
],
"existing_pending_intent_id": null,
"notes": "Your PayPal subscription will be cancelled automatically after the 2Checkout purchase is confirmed."
}
Response — not eligible
{
"success": true,
"eligible": false,
"reason": "NO_ELIGIBLE_PAYPAL_SUBSCRIPTION",
"reason_description": "No active, unmigrated PayPal subscription was found for this account."
}
Step 2 — Migration Start
Creates (or reuses an existing) migration intent. Returns the
checkout URL and an intent_id to track completion.
Intents expire after 24 hours.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
confirmation_token |
string | Required | Token scoped to migration_start. |
subscription_id |
integer | Required |
The PayPal subscription ID to migrate (must be the
eligible one returned by migration_preview).
|
target_pcode |
string | Optional |
Override the 2CO product to migrate to (e.g.
VPT_73NG_PRO_Y to upgrade during migration).
Must be a valid value from
MIGRATION_PCODE_MAP. Defaults to the
same-tier equivalent when omitted.
|
Response
{
"success": true,
"intent_id": 14,
"checkout_url": "https://secure.2checkout.com/checkout/buy?...",
"expires_at": "2026-05-20 14:35:00",
"target_product_code": "VPT_73NG_PRO_Y",
"migration_direction": "upgrade",
"bonus_pct": 20,
"bonus_extra_days": 17,
"bonus_days": 102,
"instructions": "Open checkout_url in a webview. After purchase, poll migration_status with the intent_id for completion."
}
Step 4 — Migration Status (Polling)
Poll this endpoint after the user returns from the checkout webview.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
intent_id |
integer | Required | The ID returned by migration_start. |
Status Values
| status | Meaning |
|---|---|
pending |
Awaiting IPN confirmation — continue polling. |
completed |
Migration successful. Response includes
new_subscription object.
|
expired |
24h TTL elapsed — intent discarded, PayPal subscription unaffected. |
failed |
IPN-side error — contact support. |
Response — completed
{
"success": true,
"intent_id": 14,
"status": "completed",
"target_product_code": "VPT_73NG_PRO_Y",
"migration_subscription_id": 22,
"completed_at": "2026-05-19 14:41:00",
"new_subscription": {
"id": 22,
"date_end": "2027-08-21",
"status": "Running",
"product": 5
}
}
Step 5 — Migration Cancel (Optional)
Marks the migration intent as expired. The original
PayPal subscription is not cancelled or
modified in any way.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
confirmation_token |
string | Required | Token scoped to migration_cancel. |
intent_id |
integer | Required | ID returned by migration_start. |
Response
{
"success": true,
"intent_id": 14,
"status": "expired",
"message": "Migration cancelled. Your PayPal subscription is unchanged."
}
Migration from Legacy Endpoint
If your application previously used the standalone
subscription.php endpoint with HMAC-SHA256
authentication, here is what changes:
| Legacy | New API | |
|---|---|---|
| Endpoint |
POST /subscription.php?action=user_subscriptions
|
POST /api |
| Auth | HMAC-SHA256 (X-API-Key, X-Timestamp, X-Signature) | JWT Bearer token |
| Query | URL parameter: ?action=user_subscriptions |
Body: {"query": "getSubscriptions"} |
| Response keys | snake_case | camelCase |
The legacy subscription.php endpoint remains
available for backward compatibility during the transition period.
New integrations should use the standard API endpoint documented
above.