Skip to content

Testing without a phone

Play the person's side (claim, "Pay", "Cancel", running out of time) from the console and receive exactly the events the real flow sends on your own webhook — no phone, no money. Time: 10 minutes. You need: an app in the console, a test-environment webhook endpoint (Local webhooks), a test key, and the miniapp.develop permission (for simulating).

1. Create a test intent

POST /api/v1/miniapp/dev/apps/{id}/test-intents — the body is the payments API's (Payment intent): amount, description, reference, expires_in, capture_method, metadata, plus presentment, terminal_id, sub_merchant_id, return_url, min_age, capture_within.

presentment Whose Answer
app (default) yours — the developer signed in to the console — with your user_ref intent
qr · redirect · terminal nobody's until a test user or the simulation claims it intent + pay_url

A claimless test intent goes through exactly the checks your backend's test-key create does (terminal, sub-merchant, return host, terminal_busy). Idempotency-Key is optional — without one every call is a new intent (201; same key + same body → 200). An app test intent does not keep capture_within or min_age (the manual window is 7 days) — test short windows with a claimless test intent or with your test key.

List: GET /dev/apps/{id}/test-intents?cursor= — newest first, 20 at a time, {items, next_cursor}.

2. Simulate the person

POST /api/v1/miniapp/dev/apps/{id}/test-intents/{pi}/simulate {"action": "…", "test_user_id": "…"?} — the answer is the intent after the action (the same shape as the webhook's data.object), 200.

action What it does Which part of the real flow Event
claim takes a claimless intent in a test user's name the person scanned the QR / tapped NFC (POST /payments/claim) — the same protections: 90 s, 3 open claims per person payment_intent.claimed
confirm "Pay" — the test amount decides the outcome the payment sheet's confirmation .succeeded · .requires_capture · .failed · .canceled
decline "Cancel" — the person's cancellation (releases nothing they authorised) the person's cancel payment_intent.canceled
expire brings the deadline to now: an unpaid intent becomes expired; a held one's capture window closes the reconcile job's expiry .expired (claimless) · .canceled with status: "expired" (app)
  • test_user_id — a test user or the app's owner; omitted, the caller (who must be one of them).
  • confirm and decline need a claimed intent (an app test intent is already yours).
  • Each simulation is recorded in the payment's history as "the console played the person" — never as a person.
  • Events go out at once, as in the real flow. The older POST …/test-intents/{pi}/confirm route still works, but its event waits for the delivery job (within a minute) — use simulate with confirm.

A phone at the sticker — simulate-tap

POST /api/v1/miniapp/dev/apps/{id}/terminals/{tid}/simulate-tap {"test_user_id": "…"?} — as if a test user's phone touched the sticker: claims the terminal's open test basket (never the live one). No test basket at the terminal → 404 nothing_pending.

3. Example: a whole vending-machine flow

# With the console session ($GEREGE_SESSION), app id = $APP, terminal = $TID
# 1. A manual test basket at the terminal
curl -sS "$GEREGE/dev/apps/$APP/test-intents" -H "Authorization: Bearer $GEREGE_SESSION" \
  -H 'Content-Type: application/json' -H 'Idempotency-Key: sim-basket-1' \
  -d "{\"amount\": 3500, \"description\": \"Water 0.5l\", \"presentment\": \"terminal\", \"terminal_id\": \"$TID\",
       \"capture_method\": \"manual\", \"capture_within\": 120}" | jq '{id, status, pay_url}'

# 2. A phone touched the sticker → payment_intent.claimed
curl -sS -X POST "$GEREGE/dev/apps/$APP/terminals/$TID/simulate-tap" -H "Authorization: Bearer $GEREGE_SESSION"

# 3. "Pay" → payment_intent.requires_capture
curl -sS "$GEREGE/dev/apps/$APP/test-intents/$PI/simulate" -H "Authorization: Bearer $GEREGE_SESSION" \
  -H 'Content-Type: application/json' -d '{"action": "confirm"}' | jq '{status, capture_before}'

# 4. Your backend (test key) dispensed, so it captures → payment_intent.succeeded
curl -sS -X POST "https://geregeapp.mn/api/v1/miniapp/payment-intents/$PI/capture" \
  -u "$GEREGE_CLIENT_ID:$GEREGE_CLIENT_SECRET" | jq .status

$GEREGE = https://geregeapp.mn/api/v1/miniapp. To test the "did not dispense" path, replace step 4 with cancel, or with {"action": "expire"} (the capture window closed → payment_intent.expired, failure_code: "authorization_expired").

4. Errors

HTTP code When
400 validation_error a field is wrong; action is not claim/confirm/decline/expire; test_user_id is not a UUID
400 idempotency_key_invalid test intent: Idempotency-Key is not 1–128 printable ASCII
403 permission_required simulating needs miniapp.develop (permission)
409 live_intent a live intent — a simulation never touches money
409 not_a_test_user test_user_id (or you) is neither a test user nor the app's owner
409 not_claimless claim on an app intent
409 not_claimed confirm / decline on an intent nobody has claimed
409 not_expirable the intent is in no state that can expire (payment_intent attached)
409 already_claimed (retry_after) · already_processed · not_claimable · terminal_disabled · merchant_unavailable · idempotency_conflict · terminal_busy the same reasons as the real claim, confirmation and create
404 nothing_pending simulate-tap: no open test basket at the terminal
410 expired claim — the intent ran out of time
429 too_many_open_claims the test user already holds 3 unconfirmed claims

The full list: Error codes.