Successful No success Updated --
CDK not loaded

Create, share, and monitor payment-link tasks with one CDK.

Extraction activity

Last 12 hours · Extraction activity · Each point is 5 minutes

12h · 5m per point

successful
Loading activity…
12h ago No requests 0% success Under 30% 30% or higher Now

WORKSPACE

0Pending 0Active 0Success 0Paid
0 selected

No tasks yet

Connect a CDK and import Sessions to begin.

WORKSPACE

Tools

SESSION TOOL

Check $0 eligibility

Billing country Currency

This check is informational and does not create a task.

READY TO PAY

Scan to pay

Open payment link

WORKSPACE

Filter & sort

Payment method
Status

PERMANENT ACTION

Delete stored data?

EXISTING TASK

This task already exists

PUPUPAY API

API Guide

External Paylinks API — scope, flow, and security

Set BASE_URL to this HTTPS deployment. Send JSON with Accept: application/json and Content-Type: application/json. The public API does not use an Authorization header: send the CDK in the documented body or query field.

  1. Verify that the CDK is active and supports the requested payment_method (upi, ideal, kakao, or gcash), then read its current capacity.
  2. Optionally run the authenticated eligibility check and retain its short-lived proof.
  3. Create one task, or submit an atomic batch of up to 50 tasks.
  4. Poll only queued and running tasks, then save each terminal result.
  5. Read result.payment_url and, when needed, query the payment status.

The external API does not return a QR image or QR URL; successful tasks expose only the payment link in result.payment_url. The sections below contain everything an external client needs. No server-settings lookup is required.

1. payment_method, CDK verification, and capacity

payment_method selects the type of payment link. Send one canonical lowercase value. For Kakao Pay use kakao, not kakao_pay.

ValueCountry / currencyAccepted CDK
upiIN / INRUPI-*
idealNL / EURUPI-* or IDEAL-*
kakaoKR / KRWUPI-*
gcashPH / PHPUPI-* or GCASH-*

A UPI-* CDK is valid for all four public methods. An IDEAL-* CDK is valid only for ideal; a GCASH-* CDK is valid only for gcash.

Verify a CDK

curl -X POST "$BASE_URL/api/card-key/verify" \
  -H "Content-Type: application/json" \
  -d '{"cdk":"<CDK>","payment_method":"gcash"}'

Always inspect both ok and code: a rejected CDK may still receive HTTP 200. A successful response includes allowed_payment_methods, remaining_uses, and use_status. Confirm that the requested method appears in allowed_payment_methods.

Read current capacity

curl --get "$BASE_URL/api/paylinks/tasks/summary" \
  --data-urlencode "cdk=<CDK>"
Summary fieldMeaning
remaining_totalUnconsumed uses, including uses held by unfinished tasks.
remainingUses not currently held by tasks.
reservedUses held by accepted unfinished tasks.
availableUses currently available for new tasks.
max_submit_nowMaximum number accepted by one creation request at this moment.
max_concurrencyMaximum running tasks for this CDK: currently 10.
max_tasks_per_requestMaximum tasks in one batch: currently 50.

The summary is a snapshot; task creation is authoritative. A single CDK runs at most 10 tasks concurrently. Accepted tasks above that limit remain queued and start automatically when a slot is available. One batch may contain at most 50 tasks.

2. Authenticated eligibility checks

Eligibility checks let a client screen one account before creating a task. They require a valid CDK that supports the selected method. A successful check returns a short-lived eligibility_proof that can be supplied to the single-task endpoint.

Generic check: upi, ideal, or kakao

curl -X POST "$BASE_URL/api/paylinks/eligibility/check" \
  -H "Content-Type: application/json" \
  -d '{
    "cdk": "<CDK>",
    "access_token": "<ACCESS_TOKEN>",
    "payment_method": "ideal"
  }'

The generic endpoint accepts upi, ideal, or kakao. Sending gcash returns gcash_eligibility_endpoint_required. It requires CDK authorization and shares the 60-second per-CDK limit of remaining_uses × 10 pre-checks, HTTP 429, and Retry-After rules described below.

Dedicated GCash check

curl -X POST "$BASE_URL/api/paylinks/eligibility/gcash" \
  -H "Content-Type: application/json" \
  -d '{
    "cdk": "<CDK>",
    "access_token": "<ACCESS_TOKEN>"
  }'

