API, events, and proof
API, events, and proof
Polyflight adds operator-scoped read resources and webhook events without changing the REST v1 wallet response contract. Private endpoints derive operator ownership from the authenticated API key or staff session; never send an operatorId to widen access.
Scopes
| Scope | Purpose |
|---|---|
polyflight:read | Polyflight overview, participating flights, tickets, and product activity. |
polyflight_terms:read | Current, scheduled, and historical commercial terms. |
polyflight_settlements:read | Finalized statements, statement exports/liabilities, payments, and corrections. |
balance_operations:read | Wallet request/response evidence, including Polyflight source links. |
webhooks:read | Endpoint and delivery evidence for Polyflight events. |
These scopes are assignable but are not silently added to existing restricted keys or staff memberships. * can read operator-owned resources but does not create an operator terms-write, statement-finalize, payment-record, or manual-resolution endpoint.
Operator endpoint inventory
All routes are GET-only for operator reporting:
| Resource | GET paths | Required scope |
|---|---|---|
| Overview | /api/operator/polyflight/overview | polyflight:read |
| Participating flights | /api/operator/polyflight/flights, /flights/{flightIdOrCode}, /flights/export | polyflight:read |
| Tickets | /api/operator/polyflight/tickets, /tickets/{ticketId}, /tickets/export | polyflight:read and polyflight_terms:read |
| Wallet operations | /api/operator/polyflight/balance-operations, /balance-operations/{operationId}, /balance-operations/export | polyflight:read and balance_operations:read |
| Terms and history | /api/operator/polyflight/terms | polyflight_terms:read |
| Statements | /api/operator/polyflight/settlement-reports, /settlement-reports/{id}, /settlement-reports/export, /settlement-reports/{id}/export | polyflight_settlements:read |
| Payment history | /api/operator/polyflight/settlement-reports/payments, /settlement-reports/{id}/payments | polyflight_settlements:read |
| Statement corrections | /api/operator/polyflight/settlement-reports/{id}/adjustments | polyflight_settlements:read |
| Webhook evidence | /api/operator/webhooks/deliveries?product=polyflight | webhooks:read and polyflight:read |
List filters are date_from, date_to, comma-separated status, search, page, and page_size; statement lists use pagination. Export returns CSV by default and canonical JSON with format=json; CSV uses spreadsheet-formula protection. All DTOs use camelCase and native aggregate amounts are decimal strings. Every response is tenant-derived and private, no-store.
Ticket and wallet-operation lists and exports also accept external_user_id for an exact, case-sensitive operator-user match. Unlike search, it does not include other user IDs containing the same text. The Integration UAT page uses this filter to correlate the selected user's ticket, wallet operation, and callbacks.
Use Operator Dashboard → API Playground → Polyflight (/api-playground/polyflight) for source-backed examples. The Playground inherits the staff session and does not expose its bearer token. External integrations must call the same routes from their backend with an appropriately scoped API key.
The dashboard mirrors these resources at /polyflight, /polyflight/flights, /polyflight/tickets, /polyflight/balance-operations, /polyflight/settlement-reports, and /polyflight/terms.
Opinion metrics continue to exclude Polyflight native money unless an explicitly labelled all-products wallet audit is selected. Never add IDR amounts to Credits totals.
Webhook catalogue
Polyflight uses the existing signed webhook envelope, HMAC verification, retry schedule, and event_id deduplication rules. Webhook delivery is notification only: receiving or replaying an event never authorizes a wallet mutation.
| Event type | Meaning |
|---|---|
mini_app.polyflight.purchase.processed | Debit and ticket admission completed; the immutable ticket exists. |
mini_app.polyflight.purchase.failed | No ticket was admitted. Read status and inspect the purchase wallet operation; do not assume a debit did or did not apply. |
mini_app.polyflight.ticket.resolved | The ticket result and amount owed are final. This does not assert wallet delivery. |
mini_app.polyflight.payout.pending | A winning payout is owed, or its attempted credit is in manual_review. |
mini_app.polyflight.payout.processed | Winning wallet credit is confirmed. |
mini_app.polyflight.refund.pending | A cancelled-ticket full refund is owed, or its attempted credit is in manual_review. |
mini_app.polyflight.refund.processed | Cancelled-ticket wallet credit is confirmed. |
mini_app.polyflight.purchase.compensated | An applied purchase debit with no admitted ticket was reversed. |
HTTP request and receiver response
OpenPoly sends each event as POST to your subscribed webhook endpoint with Content-Type: application/json and these headers:
X-Polynion-Event-Id: mini_app.polyflight.purchase.processed:4a81cb9d-778b-4e9c-a482-5f880e435f19
X-Polynion-Timestamp: 2026-09-16T10:05:01.000Z
X-Polynion-Signature: v1=<hex-hmac-sha256>
The header values are illustrative; the signature placeholder is not a valid signature. X-Polynion-Event-Id matches the body's event_id. The timestamp header is the delivery attempt time; occurred_at is the stored event creation time. Verify HMAC-SHA256 over {timestamp}.{raw_body} using the endpoint's webhook secret, not reserialized JSON. See Signatures.
The response contract is identical for all eight events. After signature verification and durable handling (or recognition of an already-committed duplicate), return a 2xx response. For example:
HTTP/1.1 204 No Content
There is no required response body or event-specific response schema. If your receiver prefers JSON, this is also a valid acknowledgement:
HTTP/1.1 200 OK
Content-Type: application/json
{"received": true}
received is a receiver-defined example field, not a field OpenPoly requires or parses. Success depends on the HTTP status, not JSON content; a 200 body saying {"received": false} still acknowledges delivery. OpenPoly stores a trimmed response-body sample of up to 500 characters for delivery evidence; do not include secrets.
Return success only once the event is durably handled. A non-2xx, network error, or timeout fails the attempt; 410 Gone immediately dead-letters it, while other failures follow the configured retry policy. The default endpoint timeout is 5 seconds and default maximum is 6 attempts. See Retry and replay.
These are notifications, not REST v1 wallet requests. Do not return a wallet balance, wallet reference, or debit/credit result to these callbacks; that request/response contract is documented in Native wallet contract.
The eight-event catalogue is unchanged by zero-amount loss delivery. ticket.resolved is the authoritative loss notification; OpenPoly separately sends the wallet /credit request with reason mini_app_polyflight_loss. There is no loss payout.pending or payout.processed callback. Query ticket payment lifecycle and wallet operations for acknowledgement status; do not wait for a payout event on a loss.
Parsing the envelope and amounts
The signed envelope remains snake_case. Polyflight data is normalized to camelCase.
| Field(s) | JSON type and parsing rule |
|---|---|
event_id, event_type, aggregate_type, aggregate_id, idempotency_key | Strings. Treat event_id as opaque: producers use event-name prefixes, UUIDs, or recovery prefixes. Do not derive resource IDs by splitting it. |
operator_id | Integer operator ID. |
occurred_at | ISO-8601 UTC timestamp string. |
data | Event-specific object; tolerate additional keys. |
operatorUserId | Integer operator-user record ID. |
externalUserId | String user ID in your system; preserve case and content. |
purchaseRequestId, ticketId, flightId, obligationId, commercialTermsVersionId | UUID strings when populated. Newly settled losses have a non-null delivery obligationId; historical lost results may retain null. ticketId is null on obligation-scoped compensation events. Fields may be absent on other families or older events. |
stakeMinor, feeMinor, netStakeMinor, potentialPayoutMinor, payoutMinor | Exact integer JSON numbers in current ticket/quote snapshots. Absent values are unknown, not zero. |
amountMinor | Exact integer JSON number or decimal string, depending on the producer. Initial ticket-scoped pending events use a number; obligation-scoped payment events use a string. Parse both without floating-point rounding. |
feeBps, ticketFeeShareBps, positionShareBps, payoutMultiplier | Integer numbers when captured; commercial shares are not additional user fees. |
manualResolution | Optional boolean; true means payment was confirmed through operator evidence. |
Do not require every field above on every event. On a no-ticket purchase, captured ticket terms are normally omitted; recovery-generated compensation manual-review events can contain those keys with null values. Neither omission nor null establishes commercial terms.
Full request-body examples by event
Each block is a complete webhook POST body, not merely data. These are synthetic examples, not production records. IDs distinguish separate purchases and obligations; amounts, shares, and lifecycle transitions follow the implementation. The response to every example is the shared 204 or 200 acknowledgement above.
Example economics and scenarios
All amounts below use the same integer minor-unit representation as the IDR wallet contract. For every purchase quote:
gross stake = 10000
feeBps = 500 = 5%
included ticket fee = 10000 × 500 / 10000 = 500
net stake = 10000 - 500 = 9500
payoutMultiplier = 4
potential winning payout = 9500 × 4 = 38000
player net change on a win = 38000 - 10000 = +28000
ticketFeeShareBps = 10000 = 100% of the fee to the operator
positionShareBps = 8000 = 80% of signed position to the operator
The two commercial shares use the current defaults for this example terms version; an operator's actual captured terms can differ. They do not change the player's 10,000 debit or 38,000 win credit. payoutMultiplier applies to net stake, not gross stake: a winning credit is 38,000, not 40,000.
The fee and signed position are separate settlement bases:
| Ticket outcome | Fee base after any reversal | Signed position base | Operator fee allocation (100%) | Operator position allocation (80%) | Polynion position allocation (20%) | Operator total entitlement |
|---|---|---|---|---|---|---|
| Lost | 500 | 9500 - 0 = +9500 | +500 | +7600 | +1900 | +8100 |
| Won | 500 | 9500 - 38000 = -28500 | +500 | -22800 | -5700 | -22300 |
| Cancelled and refunded | 500 - 500 = 0 | No position result | 0 | 0 | 0 | 0 |
| No ticket admitted | No earned fee | No position result | 0 | 0 | 0 | 0 |
For example, -28500 × 8000 / 10000 = -22800: the operator shares losses as well as profits. On a win, operator and Polynion entitlements sum to -22300 - 5700 = -28000, matching the player's +28,000 net gain. On a loss, they sum to 8100 + 1900 = 10000, matching the player's 10,000 loss. These exact examples have no fractional remainder; monthly statements aggregate all tickets and apply the documented rounding carry. Entitlement is not a confirmation that settlement cash has been paid.
The callback examples form these separate scenarios for operator 12 and user operator-user-123:
| Scenario | Purchase request ID | Events and correlation |
|---|---|---|
| Winning ticket | 6f9f6d61-3ac7-47d2-aa42-6ab39e492931 | Purchase processed → ticket resolved won → payout owed → payout paid. Ticket 4a81cb9d-778b-4e9c-a482-5f880e435f19; win obligation 11da0614-8149-4d6f-8bf0-aae92f422783. |
| Losing ticket | aaf3c617-486d-42e9-8d50-92c6f8135004 | An independently admitted ticket resolves lost. Ticket 71c2eb08-6b19-4270-b910-12f48c3d5004; loss-delivery obligation 55a5a948-bb38-43c6-ae9a-2672f9885004 acknowledges /credit amount 0, with no payout callbacks or money owed. |
| Rejected debit | b3f4b75a-62de-4e81-8c67-587902a19402 | Purchase failed; in this example the debit was definitively not applied. No ticket or reversal obligation. |
| Cancelled ticket | 7816e6c4-2287-4c5d-9d01-9a71b69b3002 | Refund owed → refund manual review → evidence-confirmed refund paid. Ticket 572ae20d-f9ab-4d0d-a14b-8cb7461d3002; refund obligation 31a9d2ef-bc6d-4c28-8b1b-718942b63002. |
| Unadmitted debit | 91bdce37-82f8-4d8d-9615-2b73ce6a4003 | Debit starts just before the 10:55Z gate cutoff and is confirmed, but admission reaches the closed gate. Purchase failed with compensation pending → full reversal paid through obligation 4bd8f13a-cb38-4f2f-9d28-8542e1764003. |
Flight PF-20260916-1000-A opens at 10:00Z, locks at 10:55Z, and lands at 11:00Z, consistent with the scheduled 55-minute boarding and 5-minute flight. The winning purchase occurs at 10:05Z and its result occurs after landing. The cancelled ticket belongs to a different flight, PF-20260916-1000-B, with the same schedule; cancelling it does not cancel the winning ticket's flight.
The admitted choice is zero-based ABCD option index 2 (A2), with sequence 2410. This sequence was obtained from the actual grouped-option generator using a synthetic starting board 24000100; it is not a rule that index 2 always means 2410. In a real callback, use the sequence captured from that flight's offered option.
For wallet reconciliation, consider each scenario independently, starting with a balance of 100000 and no other wallet activity:
| Outcome | Applied debit | Confirmed credit/reversal | Final balance |
|---|---|---|---|
| Win | -10000 | +38000 | 128000 |
| Loss | -10000 | 0 (result acknowledgement) | 90000 |
| Cancelled-ticket refund | -10000 | +10000 | 100000 |
| Rejected, non-applied debit | None | None | 100000 |
| Applied debit, no ticket, compensated | -10000 | +10000 reversal | 100000 |
The callbacks do not contain a wallet balance; these balances illustrate the REST v1 effects only. While a monetary credit is owed or manual_review, do not infer that it applied or infer a final wallet balance. Loss acknowledgement never changes the balance, even if delivery remains unresolved. The losing-result example below belongs to its own ticket, not a second result for the winning ticket.
mini_app.polyflight.purchase.processed
The debit succeeded and the ticket was admitted. Choice and economy fields belong to this admission payload; do not require them on every later event.
{
"event_id": "mini_app.polyflight.purchase.processed:4a81cb9d-778b-4e9c-a482-5f880e435f19",
"event_type": "mini_app.polyflight.purchase.processed",
"operator_id": 12,
"occurred_at": "2026-09-16T10:05:00.000Z",
"aggregate_type": "polyflight_ticket",
"aggregate_id": "4a81cb9d-778b-4e9c-a482-5f880e435f19",
"idempotency_key": "polyflight_ticket:4a81cb9d-778b-4e9c-a482-5f880e435f19:processed",
"data": {
"purchaseRequestId": "6f9f6d61-3ac7-47d2-aa42-6ab39e492931",
"ticketId": "4a81cb9d-778b-4e9c-a482-5f880e435f19",
"flightId": "1df82a79-f8e2-4729-bced-07ca6387524f",
"operatorUserId": 34,
"externalUserId": "operator-user-123",
"commercialTermsVersionId": "741a4e47-bfe4-4e2e-b3ec-06589549e203",
"ticketFeeShareBps": 10000,
"positionShareBps": 8000,
"flightCode": "PF-20260916-1000-A",
"status": "processed",
"currencyCode": "IDR",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"feeBps": 500,
"payoutMultiplier": 4,
"choiceKind": "abcd",
"choiceIndex": 2,
"predictedSequence": "2410",
"economyVersion": "polyflight-economy-v1"
}
}
mini_app.polyflight.purchase.failed
This example is a failed debit with no admitted ticket. Amounts are the purchase quote, not proof of a charge. This producer includes normalized data.operatorId as well as the envelope's operator_id; not every family includes it.
{
"event_id": "45e9adfe-7f42-4ae9-951a-3946039df047",
"event_type": "mini_app.polyflight.purchase.failed",
"operator_id": 12,
"occurred_at": "2026-09-16T10:05:00.000Z",
"aggregate_type": "polyflight_purchase_request",
"aggregate_id": "b3f4b75a-62de-4e81-8c67-587902a19402",
"idempotency_key": "polyflight_purchase_request:b3f4b75a-62de-4e81-8c67-587902a19402:failed",
"data": {
"purchaseRequestId": "b3f4b75a-62de-4e81-8c67-587902a19402",
"operatorUserId": 34,
"externalUserId": "operator-user-123",
"currencyCode": "IDR",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"operatorId": 12,
"flightId": "1df82a79-f8e2-4729-bced-07ca6387524f",
"flightCode": "PF-20260916-1000-A",
"status": "failed",
"failureReason": "OPERATOR_DEBIT_FAILED"
}
}
Other forms of this same event type:
- Uncertain debit: purchase-request aggregate,
status: "manual_review"and a diagnosticfailureReason(for example,OPERATOR_DEBIT_UNCERTAIN). Do not infer whether money moved. - Confirmed debit awaiting reversal: purchase-request aggregate,
status: "compensation_pending"; addsobligationIdandamountMinor: "10000"for the full debit reversal, plus the admission failure reason. - Reversal in manual review: obligation aggregate (
mini_app_polyflight_payment_obligation),aggregate_idequal toobligationId, andidempotency_keyequal tomini_app_polyflight_payment_obligation:{obligationId}:failed. Itsdataincludeseffect: "compensation",ticketId: null,status: "manual_review",amountMinor: "10000", quote/identity fields, andfailureReason. No ticket was admitted.
Confirmed debit awaiting compensation
This is the unadmitted-debit scenario: the debit is confirmed, but the 10:55Z gate cutoff prevents admission. The reversal is now owed. amountMinor: "10000" is the full debit, not the quoted fee. The later purchase.compensated example uses this same purchase and obligation.
{
"event_id": "5ca6216b-539a-432e-a124-2f0d86584003",
"event_type": "mini_app.polyflight.purchase.failed",
"operator_id": 12,
"occurred_at": "2026-09-16T10:55:02.000Z",
"aggregate_type": "polyflight_purchase_request",
"aggregate_id": "91bdce37-82f8-4d8d-9615-2b73ce6a4003",
"idempotency_key": "polyflight_purchase_request:91bdce37-82f8-4d8d-9615-2b73ce6a4003:failed",
"data": {
"purchaseRequestId": "91bdce37-82f8-4d8d-9615-2b73ce6a4003",
"operatorUserId": 34,
"externalUserId": "operator-user-123",
"currencyCode": "IDR",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"obligationId": "4bd8f13a-cb38-4f2f-9d28-8542e1764003",
"operatorId": 12,
"flightId": "1df82a79-f8e2-4729-bced-07ca6387524f",
"flightCode": "PF-20260916-1000-A",
"amountMinor": "10000",
"status": "compensation_pending",
"failureReason": "GATE_CLOSED"
}
}
mini_app.polyflight.ticket.resolved
This is a winning result with an amount owed, not a wallet-credit confirmation.
{
"event_id": "mini_app.polyflight.ticket.resolved:4a81cb9d-778b-4e9c-a482-5f880e435f19",
"event_type": "mini_app.polyflight.ticket.resolved",
"operator_id": 12,
"occurred_at": "2026-09-16T11:00:03.000Z",
"aggregate_type": "polyflight_ticket",
"aggregate_id": "4a81cb9d-778b-4e9c-a482-5f880e435f19",
"idempotency_key": "polyflight_ticket:4a81cb9d-778b-4e9c-a482-5f880e435f19:resolved",
"data": {
"ticketId": "4a81cb9d-778b-4e9c-a482-5f880e435f19",
"purchaseRequestId": "6f9f6d61-3ac7-47d2-aa42-6ab39e492931",
"flightId": "1df82a79-f8e2-4729-bced-07ca6387524f",
"flightCode": "PF-20260916-1000-A",
"operatorUserId": 34,
"externalUserId": "operator-user-123",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"feeBps": 500,
"payoutMultiplier": 4,
"commercialTermsVersionId": "741a4e47-bfe4-4e2e-b3ec-06589549e203",
"ticketFeeShareBps": 10000,
"positionShareBps": 8000,
"status": "won",
"currencyCode": "IDR",
"payoutMinor": 38000,
"obligationId": "11da0614-8149-4d6f-8bf0-aae92f422783"
}
}
A newly settled losing result uses status: "lost", payoutMinor: 0, and a non-null loss-delivery obligationId. The following independently admitted ticket chose a different option from the winning ticket. potentialPayoutMinor remains the original 38000 quote; it does not become zero on loss. The zero-amount /credit records the result without paying money or creating a monetary liability. Its immutable metadata includes operation: "loss" and result: "lost"; newly planned winning credits include operation: "win" and result: "won". See Native wallet contract for the wallet request and response.
{
"event_id": "mini_app.polyflight.ticket.resolved:71c2eb08-6b19-4270-b910-12f48c3d5004",
"event_type": "mini_app.polyflight.ticket.resolved",
"operator_id": 12,
"occurred_at": "2026-09-16T11:00:03.000Z",
"aggregate_type": "polyflight_ticket",
"aggregate_id": "71c2eb08-6b19-4270-b910-12f48c3d5004",
"idempotency_key": "polyflight_ticket:71c2eb08-6b19-4270-b910-12f48c3d5004:resolved",
"data": {
"ticketId": "71c2eb08-6b19-4270-b910-12f48c3d5004",
"purchaseRequestId": "aaf3c617-486d-42e9-8d50-92c6f8135004",
"flightId": "1df82a79-f8e2-4729-bced-07ca6387524f",
"flightCode": "PF-20260916-1000-A",
"operatorUserId": 34,
"externalUserId": "operator-user-123",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"feeBps": 500,
"payoutMultiplier": 4,
"commercialTermsVersionId": "741a4e47-bfe4-4e2e-b3ec-06589549e203",
"ticketFeeShareBps": 10000,
"positionShareBps": 8000,
"status": "lost",
"currencyCode": "IDR",
"payoutMinor": 0,
"obligationId": "55a5a948-bb38-43c6-ae9a-2672f9885004"
}
}
Historical loss events keep their original obligationId: null; no historical loss delivery is backfilled, and webhook replay does not generate one. Already-planned/dispatched wallet requests also preserve their original metadata and evidence. A lost result remains final regardless of delivery state, and there is no subsequent loss payout pending/processed event.
mini_app.polyflight.payout.pending
The initial event is ticket-scoped and uses status: "owed" with a numeric amount.
{
"event_id": "mini_app.polyflight.payout.pending:4a81cb9d-778b-4e9c-a482-5f880e435f19",
"event_type": "mini_app.polyflight.payout.pending",
"operator_id": 12,
"occurred_at": "2026-09-16T11:00:03.000Z",
"aggregate_type": "polyflight_ticket",
"aggregate_id": "4a81cb9d-778b-4e9c-a482-5f880e435f19",
"idempotency_key": "polyflight_ticket:4a81cb9d-778b-4e9c-a482-5f880e435f19:pending",
"data": {
"ticketId": "4a81cb9d-778b-4e9c-a482-5f880e435f19",
"purchaseRequestId": "6f9f6d61-3ac7-47d2-aa42-6ab39e492931",
"flightId": "1df82a79-f8e2-4729-bced-07ca6387524f",
"operatorUserId": 34,
"externalUserId": "operator-user-123",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"feeBps": 500,
"payoutMultiplier": 4,
"commercialTermsVersionId": "741a4e47-bfe4-4e2e-b3ec-06589549e203",
"ticketFeeShareBps": 10000,
"positionShareBps": 8000,
"flightCode": "PF-20260916-1000-A",
"status": "owed",
"effect": "win",
"currencyCode": "IDR",
"amountMinor": 38000,
"obligationId": "11da0614-8149-4d6f-8bf0-aae92f422783"
}
}
mini_app.polyflight.payout.processed
The obligation is paid. Unlike the initial pending event, this event is obligation-scoped and serializes amountMinor as a decimal string.
{
"event_id": "8e53a2bc-e2c8-42f2-bf6b-086ea50c6a12",
"event_type": "mini_app.polyflight.payout.processed",
"operator_id": 12,
"occurred_at": "2026-09-16T11:00:05.000Z",
"aggregate_type": "mini_app_polyflight_payment_obligation",
"aggregate_id": "11da0614-8149-4d6f-8bf0-aae92f422783",
"idempotency_key": "mini_app_polyflight_payment_obligation:11da0614-8149-4d6f-8bf0-aae92f422783:processed",
"data": {
"obligationId": "11da0614-8149-4d6f-8bf0-aae92f422783",
"effect": "win",
"purchaseRequestId": "6f9f6d61-3ac7-47d2-aa42-6ab39e492931",
"operatorUserId": 34,
"externalUserId": "operator-user-123",
"currencyCode": "IDR",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"feeBps": 500,
"payoutMultiplier": 4,
"commercialTermsVersionId": "741a4e47-bfe4-4e2e-b3ec-06589549e203",
"ticketFeeShareBps": 10000,
"positionShareBps": 8000,
"ticketId": "4a81cb9d-778b-4e9c-a482-5f880e435f19",
"flightId": "1df82a79-f8e2-4729-bced-07ca6387524f",
"flightCode": "PF-20260916-1000-A",
"amountMinor": "38000",
"status": "paid"
}
}
When confirmed through operator evidence rather than the normal adapter result, the same body shape additionally contains manualResolution: true in data.
mini_app.polyflight.refund.pending
The cancelled ticket is owed its full gross stake, including the original fee. This is the initial ticket-scoped event; an engine cancellation can additionally include voidCode in data.
{
"event_id": "mini_app.polyflight.refund.pending:572ae20d-f9ab-4d0d-a14b-8cb7461d3002",
"event_type": "mini_app.polyflight.refund.pending",
"operator_id": 12,
"occurred_at": "2026-09-16T10:55:00.000Z",
"aggregate_type": "polyflight_ticket",
"aggregate_id": "572ae20d-f9ab-4d0d-a14b-8cb7461d3002",
"idempotency_key": "polyflight_ticket:572ae20d-f9ab-4d0d-a14b-8cb7461d3002:pending",
"data": {
"ticketId": "572ae20d-f9ab-4d0d-a14b-8cb7461d3002",
"purchaseRequestId": "7816e6c4-2287-4c5d-9d01-9a71b69b3002",
"flightId": "28e6fd82-3513-4a43-b6a0-6e9d371f3002",
"operatorUserId": 34,
"externalUserId": "operator-user-123",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"feeBps": 500,
"payoutMultiplier": 4,
"commercialTermsVersionId": "741a4e47-bfe4-4e2e-b3ec-06589549e203",
"ticketFeeShareBps": 10000,
"positionShareBps": 8000,
"flightCode": "PF-20260916-1000-B",
"status": "owed",
"effect": "refund",
"currencyCode": "IDR",
"amountMinor": 10000,
"obligationId": "31a9d2ef-bc6d-4c28-8b1b-718942b63002"
}
}
Payout/refund pending in manual review
A later pending event is a distinct event, not a replay of the initial owed event. It uses the same event_type but an obligation aggregate, a string amount, and failureReason. For example:
{
"event_id": "f5b879c3-90e9-47f5-bc20-016bdd3930aa",
"event_type": "mini_app.polyflight.refund.pending",
"operator_id": 12,
"occurred_at": "2026-09-16T10:55:05.000Z",
"aggregate_type": "mini_app_polyflight_payment_obligation",
"aggregate_id": "31a9d2ef-bc6d-4c28-8b1b-718942b63002",
"idempotency_key": "mini_app_polyflight_payment_obligation:31a9d2ef-bc6d-4c28-8b1b-718942b63002:pending",
"data": {
"obligationId": "31a9d2ef-bc6d-4c28-8b1b-718942b63002",
"effect": "refund",
"purchaseRequestId": "7816e6c4-2287-4c5d-9d01-9a71b69b3002",
"operatorUserId": 34,
"externalUserId": "operator-user-123",
"currencyCode": "IDR",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"feeBps": 500,
"payoutMultiplier": 4,
"commercialTermsVersionId": "741a4e47-bfe4-4e2e-b3ec-06589549e203",
"ticketFeeShareBps": 10000,
"positionShareBps": 8000,
"ticketId": "572ae20d-f9ab-4d0d-a14b-8cb7461d3002",
"flightId": "28e6fd82-3513-4a43-b6a0-6e9d371f3002",
"flightCode": "PF-20260916-1000-B",
"amountMinor": "10000",
"status": "manual_review",
"failureReason": "OPERATOR_PAYMENT_UNCERTAIN"
}
}
The winning-payout form uses event_type: "mini_app.polyflight.payout.pending", effect: "win", and amountMinor: "38000" with the win obligation ID. The diagnostic failureReason may differ; do not treat the example reason as an exhaustive enum.
mini_app.polyflight.refund.processed
The full cancelled-ticket refund is confirmed from operator-ledger evidence after the manual-review event above. manualResolution: true records that confirmation; this scenario does not send a second credit with a new key.
{
"event_id": "c95e57ea-2e29-45cf-a6a9-ffb6d4e89c0c",
"event_type": "mini_app.polyflight.refund.processed",
"operator_id": 12,
"occurred_at": "2026-09-16T10:56:00.000Z",
"aggregate_type": "mini_app_polyflight_payment_obligation",
"aggregate_id": "31a9d2ef-bc6d-4c28-8b1b-718942b63002",
"idempotency_key": "mini_app_polyflight_payment_obligation:31a9d2ef-bc6d-4c28-8b1b-718942b63002:processed",
"data": {
"obligationId": "31a9d2ef-bc6d-4c28-8b1b-718942b63002",
"effect": "refund",
"purchaseRequestId": "7816e6c4-2287-4c5d-9d01-9a71b69b3002",
"operatorUserId": 34,
"externalUserId": "operator-user-123",
"currencyCode": "IDR",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"feeBps": 500,
"payoutMultiplier": 4,
"commercialTermsVersionId": "741a4e47-bfe4-4e2e-b3ec-06589549e203",
"ticketFeeShareBps": 10000,
"positionShareBps": 8000,
"ticketId": "572ae20d-f9ab-4d0d-a14b-8cb7461d3002",
"flightId": "28e6fd82-3513-4a43-b6a0-6e9d371f3002",
"flightCode": "PF-20260916-1000-B",
"amountMinor": "10000",
"status": "paid",
"manualResolution": true
}
}
When the normal adapter response directly confirms the refund, the same shape has status: "paid" but omits manualResolution. That is a different confirmation path, not another wallet credit.
mini_app.polyflight.purchase.compensated
A confirmed debit that could not become a ticket has been fully reversed. This is not a cancelled-ticket refund: ticketId is explicitly null, and the economics are the purchase quote, without captured ticket terms.
{
"event_id": "35c14c62-0c28-48a1-bf54-c874915cdf97",
"event_type": "mini_app.polyflight.purchase.compensated",
"operator_id": 12,
"occurred_at": "2026-09-16T10:55:07.000Z",
"aggregate_type": "mini_app_polyflight_payment_obligation",
"aggregate_id": "4bd8f13a-cb38-4f2f-9d28-8542e1764003",
"idempotency_key": "mini_app_polyflight_payment_obligation:4bd8f13a-cb38-4f2f-9d28-8542e1764003:compensated",
"data": {
"obligationId": "4bd8f13a-cb38-4f2f-9d28-8542e1764003",
"effect": "compensation",
"purchaseRequestId": "91bdce37-82f8-4d8d-9615-2b73ce6a4003",
"operatorUserId": 34,
"externalUserId": "operator-user-123",
"currencyCode": "IDR",
"stakeMinor": 10000,
"feeMinor": 500,
"netStakeMinor": 9500,
"potentialPayoutMinor": 38000,
"ticketId": null,
"flightId": "1df82a79-f8e2-4729-bced-07ca6387524f",
"flightCode": "PF-20260916-1000-A",
"amountMinor": "10000",
"status": "paid"
}
}
The operator-evidence confirmation variant additionally contains manualResolution: true in data. A quoted feeMinor: 500 here does not mean a fee was earned; no ticket was admitted.
Event identity and wallet identity
event_id is the webhook deduplication key. The envelope idempotency_key identifies the event projection; neither value is a wallet key. Money remains deduplicated by the REST v1 idempotency_key listed in Native wallet contract.
Exact data families
All new events identify the owning operatorUserId and externalUserId. The amount snapshot includes currencyCode: "IDR", stakeMinor (gross purchase debit), feeMinor (ticket fee), netStakeMinor (gross less fee), and potentialPayoutMinor (quoted maximum win). On an admitted ticket these are the immutable ticket economics; on a failed purchase with no ticket they are the purchase quote, not evidence of a charge or an earned ticket fee. Historical events may omit fields; do not substitute zero.
| Family | Additional fields and interpretation |
|---|---|
purchase.processed | purchaseRequestId, ticketId, flightId, flightCode, status: "processed", choice details, economyVersion, feeBps, payoutMultiplier, commercialTermsVersionId, ticketFeeShareBps, positionShareBps. Native debit and ticket admission are confirmed. |
purchase.failed | purchaseRequestId, flightId, flightCode, status, failureReason, and quoted stake/fee/net/potential. No admitted ticket or earned ticket fee. amountMinor and obligationId are included when a confirmed debit has a compensation reversal owed or under manual review; amountMinor is the full debit, not the fee. manual_review alone never proves a debit. |
ticket.resolved | ticketId, purchaseRequestId, flightId, flightCode, status: "won" / "lost", payoutMinor (amount owed by result, 0 on loss), obligationId, ticket economics and captured terms. Newly settled losses have a non-null zero-amount delivery obligation ID; historical losses may have null. It is not a wallet-credit confirmation or evidence of money owed on a loss. |
Payout/refund pending | Ticket and obligation IDs, status: "owed" / "manual_review", effect: "win" / "refund", amountMinor owed and ticket economics/terms. Refund amountMinor is the full gross stake, not the ticket fee. A manual-review event includes failureReason; an engine cancellation can include voidCode. |
Payout/refund processed | Obligation, purchase, ticket, flight IDs/code, effect, status: "paid", amountMinor confirmed, and ticket economics/terms. |
purchase.compensated | Obligation, purchase, flight IDs/code, effect: "compensation", status: "paid", amountMinor full debit reversed, plus the purchase quote. No admitted ticket, earned fee, or captured commercial terms. |
feeBps is the ticket fee rate captured on admission; ticketFeeShareBps and positionShareBps are the operator's commercial allocation shares from the ticket's captured commercialTermsVersionId, not additional user fees. payoutMultiplier is the ticket's frozen prize multiplier. The fee can subsequently be reversed on a cancelled ticket; neither feeMinor nor the commercial shares mean confirmed cash to the operator. Check settlement reports for allocated operator revenue.
positionShareBps allocates the operator's share of the fee-exclusive signed game result, including both profits and losses. 8000 means 80%; Polynion receives the remaining 20%. The position base is netStakeMinor - payoutMinor on resolution: positive on a loss, negative on a win when payout exceeds net stake. This does not reduce the user's payout. The permitted share is 5000–10000 in steps of 100; the default is 8000. It is frozen through the ticket's captured terms version. Fee allocation is separate, and a cancelled ticket reverses the fee without creating a position result. See Terms, statements, and settlement for signed examples and monthly rounding.
An initial payout/refund pending event uses status = "owed" and does not imply a failed wallet call. A later pending event with status = "manual_review" includes failureReason. A processed event confirmed through operator evidence adds manualResolution: true.
Payloads are stored when each transition occurs, then normalized from legacy snake_case fields to camelCase for delivery and replay. Delivery does not re-read current request status, wallet state, current terms, or today's economy. Ticket/request amounts fit safe JSON integers; recorded wallet obligation amounts are decimal strings. Treat both as exact integer minor units; do not convert to floating point. Older outbox rows do not acquire newly added snapshots on replay, and omitted values are unknown, not zero.
The purchase-request purchase.failed event is stored once per request and is not rewritten when compensation starts. A separate obligation-scoped purchase.failed may report a compensation reversal in manual review; follow the purchase.compensated obligation event and wallet operation for confirmed reversal.
Receiver actions
| Event | Safe handling |
|---|---|
purchase.processed | Record the admitted ticket and correlate the purchase. Do not debit again; the REST v1 debit already completed. |
purchase.failed | Record the failure and inspect the purchase balance operation. If status is manual_review or compensation_pending, reconcile the original debit key. Do not issue an operator-created refund. |
ticket.resolved | Record won or lost and payoutMinor. For a win, correlate the payout obligation and pending/processed evidence. For a new loss, correlate the zero-amount delivery obligation using ticket/payment and wallet-operation reads; do not await payout callbacks or create a payout. |
Payout/refund pending | For owed, observe the obligation while OpenPoly invokes the adapter. For manual_review, reconcile the existing win/refund key; never create a new credit key. |
Payout/refund processed | Mark the obligation delivered. The wallet must still deduplicate the corresponding credit key independently of webhook deduplication. |
purchase.compensated | Mark the no-ticket purchase debit as reversed and correlate the compensation obligation. |
Receivers must tolerate additive fields, branch only on known event_type, and deduplicate by event_id in the same durable transaction as their business handling. A replay repeats the same event; after a duplicate is recognized, return 2xx so delivery stops retrying.
Comparison-only FX
A polyflight-v5 flight freezes one Indodax USDT_IDR observation before boarding. The committed valuation contains:
- policy
indodax-usdt-idr-v1; - source
indodaxand pairUSDT_IDR; - positive canonical rate with exactly 8 fractional digits and either
0or 1–12 integer digits (maximum serialized length 21); - source timestamp and observation correlation ID;
- observation age and
fallbackUseddisclosure.
A refresh targets five-minute freshness, but refresh failure may use the latest persisted valid observation. That cached fallback is frozen and disclosed; it is not refreshed at purchase, lock, result, payout, or replay. If no valid observation has ever existed, that flight remains Credits-capable but IDR entry is unavailable.
For one option, the verifier compares exact integer weight:
C = gross first-party Credits stake
I = gross IDR stake across all operators
R = canonical rate × 10^8
K = 10^8
comparisonWeight = C × R + I × 10,000 × K
Every option uses the same denominator, so ranking compares the integer weights directly. Commercial shares, ticket-fee earnings, and wallet-delivery status do not affect ranking. The rate is comparison evidence only: users are debited and credited directly in their original currency.
Locate and correlate proof
After a flight is revealed, fetch the public machine-readable proof:
GET /api/polyflight/flights/{flightCode}/proof
The response contains proof and verification; verification has a boolean verified and a checks array. OpenPoly schema-validates the proof and requested flight code, computes every verification check, and serves the response with immutable public caching. Consumers must require verification.verified === true; inspect failed checks rather than trusting the result alone. GET /api/polyflight/flights/{flightCode}/black-box returns the smaller summary and rejects a machine proof whose verification is false. A reveal-not-available response means the immutable proof is not published yet; do not substitute ticket or wallet state for it.
To correlate one operator-owned ticket:
- Read
proofTicketIdfromGET /api/operator/polyflight/tickets/{ticketId}or the ticket list. - Find the same UUID in
proof.snapshotTickets[].ticketId. - Compare its choice, native amount, fee, potential payout, and classification with the private ticket report.
proofTicketId is the anonymous v5 snapshot identifier, not the private ticket ID. It is null for legacy v4 tickets.
v5 mixed-money proof
New mixed-money-capable flights use:
- rules/protocol
polyflight-v5; - public package codec 4, domain
polyflight-public-package-v5; - snapshot codec 3, domain
polyflight-snapshot-v5; - lock package codec 4, domain
polyflight-lock-package-v5; - unchanged grouped
abcdOptions/efghOptionsandcount-v2strategy behavior.
The public package declares currencyCodes as exactly ['CREDITS'] or ['CREDITS', 'IDR'], stakeUnit: 'minor', and either the frozen valuation or null. null means Credits-only entry for that flight.
After landing, the anonymous v5 snapshot discloses each ticket's anonymous UUID, choice group/index, CREDITS or IDR, gross stake, fee, and potential payout. Per-option ranking totals publish totalStakeCredits and totalStakeIdr as decimal strings, plus ticket count and rank.
The public proof never contains operator ID, external user ID, linked Polynion user ID, wallet reference, balance, commercial terms, or settlement allocation. Native amounts are intentionally disclosed after landing so independent verifiers can recompute the committed mixed-money ranking and result.
Private operator ticket reports expose proofTicketId to correlate an owned v5 ticket with that anonymous UUID; it is null for legacy v4. The mapping remains private and is not itself added to the public proof.
The verifier checks native ticket economics, snapshot/public/lock digests, exact ranking, fallback ranks, unchanged strategy candidate, draw, selected branch, result, approach tracks, and owned-ticket classification. v1–v4 codecs and verifiers remain supported for their historical flights; v5 support is additive and does not rewrite old commitments or apply today's economy to an older proof.
