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).confirmanddeclineneed a claimed intent (anapptest 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}/confirmroute still works, but its event waits for the delivery job (within a minute) — usesimulatewithconfirm.
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.