The GCash endpoint always checks gcash; it does not accept payment_method. Use a UPI-* or GCASH-* CDK. It requires the same CDK authorization and shares the same 60-second per-CDK limit of remaining_uses × 10 pre-checks, HTTP 429, and Retry-After rules. An eligible GCash response also reports payment_method_available=true and checkout_amount_is_zero=true. A failed preflight creates no task and does not enqueue anything.

Read the result

{
  "ok": true,
  "valid": true,
  "payment_method_available": true,
  "checkout_amount_is_zero": true,
  "eligible": true,
  "outcome": "eligible",
  "retryable": false,
  "reason": "",
  "message": "$0 eligible",
  "eligibility_proof": "<PROOF>",
  "proof_expires_at": 1786200000
}

HTTP 200 alone is not an eligibility decision. For GCash, show the three public checks—valid (Session), payment_method_available (GCash), and checkout_amount_is_zero ($0)—then require eligible=true. Otherwise display reason and message, and use retryable to decide whether retrying later makes sense. A proof is bound to the checked Access Token and payment method, expires quickly, and must not be reused for another account.

Authorization and limits shared by both endpoints

LimitCurrent value
Rate-limit window60 seconds
Pre-checks per CDKremaining_uses × 10 per window

Both endpoints share the same per-CDK counter. A limit rejection returns HTTP 429 with detail=zero_trial_rate_limited and a Retry-After response header. Wait for that duration; do not send parallel retries.

3. Create a single task or an atomic batch

Single task with an eligibility proof

curl -X POST "$BASE_URL/api/paylinks/tasks" \
  -H "Content-Type: application/json" \
  -d '{
    "payment_method": "gcash",
    "cdk": "<CDK>",
    "access_token": "<ACCESS_TOKEN>",
    "country": "PH",
    "currency": "PHP",
    "eligibility_proof": "<PROOF>"
  }'

Required fields are payment_method, cdk, and access_token. country and currency may be omitted to use the method defaults in section 1. eligibility_proof is optional; when supplied, it must be valid and match this token and method. A successful response includes task_id and an initial status.

Atomic batch

curl -X POST "$BASE_URL/api/paylinks/tasks/batch" \
  -H "Content-Type: application/json" \
  -d '{
    "payment_method": "gcash",
    "cdk": "<CDK>",
    "access_tokens": ["<ACCESS_TOKEN_1>", "<ACCESS_TOKEN_2>"]
  }'

access_tokens must contain 1–50 non-empty, unique values. The batch endpoint does not accept one proof per item; eligibility is checked as part of batch submission. The batch is atomic: an invalid field, an active duplicate, insufficient CDK uses, or an ineligible account rejects the whole request and creates no partial set. Save every returned task ID immediately.

When an accepted task is created, one CDK use is reserved. succeeded consumes it; failed, cancelled, or stale returns it. A token may be submitted again after its earlier task has finished or been deleted.

4. Poll task status, read the result, and check payment

Poll up to 50 IDs at once every 2–5 seconds. Continue only for queued and running; stop polling for succeeded, failed, cancelled, or stale. Avoid overlapping polls for the same ID set.

curl --get "$BASE_URL/api/paylinks/tasks/statuses" \
  --data-urlencode "task_ids=<TASK_ID_1>,<TASK_ID_2>"
{
  "ok": true,
  "items": [
    {
      "task_id": "<TASK_ID>",
      "payment_method": "gcash",
      "status": "succeeded",
      "failure": null,
      "result": {
        "payment_url": "https://payments.example.invalid/..."
      }
    }
  ],
  "missing": []
}

missing contains unknown IDs. To read one task, call GET /api/paylinks/tasks/<task_id>. The single-task response wraps the object in task. Task-ID reads do not require the CDK because the full task ID is the read credential.

A successful external task guarantees result.payment_url. Persist the complete terminal object. For failed or stale, display failure.code and failure.summary; if failure.action=retry, create a new task instead of reusing the old ID.

Payment status

curl "$BASE_URL/api/paylinks/tasks/<TASK_ID>/payment-status"

Call this only after the task succeeds. Payment monitoring is available for upi, kakao, and gcash; an ideal task returns a terminal unsupported response. HTTP 200 does not mean paid: payment_state=unpaid is still unpaid. Inspect paid, payment_state, terminal, retryable, reason, and retry_after_seconds. If terminal=false and retryable=true, wait at least retry_after_seconds before trying again. Stop when terminal=true or retryable=false.

5. Public errors and client handling

For a non-2xx response, parse JSON detail; validation errors may use an array. For a terminal task returned with HTTP 200, parse failure instead. Always show the stable code and useful message—never only “HTTP 422”.

