DocsHTTP API

Flight Endpoint

GET and POST /api/flight, the practice crash game served by the Galabet site itself, with its session cookie, five actions, server-clock settlement and the record a finished flight leaves behind.

Galabet Flight is a one-player crash game for practice credits. You stake, a multiplier climbs from 1.00×, and you get out before it stops or you lose the stake. The whole game is one URL, /api/flight, and the server behind it is the Astro site, not the NestJS API. Production Flight uses Postgres through FLIGHT_DATABASE_URL; it does not use Redis. Without that configuration the production endpoint returns 503. astro dev without a database retains a disposable memory mode. The widget that plays it is on the Crash game page.

The historical example responses below were captured with curl.exe from the site's dev server at http://localhost:4321. Ids, hashes and timestamps are from that session and yours will differ. If you're on Windows, read curl on Windows before copying a POST.

Finished a flight and want to know whether the crash point was honest? Copy the round's record into the verifier, or skip to Checking a Finished Flight. The answer has a limit and that section states it.

Opening a Session

GET creates the session
curl -s -i -c jar.txt -b jar.txt http://localhost:4321/api/flight
Captured response, cookie value shortened
HTTP/1.1 200 OK
set-cookie: galabet-flight=a2a218b3...c4ef6b88; Max-Age=86400; Path=/api/flight; HttpOnly; SameSite=Strict
cache-control: no-store
content-type: application/json

{"balance":1000,"serverNow":1789945862713,"id":"bc1e9258-9edd-4002-836f-b0abf4bca7ee","commitment":"14dbd8b4ae7a5d09eba2dec9c4e5fd7567f110a6276c4e42184237d21c7c6ba5","salt":"galabet-flight:bc1e9258-9edd-4002-836f-b0abf4bca7ee","phase":"ready","startsAt":0,"amount":0,"autoCashout":2,"multiplier":1,"record":null,"history":[]}

There is no login and no session route. A GET without a valid galabet-flight cookie gets a new session: 1,000 credits, an empty history, and a round that is already prepared. The cookie value is 32 random bytes as hex and it is the only key to the session. It is HttpOnly, so page scripts can't read it, SameSite=Strict, scoped to /api/flight so no other route on the site ever receives it, and good for 86,400 seconds. Secure is added when the request URL is https, which is why it's missing from a capture made on localhost. The cookie is renewed on responses that return a session. Mutations with an expired or missing session return 401 and require a fresh GET. A shared creation limit allows 30 new sessions per source address per hour.

Look at what the body already contains before a credit has been staked. commitment is the SHA-256 of this round's game hash, and salt is galabet-flight: plus the round id. The crash point was computed from the hidden game hash and that salt when the round was prepared. Staking doesn't change it and neither does anything else.

The FlightView

Every successful response, from GET or from any action, is the same object.

FieldMeaning
balancePractice credits, whole numbers
serverNowThe server's clock when the response was built, Unix milliseconds
idThe current round, a UUID. start, cashout and next must quote it
commitmentSHA-256 of the round's game hash, 64 hex
saltgalabet-flight:<id>
phaseready, waiting, running, cashed or crashed
startsAtWhen the multiplier leaves 1.00, server clock. 0 until the round is started
amountThe stake. 0 until started
autoCashoutThe automatic exit. 2 until started
multiplier1 while ready or waiting, the live value while running, then the cash-out multiplier or, after a crash, the crash point
recordnull until the round is over, then its FlightRecord
historyThe last 10 records, newest first

startsAt and serverNow are on the same clock. A client drawing the curve should work from the difference between them and its own clock at the moment the response arrived, never from its own clock alone.

Five Actions

GET is the state action. Everything else is a POST with a JSON body.

Stake 25 credits, leave automatically at 50x
curl -s -c jar.txt -b jar.txt -X POST http://localhost:4321/api/flight \
  -H 'origin: http://localhost:4321' \
  -H 'content-type: application/json' \
  -H 'idempotency-key: flight-example-start-001' \
  -d '{"action":"start","id":"bc1e9258-9edd-4002-836f-b0abf4bca7ee","amount":25,"autoCashout":50}'
actionOther fieldsDoes
statenoneReturns the view. Same as GET
startid, amount, autoCashoutDebits the stake and sets startsAt to now plus 1,800 ms
cashoutidSettles a running flight at the multiplier the server computes for this instant
nextidReplaces a finished round with a freshly prepared one
refillidRaises the balance to 1,000 if it is below that. Never lowers it

