Partner API of an organization (API keys)
An HTTP API a third-party partner of the club owner uses to read the organization's data: a till, a loyalty system, a booking site, a chain's own storefront. The key is issued by the owner in the panel (the API keys section), and the owner also decides what the key may do and until when it works.
This is not the game-station protocol (that one runs over WebSocket) and not the client mobile API — those are separate contracts. There are no client sessions and no human login here: the request comes from the partner's software and is identified by the key.
Postman. Two files are ready for import:
| File | What it is |
|---|---|
| spike_api_postman_collection.json | The collection: 51 requests in 8 folders — every action plus the refusal checks, with tests included |
| spike_api_postman_environment.json | The environment: base_url and api_key (type *secret*), the rest of the variables fill themselves |
Import both (*Import* → drop the files in), pick the environment in the top-right corner and put your api_key into it. Keep the key in the environment, not in the collection: collections get shared and exported, the environment stays with you.
After that the whole thing can be run with the Runner — the lists remember the id of their first item, so gs.get, product.get, session.get, order.get and chat.messages work without typing anything. Folders 1–7 expect a 200, that is they assume a key with full access; a key with trimmed rights lawfully answers 403 forbidden_action on actions it cannot reach — what exactly it can reach is told by meta.actions.
---
1. Where to get a key
The key is issued by the club owner in their panel: the API keys section → "Create". That is also where they choose what the key may do and until when it works, so ask for a key together with the list of actions you need — the owner ticks exactly those.
What to tell the owner:
- which data you need — by the actions from §4 (say
gs.status,product.list); in the key's card they tick the rights whose names are printed next to each action; - for how long — a key can be issued for a day, a week, a month, a year, until a given date, or with no expiry at all;
- whether the request log is needed — while an integration is still being sorted out, the owner switches the checkbox on and sees your requests (see §8).
The finished key is handed to you like a password. In the list of keys it is shown masked (the first and last four characters); in full it is only in the card, which is where it gets copied from.
More about it from the owner's side is in the SpikePC user guide, the section API keys.
A key is not tied to a club: it works for the whole organization, and you point at a particular club with the club_id parameter.
---
2. Transport
POST https://spikepc.com/spike_api/
X-Api-Key: 7b2d4f9a1c3e5d7f9b0a2c4e6f8a0b1d
Content-Type: application/json
{ "action": "gs.list", "v": 1, "data": { "club_id": 3, "limit": 50, "offset": 0 } }
Write the address with the trailing slash (/spike_api/). Without it the web server answers with a redirect to the address with the slash, and the body of a POST request is lost on a redirect — the request arrives empty and gets 400 bad_request.
The same handler is also reachable at the internal address ?loc=spike_user_api_request — it keeps working, but use /spike_api/ in integrations.
| Rule | Why |
|---|---|
POST only, the body is one JSON object | Query parameters never reach the handler: the panel lets only a whitelist through the URL (id, loc, page, token, hash) |
The key goes in the X-Api-Key header | Verified: the front proxy passes it through. Authorization: Bearer does not reach PHP at all, so it cannot be used |
Fallback — the api_key field in the body | For clients that cannot send headers. This field is never written to the log |
| The key is never passed in the URL | Addresses end up in the proxy and web-server logs |
Business parameters live inside data | The system fields (action, v, api_key) sit next to it instead of mixing with yours |
org_id in a request is not accepted | The organization comes from the key. Otherwise a partner of one club would read another's data |
club_id is accepted | But it is checked against the clubs of the key's organization → otherwise club_foreign (403) |
v is the contract version, optional (currently 1).
---
3. The response
The same envelope as the station and the mobile app use; code is mirrored in the HTTP status:
{
"code": 200,
"error_key": "",
"error_text": "",
"action": "gs.list",
"data": { "items": [ … ], "total": 38, "limit": 50, "offset": 0 }
}
error_keyis the stable machine code — rely on it.error_textis an explanation in English, and its wording may change.datais always an object ({}on an error): a field can be added to an object without breaking your parser. Lists areitems+total+limit/offset, a single record isitem.actionechoes the requested action, which helps when your side runs an asynchronous queue.
Codes and error keys
| HTTP | error_key | When |
|---|---|---|
| 400 | bad_request | The body is not a JSON object, or the action field is missing |
| 401 | empty_key | No key was sent |
| 401 | unknown_key | The key was not found |
| 401 | key_expired | The key's validity has run out |
| 403 | forbidden_action | This key is not allowed to call this action |
| 403 | club_foreign | club_id belongs to another organization |
| 404 | unknown_action | No such action |
| 404 | not_found | The object was not found (a club, a station) |
| 422 | bad_params | A required parameter is missing |
| 429 | rate_limited | The rate was exceeded (see §7), with a Retry-After header |
| 500 | org_unavailable | The organization's database is unavailable |
| 500 | query_failed | A query failed. Do not confuse it with an empty list: on a failure you get an error, not items: [] |
---
4. Actions
What to do is told by the action field. There is a single entry point, so adding a method changes no address and needs no changes on your side. The right behind every action is the name of a function in the panel: it is printed in the key's card next to the rights checkbox, and that is exactly what the owner ticks to open the action to a partner.
The actions in this section are read-only. The ones that change data are in §5.
Service actions (no right required)
action | Purpose |
|---|---|
ping | The key is accepted: organization, key name, access mode, expiry, server time |
meta.actions | What this key may call — the list of actions and their rights |
The club and the floor
action | Right | Parameters | Purpose |
|---|---|---|---|
club.list | spike_clubs_list | — | Clubs: name, address, phones, coordinates |
club.map | spike_club_maps_list | club_id* | The floor plan (an object, or null if no map was drawn) |
gs_group.list | spike_gs_groups_list | club_id | Station groups — tariffs and passes hang off them |
gs.list | spike_gs_list | club_id, group_id, limit, offset | Stations: number, type, IP, MAC, group, mode, screen refresh rate, client software version (app_version — the station itself reports it on connect; empty = never reported) |
gs.get | spike_gs_list | id or mac | A single station |
gs.status | spike_club_maps_list | club_id | The live state of the floor (see below) |
snap.list | spike_gs_snaps_list | club_id* | The club's golden images: which one is active, when it was taken |
The catalogue
action | Right | Parameters | Purpose |
|---|---|---|---|
game.list | spike_games_list | club_id, genre_id, status, search, locale, limit, offset | Games of a club: how it launches, rating, genres |
game_genre.list | spike_game_genres_list | locale | Genres of the organization (one list for the whole chain) |
banner.list | spike_banners_list | club_id | Banners: the area on the station screen, the period, the time window |
category.list | spike_market_category_list | locale | Product groups |
product.list | spike_market_products_list | club_id*, cat_id, status, search, locale, limit, offset | Products with the club's price and the stock left |
product.get | spike_market_products_list | club_id*, id or code or barcode | A single product (handy from a scanner) |
stock.list | spike_stock_list | club_id, type, product_id, date_from, date_to, limit, offset | Stock movements: receipt, write-off, sale, return, adjustment |
search on products looks through the name, the article code and the barcode. locale replaces name and description with the translation of that locale; with no translation the base value stays, so the response never comes back empty.
club_id is required for products: a product has its own price in every club, and without a club we would have to return "the price in general" — a partner would sell at someone else's. If this club has no price, price: null and available: false arrive: the product is not sold here.
Product availability hours come in the hours field — one row per weekday: [{day, from_sec, to_sec}], where day is 0=Mon … 6=Sun and from_sec/to_sec are seconds from midnight. An empty array means the product is sold around the clock. to_sec is the last included second (the window "18:00–22:00" arrives as 64800…79259), and a to_sec lower than from_sec is a window that runs past midnight: the listed day is where it starts and the end falls on the next day. Next to it comes a ready answer, hours_open (true/false — whether the product is on sale right now): working that out yourself would take both our boundary convention and our timezone. The schedule does not affect available — that field still means the price in this club plus the product being active.
Tariffs and passes
action | Right | Parameters | Purpose | |
|---|---|---|---|---|
tariff.list | spike_tariffs_list | club_id | Tariffs: price per hour, colour, whether discounts apply, status (0 = the tariff is withdrawn: it is not assigned to new schedule hours, yet the hours it already occupies are still charged at its price) | |
tariff.schedule | spike_tariffs_schedule | club_id* | The weekly grid: day+time → station group → tariff | |
ticket.list | spike_tickets_list | club_id | Passes: time, price, auto_apply, status (0 = disabled by the club, not for sale) | |
ticket.schedule | spike_tickets_schedule | club_id*, kind (sale \ | valid) | When a pass is sold, or when it can be spent |
discount.list | spike_discounts_list | status | The list of one-off discounts | |
promocode.list | spike_promocodes_list | status, search, club_id, limit, offset | Promo codes: event, amount, window, activations left | |
payment_method.list | spike_payment_methods_list | type, status, club_id | The organization's tills with a computed balance |
Schedule interval bounds are inclusive, and time_end is the last included second of the block (hour 13 → 50399 = 13:59:59). Next to the seconds the response carries ready-made from/to, so you do not repeat this convention on your side and do not lose the last hour.
The two pass grids are different things: kind=sale answers "when it can be bought", kind=valid — "when what was bought can be spent". An empty validity grid means "valid at any time".
For a bonus till balance comes back as null, not zero: bonuses are not real money in a till.
Clients and money
action | Right | Parameters | Purpose | ||
|---|---|---|---|---|---|
client.list | spike_clients_list | search, is_banned, limit, offset | Clients of the organization | ||
client.get | spike_clients_list | id or email or phone | The client's card plus their passes | ||
client.packs | spike_clients_list | id \ | email \ | phone | The passes only |
transaction.list | spike_transactions_list | client_id, club_id, type, date_from, date_to, limit, offset | The ledger of operations | ||
shift.list | spike_cash_shifts_list | club_id, status, date_from, date_to, limit, offset | Cash shifts with totals and the difference | ||
postpay.list | spike_cashdesk_list | club_id, status, date_from, date_to, limit, offset | Pay-after-play records |
search on clients looks through the email, phone, login, first and last name. The phone in client.get is looked up in every format it could have been stored in (380…, +380…, 0…, 38…) — the import and the sign-up wrote it differently.
The service guest records of stations (guest pay-after-play) do not appear among clients. Operator notes and the avatar are not handed out.
A pass's remainder is counted by the wall clock: an activated pass burns down from the moment of activation, so remaining_seconds will not show time that has already burned while the client was away.
Sessions, orders, bookings, chat
action | Right | Parameters | Purpose |
|---|---|---|---|
session.list | spike_gs_sessions_list | club_id, gs_id, client_id, status, date_from, date_to, limit, offset | The history of sessions |
session.get | spike_gs_sessions_list | id* | A session plus its event log (start, game launches, orders, end) |
order.list | spike_orders_list | club_id, status, gs_id, client_id, date_from, date_to, limit, offset | Shop orders with their lines |
order.get | spike_orders_list | id* | A single order; a combo line carries the client's choice in options |
reserve.list | spike_gs_reserve_list | club_id, gs_id, client_id, date_from, date_to, limit, offset | Station bookings |
chat.list | spike_chats_list | club_id, date_from, date_to, limit, offset | Conversations (one conversation = one session) |
chat.messages | spike_chats_list | session_id*, limit, offset | Messages of a conversation |
An active session is still accruing its total, so the response carries active: true — do not take an interim sum for the final one.
An order does not charge the client's balance — it is paid on the spot to the staff, so total_amount is the sum due, not a payment that went through.
The period for bookings is matched by overlap, not by their start: a booking that started yesterday and is running now falls into today's selection.
For conversations total is the number of conversations, not of messages.
The organization
action | Right | Parameters | Purpose |
|---|---|---|---|
org.info | spike_org_config_list | — | Time zone, stock rules, pass and pay-after-play limits, clubs with order taking |
locale.list | spike_locale_list | status | Catalogue languages and which one is default |
staff.list | spike_staff_list | — | Staff and their clubs |
event.list | spike_events_list | search, date_from, date_to, limit, offset | The log of changes in the panel |
analytics.daily | spike_analytics_list | club_id, date_from, date_to | The daily pre-computation: occupancy and revenue |
org.info hands out no credentials: no database access, no SMTP, no Telegram tokens, no station gs_token, no organization balance. The "view settings" right is given to a member of staff inside the panel, and here the response goes to a third party.
event.list does not hand out the before/after snapshots: those carry whole fields, including the club's Windows passwords. What stays is who, when, in which section and over what.
analytics.daily is computed by a nightly job in the organization's time zone, so the current day is usually empty — that is not lost data: for today use the live gs.status, transaction.list, order.list. The default period is the last 30 days.
Pages and filters
limit— 50 by default, 200 at most;offsetstarts at zero. Both come back in the response.date_from/date_to—YYYY-MM-DDorYYYY-MM-DD HH:MM:SS. A date without a time indate_tomeans the whole day, otherwise2026-10-01would cut the day off and leave midnight only.- Values of enumerated filters (
status,type,kind) are checked against a whitelist: an unknown value gets422 bad_paramsinstead of a silently ignored filter.
Example: the live state of the floor
→ { "action": "gs.status", "data": { "club_id": 3 } }
← { "code": 200, …, "data": { "items": [
{ "gs_id": 587, "club_id": 3, "number": "2",
"online": true, "mode": "active",
"busy": true, "client_id": 1402, "started_at": "2026-10-01 14:05:11",
"time_left": { "seconds": 4260, "text": "1:11", "unlimited": false } }
], "total": 38, "server_time": "2026-10-01 16:10:59" } }
| Field | Meaning |
|---|---|
online | The station has an open connection to the service. This is about the link, not the mode |
mode | active / service (maintenance) / admin (administration) / disabled (withdrawn from rental) |
busy | A client is playing at the station. A station can be busy with the link lost too — the session is closed by reconciliation |
time_left | An estimate of what is left: the club's free minutes + applicable passes + money at the current rate. unlimited — there is no rate or it is zero. null — nobody is at the station |
time_left and online are computed by the same code as the club map, so a partner and an operator see the same figure.
---
5. Writing
These actions change the club's data: they take money, sell a pass, move an order, create a booking, grant a discount and a credit, send a command to a station. Their rights are separate — the owner ticks them in the key's card apart from viewing, so "look at" and "change" never come with the same checkbox.
The rules are the same as for an operator in the panel: no money is taken without an open cash shift, a pass is not sold over the organization's limit, a booking is not created outside the club's hours. These are not "similar checks" — both sides call the very same code, so a partner cannot do what an operator is not allowed to.
action | Right | Parameters | What it does |
|---|---|---|---|
client.topup | spike_clients_topup | client_id*, amount*, payment_method_id*, comment, club_id | Tops up the client's balance through a till |
client.sell_ticket | spike_clients_sell_ticket | client_id*, ticket_id* | Sells a pass, charging the client's balance |
transaction.refund | spike_transactions_refund | tx_id* | Refunds a top-up |
order.set_status | spike_orders_list | order_id*, status* | Moves an order: new→processing→done, or cancelled |
reserve.create | spike_gs_reserve_add | club_id*, gs_id*, date_start*, date_end*, client_id, description | Creates a station booking |
reserve.cancel | spike_gs_reserve_del | reserve_id* | Cancels a booking |
discount.grant | spike_club_maps_gs_discount | gs_id*, discount_id* | Grants a one-off discount (0 clears it) |
credit.set | spike_club_maps_gs_credit | gs_id*, amount* | Sets a credit for the session (0 clears it) |
postpay.enable | spike_cashdesk_postpay_enable | gs_id*, limit, guest, comment | Switches pay-after-play on at a station |
postpay.limit | spike_cashdesk_postpay_enable | postpay_id*, limit | Changes the pay-after-play limit |
postpay.stop | spike_cashdesk_postpay_enable | postpay_id* | Ends pay-after-play (the station gets session_end) |
postpay.cancel | spike_cashdesk_postpay_enable | postpay_id* | Cancels pay-after-play that is still waiting for a client |
postpay.pay | spike_cashdesk_postpay_enable | postpay_id*, payment_method_id*, comment | Takes the pay-after-play payment into a till |
gs.command | spike_club_maps_gs_command | gs_id*, action*, data | A command to a station: message, poweroff, poweron, session_end |
Money
A till is required (payment_method_id); the list comes from payment_method.list. For a bonus till comment is required — a bonus with no explanation is not accepted, otherwise there is nothing to answer "what was it for" with. If a till is shared across the organization and there is more than one club, club_id is needed: without it the operation would be lost for the club's own reports.
An open cash shift is required in that club, otherwise 409 no_shift. A shift is opened by a cashier in the panel and this is not worked around over the API: money has to belong to someone's shift.
A refund (transaction.refund) is possible only for a top-up and only once; the balance may go negative — the money has been handed back from the till, so it leaves the balance in full.
Selling a pass charges the client's balance, taking their permanent discount into account (if the pass allows discounts). The pass goes into store: its clock starts when it is activated at a station, not when it is sold, otherwise a pass bought in advance would burn down before the client arrives.
The floor
A discount: if a client is at the station, the percentage goes as a snapshot into their session (the list may be edited later, while the person plays on what was promised); if the station is free, the request waits for whoever sits down, for 30 minutes.
A credit is the allowed depth of a negative balance until the end of the session, not a deposit: the client plays on in debt and clears it with an ordinary top-up. An active client session is required.
Commands: only message, poweroff, poweron and session_end are allowed. Station modes, golden images and the rest of running the floor stay with the operator — that is the club's business, not an outside system's. The response carries delivery: done/delivered — the station accepted it, offline — it is not connected, timeout — it did not answer in time.
Refusal keys for writing
error_key | When |
|---|---|
bad_amount | The amount is not above zero (or the credit is negative) |
invalid_payment_method | The till does not exist or is disabled |
comment_required | A bonus till without a comment |
club_required | The till is shared and club_id was not passed |
no_shift | There is no open cash shift in the club |
client_not_found, ticket_not_found, order_not_found, reserve_not_found, tx_not_found | The object does not exist |
no_balance | The client's balance is not enough for the pass |
ticket_disabled | The pass is disabled by the club and not for sale |
pack_single_only, pack_limit_reached | The organization's limits on passes |
not_a_topup, already_refunded | Refund: this is not a top-up, or it is already refunded |
bad_status, order_finalized | Order: unknown status, or it is already closed |
bad_time, past_time, too_far, too_short, too_long | Booking: the interval does not fit |
outside_window, gs_busy, too_many_reserves | Booking: outside the club's hours, the time is taken, too many bookings |
no_client | No client is playing at the station (credit) |
discount_not_found, credit_unavailable | The discount does not exist, or credit is unavailable for the organization |
---
6. The key's rights
The owner picks the key's access mode:
- Full access — the key may do everything the organization owner can;
- Access group — the rights of the chosen group, as for a member of staff;
- Custom set — only the ticked functions.
The right behind every action is the name of a function in the panel, and it is printed in the key's card next to the rights checkbox. So the owner sees exactly what is checked on a request: if a partner gets forbidden_action on gs.list, the item with the right spike_gs_list has to be ticked in the key's card.
The key's expiry is checked on the server: once it has run out, every request answers key_expired (401), even if the partner does not know about it.
---
7. Request rate
The limit is 120 requests per minute per key (the value is changed by the system administrator). Over it you get 429 rate_limited and a Retry-After: 60 header.
Polling the floor state every 2–5 seconds fits the limit with room to spare. If you need it more often, ask the club owner to raise the limit instead of working around it with several keys: the limit is per key precisely so that one partner does not get in the way of another.
---
8. The request log
In the key's card the owner can switch on the request log: then every call made with this key is kept for 7 days — the action, the response code, the duration, the IP and the request body (the key is cut out of the body). It is a tool for sorting out "it does not work for the partner": there is no need to keep it on all the time.
If your request is refused and the reason is unclear, ask the owner to switch the log on and repeat the request — the log will show the action, the code, and exactly what you sent.
---
9. What this API does not have
Editing the reference data. Tariffs, passes, products, clubs, stations, staff, rights and the organization's settings are not changed over the API — that happens in the panel. A partner needs to work with clients and the floor, not rewrite the club's settings, and every such edit would need its own form checks.
The list of keys. A key cannot read the keys table: a partner would see the keys of other partners of the same organization.
The panel's rights structure is not handed out: it tells an outside system nothing, while the response would start carrying the names of our internal functions.
---
10. Webhooks: events come to you
So you do not have to poll the API looking for a new order, give the club owner your address — events will arrive there on their own. It is set up in the organization panel (the "Clients and orders" card): address, switch, signature key and the list of events.
Request: POST <your address>, body is JSON:
{ "event": "order.new",
"org_id": 11,
"sent_at": "2026-10-08T12:46:08+03:00",
"data": { "order_id": 42, "club_id": 1, "gs_id": 11, "client_id": 35839,
"total": 320.00, "items": 3 } }
Headers: X-Spike-Event (event name), X-Spike-Org (organization) and X-Spike-Signature — the body signature.
Events
| Event | When | What is in data |
|---|---|---|
order.new | a client placed an order at a station | order_id, club_id, gs_id, client_id, total, items |
order.status | an order status changed | order_id, club_id, gs_id, old_status, status, total, user_login |
session.start | a client signed in at a station | session_id, club_id, gs_id, client_id, auth_method |
session.end | a session was closed | the same plus date_start, date_end, duration_seconds, time_cost, products_cost, total_cost, reason |
client.topup | a client balance top-up | client_id, login, amount, balance, club_id, payment_method, type, comment, source |
An organization may subscribe to a subset of events — then only those arrive.
The signature: check it
X-Spike-Signature: sha256=<hex> is HMAC-SHA256 over the entire request body with the key the club owner set in the settings. Compute it yourself and compare with the header: without this check anyone could post to your address pretending to be us. If no key is set, the header is absent; asking the owner to set one is worth it in any case.
# verification example (bash)
calc=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
[ "sha256=$calc" = "$SIG" ] || exit 1
Delivery
Sending is done by the queue, not by the operator's web request: the panel and the socket service never wait for your server. Hence the rules you can rely on:
- timeouts — 5 seconds to connect and 10 for the whole exchange;
- three attempts with pauses (immediately, after 2 and after 5 seconds) until the answer is
2xx; - a
4xxanswer (except408and429) is not retried — we treat the request as rejected; - redirects are not followed:
Locationis ignored, the POST will not go elsewhere; - event order is not guaranteed and at-least-once delivery is not guaranteed — if your server was down for all three attempts, the event is lost. Reconcile anything critical by reading the API: it has orders, sessions and transactions.
Answer 200 as fast as you can and process the event asynchronously on your side: while you hold the connection, a queue task is busy with you.