HTTPdetail / codeClient action
200CDK verify returns ok=falseRead code; do not continue.
400payment_method_required, payment_method_unsupported, cdk_type_mismatchCorrect the method or use a compatible CDK.
400gcash_eligibility_endpoint_required, eligibility_payment_method_unsupportedUse the correct eligibility endpoint or supported method.
401/403zero_trial_authorization_required, zero_trial_authorization_invalid, zero_trial_authorization_insufficientSupply a valid compatible CDK with remaining uses.
422zero_trial_access_token_required, zero_trial_ineligible, eligibility_proof_invalidCorrect or replace the account/proof; do not blind-retry.
429zero_trial_rate_limitedWait for the Retry-After duration.
400/409too_many_tasks, insufficient_remaining_uses, duplicate_access_tokenRespect the 50-task limit, refresh summary, or remove active duplicates.
503eligibility_check_unavailable, eligibility_service_unavailableTemporary failure; retry later with backoff.
400task_ids_required, too_many_task_idsSend 1–50 valid task IDs.
404task_not_foundThe ID is unknown or no longer retained.

Eligibility responses may also return eligible=false with reasons such as invalid_session, plus_account, checkout_amount_nonzero, gcash_payment_method_unavailable, or gcash_currency_mismatch. Show message and do not create a task unless eligible=true.

6. Complete JavaScript example (Node.js 18+)

Set BASE_URL, CDK, PAYMENT_METHOD, and ACCESS_TOKEN. This example verifies the CDK, checks capacity, calls the correct eligibility endpoint, creates one task with the proof, polls to a terminal status, prints the payment URL, and follows the payment-status interval. It never prints the CDK, token, or proof.