amount is a whole number of credits from 1 to 1,000 and no more than the balance. autoCashout is a number from 1.01 to 100 with at most two decimals, and it is required: there is no way to fly without one, so 100 is the closest thing to "manual only".

Three things must be true of a POST before its body is read. The Origin header has to equal the site's own origin exactly, which is what stops another website from driving your session and is why the curl command has to fake one. content-type has to start with application/json. And the body has to be at most 1,024 characters and parse to a JSON object.

Production mutations require an Idempotency-Key of 16–100 letters, digits, underscores or hyphens. Use a new key for a new action and retain it with the exact payload until its outcome is known. The session row is locked while the action, balance ledger, completed record and response are committed together. A retry returns the original response; GET retrieves the current view. Reusing a key with different action inputs returns 409. Repeating a started round with a different stake or automatic target is also refused.

A response can be lost after its transaction commits. The Flight widget retains that request’s key and body in memory and resolves it on reconnect, rather than inventing a second action. Reloading the page discards that browser retry state; the next GET still recovers the durable round. Idempotency responses remain with the session until retention cleanup. Expired credentials cannot be used to mutate it.

Phases and the Curve

ready ──start──▶ waiting ──1.8 s──▶ running ──┬─ cashout, or autoCashout reached ─▶ cashed
                                              └─ crash point reached ─────────────▶ crashed
cashed / crashed ──next──▶ ready

Nothing but start, cashout and next moves a round by request. The other transitions are the clock's. waiting lasts 1,800 ms, during which cashout is refused with The flight has not started yet. Then the multiplier follows one curve for every round:

curve.mjs
const GROWTH = 0.16; // per second, from flight-rules.ts

const multiplier = (ms) => Math.floor(Math.exp(GROWTH * Math.max(0, ms) / 1000) * 100) / 100;
const reachedAfter = (m) => Math.log(m) / GROWTH * 1000;

for (const ms of [0, 62, 63, 1000, 2342, 5000]) console.log(`${String(ms).padStart(5)} ms  ${multiplier(ms).toFixed(2)}x`);
for (const m of [1.01, 2, 10, 100]) console.log(`${String(m).padStart(5)}x after ${(reachedAfter(m) / 1000).toFixed(2)} s`);
Output
    0 ms  1.00x
   62 ms  1.00x
   63 ms  1.01x
 1000 ms  1.17x
 2342 ms  1.45x
 5000 ms  2.22x
 1.01x after 0.06 s
    2x after 4.33 s
   10x after 14.39 s
  100x after 28.78 s

The multiplier is floored to two decimals, so it reads 1.00 for the first 62 ms.

The longest possible flight is the one that reaches 100×, and the last line above is how long that takes. An autoCashout of 100 is the ceiling, so no round outlives it.

2,342 ms is in that list because it's the real one. In the captured session the cashout request was handled 2,342 ms after startsAt, and the server paid 1.45×.

Settlement Runs on the Server Clock

No timer runs on the server. A round is settled lazily, at the start of whichever request arrives next, by asking what the server clock says should already have happened. The order of the questions is the rule of the game:

decide.mjs
const GROWTH = 0.16;
const multiplier = (ms) => Math.floor(Math.exp(GROWTH * ms / 1000) * 100) / 100;

// The decision inside settle() in flight-store.ts, copied out so it can run here.
function decide({ result, autoCashout, elapsed, manual = false }) {
  const crashMs = Math.log(result) / GROWTH * 1000;
  const autoMs = Math.log(autoCashout) / GROWTH * 1000;
  if (autoCashout < result && elapsed >= autoMs) return `cashed at ${autoCashout}`;
  if (elapsed >= crashMs) return 'crashed';
  if (manual) return `cashed at ${Math.min(multiplier(elapsed), autoCashout)}`;
  return 'still running';
}

console.log(decide({ result: 2.08, autoCashout: 2.07, elapsed: 60000 }));
console.log(decide({ result: 2.08, autoCashout: 2.08, elapsed: 60000 }));
console.log(decide({ result: 2.08, autoCashout: 50, elapsed: 2342, manual: true }));
console.log(decide({ result: 2.08, autoCashout: 50, elapsed: 30, manual: true }));
console.log(decide({ result: 2.08, autoCashout: 50, elapsed: 4578, manual: true }));
console.log(decide({ result: 1, autoCashout: 1.01, elapsed: 0, manual: true }));
Output
cashed at 2.07
crashed
cashed at 1.45
cashed at 1
crashed
crashed

