Documentation/App Hosting
Verified GuideApp HostingEN

Order App Hosting by API

Step-by-step guide to order and deploy an App Hosting app with an organization API key, from creating the key to canceling the order.

7 min read•Last updated 10/24/2026•/app-hosting/order-by-api

For developers. This guide needs some programming. If you only use the console, start at App Hosting: Put Your First App Online.

This guide takes you from nothing to a running app using only HTTP requests. Every example uses https://pfnapp.id. The full endpoint list is in the API Reference.

Warning: step 4 charges your organization balance with real money. Check prices and balance first (steps 2 and 3).

flowchart LR
  A[1. Create API key] --> B[2. Check balance]
  B --> C[3. Pick plan and template]
  C --> D[4. Send the order]
  D --> E[5. Watch the status]
  E --> F[6. Cancel if needed]

1. Create an API key

  1. Open Console > API Keys (/console/api-keys) and click Create API key.
  2. Enter a Name (for example order-app-hosting), choose the App Hosting or All products product, and Write access.
  3. Click Create key and copy the key that appears. It is shown only once.

API key list

Create API key dialog

Key shown once

Key rules:

  • Write access is required to order and cancel. Read access only views data, and ordering with a Read key returns 403 API_KEY_ACCESS_NOT_ALLOWED.
  • Only the organization Owner can create a Write key.
  • A key of another product (for example WhatsApp) is rejected with 403 API_KEY_PRODUCT_NOT_ALLOWED.

Keep the key in an environment variable, never in code or a repository:

BASH
export PFN_API_KEY="app_live_..."

Every request below sends the header Authorization: Bearer $PFN_API_KEY.


2. Check the balance

BASH
curl -s https://pfnapp.id/api/billing/account \
  -H "Authorization: Bearer $PFN_API_KEY"

Response:

JSON
{ "ok": true, "currency": "IDR", "balance": "7107659.35" }

When the balance is too low at order time, the API answers 402 INSUFFICIENT_BALANCE with a topupUrl and creates nothing.


3. Pick a plan, a region, and a template

Prices and plans

BASH
curl -s https://pfnapp.id/api/billing/catalog \
  -H "Authorization: Bearer $PFN_API_KEY"

Find the product with code APP_HOSTING. Take two values from it:

  • plans[].id, for resourcePlanId. Also check plans[].resources.provisioning.storage (GB), because some templates need a minimum amount of storage.
  • plans[].offers[].regionId, for regionId. Each offer also carries billingPeriod and periodPrice.

Shape of the response (trimmed):

JSON
1{
2 "currency": "IDR",
3 "products": [
4 {
5 "code": "APP_HOSTING",
6 "plans": [
7 {
8 "id": "<plan-id>",
9 "code": "PRO",
10 "name": "Pro",
11 "resources": {
12 "provisioning": { "cpu": 1000, "memory": 4096, "storage": 10 }
13 },
14 "offers": [
15 {
16 "billingPeriod": "MONTHLY",
17 "periodPrice": "75000",
18 "regionId": "<region-id>",
19 "regionCode": "INDONESIA"
20 }
21 ]
22 }
23 ]
24 }
25 ]
26}

Templates

BASH
curl -s https://pfnapp.id/api/templates \
  -H "Authorization: Bearer $PFN_API_KEY"

This endpoint returns a bare array (no ok). Use the template slug as templateId, for example 9router.

A plan that is too small for the template returns 422 INSUFFICIENT_PLAN_STORAGE and costs nothing. The 9router template needs 10 GB, so a 5 GB plan (Small, Starter) is rejected and Pro or larger is accepted.


4. Send the order

Endpoint: POST /api/deploy/submit. The Idempotency-Key header is required for API keys. Create one unique value per order, for example a UUID.

cURL

BASH
1curl -X POST "https://pfnapp.id/api/deploy/submit" \
2 -H "Authorization: Bearer $PFN_API_KEY" \
3 -H "Idempotency-Key: $(uuidgen)" \
4 -H "Content-Type: application/json" \
5 -d '{
6 "sourceType": "MANAGED_TEMPLATE",
7 "templateId": "9router",
8 "name": "my-app",
9 "resourcePlanId": "<plan-id>",
10 "billingPeriod": "MONTHLY",
11 "periodMonths": 1,
12 "regionId": "<region-id>"
13 }'

Node.js / TypeScript