const BASE = (process.env.BASE_URL || "").replace(/\/+$/, "");
const CDK = process.env.CDK || "";
const METHOD = (process.env.PAYMENT_METHOD || "gcash").toLowerCase();
const TOKEN = process.env.ACCESS_TOKEN || "";
const METHODS = {
  upi: { country: "IN", currency: "INR" },
  ideal: { country: "NL", currency: "EUR" },
  kakao: { country: "KR", currency: "KRW" },
  gcash: { country: "PH", currency: "PHP" },
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

function describe(body) {
  const detail = body && (body.detail !== undefined ? body.detail : body.error);
  if (Array.isArray(detail)) {
    return detail.map((item) => {
      const path = Array.isArray(item.loc) ? item.loc.join(".") : "request";
      return path + ": " + (item.msg || JSON.stringify(item));
    }).join("; ");
  }
  if (typeof detail === "string") return detail;
  if (detail) return JSON.stringify(detail);
  return (body && body.message) || "unknown_error";
}

async function api(path, init = {}) {
  const response = await fetch(BASE + path, {
    ...init,
    headers: { Accept: "application/json", "Content-Type": "application/json", ...(init.headers || {}) },
  });
  const text = await response.text();
  let body = {};
  try { body = text ? JSON.parse(text) : {}; } catch { body = { detail: text }; }
  if (!response.ok) {
    const wait = response.headers.get("retry-after");
    throw new Error("HTTP " + response.status + ": " + describe(body) + (wait ? " (retry after " + wait + "s)" : ""));
  }
  return body;
}

async function preflight() {
  const path = METHOD === "gcash"
    ? "/api/paylinks/eligibility/gcash"
    : "/api/paylinks/eligibility/check";
  const payload = { cdk: CDK, access_token: TOKEN };
  if (METHOD !== "gcash") payload.payment_method = METHOD;
  const result = await api(path, { method: "POST", body: JSON.stringify(payload) });
  const outcome = String(result.outcome || "").toLowerCase();
  if (outcome === "disabled") {
    if (METHOD !== "gcash") return "";
    throw new Error("GCash eligibility check is disabled; it cannot be skipped");
  }
  const checks = {
    valid: result.valid === true,
    payment_method_available: result.payment_method_available === true,
    checkout_amount_is_zero: result.checkout_amount_is_zero === true,
    eligible: result.eligible === true,
    outcome: outcome === "eligible",
  };
  const failed = Object.keys(checks).filter((key) => !checks[key]);
  if (failed.length) {
    throw new Error("Not eligible (failed checks: " + failed.join(", ") + "): " + (result.reason || outcome || "unknown") + " - " + (result.message || ""));
  }
  const proofExpiry = Number(result.proof_expires_at);
  if (!Number.isFinite(proofExpiry) || proofExpiry <= Math.floor(Date.now() / 1000)) {
    throw new Error("Eligibility proof is missing or expired");
  }
  const proof = String(result.eligibility_proof || "").trim();
  if (!proof) throw new Error("Eligibility response did not include a proof");
  return proof;
}

async function paymentStatus(taskId) {
  for (;;) {
    const state = await api("/api/paylinks/tasks/" + encodeURIComponent(taskId) + "/payment-status");
    console.log("payment_state=" + (state.payment_state || "unknown") + " paid=" + Boolean(state.paid));
    if (state.terminal || !state.retryable) return state;
    await sleep(Math.max(1, Number(state.retry_after_seconds) || 30) * 1000);
  }
}

async function main() {
  if (!BASE.startsWith("https://") || !CDK || !TOKEN || !METHODS[METHOD]) {
    throw new Error("Set HTTPS BASE_URL, CDK, ACCESS_TOKEN, and a supported PAYMENT_METHOD");
  }

  const verified = await api("/api/card-key/verify", {
    method: "POST",
    body: JSON.stringify({ cdk: CDK, payment_method: METHOD }),
  });
  if (!verified.ok || !(verified.allowed_payment_methods || []).includes(METHOD)) {
    throw new Error("CDK rejected: " + (verified.code || "method_not_allowed"));
  }

  const summary = await api("/api/paylinks/tasks/summary?cdk=" + encodeURIComponent(CDK));
  if (Number(summary.max_submit_now) < 1) throw new Error("CDK has no capacity now");

  const proof = await preflight();
  const defaults = METHODS[METHOD];
  const created = await api("/api/paylinks/tasks", {
    method: "POST",
    body: JSON.stringify({
      payment_method: METHOD,
      cdk: CDK,
      access_token: TOKEN,
      country: defaults.country,
      currency: defaults.currency,
      eligibility_proof: proof,
    }),
  });
  const taskId = created.task_id;
  if (!taskId) throw new Error("Create response did not include task_id");

  let task;
  while (!task) {
    const snapshot = await api("/api/paylinks/tasks/statuses?task_ids=" + encodeURIComponent(taskId));
    if ((snapshot.missing || []).includes(taskId)) throw new Error("Task is missing");
    const current = (snapshot.items || []).find((item) => item.task_id === taskId);
    if (!current || current.status === "queued" || current.status === "running") {
      await sleep(3000);
      continue;
    }
    task = current;
  }

  if (task.status !== "succeeded") {
    const failure = task.failure || {};
    throw new Error("Task " + task.status + ": " + (failure.code || "") + " " + (failure.summary || ""));
  }
  console.log("payment_url=" + (task.result && task.result.payment_url));
  if (METHOD !== "ideal") await paymentStatus(taskId);
}

main().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});
7. Complete Python example (3.11+, standard library only)

This dependency-free example follows the same authenticated preflight, single-task creation, task polling, result, and payment-status flow.

import json
import os
import time
from urllib.error import HTTPError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen

BASE = os.environ.get("BASE_URL", "").rstrip("/")
CDK = os.environ.get("CDK", "")
METHOD = os.environ.get("PAYMENT_METHOD", "gcash").lower()
TOKEN = os.environ.get("ACCESS_TOKEN", "")
METHODS = {
    "upi": {"country": "IN", "currency": "INR"},
    "ideal": {"country": "NL", "currency": "EUR"},
    "kakao": {"country": "KR", "currency": "KRW"},
    "gcash": {"country": "PH", "currency": "PHP"},
}


def describe(body):
    detail = body.get("detail", body.get("error")) if isinstance(body, dict) else None
    if isinstance(detail, list):
        return "; ".join(
            f"{'.'.join(map(str, item.get('loc', [])))}: {item.get('msg', item)}"
            for item in detail
        )
    if isinstance(detail, str):
        return detail
    if detail:
        return json.dumps(detail, ensure_ascii=False)
    return body.get("message", "unknown_error") if isinstance(body, dict) else "unknown_error"


