API referenceTenvelo API
https://api.tenvelo.com The contract, as OpenAPI

Contract 2026-10-10. Every path below carries its own major version.

Platform · Reference

Sync

How a client keeps up: pulling what changed since it last looked, and being told when to look. What it sends goes to each collection's batch operation.

Operations
3
Base
https://api.tenvelo.com
  1. POST/v1/refusal-resolutions/batch-create

    Resolve refused items

    No. batchCreateRefusalResolutionsAuth pos:orders:write

    Records what a person on a till did about items the server refused it: wrote a sale off, or dismissed a write the world had moved past. The server records every item it refuses a device at the moment it refuses it, with the problem document it answered; this is the other half, so the back office can show which refusals are still owed an answer and who answered the rest.

    A refused item sent again that lands needs nothing from here: the server sees the same idempotency_key accepted and records the refusal as resubmitted itself.

    An item is resolved once. The first resolution stands, and another for the same refused key — sent twice, or racing the item landing — is answered unchanged. A device resolves only its own refusals, and only a device may call this: an integration's refusals are its own to keep, and the back office has no till to act from.

    Each item carries its own idempotency_key and stands or falls on its own.

    Request body application/json · RefusalResolutionBatchCreateRequest

    • itemsarray of RefusalResolutionBatchCreateItem

      Required

      ≥ 1 items · ≤ 500 items

      • hlcHLC

        A hybrid logical clock stamp (Kulkarni et al., 2014): the recorder's wall clock in milliseconds in the high 48 bits and a counter in the low 16, a 64-bit integer written as its decimal digits in a string. A stamp is past 2^53, beyond the integers a JSON number holds exactly as most parsers read it, so I-JSON writes it as a string (RFC 7493 §2.2).

        A device stamps every fact it records, as it records it, after every stamp it has made or pulled — every change on the feed carries one — so a fact recorded after seeing another ranks after it whatever either wall clock says. The stamp is when the fact happened. The server keeps it unless it is more than ten minutes ahead of the server's clock, more than thirty days behind it, or from before the device was paired. A fact is never refused for its stamp: one the server does not believe, cannot read or was not given is stamped as the server takes it, and flagged on what it dates — the order, the shift, the drawer or the shelf.

      • idempotency_keyItemIdempotencyKey

        Required

        A key for one item in a batch, minted by the client when it queues the item and reused on every attempt to send it. The item, not the request, is the unit of work: a retried batch returns the stored result for every item already applied under its key. A fresh key per attempt defeats the whole mechanism.

        Scoped per merchant, so keys need only be unique within a merchant's own traffic.

        16–255 chars

      • member_idstring · uuid

        The member signed on to the till who did it, when somebody was.

      • outcomestring · enum

        Required

        written_off is a sale whose money never reached the books, accounted for as lost. dismissed is a write the world had moved past, or one the server already had — nothing to write off.

        written_offdismissed

      • reasonstring

        Required

        Why, in the words of the person who did it.

        1–512 chars

      • refused_idempotency_keystring

        Required

        The key the refused item was sent under, as the device sent it — its idempotency_key, or, on a collection whose items are facts, the fact's own id, which is the key a fact is retried under. The field is named for the commoner of the two and holds either.

        1–255 chars

      • resolved_atstring · date-time

        Required

        What the device's own clock read when they did it, kept as it said it. Nothing is decided by it: the resolution is dated by its stamp (hlc).

    Responses

    • 200

      The batch was processed. results says what happened to each item.

      BatchResponse · application/json
      • resultsarray of BatchResult

        Required

        • idstring · uuid

          The resource the item created or changed. Absent when it failed.

        • idempotency_keystring

          Required

          The item's key, echoed so a result can be matched by key as well as position.

        • problemProblem

          Nullable

          Why the item failed, as an RFC 9457 problem. A 422 means the item is malformed and will never succeed — drop it and show it to staff rather than retrying for ever. A 409 or a 5xx means try again later.

          • typestring · uri

            Required

            A stable URI identifying the problem kind. This is the field to branch on.

            default about:blank

          • api_versionstring

            Present on an api-version-retired or api-version-unknown refusal: the API-Version this server speaks (docs/decisions/0011).

          • detailstring

            A human-readable explanation specific to this occurrence. Safe to show to a developer; never contains internal state, SQL, or stack traces.

          • instancestring · uri-reference

            A URI reference identifying the specific occurrence.

          • invalid_paramsarray of InvalidParam

            Present on 422 responses. Lists every field that failed validation, rather than only the first, so a client can highlight an entire form in one pass.

            • codestring

              Stable machine-readable code for this failure, for clients that localise their own messages rather than showing reason.

            • pointerstring

              Required

              RFC 6901 JSON Pointer to the offending value within the request body. A pointer rather than a field name so that failures nested inside arrays — the third line item of an order — are addressable at all.

            • reasonstring

              Required

              Why this value was rejected, in human-readable form.

          • refusalsarray of Location refusal

            Present on 409 responses to a withdrawal refused by the state it depends on (deleting a location with tills still assigned, open orders, …). Lists every reason at once, each with how much stands in the way, so a client can say what to put right and link to it rather than walking a person through one refusal after another.

            • kindstring

              Required

              A stable refusal kind — devices, open_orders, drawer_sessions, bookings or stock — that a client links to the screen that puts it right. Branch on this field, never on the surrounding prose.

            • countinteger

              Required

              How many things stand in the way of the withdrawal, so a client can repeat the number rather than re-read prose.

              ≥ 1

          • statusinteger

            Required

            The HTTP status code, repeated here so the body is self-contained.

            100–599

          • titlestring

            Required

            A short, human-readable summary of the problem kind.

          • trace_idstring

            The OpenTelemetry trace id for this request. Quote it in a support ticket and the whole request can be pulled up.

        • statusstring · enum

          Required

          created — the item made a new resource. updated — it changed an existing one.

          unchanged — this key was already applied and the original outcome stands. A success, not an error: it is what a client retrying a batch it was unsure landed should expect, and it clears the item from its queue exactly as created does.

          failed — the item was refused, and nothing else in the batch was affected. See problem.

          createdupdatedunchangedfailed

        • versioninteger · int64

          The resource's version after the item. Absent when it failed.

    • 400

      The request could not be parsed. Malformed JSON, a bad query parameter, or a header that failed to decode, among them an API-Version later than the server speaks or not a date (api-version-unknown, with the server's own in api_version).

      Distinct from 422: a 400 never reached validation, so invalid_params is absent. Retrying without changing the request will fail again, except that a request refused for its API-Version goes once the server is updated.

    • 401

      No credentials, or credentials that could not be verified — missing bearer token, malformed JWT, expired token, or a signature that failed against the issuer's JWKS.

      Refresh the token and retry once. Repeated 401 after a refresh means the device needs to re-enrol.

    • 403

      Authenticated, but not permitted. The token is valid and its merchant was resolved, but the caller's role does not allow this operation, or the target belongs to a different merchant.

      Cross-tenant access is reported as 403 on a resource the caller may not see, and as 404 where even confirming existence would leak information. Retrying will not help.

    • 410

      The request names an API-Version this server no longer serves (docs/decisions/0011). Nothing in it was read. api_version names the version the server speaks: the client is behind, and sends the request again once it is updated.

    • 415

      The request Content-Type is not supported. This API accepts application/json on all mutations.

    • 422

      The request parsed but failed validation.

      invalid_params lists every failing field with a JSON Pointer, not just the first, so a client can mark up a whole form in one pass rather than making the user fix errors one round trip at a time.

      Also returned when an Idempotency-Key is replayed with a different body, and when device-computed order totals disagree with the server's recomputation.

    • 429

      Rate limit exceeded. Honour Retry-After rather than applying your own backoff.

      A fleet of tills coming back online together after an outage is the realistic cause, which is exactly when uncoordinated client-side retry turns a brief outage into a sustained one. The limit is per process, and per site for what records trade there — a request spends its site's budget, and a till's own smaller one inside it — and per (client, merchant) for everything else; no RateLimit field is sent, so this response and its Retry-After are the whole of the contract.

    • 500

      Something failed on our side. trace_id identifies the request in our tracing — quote it in a support ticket.

      The body never carries internal detail: no SQL, no stack traces, no upstream error strings. Safe to retry with the same Idempotency-Key.

    • 503

      Temporarily unable to serve the request — a dependency is down or the service is shedding load.

      Retry with the same Idempotency-Key after Retry-After. This is always safe: a request that did take effect before the failure will be recognised as a replay.

  2. GET/v1/sync/changes

    Pull changes since a cursor

    No. listChangesAuth sync catalog:read customers:read bookings:read inventory:read kitchen:read pos:tables:read pos:printers:read pos:shifts:read pos:drawer-sessions:read pos:cash-movements:read pos:orders:write ordering:write members:read billing:read terminal-checkouts:read

    Returns everything that has changed for this merchant since the given cursor, in a stable order, including deletions.

    This is the endpoint a till lives on. It replaces re-downloading the catalogue: a device holds a cursor, polls, applies what comes back, and stores the new cursor.

    Draining the feed

    Loop until has_more is false. Persist next_cursor after each page has been durably applied, never before — a crash between saving the cursor and applying the page would skip those changes permanently, and they would never be offered again. Applying a page twice is harmless because every change is idempotent, so the safe failure direction is to re-apply, not to skip.

    Ordering guarantees

    Changes arrive in ascending sequence, and a given sequence is emitted exactly once. The feed deliberately does not order by timestamp: a transaction that begins earlier can commit later, so a reader ordering by updated_at — or advancing past a sequence belonging to a transaction still in flight — would step over a row that had not yet become visible and never see it again. In a POS that means a sale that silently never syncs. The server only releases changes below the oldest in-flight transaction, so the cursor is always safe to advance.

    Kitchen tickets

    A device is offered an order's kitchen_ticket changes only while the order is on its location's kitchen board — open, or completed on the location's current business day — which is what listKitchenTickets returns. When an order is voided or canceled a kitchen_ticket deletion takes its tickets off. A device keeping a board across business days refreshes it from listKitchenTickets when the day changes: an order completed yesterday is never deleted, it just stops being offered.

    Who sees what

    A person or a device is admitted by role, as everywhere. An integration is scoped by its token, and the feed honours that scope: it receives only the resource types its scopes unlock (catalog:read gets catalogue changes and silently no customers), and a requested type it cannot read is dropped rather than refused, so a partial-scope integration keeps what it can read instead of losing the feed entirely. The sync scope unlocks the whole feed, which is what a fully-scoped reader should ask for.

    That filtering matters for a cursor: a page the server returns advances the cursor past changes the caller was not shown. An integration that later gains a scope starts from where it is told to, so it should bootstrap rather than assume it has seen everything.

    Steady state

    A caught-up device receives an empty changes array and the same cursor back. That is the normal case and is not an error.

    Parameters

    • cursorquery · string

      Opaque cursor from a previous page's next_cursor. Omit to start from the beginning.

      Pass it back exactly as received. It is base64 and its structure is not part of this contract — decoding it, or constructing one by hand, will break without notice.

    • limitquery · integer · int32

      Maximum items to return. The server may return fewer, so always drive the loop from has_more rather than from the size of the page you got back.

    • typesquery · array of string · enum

      Restrict the feed to these resource types. Omit for everything.

      Worth setting: a countertop till needs the catalogue and its own orders, and pulling invite or merchant changes it will never act on just burns metered mobile data.

    • location_idquery · string · uuid

      Restrict the feed to one site: that location's changes plus the merchant-wide ones, on the location's current trading day — the same scope a device standing there would get. A caller that is not a device may name one; a device's location always comes from its own token, never from the request, so a till cannot read another shop's tabs. A caller whose membership is scoped to locations must name a location it holds. Omit for the whole estate.

    • outbox_depthquery · integer · int32

      How many mutations this till has queued that the server has not confirmed.

    • oldest_queued_atquery · string · date-time

      When the oldest queued mutation was created, absent when the outbox is empty.

    • clock_offset_msquery · integer · int64

      How far the server's clock is ahead of this device's own, in milliseconds, as the device measured it from the answers it gets (RFC 5905's offset θ): negative when the device's clock is ahead. Sent with clock_delay_ms, by a device that has heard from the server since it started; absent before then, and from a caller that is not a device. Bounded by the most milliseconds a server-side duration holds, about 292 years.

    • clock_delay_msquery · integer · int64

      The round trip of the exchange clock_offset_ms was measured on, in milliseconds (RFC 5905's delay δ): the offset is good to half of it. Sent with clock_offset_ms, and only with it.

    • bootstrapquery · boolean

      Return the latest change per resource instead of the history: the snapshot a new till loads instead of replaying the merchant's whole feed. The response carries one change per resource — the row a full replay would end on — and next_cursor to continue polling from. Same scoping and ordering guarantees as the feed; limit is ignored.

    Responses

    • 200

      A page of changes.

      ChangePage · application/json
      • changesarray of Change

        Required

        Changes in ascending sequence order. May be empty when the client is already caught up, which is the normal steady state.

        • hlcHLC

          Required

          A hybrid logical clock stamp (Kulkarni et al., 2014): the recorder's wall clock in milliseconds in the high 48 bits and a counter in the low 16, a 64-bit integer written as its decimal digits in a string. A stamp is past 2^53, beyond the integers a JSON number holds exactly as most parsers read it, so I-JSON writes it as a string (RFC 7493 §2.2).

          A device stamps every fact it records, as it records it, after every stamp it has made or pulled — every change on the feed carries one — so a fact recorded after seeing another ranks after it whatever either wall clock says. The stamp is when the fact happened. The server keeps it unless it is more than ten minutes ahead of the server's clock, more than thirty days behind it, or from before the device was paired. A fact is never refused for its stamp: one the server does not believe, cannot read or was not given is stamped as the server takes it, and flagged on what it dates — the order, the shift, the drawer or the shelf.

        • occurred_atstring · date-time

          Required

          Server-assigned commit time, RFC 3339. Provided for display and diagnostics only — ordering is defined by sequence, never by this timestamp.

        • opstring · enum

          Required

          What happened. deleted is a tombstone: apply it by removing the row locally.

          createdupdateddeleted

        • resourceobject

          Nullable

          The full resource after the change, saving a follow-up read.

          Null when op is deleted, and null on a compacted row. A body is dropped once it is no longer worth keeping — today when the guest it names is erased, so that personal data does not outlive the erasure in the log. Decide what a change means from op, never from whether a body is present: a row with no body and an op other than deleted is one whose resource you re-read or ignore, not one that was removed.

        • resource_idstring · uuid

          Required

          The id of the resource that changed.

        • resource_typeResourceType

          Required

          The kind of resource a change or operation refers to.

          Used to filter the change feed so a till can pull only what it needs — a countertop device syncing the catalogue has no use for invite or payout changes.

          This list grows, and a client is expected to meet a value it predates. A device is deployed behind the server and cannot be force-upgraded, so the contract's instruction is to ignore a type you do not understand rather than fail the page — failing it would wedge the device's cursor and stop it receiving anything at all.

          Declared with x-extensible-enum rather than enum for exactly that reason. The cost is that generated clients type this as a string instead of an enumeration; the alternative is that every new resource type reads as a breaking change, which would be true only for a client that treated the list as closed — and this one says, in writing, that it is not.

          open_order is historic: a tab is an order in the open state now (docs/decisions/0012), and the value appears only on changes recorded before that. Nothing new carries it. So are tax, whose changes are tax_rate now, and modifier_option, whose are modifier.

          customer only ever arrives as a tombstone (op: deleted, no body): the guest was erased. The guest book itself is never on the feed, so there is nothing to remove by that id; forget whatever you cached about the guest under it. Their bookings and queue entries arrive as changes of their own, already scrubbed.

        • sequenceinteger · int64

          RequiredRead only

          Monotonic position of this change within the merchant's feed.

          Exposed for debugging and for detecting gaps. Do not synthesize a cursor from it — always send back the opaque next_cursor the server gave you. The mapping from sequence to cursor is not part of this contract and will change.

        • versioninteger · int64

          The resource's version after this change. Absent for deleted. Compare against the locally held version to decide whether this change is already applied — replaying a change you have already seen must be a no-op.

      • has_moreboolean

        Required

        True when more changes are already available, so the client should immediately request the next page rather than waiting for its next poll interval.

      • next_cursorstring

        Required

        Opaque cursor to pass to the next request. Always present, including on the last page — a caught-up device keeps polling with it and receives empty pages.

        Treat this as a blob: it is base64 and its contents are not part of this contract.

      • settled_throughinteger · int64

        RequiredNullable

        How far the calling device is settled: the highest device_seq of its facts that have been applied to their orders, with no gap below it. The same cumulative acknowledgment as orders/batch-create answers, carried on every page as TCP carries its acknowledgment on every segment (RFC 9293, section 3.4), so a device with nothing left to send still learns what it may stop holding as the record. Null for a caller that is not a device.

        ≥ 0

    • 400

      The request could not be parsed. Malformed JSON, a bad query parameter, or a header that failed to decode, among them an API-Version later than the server speaks or not a date (api-version-unknown, with the server's own in api_version).

      Distinct from 422: a 400 never reached validation, so invalid_params is absent. Retrying without changing the request will fail again, except that a request refused for its API-Version goes once the server is updated.

    • 401

      No credentials, or credentials that could not be verified — missing bearer token, malformed JWT, expired token, or a signature that failed against the issuer's JWKS.

      Refresh the token and retry once. Repeated 401 after a refresh means the device needs to re-enrol.

    • 403

      Authenticated, but not permitted. The token is valid and its merchant was resolved, but the caller's role does not allow this operation, or the target belongs to a different merchant.

      Cross-tenant access is reported as 403 on a resource the caller may not see, and as 404 where even confirming existence would leak information. Retrying will not help.

    • 410

      The request names an API-Version this server no longer serves (docs/decisions/0011). Nothing in it was read. api_version names the version the server speaks: the client is behind, and sends the request again once it is updated.

    • 422

      The request parsed but failed validation.

      invalid_params lists every failing field with a JSON Pointer, not just the first, so a client can mark up a whole form in one pass rather than making the user fix errors one round trip at a time.

      Also returned when an Idempotency-Key is replayed with a different body, and when device-computed order totals disagree with the server's recomputation.

    • 429

      Rate limit exceeded. Honour Retry-After rather than applying your own backoff.

      A fleet of tills coming back online together after an outage is the realistic cause, which is exactly when uncoordinated client-side retry turns a brief outage into a sustained one. The limit is per process, and per site for what records trade there — a request spends its site's budget, and a till's own smaller one inside it — and per (client, merchant) for everything else; no RateLimit field is sent, so this response and its Retry-After are the whole of the contract.

    • 500

      Something failed on our side. trace_id identifies the request in our tracing — quote it in a support ticket.

      The body never carries internal detail: no SQL, no stack traces, no upstream error strings. Safe to retry with the same Idempotency-Key.

    • 503

      Temporarily unable to serve the request — a dependency is down or the service is shedding load.

      Retry with the same Idempotency-Key after Retry-After. This is always safe: a request that did take effect before the failure will be recognised as a replay.

  3. GET/v1/sync/events

    Subscribe to change notifications

    No. getSyncEventsAuth sync

    An event stream that says something changed and nothing more. A device that receives one pulls /v1/sync/changes exactly as it would on a timer.

    Why the events carry no data

    The change feed stays the only path data travels. It withholds rows belonging to transactions still in flight, so a device that advances its cursor never steps over a change it has not seen — delivering the changes down this stream instead would hand them over by a route that does not respect that watermark, out of order and before they are readable. See listChanges for what that costs when it goes wrong.

    It also makes this transport disposable. A lost notification costs a device latency and nothing else, because polling remains the backstop.

    Using it

    Open the stream and keep it open. Named events:

    EventMeaning
    syncSomething in this merchant's estate changed. Pull the feed.
    pingLiveness, every 25 seconds. Proxies and mobile networks drop idle connections, and a stream that produces nothing for a minute is indistinguishable from one that has died.

    Reconnect on any disconnection, with backoff and jitter — an estate whose devices all reconnect in lockstep after a deploy is a self-inflicted load test. Keep polling as well, on a slower interval: a device that trusts this stream alone will trade on a stale catalogue for as long as a silent failure lasts.

    No Last-Event-ID handling and no replay. There is nothing to replay — the device's cursor is the only position that matters, and it is already durable on the device.

    Responses

    • 200

      An open stream. Ends only when the client disconnects, the server shuts down, or the token expires.

    • 400

      The request could not be parsed. Malformed JSON, a bad query parameter, or a header that failed to decode, among them an API-Version later than the server speaks or not a date (api-version-unknown, with the server's own in api_version).

      Distinct from 422: a 400 never reached validation, so invalid_params is absent. Retrying without changing the request will fail again, except that a request refused for its API-Version goes once the server is updated.

    • 401

      No credentials, or credentials that could not be verified — missing bearer token, malformed JWT, expired token, or a signature that failed against the issuer's JWKS.

      Refresh the token and retry once. Repeated 401 after a refresh means the device needs to re-enrol.

    • 403

      Authenticated, but not permitted. The token is valid and its merchant was resolved, but the caller's role does not allow this operation, or the target belongs to a different merchant.

      Cross-tenant access is reported as 403 on a resource the caller may not see, and as 404 where even confirming existence would leak information. Retrying will not help.

    • 410

      The request names an API-Version this server no longer serves (docs/decisions/0011). Nothing in it was read. api_version names the version the server speaks: the client is behind, and sends the request again once it is updated.

    • 429

      Rate limit exceeded. Honour Retry-After rather than applying your own backoff.

      A fleet of tills coming back online together after an outage is the realistic cause, which is exactly when uncoordinated client-side retry turns a brief outage into a sustained one. The limit is per process, and per site for what records trade there — a request spends its site's budget, and a till's own smaller one inside it — and per (client, merchant) for everything else; no RateLimit field is sent, so this response and its Retry-After are the whole of the contract.

    • 500

      Something failed on our side. trace_id identifies the request in our tracing — quote it in a support ticket.

      The body never carries internal detail: no SQL, no stack traces, no upstream error strings. Safe to retry with the same Idempotency-Key.

    • 503

      Temporarily unable to serve the request — a dependency is down or the service is shedding load.

      Retry with the same Idempotency-Key after Retry-After. This is always safe: a request that did take effect before the failure will be recognised as a replay.