An automatic cash-out has to be strictly below the crash point. Equal loses, as the second line shows, and the source says so in a comment: equal to the endpoint is too late. The fourth line is a manual cashout in the first 62 ms, while the floored multiplier still reads 1.00. It is accepted and pays the stake back, no more. The fifth is a click handled at 4,578 ms, a fraction of a millisecond after a 2.08× round ends. The last line is an instant crash. A round whose point is 1.00 is over at the moment it starts and no autoCashout can be low enough to beat it.

Because the elapsed time is the server's, closing the tab doesn't change the outcome. We started a 100-credit flight with autoCashout 1.01, sent nothing for six seconds, and then asked for the state:

Captured: the record inside the first GET after six seconds of silence
{
  "id": "7f764003-29ac-4d42-a63c-4f8e3fc19c95",
  "commitment": "bfc6fcfa546ba893a4e53a7b7f430792fcd0ab2022aef2cb6f7d70198fc270ef",
  "gameHash": "d829a7aea62d1de1dd75d8f5c21605dc6277539947cb67cbfd9678d70e43c710",
  "salt": "galabet-flight:7f764003-29ac-4d42-a63c-4f8e3fc19c95",
  "result": 2.99,
  "houseEdge": 0.01,
  "amount": 100,
  "autoCashout": 1.01,
  "cashedAt": 1.01,
  "payout": 101,
  "endedAt": 1789945909824.1895
}

startsAt for that round was 1789945909762. The record says it ended 62.19 ms later, which is when the curve crosses 1.01, and not at 1789945914295, which is when the request that did the settling arrived. The fraction in endedAt is a consequence: automatic exits and crashes are stamped with a computed time, manual ones with the integer clock reading of the request.

The same laziness cuts the other way. A manual cashout is priced when the server handles it, so network delay comes out of the player's multiplier, and a click that arrives after the crash time is a crash however early it was made. That isn't a quirk of this implementation. Every crash game has to pick a clock, and the only one the operator can defend is its own.

The FlightRecord

FieldMeaning
idRound id
commitmentWhat the view showed before the stake
gameHashThe secret, revealed now that the round is over. 64 lowercase hex
saltgalabet-flight:<id>
resultThe crash point
houseEdgeAlways 0.01
amountStake
autoCashoutThe automatic exit that was set
cashedAtThe multiplier paid, or null for a crash
payoutfloor(amount × cashedAt), or 0
endedAtServer time of the exit or crash. May carry a fraction

The game hash appears nowhere until the round has a record. GET during waiting or running returns the commitment and the salt and that's all, so there is nothing in a live response to compute the crash point from.

Checking a Finished Flight

This is the record from the 25-credit flight, pasted in as it came back.

check-flight.mjs
import { crashResult, inspectRecord, sha256Hex } from '@galabet/fair';

const record = {
  id: 'bc1e9258-9edd-4002-836f-b0abf4bca7ee',
  commitment: '14dbd8b4ae7a5d09eba2dec9c4e5fd7567f110a6276c4e42184237d21c7c6ba5',
  gameHash: '148d8a25a388102a92b11977037adcc9f46bb8ea13e3c431e3782f6ee9701650',
  salt: 'galabet-flight:bc1e9258-9edd-4002-836f-b0abf4bca7ee',
  result: 2.08, houseEdge: 0.01, amount: 25, autoCashout: 50,
  cashedAt: 1.45, payout: 36, endedAt: 1789945893317,
};

console.log('commitment', (await sha256Hex(record.gameHash)) === record.commitment);
console.log('crash point', await crashResult(record.gameHash, record.salt, record.houseEdge));
console.log('payout', Math.floor(record.amount * record.cashedAt));

const forged = { ...record, cashedAt: 2.07, payout: 51 };
const report = await inspectRecord(forged);
console.log(report.status, report.checks.map((check) => `${check.name}: ${check.state}`).join(', '));
console.log(report.note);
Output
commitment true
crash point 2.08
payout 36
matches Commitment: matches, Outcome: matches
Endpoint checks do not authenticate cash-out timing, stakes or payouts. Those fields are a supplied receipt.

Two checks carry weight. The game hash revealed at the end hashes to the commitment shown at the start, so the server didn't swap it after seeing the stake. And crashResult, the same function the Crash page describes, turns that hash and salt into the 2.08 the record claims. The third line only shows the payout is consistent with the multiplier written next to it.