def api(path, method="GET", payload=None):
    data = json.dumps(payload).encode() if payload is not None else None
    request = Request(
        BASE + path,
        data=data,
        method=method,
        headers={"Accept": "application/json", "Content-Type": "application/json"},
    )
    try:
        with urlopen(request, timeout=300) as response:
            raw = response.read().decode()
            return json.loads(raw) if raw else {}
    except HTTPError as error:
        raw = error.read().decode(errors="replace")
        try:
            body = json.loads(raw) if raw else {}
        except json.JSONDecodeError:
            body = {"detail": raw}
        retry_after = error.headers.get("Retry-After")
        suffix = f" (retry after {retry_after}s)" if retry_after else ""
        raise RuntimeError(f"HTTP {error.code}: {describe(body)}{suffix}") from None


def preflight():
    if METHOD == "gcash":
        path = "/api/paylinks/eligibility/gcash"
        payload = {"cdk": CDK, "access_token": TOKEN}
    else:
        path = "/api/paylinks/eligibility/check"
        payload = {"cdk": CDK, "access_token": TOKEN, "payment_method": METHOD}
    result = api(path, "POST", payload)
    outcome = str(result.get("outcome") or "").strip().lower()
    if outcome == "disabled":
        if METHOD != "gcash":
            return ""
        raise RuntimeError("GCash eligibility check is disabled; it cannot be skipped")
    checks = {
        "valid": result.get("valid") is True,
        "payment_method_available": result.get("payment_method_available") is True,
        "checkout_amount_is_zero": result.get("checkout_amount_is_zero") is True,
        "eligible": result.get("eligible") is True,
        "outcome": outcome == "eligible",
    }
    failed = [name for name, passed in checks.items() if not passed]
    if failed:
        reason = result.get("reason") or outcome or "unknown"
        raise RuntimeError(
            f"Not eligible (failed checks: {', '.join(failed)}): "
            f"{reason} - {result.get('message', '')}"
        )
    proof = str(result.get("eligibility_proof") or "").strip()
    try:
        proof_expires_at = float(result.get("proof_expires_at") or 0)
    except (TypeError, ValueError):
        proof_expires_at = 0
    if not proof or proof_expires_at <= time.time():
        raise RuntimeError("Eligibility proof is missing or expired")
    return proof


def payment_status(task_id):
    encoded = quote(task_id, safe="")
    while True:
        state = api(f"/api/paylinks/tasks/{encoded}/payment-status")
        print(f"payment_state={state.get('payment_state', 'unknown')} paid={bool(state.get('paid'))}")
        if state.get("terminal") or not state.get("retryable"):
            return state
        time.sleep(max(1, int(state.get("retry_after_seconds") or 30)))


def main():
    if not BASE.startswith("https://") or not CDK or not TOKEN or METHOD not in METHODS:
        raise RuntimeError("Set HTTPS BASE_URL, CDK, ACCESS_TOKEN, and a supported PAYMENT_METHOD")

    verified = api(
        "/api/card-key/verify",
        "POST",
        {"cdk": CDK, "payment_method": METHOD},
    )
    if not verified.get("ok") or METHOD not in verified.get("allowed_payment_methods", []):
        raise RuntimeError(f"CDK rejected: {verified.get('code', 'method_not_allowed')}")

    summary = api("/api/paylinks/tasks/summary?" + urlencode({"cdk": CDK}))
    if int(summary.get("max_submit_now") or 0) < 1:
        raise RuntimeError("CDK has no capacity now")

    proof = preflight()
    created = api(
        "/api/paylinks/tasks",
        "POST",
        {
            "payment_method": METHOD,
            "cdk": CDK,
            "access_token": TOKEN,
            "country": METHODS[METHOD]["country"],
            "currency": METHODS[METHOD]["currency"],
            "eligibility_proof": proof,
        },
    )
    task_id = created.get("task_id")
    if not task_id:
        raise RuntimeError("Create response did not include task_id")

    task = None
    while task is None:
        query = urlencode({"task_ids": task_id})
        snapshot = api("/api/paylinks/tasks/statuses?" + query)
        if task_id in snapshot.get("missing", []):
            raise RuntimeError("Task is missing")
        current = next(
            (item for item in snapshot.get("items", []) if item.get("task_id") == task_id),
            None,
        )
        if current is None or current.get("status") in {"queued", "running"}:
            time.sleep(3)
            continue
        task = current

    if task.get("status") != "succeeded":
        failure = task.get("failure") or {}
        raise RuntimeError(
            f"Task {task.get('status')}: {failure.get('code', '')} {failure.get('summary', '')}"
        )
    print("payment_url=" + str((task.get("result") or {}).get("payment_url", "")))
    if METHOD != "ideal":
        payment_status(task_id)


if __name__ == "__main__":
    main()

Full reference documents: docs/api_eng.md and docs/api.md.