Native wallet contract
Native wallet contract
Polyflight uses the existing REST v1 wallet paths and response contract. Do not create separate credentials or Polyflight-only endpoints.
Request matrix
| Effect | Method and path | reason | Stable idempotency_key |
|---|---|---|---|
| Ticket purchase | POST /debit | mini_app_polyflight_purchase | mini_app_polyflight_purchase:{purchaseRequestId}:debit |
| Debit applied but no ticket admitted | POST /debit-reversal | mini_app_polyflight_purchase_compensation | mini_app_polyflight_purchase:{purchaseRequestId}:debit_reversal |
| Winning payout | POST /credit | mini_app_polyflight_win | mini_app_polyflight_ticket:{ticketId}:win |
| Losing result delivery | POST /credit | mini_app_polyflight_loss | mini_app_polyflight_ticket:{ticketId}:loss |
| Cancelled-ticket refund | POST /credit | mini_app_polyflight_refund | mini_app_polyflight_ticket:{ticketId}:refund |
The endpoint determines direction; there is no direction field in the REST v1 mutation body. A newly settled losing ticket creates a durable zero-amount credit delivery. It records the final losing result in the operator wallet without changing the balance; it is not a payout or a second stake debit.
Request shape
The purchase and compensation examples correlate with the distinct scenarios in API, events, and proof. Both quote a gross stake of 10000, an included 5% fee of 500, net stake of 9500, and a potential payout of 9500 × 4 = 38000. Flight codes identify the opening slot: the example flight opens at 10:00Z, locks at 10:55Z, and lands at 11:00Z.
{
"external_user_id": "operator-user-123",
"amount_minor": 10000,
"currency_code": "IDR",
"idempotency_key": "mini_app_polyflight_purchase:6f9f6d61-3ac7-47d2-aa42-6ab39e492931:debit",
"reason": "mini_app_polyflight_purchase",
"metadata": {
"product": "polyflight",
"operation": "purchase",
"walletTransport": "rest_v1",
"purchaseRequestId": "6f9f6d61-3ac7-47d2-aa42-6ab39e492931",
"flightId": "1df82a79-f8e2-4729-bced-07ca6387524f",
"flightCode": "PF-20260916-1000-A",
"choiceKind": "abcd",
"choiceIndex": 2,
"currencyCode": "IDR",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"opensAt": "2026-09-16T10:00:00.000Z",
"locksAt": "2026-09-16T10:55:00.000Z",
"landsAt": "2026-09-16T11:00:00.000Z",
"economyVersion": "polyflight-economy-v1"
}
}
Effect-specific request values
The purchase example above is the full debit shape. The other effects use the same top-level fields and common metadata, with these exact variations:
| Effect | Path | amount_minor | Key and reason | Metadata changes from purchase |
|---|---|---|---|---|
| Compensation | POST /debit-reversal | Original stakeMinor | mini_app_polyflight_purchase:{purchaseRequestId}:debit_reversal; mini_app_polyflight_purchase_compensation | operation = "compensation"; add originalDebitOperationId and originalDebitIdempotencyKey; no ticketId because no ticket was admitted. |
| Win | POST /credit | Stored potentialPayoutMinor | mini_app_polyflight_ticket:{ticketId}:win; mini_app_polyflight_win | operation = "win"; result = "won"; add ticketId. There are no original-debit fields. |
| Loss | POST /credit | Exactly 0 | mini_app_polyflight_ticket:{ticketId}:loss; mini_app_polyflight_loss | operation = "loss"; result = "lost"; add ticketId. There are no original-debit fields. |
| Cancelled-ticket refund | POST /credit | Original stakeMinor | mini_app_polyflight_ticket:{ticketId}:refund; mini_app_polyflight_refund | operation = "refund"; add ticketId, originalDebitOperationId, and originalDebitIdempotencyKey. |
For example, the wallet receives this compensation reversal after a confirmed debit could not become a ticket:
{
"external_user_id": "operator-user-123",
"amount_minor": 10000,
"currency_code": "IDR",
"idempotency_key": "mini_app_polyflight_purchase:91bdce37-82f8-4d8d-9615-2b73ce6a4003:debit_reversal",
"reason": "mini_app_polyflight_purchase_compensation",
"metadata": {
"product": "polyflight",
"operation": "compensation",
"walletTransport": "rest_v1",
"purchaseRequestId": "91bdce37-82f8-4d8d-9615-2b73ce6a4003",
"flightId": "1df82a79-f8e2-4729-bced-07ca6387524f",
"flightCode": "PF-20260916-1000-A",
"choiceKind": "abcd",
"choiceIndex": 2,
"currencyCode": "IDR",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"opensAt": "2026-09-16T10:00:00.000Z",
"locksAt": "2026-09-16T10:55:00.000Z",
"landsAt": "2026-09-16T11:00:00.000Z",
"economyVersion": "polyflight-economy-v1",
"originalDebitOperationId": 9124,
"originalDebitIdempotencyKey": "mini_app_polyflight_purchase:91bdce37-82f8-4d8d-9615-2b73ce6a4003:debit"
}
}
A refund credit uses "operation": "refund", includes the ticket and original-debit fields, and sends 10000 with mini_app_polyflight_ticket:{ticketId}:refund and mini_app_polyflight_refund.
Winning-payout request
Win is encoded as "result": "won"; the value is case-sensitive. This complete POST /credit body matches the winning-ticket scenario in API, events, and proof. The user receives the stored 38000 payout: 9500 net stake × 4, with no second fee or extra stake return.
{
"external_user_id": "operator-user-123",
"amount_minor": 38000,
"currency_code": "IDR",
"idempotency_key": "mini_app_polyflight_ticket:4a81cb9d-778b-4e9c-a482-5f880e435f19:win",
"reason": "mini_app_polyflight_win",
"metadata": {
"product": "polyflight",
"operation": "win",
"result": "won",
"walletTransport": "rest_v1",
"purchaseRequestId": "6f9f6d61-3ac7-47d2-aa42-6ab39e492931",
"ticketId": "4a81cb9d-778b-4e9c-a482-5f880e435f19",
"flightId": "1df82a79-f8e2-4729-bced-07ca6387524f",
"flightCode": "PF-20260916-1000-A",
"choiceKind": "abcd",
"choiceIndex": 2,
"currencyCode": "IDR",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"opensAt": "2026-09-16T10:00:00.000Z",
"locksAt": "2026-09-16T10:55:00.000Z",
"landsAt": "2026-09-16T11:00:00.000Z",
"economyVersion": "polyflight-economy-v1"
}
}
Losing-result request
Lost is encoded as "result": "lost"; the value is case-sensitive. This complete POST /credit body matches the independent losing-ticket scenario in API, events, and proof. The original quote stays intact even though the delivery amount is zero:
{
"external_user_id": "operator-user-123",
"amount_minor": 0,
"currency_code": "IDR",
"idempotency_key": "mini_app_polyflight_ticket:71c2eb08-6b19-4270-b910-12f48c3d5004:loss",
"reason": "mini_app_polyflight_loss",
"metadata": {
"product": "polyflight",
"operation": "loss",
"result": "lost",
"walletTransport": "rest_v1",
"purchaseRequestId": "aaf3c617-486d-42e9-8d50-92c6f8135004",
"ticketId": "71c2eb08-6b19-4270-b910-12f48c3d5004",
"flightId": "1df82a79-f8e2-4729-bced-07ca6387524f",
"flightCode": "PF-20260916-1000-A",
"choiceKind": "abcd",
"choiceIndex": 1,
"currencyCode": "IDR",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"opensAt": "2026-09-16T10:00:00.000Z",
"locksAt": "2026-09-16T10:55:00.000Z",
"landsAt": "2026-09-16T11:00:00.000Z",
"economyVersion": "polyflight-economy-v1"
}
}
Parsing the two result requests
| Display outcome | reason | metadata.operation | metadata.result | amount_minor |
|---|---|---|---|---|
| Win | mini_app_polyflight_win | win | won | Stored potentialPayoutMinor; 38000 in the example |
| Lost | mini_app_polyflight_loss | loss | lost | Exactly 0 |
- Both examples use the same endpoint and JSON field names. Use the reason and operation to identify the effect, then map the exact
won/lostresult value to your display label; the body does not send"Win"or"Lost". - Read
amount_minoras an integer, including zero. A truthiness check such asif (!amount_minor)would incorrectly reject a valid loss acknowledgement. - Preserve the quoted
potentialPayoutMinoron loss. It is a possible winning amount, not the actual credit amount; apply only top-levelamount_minorto the wallet. - Win and loss requests include
ticketIdbut no original-debit fields. Refunds and compensation retain their separate reasons, operations, and keys. - These result fields apply to newly planned requests. An older immutable win request may omit
result; keep its original request evidence and use its existing win reason/operation rather than rejecting or rewriting a replay.
Accept zero only for this valid Polyflight loss-credit contract: /credit, mini_app_polyflight_loss, the ticket's :loss key, product: "polyflight", operation: "loss", result: "lost", and the immutable ticket context. Purchase, win, refund, compensation, Opinion, and generic wallet-test amounts remain positive. Do not relax validation for arbitrary zero wallet mutations.
Every Polyflight request carries the common native-ticket context below. Treat metadata fields as additive; do not reject an otherwise valid request because a later version adds a field.
| Metadata field | Meaning |
|---|---|
product | Always polyflight. |
operation | purchase, compensation, win, loss, or refund. |
result | won on newly planned win credits; lost on loss credits. Not a replacement for operation. |
walletTransport | Immutable simulator or rest_v1 transport pinned under the live purchase claim before the debit is sent. |
purchaseRequestId | Stable purchase intent UUID. |
ticketId | Ticket UUID; present for admitted-ticket effects and absent when compensation occurs before admission. |
flightId, flightCode | Flight identifiers. |
choiceKind, choiceIndex | Selected option group and index. |
currencyCode | IDR. |
stakeMinor, feeMinor, netStakeMinor, potentialPayoutMinor | Immutable ticket quote amounts. |
opensAt, locksAt, landsAt | ISO-8601 flight times. |
economyVersion | Economy version committed to the ticket. |
Refund and compensation metadata both include originalDebitOperationId and originalDebitIdempotencyKey; win and loss metadata do not. Polyflight metadata does not contain fake marketId or orderId values, an operator Credits balance, or a purchase fxRateSnapshotId.
The admitted ticket and every later win, loss, refund, or compensation inherit the purchase's pinned walletTransport. Lease renewal or recovery cannot overwrite it with a different transport. This prevents an existing real-wallet obligation from being redirected through the simulator, or the reverse.
Amount by effect
- Purchase debit: exact
stakeMinor. - Win credit: exact stored
potentialPayoutMinor. - Loss credit: exactly
0; retain the original stake, fee, net stake, and potential payout in metadata. - Cancelled-ticket refund credit: exact original
stakeMinor, including the ticket fee. - Unadmitted-purchase debit reversal: exact original purchase debit.
No request recalculates an amount from a current FX rate or current commercial terms.
Existing response contract
The response is unchanged from the existing debit/credit/reversal adapter. For the purchase example, a wallet starting at 100000 returns balance_minor: 90000 after the single 10000 debit:
{
"external_user_id": "operator-user-123",
"status": "succeeded",
"currency_code": "IDR",
"amount_minor": 10000,
"idempotency_key": "mini_app_polyflight_purchase:6f9f6d61-3ac7-47d2-aa42-6ab39e492931:debit",
"remote_reference": "wallet-op-123",
"balance_minor": 90000
}
status is required and must be succeeded, failed, or pending. remote_reference, balance_minor, echoed identity/key fields, and failure_reason are optional under the existing adapter contract. OpenPoly preserves the immutable request amount and currency; response echoes do not replace them.
For the winning-payout request above, an independently purchased 10000 ticket leaves a starting wallet of 100000 at 90000; the 38000 credit raises it to 128000 (assuming no other activity):
{
"external_user_id": "operator-user-123",
"status": "succeeded",
"currency_code": "IDR",
"amount_minor": 38000,
"idempotency_key": "mini_app_polyflight_ticket:4a81cb9d-778b-4e9c-a482-5f880e435f19:win",
"remote_reference": "wallet-win-123",
"balance_minor": 128000
}
For the losing-result request above, the wallet durably records acknowledgement under the loss key and leaves the post-debit balance at 90000 (assuming no other activity):
{
"external_user_id": "operator-user-123",
"status": "succeeded",
"currency_code": "IDR",
"amount_minor": 0,
"idempotency_key": "mini_app_polyflight_ticket:71c2eb08-6b19-4270-b910-12f48c3d5004:loss",
"remote_reference": "wallet-loss-5004",
"balance_minor": 90000
}
remote_reference remains optional, including for a zero-amount acknowledgement. If returned, balance_minor is the current unchanged balance, not a fabricated payout balance. A successful acknowledgement means the wallet recorded the result, not that any money was paid.
If external_user_id or idempotency_key is echoed, it must match the request. A mismatch is treated as an unknown wallet outcome and requires reconciliation.
X-Polynion-Request-Id identifies one HTTP attempt for tracing. It is not the financial idempotency key.
Idempotency and ambiguous outcomes
The operator wallet must enforce all of these rules:
- The same idempotency key and same parameters return the same effective result without applying money twice or recording a zero-amount loss twice.
- Reusing a key with different user, amount, currency, reason, or material metadata is a conflict.
- Timeout, network failure, or an HTTP
2xxresponse that cannot be parsed as the REST v1 contract is ambiguous. - Without a valid response body, HTTP
408,429, and5xxare ambiguous; other HTTP4xxare failed mutations. - A response body with valid
status = succeeded,failed, orpendingis honored even when the HTTP status is non-2xx. Return a status/body combination that expresses one outcome unambiguously. pendingis an ambiguous business outcome. OpenPoly does not blindly resend an ambiguous primary debit or credit.- An ambiguous purchase is not admitted as a ticket until authenticated operator-ledger evidence proves the debit applied.
- A known applied debit that can no longer be admitted is returned through the dedicated debit-reversal key.
- A known non-applied debit can be closed without inventing a compensating money movement.
Operators must retain ledger evidence keyed by idempotency_key. remote_reference is useful when returned but remains optional. Manual resolution requires authenticated operator-ledger evidence even when no remote reference was returned.
Durable lifecycle values
The purchase wallet lifecycle is separate from purchase/admission status:
The durable balance-operation states are planned, sent, succeeded, failed, unknown, retrying, and manual_review. For Polyflight, an ambiguous primary debit or payment is surfaced for manual review rather than automatically replayed. Purchase state separately records compensation_pending and compensated when an applied debit cannot produce a ticket.
Winning, loss-delivery, refund, and compensation obligations use owed, in_flight, paid, or manual_review. For a loss, these values track delivery only: owed means acknowledgement is outstanding and paid means acknowledgement is confirmed, not money owed or paid. An explicit wallet failed result leaves the existing obligation in manual_review; it does not erase an owed payout or an outstanding loss delivery. Do not infer ticket outcome from payment state.
Loss delivery creates no monetary accounting entry, clearing outflow, or liability, and an unresolved loss acknowledgement does not block flight financial settlement. The existing eight-event webhook catalogue is unchanged: ticket.resolved remains authoritative; no loss payout.pending or payout.processed callback is emitted.
This applies to newly settled losses only. Historical losses are not replayed or backfilled. Already-planned or dispatched requests retain their original amount, key, metadata, pinned transport, and request evidence; do not rewrite older win requests to add result or replay them under a changed fingerprint.
Use Operations, reconciliation, and rollback to resolve these states from operator-ledger evidence. Never manufacture a new wallet key to clear a pending or manual-review row.
Purchase, result, and cancellation boundaries
A successful remote debit and an admitted ticket are two separate durable steps. If debit succeeds after the flight gate closes, OpenPoly schedules exact compensation rather than forcing a late ticket. If the ticket was admitted and the flight is later cancelled, OpenPoly uses /credit with the refund key instead of /debit-reversal.
A resolved winning ticket can show payout pending or manual review while the public result remains final. A lost ticket likewise stays lost irrespective of zero-amount delivery status; delayed acknowledgement never creates a payout. External wallet delay does not change the winner. A cancelled ticket can show refund owed before the wallet credit completes. Operator or user suspension blocks new admission but does not block existing owed money or loss delivery.