TYPESCRIPT
1import { randomUUID } from "node:crypto"
2
3const res = await fetch("https://pfnapp.id/api/deploy/submit", {
4 method: "POST",
5 headers: {
6 Authorization: `Bearer ${process.env.PFN_API_KEY}`,
7 "Idempotency-Key": randomUUID(),
8 "Content-Type": "application/json",
9 },
10 body: JSON.stringify({
11 sourceType: "MANAGED_TEMPLATE",
12 templateId: "9router",
13 name: "my-app",
14 resourcePlanId: "<plan-id>",
15 billingPeriod: "MONTHLY",
16 periodMonths: 1,
17 regionId: "<region-id>",
18 }),
19})
20console.log(res.status, await res.json())

Body fields

FieldDescription
sourceTypeTEMPLATE, MANAGED_TEMPLATE, or PUBLIC. GITHUB is rejected with 422 SOURCE_TYPE_NOT_ALLOWED_FOR_KEY.
templateIdThe slug from GET /api/templates.
nameApp name.
resourcePlanIdplans[].id from the catalog.
billingPeriod, periodMonthsFor example MONTHLY and 1.
regionIdoffers[].regionId from the catalog.

The server resolves the billing mode from the catalog, so a billingMode you send is ignored.

Success response

JSON
1{
2 "ok": true,
3 "data": {
4 "stackId": "...",
5 "stackSlug": "my-app",
6 "deploymentId": "...",
7 "status": "...",
8 "hourlyCost": "..."
9 }
10}

The balance is charged and the first deployment starts right away.


5. Idempotency: safe to retry

SituationResult
Same request, same Idempotency-KeyThe first answer is returned with the header Idempotent-Replayed: true. It never orders twice.
Same Idempotency-Key, different body422 IDEMPOTENCY_KEY_REUSED
First request still running409 IDEMPOTENCY_IN_PROGRESS
The order failedThe key is freed, so you may retry with the same key

A key is kept for 24 hours. If the connection drops after you sent the order, resend the exact same request with the same Idempotency-Key. Do not create a new key, because that would order again.


6. Watch the status

BASH
curl -s https://pfnapp.id/api/deploy/apps/my-app \
  -H "Authorization: Bearer $PFN_API_KEY"

The response has data.stack (app status, resourcePlanId, subdomain) and data.latestDeployment (latest deployment status). Repeat until status is running.

The same app also shows in the console:

App created by an API order


7. Cancel

BASH
curl -X POST "https://pfnapp.id/api/deploy/apps/my-app/cancel" \
  -H "Authorization: Bearer $PFN_API_KEY" \
  -H "Content-Type: application/json"

No body is needed. The cancel is scheduled for the end of the current period. The app stays active until that date, then all its resources are deleted permanently, and there is no refund. Export important data before that date.


8. First-period cost

The first period is prorated to the end of the month. Example from a test: a Pro plan with a monthly price of Rp75,000 ordered on the 16th (16 of 31 days) was charged Rp38,709.68. After that, billing follows the period you chose.


9. Error list

StatusCodeMeaning
400IDEMPOTENCY_KEY_REQUIRED, IDEMPOTENCY_KEY_INVALID, or PARSE_ERRORThe Idempotency-Key header is missing or malformed, or the JSON is broken.
401UNAUTHORIZEDThe key is missing, wrong, or revoked.
402INSUFFICIENT_BALANCEBalance too low. Open the topupUrl from the response.
403API_KEY_PRODUCT_NOT_ALLOWEDThe key belongs to another product (for example VPN-only or WhatsApp), not App Hosting.
403API_KEY_ACCESS_NOT_ALLOWEDA Read key was used to order or cancel.
409IDEMPOTENCY_IN_PROGRESSA request with the same key is still being processed.
422IDEMPOTENCY_KEY_REUSEDThe same key was used with a different body.
422SOURCE_TYPE_NOT_ALLOWED_FOR_KEYGITHUB is not allowed through an API key.
422INSUFFICIENT_PLAN_STORAGEThe plan is too small for the template.
429RATE_LIMITEDAt most 60 requests per minute per key.

10. Safe practices

  • Keep the key in a secret manager or an environment variable, not in code.
  • Use a Read key for dashboards and monitoring, and a Write key only for the process that orders.
  • Create a new Idempotency-Key for every order, and reuse one only when retrying the same order.
  • Revoke unused keys in Console > API Keys. A call with a revoked key answers 401 right away.