Then the forged copy. We changed the cash-out to 2.07×, a hair under the crash point, raised the payout to match, and the report still says matches. Reproducing the crash point does not authenticate the cash-out time. Nothing in the record can. cashedAt is the server's statement of when your request arrived, there is no startsAt in the record to even recompute the curve from, and a player's own clock isn't evidence. inspectRecord says this in its note and any interface showing a Flight verdict should show the note with it. The Crash page has the same limit for the multiplayer game.

A second limit is particular to Flight. Multiplayer Crash takes its salt from somewhere the operator doesn't control, so the operator can't shop for a chain that pays badly. Here the server chooses the game hash and the round id, and the salt is made from the id. Both are fixed before you stake, which the commitment proves. That they were chosen blind is not something a record can prove. For practice credits that is a reasonable trade, and it is the reason this page doesn't call Flight a Crash implementation.

Refusals

Errors are { "message": "..." } and a status. All of these were captured except the three marked as read from the source.

StatusMessageCause
400Unknown flight action.action missing or not one of the five
400Invalid request.Body is valid JSON but not an object
400Unexpected end of JSON inputBody isn't JSON. The text is the JavaScript engine's and varies
400Choose 1–1,000 whole credits.amount of 0, 25.5, a string
400Automatic cash-out must be 1.01×–100×, with up to two decimals.autoCashout of 1.505, 101, missing
400Not enough practice credits. Lower the stake or refill after the round.Stake above balance. From the source
403Use the flight controls on this site.Origin missing or different from the site's
409This round has changed. Refresh the flight.start with an id that isn't current
409That flight has already finished.cashout with an id that isn't current
409The flight has not started yet.cashout while ready or waiting
409Finish this flight first.next before the round has a record
409Finish this flight before refilling.refill while waiting or running
409An action is processing. Please try again.A request arrived while another for the same session was mid-action. From the source
413Request too large.Body over 1,024 characters
415Expected JSON.content-type isn't JSON
429Please wait a moment before trying again.More than 10 requests in a second
503Practice is busy. Please try again later.The session store is full. From the source
503Flight service unavailable. Please reconnect.Anything else. The real error is not sent

What the Store Is

In production, Postgres: sessions, balances, the ledger, receipts and idempotency responses, shared by every site process. A development server started without FLIGHT_DATABASE_URL keeps them in a Map in memory instead, and loses them on restart.

Session lifetime24 idle hours, and never more than 30 days from creation; cookie expiry does not delete completed records immediately
Requests10 per second per session, including replayed actions and GETs
Session creation30 per source address per hour, shared in Postgres
Visible historyLast 10 records
PersistencePostgres session, ledger, receipts and idempotency responses
Retry responsesNewest 50 per session, none older than 24 hours. An older retry runs again, which is safe because every action is tied to its round id
LedgerOne row per real balance change. A refused action writes nothing
Retention cleanupSessions and their receipts 30 days after expiry, when the recovery job runs

Production site processes share the same database. Row locks serialize requests from the same session. The database clock is read after acquiring the lock, so a queued cash-out is priced when it can be accepted. Automatic cash-outs still settle at their configured target after a reconnect.

Run pnpm --filter site flight:recover periodically to settle abandoned flights and prune expired retention data. It picks only rounds whose result is already fixed, meaning the earlier of the automatic target and the crash point has passed, and settles each session in its own short transaction with FOR UPDATE SKIP LOCKED. A player acting on a live round is never kept waiting by it, and overlapping runs do not credit twice. Requests also settle their current round. A database failure returns 503; it never creates a temporary in-memory replacement. /api/flight-health checks storage availability.

Apply pnpm --filter site db:migrate before starting the production site. The memory-only limitations apply solely to an unconfigured development server. These balances are virtual practice credits, not money.

The Nginx Route

On the production box the site listens on 4321 and the NestJS API on 3000, and Nginx sends anything under /api/ to the API. Flight is the exception and has to be carved out ahead of that rule:

From deploy/nginx.galabets.conf
location = /api/flight {
    client_max_body_size 2k;
    proxy_pass http://galabet_site;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-For $remote_addr;
}

location = is an exact match, and Nginx tries exact matches before regular expressions, so this wins over the ^/(api|badge|ws)/ block regardless of order in the file. Exact also means exact: /api/flight/ with a trailing slash goes to the API. The 2k body cap sits in front of the endpoint's own 1,024-byte streaming check. Leave the block out and every Flight request lands on a NestJS process that has never heard of the route.