{"openapi":"3.1.0","info":{"title":"STRYK POS Posting API","version":"1.3.0","description":"Server-to-server interface for posting till items to physically checked-in hotel stays. STRYK issues a hotel-bound Bearer key once at creation; the stored hash cannot be revealed later. This documents the existing direct API, not a certified adapter for any named POS vendor. No public sandbox or automatic vendor onboarding is implied. Unknown optional response fields must be tolerated. The hotel needs the add-on STRYK POS; without it every call answers 403 with code module_disabled (no posting is written)."},"servers":[{"url":"https://app.staystryk.com/api/v1/pos/public","description":"Production; writes affect the authenticated hotel"}],"security":[{"posBearer":[]}],"paths":{"/ping":{"get":{"operationId":"pingPosConnection","summary":"Check the connection","responses":{"200":{"description":"Connection accepted","content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"type":"string","const":"ok"}}}}}},"400":{"$ref":"#/components/responses/Error"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/accounts":{"get":{"operationId":"listChargeableAccounts","summary":"List currently checked-in room accounts","responses":{"200":{"description":"Current in-house accounts, including No-Post and limit state","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RoomAccount"}}}}},"400":{"$ref":"#/components/responses/Error"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/room-lookup":{"get":{"operationId":"lookupRoomAccount","summary":"Resolve a checked-in stay by room number","parameters":[{"name":"room","in":"query","required":true,"schema":{"type":"string","minLength":1}}],"responses":{"200":{"description":"Unique current room account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoomAccount"}}}},"400":{"$ref":"#/components/responses/Error"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/Error"},"409":{"$ref":"#/components/responses/Error"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}},"description":"404 when no eligible current occupant exists. 409 when the room lookup is ambiguous. Never guess a guest from a room label or silently select the first match."}},"/charges":{"post":{"operationId":"postCharge","summary":"Post one item to an in-house account","description":"externalRef is the immutable idempotency key of this POS connection. An exact retry returns the original charge with idempotentReplay=true. Reusing the reference with changed content or after cancellation returns 409.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChargeRequest"}}}},"responses":{"201":{"description":"Created or exact idempotent replay","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Charge"}}}},"400":{"$ref":"#/components/responses/Error"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"},"409":{"$ref":"#/components/responses/Error"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/charges/batch":{"post":{"operationId":"postChargeBatch","summary":"Post up to 50 items atomically","description":"1-50 positions, each with its own externalRef; output order matches input. All newly created items commit together or none do. Existing exact replays may appear alongside new items. On uncertain outcome query each original reference before resending the unchanged batch. A conflicting or cancelled reference rejects the batch.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"array","minItems":1,"maxItems":50,"items":{"$ref":"#/components/schemas/ChargeRequest"}}}}},"responses":{"201":{"description":"Charges in request order","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Charge"}}}}},"400":{"$ref":"#/components/responses/Error"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Error"},"404":{"$ref":"#/components/responses/Error"},"409":{"$ref":"#/components/responses/Error"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/charges/{externalRef}":{"get":{"operationId":"getChargeByExternalReference","summary":"Recover the durable state of one POS transaction","description":"Use this read before retrying after a timeout or lost response. The externalRef is resolved only inside the authenticated API-key connection and returns open, cancelled, and transferred charges.","parameters":[{"name":"externalRef","in":"path","required":true,"schema":{"type":"string","minLength":1,"maxLength":255}}],"responses":{"200":{"description":"Current durable transaction state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Charge"}}}},"400":{"$ref":"#/components/responses/Error"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/Error"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}},"delete":{"operationId":"cancelCharge","summary":"Void an open charge owned by this POS connection","parameters":[{"name":"externalRef","in":"path","required":true,"schema":{"type":"string","minLength":1,"maxLength":255}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","maxLength":500}}}}}},"responses":{"200":{"description":"Cancelled charge, or an idempotent repeat of the cancellation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Charge"}}}},"400":{"$ref":"#/components/responses/Error"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/Error"},"409":{"$ref":"#/components/responses/Error"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}},"description":"Only this key’s open items can be cancelled. Repeating a successful cancellation returns the existing cancelled item. Transferred/invoiced items return 409 pos_charge_already_invoiced and need the Finance correction workflow. Cancellation is not deletion or a refund."}}},"components":{"securitySchemes":{"posBearer":{"type":"http","scheme":"bearer","bearerFormat":"pos_live","description":"Authorization: Bearer <complete pos_live_ key>. Header only, never query parameters. No employee JWT. Hotel and optional outlet scope are resolved from the key."}},"schemas":{"RoomAccount":{"type":"object","required":["bookingId","bookingNumber","guestName","roomNumber","allowPosting","allowCharge","accountClosed","openChargesCount","openChargesTotal","stayChargesTotal","currency"],"properties":{"bookingId":{"type":"string","format":"uuid"},"bookingNumber":{"type":"string"},"guestName":{"type":"string"},"roomNumber":{"type":"string"},"allowPosting":{"type":"boolean","description":"Hotel No-Post policy. true alone does not imply a charge is permitted."},"allowCharge":{"type":"boolean","description":"Combined eligibility, taking policy, available credit and closed invoice account into account."},"accountClosed":{"type":"boolean"},"postingBlockReason":{"type":["string","null"],"enum":["no_post","credit_limit_reached","account_closed",null]},"openChargesCount":{"type":"integer","minimum":0},"openChargesTotal":{"type":"number"},"stayChargesTotal":{"type":"number","description":"Non-cancelled stay charges, including transferred items. Credit policy uses this cumulative total."},"creditLimit":{"type":["number","null"]},"remainingCredit":{"type":["number","null"]},"currency":{"type":"string","pattern":"^[A-Z]{3}$"}},"description":"Current physical in-house room segments, including blocked accounts. Check allowCharge immediately before posting; final permission is rechecked atomically on the server. Nullable/unknown optional values may be omitted."},"ChargeRequest":{"type":"object","required":["description","unitPrice","externalRef"],"anyOf":[{"required":["bookingId"]},{"required":["room"]}],"properties":{"bookingId":{"type":"string","format":"uuid","description":"Prefer this durable identity together with room; several room segments in one booking require an unambiguous room."},"room":{"type":"string","minLength":1,"description":"Actual room label, including letters; do not convert to a number. Must match bookingId when both are provided."},"outletId":{"type":"string","format":"uuid","description":"Optional for an outlet-scoped key (scope is applied automatically). Any supplied value must match the key scope; mismatch is 403. An unrestricted key may choose an outlet."},"description":{"type":"string","minLength":1,"maxLength":500},"quantity":{"type":"number","minimum":0.001,"maximum":9999,"default":1,"description":"Defaults to 1; HALF_UP to 3 decimal places before validation."},"unit":{"type":"string","default":"Stk.","maxLength":20},"unitPrice":{"type":"number","exclusiveMinimum":0,"maximum":99999.99,"description":"Gross price per unit, not net. HALF_UP to 2 decimal places before validation. Positive only. Total gross (quantity × unitPrice rounded HALF_UP to 2 places) must be > 0 and <= 99999999.99."},"taxRate":{"type":"number","minimum":0,"maximum":100,"description":"Configured active tax on the service date, rounded to 2 decimals. If omitted, use the outlet default, then the central service tax. An unconfigured rate is rejected."},"currency":{"type":"string","pattern":"^[A-Z]{3}$","description":"Optional assertion; must equal the hotel's billing currency."},"externalRef":{"type":"string","minLength":1,"maxLength":255,"description":"Stable, trimmed, case-sensitive receipt/item reference owned by the API key. Use URL-safe values (e.g. receipt-123-item-1) so GET/DELETE paths work through proxies. Never reuse for another item or replay under another key."},"chargedAt":{"type":"string","format":"date-time","description":"Optional service timestamp; defaults to now. Prefer ISO-8601 with offset. Legacy local timestamps use the hotel timezone. A time more than 5 minutes ahead is rejected. Service date must match a room segment, and the guest must still be physically in-house when posting. Keep the original timestamp unchanged on retries."},"notes":{"type":"string","maxLength":1000},"posRef":{"type":"string","maxLength":100,"description":"Optional receipt reference for staff reconciliation. It does not replace externalRef and does not provide idempotency."}}},"Charge":{"type":"object","required":["chargeId","externalRef","bookingId","roomNumber","guestName","description","quantity","unitPrice","taxRate","grossAmount","currency","status","chargedAt"],"properties":{"chargeId":{"type":"string","format":"uuid"},"externalRef":{"type":"string"},"bookingId":{"type":"string","format":"uuid"},"roomNumber":{"type":"string"},"guestName":{"type":"string"},"outletId":{"type":"string","format":"uuid"},"outletName":{"type":"string"},"description":{"type":"string"},"quantity":{"type":"number"},"unit":{"type":"string"},"unitPrice":{"type":"number"},"taxRate":{"type":"number"},"grossAmount":{"type":"number"},"currency":{"type":"string"},"status":{"type":"string","enum":["open","cancelled","transferred"]},"chargedAt":{"type":"string","format":"date-time"},"cancelledAt":{"type":"string","format":"date-time"},"posRef":{"type":"string"},"idempotentReplay":{"type":"boolean","default":false},"duplicateWarning":{"type":"object","description":"Advisory only: a potentially matching staff-entered item was found. The new charge has ALREADY been posted; reconcile in STRYK, do not resend.","properties":{"chargeId":{"type":"string","format":"uuid"},"description":{"type":"string"},"grossAmount":{"type":"number"},"chargedAt":{"type":"string"},"posRef":{"type":"string"}}}},"description":"Public response. Optional values without data are omitted. idempotentReplay is present on exact POST replay, not guaranteed on other responses. No invoice ID is exposed by this API."},"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string"},"code":{"type":"string","description":"Optional machine-readable code. Do not parse localized error text. This list is extensible.","examples":["pos_currency_mismatch","pos_external_ref_conflict","pos_external_ref_cancelled","pos_room_required","pos_guest_not_inhouse","pos_posting_blocked","pos_credit_limit_exceeded","pos_charge_already_invoiced","pos_account_closed"]},"requestId":{"type":"string","description":"Optional diagnostic request identifier; retain for support without credentials or guest data."}}}},"responses":{"Error":{"description":"Request rejected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Unauthorized":{"description":"Key could not be validated (missing, invalid, revoked, or verification unavailable). Check configuration and service status; do not automatically rotate credentials or replay under a new key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"RateLimited":{"description":"100 requests per 60-second window per key, shared across application instances. No Retry-After header is currently returned. Wait at least 60 seconds, then retry with backoff and jitter. Reconcile uncertain writes before retrying.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"ServerError":{"description":"Unexpected service error. A failed or lost response is not proof that a write was rolled back. Reconcile GET /charges/{externalRef} before retrying with the original key and request. Back off for transient 5xx and network failures. Escalate persistent errors with requestId; never change reference to bypass an error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"externalDocs":{"description":"Setup and integration guide","url":"https://staystryk.com/en/documentation/pos-integration/"},"x-integration-notes":[{"title":"Authentication and provider direction","body":"STRYK implements an inbound REST API with Bearer authentication. Mews similarly offers PMS APIs that a POS calls. Lightspeed K-Series specifies an OAuth-authorized PMS registration and its own callback protocol; that requires a provider-specific adapter, not merely pasting a STRYK key. No named vendor adapter is claimed here. Agree on integration ownership, outlet mapping, tax mapping and acceptance tests with the vendor before configuring a live till."},{"title":"Supported scope","body":"This interface performs room-account lookup, positive item posting, state recovery and cancellation. It is not a payment-terminal API, cash register, fiscal receipt service, product catalogue sync, stock system or full restaurant sales import. Cash/card payments settled at the till must not also be posted to the room. For split tenders send only the unpaid room portion, with correctly allocated tax and a unique reference per item; reconcile rounding explicitly. No negative-price, zero-price, discount, tip or refund endpoint exists. Apply item discounts before posting positive gross prices; financial corrections use the controlled cancellation or invoice workflow."},{"title":"Hotel setup and account selection","body":"Create the sales outlet with an active central tax, grant POS management only to authorized staff and issue one appropriately scoped key per connection. /ping only confirms key authentication; verify a controlled room posting before go-live. Use the live /accounts result or /room-lookup, show guest and room for confirmation, and send bookingId plus room. Do not persist the account list as an indefinite guest directory. No-Post, available credit, account closure, actual check-in/out and room segments are validated again when writing. Departure-day postings are allowed while the guest remains checked in; post-checkout imports are rejected."},{"title":"Money, taxes and time","body":"Use JSON numbers in hotel currency, positive gross unit prices, decimal arithmetic, quantity rounded HALF_UP to 3 decimals and money to 2. Maximum quantity 9999, unit price 99999.99, gross item total 99999999.99. Tax must be an active configured rate on the service date; omission resolves outlet/central defaults. Hotel timezone determines service dates. Freeze chargedAt and the complete request before first transmission. Never recalculate timestamps, prices, tax or references on retry."},{"title":"Idempotency, offline recovery and key rotation","body":"Persist outgoing requests locally on the POS server before sending. An externalRef belongs to hotel + API key, not the whole hotel. Exact POST replay returns 201, including an already transferred original item; cancelled references and changed payloads return 409. On timeout or 5xx first query the original reference. Only a confirmed 404 permits resending the same request with the same key. Check every line after an uncertain batch. If a member of staff manually posted the receipt during an outage, reconcile it before replay; posRef can assist but does not deduplicate. A new key creates a different idempotency scope and cannot look up/cancel the old key’s charges. Drain and reconcile outstanding work before revoking an old key. If it is lost or compromised, revoke it and resolve uncertain transactions in STRYK before submitting any replacements."},{"title":"Error handling","body":"400: fix invalid fields; 401: authentication could not be validated; 403: scope not allowed; 404: account/item absent; 409: business conflict (inspect code, do not blindly retry); 429: rate limit, wait at least 60 seconds; 5xx/network error: bounded backoff, state reconciliation and visible operator escalation. error contains human-readable text; code and requestId are optional. Never branch on German text or invent a new reference to bypass a failure. Any unlisted 5xx is also a service failure, never success."},{"title":"Invoice handoff and audit","body":"POST creates an open charge; it does not record payment. Checkout invoice generation transfers open charges into invoice positions. The API then returns status=transferred. Those items cannot be cancelled by the POS API; correct the invoice through STRYK Finance. Cancellation leaves an auditable cancelled charge. State lookup and cancellation cannot access another key’s items, even in the same hotel. General POS exports are available through the module statistics in STRYK."},{"title":"Webhook delivery and verification","body":"Configure an HTTPS target and a separate secret in POS Setup. Outbound POSTs use JSON and User-Agent STRYK-POS-Webhook/1.0. X-STRYK-Event is charge.posted or charge.cancelled; a manual connection test uses ping. X-STRYK-Webhook-Id is the subscription UUID, not an event or delivery ID. X-STRYK-Signature is sha256=<hex HMAC of exact UTF-8 request bytes> only when a secret is configured. Reject unsigned requests when expecting signed delivery. Persistently deduplicate event + data.chargeId (and hotelId where shared). Timestamp is generated per attempt, not a stable deduplication key. Deliveries may repeat or arrive out of order; do not let an old posted event overwrite cancellation. Respond 2xx only after durable acceptance, within 10 seconds; redirects are not followed. Failed deliveries enter the durable retry queue with delays of 30 seconds, 5 minutes, 30 minutes, then 2 hours, up to the stored attempt limit. Exhausted delivery state is visible in STRYK and needs operator reconciliation. There is no invoice-transfer or check-in/out webhook in this contract; re-read account/charge state as needed."},{"title":"Acceptance checklist","body":"Use an explicitly approved test hotel and real in-house test stay. Check ping, guest/room identity, multi-room booking selection, scoped outlet rejection, configured tax and currency, exact retry without a duplicate, changed-payload 409, lost response recovery, batch all-or-none, No-Post and credit-limit rejection, cancellation replay, checkout invoice totals and transferred cancellation rejection. Verify a signed webhook, invalid signature rejection and duplicate/out-of-order delivery. Confirm error visibility, key rotation and staff reconciliation before release. Do not execute these tests against unrelated guest stays."}],"webhooks":{"chargeEvent":{"post":{"summary":"STRYK sends a charge event to the configured HTTPS receiver","security":[],"parameters":[{"name":"X-STRYK-Event","in":"header","required":true,"schema":{"type":"string","enum":["charge.posted","charge.cancelled","ping"]}},{"name":"X-STRYK-Webhook-Id","in":"header","required":true,"schema":{"type":"string","format":"uuid"},"description":"Subscription ID, not unique delivery ID."},{"name":"X-STRYK-Signature","in":"header","required":false,"schema":{"type":"string","pattern":"^sha256=[0-9a-f]{64}$"},"description":"Present only for subscriptions with a signing secret. Configure one and verify the original raw bytes."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["event","hotelId","timestamp","data"],"properties":{"event":{"type":"string","enum":["charge.posted","charge.cancelled","ping"]},"hotelId":{"type":"string"},"timestamp":{"type":"string","format":"date-time","description":"Attempt timestamp, may differ on retry."},"data":{"type":"object","description":"Charge events contain chargeId, externalRef if present, bookingId, roomNumber, guestName, description, grossAmount, currency, outletId/outletName if present. A ping carries test data instead.","properties":{"chargeId":{"type":"string","format":"uuid"},"externalRef":{"type":"string"},"bookingId":{"type":"string","format":"uuid"},"roomNumber":{"type":"string"},"guestName":{"type":"string"},"description":{"type":"string"},"grossAmount":{"type":"number"},"currency":{"type":"string"},"outletId":{"type":"string","format":"uuid"},"outletName":{"type":"string"},"test":{"type":"boolean"}}}}}}}},"responses":{"200":{"description":"Durably accepted; any 2xx is accepted"},"default":{"description":"Delivery failed; queued retry subject to attempt limit"}}}}},"x-examples":{"Connectivity (read-only shell example)":"# STRYK_POS_KEY must be supplied securely to the server process.\ncurl --fail-with-body --silent --show-error \\\n  -H \"Authorization: Bearer $STRYK_POS_KEY\" \\\n  https://app.staystryk.com/api/v1/pos/public/ping","Charge body (illustrative only; replace with a verified current stay and configured tax)":{"bookingId":"00000000-0000-4000-8000-000000000001","room":"101","description":"Restaurant consumption","quantity":1,"unit":"Stk.","unitPrice":18.5,"taxRate":10,"currency":"EUR","externalRef":"receipt-123-item-1","posRef":"receipt-123"},"Created response (201, illustrative)":{"chargeId":"00000000-0000-4000-8000-000000000002","externalRef":"receipt-123-item-1","bookingId":"00000000-0000-4000-8000-000000000001","roomNumber":"101","guestName":"Test stay","description":"Restaurant consumption","quantity":1,"unit":"Stk.","unitPrice":18.5,"taxRate":10,"grossAmount":18.5,"currency":"EUR","status":"open","chargedAt":"2026-09-12T12:00:00Z"},"Cancel body":{"reason":"Receipt cancelled at till"},"HMAC verifier (Node.js, original raw Buffer required)":"import { createHmac, timingSafeEqual } from 'node:crypto';\nexport function verifyStrykWebhook(rawBody, signature, secret) {\n  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret\n      || typeof signature !== 'string' || !/^sha256=[0-9a-f]{64}$/.test(signature)) return false;\n  const expected = createHmac('sha256', secret).update(rawBody).digest();\n  const received = Buffer.from(signature.slice(7), 'hex');\n  return received.length === expected.length && timingSafeEqual(received, expected);\n}\n// Read the unmodified raw body BEFORE JSON parsing. Verify, parse, deduplicate\n// event + data.chargeId in durable storage, then acknowledge. Handle ping separately."}}