Read this first#

Three facts decide most of your design, and all three are easier to accept now than to discover in week three.

  1. Keyring is authoritative for the redemption. Your till asks Keyring to spend value and Keyring decides. It does not ask your system for permission, and it does not wait for your system to confirm. If you need the discount to land on the customer's real bill, that is a second step your till performs after Keyring has said yes.
  2. A till is a staff identity, not an API client. There is deliberately no separate API-key lane. The operator creates a staff contact representing the till, and your integration signs in as that contact at one specific venue. Everything you may do follows from that venue's capabilities, enforced on the server.
  3. Every mutation needs an idempotency key that you generate and persist. Not per HTTP attempt — per business operation. This is a hard requirement, not a recommendation; a call without one is refused.

Base URL: https://<the portal's public domain>/hs/serverless/

JSON in, JSON out. Every response carries success, and every failure carries a structured code you can switch on. The portal's domain is the customer's own HubSpot site domain — ask the operator for it, and do not assume it matches any other environment.

Authenticating a till#

Sign in once per till, then reuse the token until it is refused.

POST /hs/serverless/keyring-staff-session
Content-Type: application/json

{ "email": "pos-frontdesk@venue.example", "locationId": "<location record id>", "pin": "<install PIN>" }
{ "success": true, "token": "<staff token>", "expiresAt": 1786000000000 }

Send the returned value as staffToken on every authenticated call.

  • The PIN is required whenever one is configured, which it should be on any install that matters. Provision it to the till like any other credential.
  • Tokens expire — eight hours by default. On 401 with STAFF_TOKEN_INVALID or STAFF_TOKEN_REQUIRED, re-mint and retry the call once. Do not treat it as fatal, and do not re-mint on every request.
  • One token binds one venue. A multi-till site mints one per till, which also gives you per-till attribution — every ledger row records the acting staff identity.
  • Revocation is secret rotation, which invalidates every outstanding token at once. Your till should survive that by simply re-minting; build the 401 path first and you get this for free.
  • The token is a credential. HTTPS only, never logged, never in a receipt or a URL, stored the way you would store a password.

What a scan contains#

Your scanner will meet exactly two payload shapes:

Scanned thingPayloadWhat it means
Member loyalty cardA wallet URL — https://<domain>/wallet?t=<token>Identity. Take the t parameter; that is the input to keyring-resolve-token
Voucher or gift cardThe code itself, e.g. WELCOME-A1B2C3Value. The code is the record's identifier — input to keyring-fetch-incentive and to a redeem

The parsing rule, which mirrors the scanner's own: if the payload is a URL, take ?t= if present, otherwise the last path segment that looks like a code. A bare xxxxx.yyyyy base64url pair is a token. Anything else is a code. Codes are case-insensitive — the server uppercases them.

Warning

If you decode token payloads in a browser, re-pad the base64url before decoding. Browsers reject unpadded base64 where server-side decoders accept it, so this works in your Node prototype and fails on the tablet.

Endpoint index#

Generated by cross-checking two independent sources inside the app — the deployment manifests that tell HubSpot what to publish, and the policy manifest whose own test asserts full coverage against them. Neither the list nor the auth column is typed by hand, and a disagreement between them fails the build rather than producing a plausible table.

Public, Staff and Customer are surfaces you may call. Workflow endpoints are HubSpot automation backends and Install endpoints belong to provisioning; both are listed for completeness and are not part of your contract. Retired endpoints still answer, with 410 — they are listed precisely so a cached client gets an explanation instead of a mystery.

27 endpoints — 8 public, 7 staff, 4 customer, 4 workflow, 2 install, 2 retired.

EndpointSurfaceAuth required
GET/POST keyring-fetch-locationsPublicNone
GET/POST keyring-qr-sheetPublicNone
GET/POST keyring-reportsPublicNone (no personal data)
GET/POST keyring-workflow-optionsPublicNone (no personal data)
POST keyring-claim-incentivePublicNone
POST keyring-fetch-incentivePublicNone
POST keyring-fetch-productPublicNone
POST keyring-staff-sessionPublicNone (the PIN is enforced inside when one is configured)
POST keyring-enrollment-createStaffStaff token + the location capability for the action
POST keyring-enrollment-searchStaffStaff token + the location capability for the action
POST keyring-generate-incentivesStaffStaff token + the location capability for the action
POST keyring-print-member-cardStaffStaff token + the location capability for the action
POST keyring-staff-configStaffStaff token + the location capability for the action
POST keyring-staff-homeStaffStaff token + the location capability for the action
POST keyring-transactStaffStaff token + the location capability for the action
POST keyring-fetch-walletCustomerCustomer token — identity comes from the token
POST keyring-offer-decisionCustomerCustomer token — identity comes from the token
POST keyring-request-linkCustomerCustomer token — identity comes from the token
POST keyring-resolve-tokenCustomerCustomer token — identity comes from the token
POST keyring-workflow-award-pointsWorkflowHubSpot signature (v3)
POST keyring-workflow-enrollWorkflowHubSpot signature (v3)
POST keyring-workflow-issue-voucherWorkflowHubSpot signature (v3)
POST keyring-workflow-mint-magic-linkWorkflowHubSpot signature (v3)
GET keyring-statusInstallNone
POST keyring-bootstrapInstallNone (deliberate — it runs before any token can exist)
POST keyring-user-createRetiredRetired — returns 410 ENDPOINT_RETIRED
POST keyring-user-lookupRetiredRetired — returns 410 ENDPOINT_RETIRED

Use case → endpoint#

What the till needs to doCallRequires
Read a voucher or gift card — balance, type, validity, whether it is claimedkeyring-fetch-incentive { incentiveId: <code> }nothing
Identify a customer from a scanned member cardkeyring-resolve-token { token, staffToken }the scanned token proves identity; adding a staff token also records the identification
List venues and their capabilitieskeyring-fetch-locationsnothing
Read what can be issued or awarded herekeyring-staff-config { staffToken }staff token
Read today's activity at this venuekeyring-staff-home { staffToken }staff token
Redeem a voucher in fullkeyring-transact action: "redeem" { code }redeem capability
Spend part of a gift-card balancekeyring-transact action: "redeem" { code, amount }redeem capability
Top up a gift-card balancekeyring-transact action: "reload" { recordId, amount }issue capability
Issue a voucher from the cataloguekeyring-transact action: "issue" { templateKey, contactId | email }issue capability
Award points, from a spend amount or a literal deltakeyring-transact action: "award" { contactId | email, programId, points | amount }earn capability
Reverse a transactionkeyring-transact action: "void" { transactionId }staff token
Enrol a customer at the counterkeyring-enrollment-search then keyring-enrollment-createstaff token
Create vouchers in bulk for a campaignkeyring-generate-incentivesstaff token
Read aggregate numbers, with no personal datakeyring-reportsnothing

There is no hard delete. Keyring is a ledger: reversing something is void, which writes a compensating row and leaves the original visible.

Warning

There is no unauthenticated contact lookup, and there will not be one. Two endpoints that once offered it — keyring-user-lookup and keyring-user-create — were withdrawn in a security review: one returned a contact's phone, address and company to anyone who guessed an email, and the other wrote to a contact record without checking the caller owned it. They still answer, with 410 ENDPOINT_RETIRED, so a cached client gets an explanation. If your design needs to find a customer without a scan, resolve them through a staff-gated call instead.

The two flows that matter#

Scan a voucher, show it, redeem it#

Read before you charge, so the cashier can see what they are holding:

POST /hs/serverless/keyring-fetch-incentive
{ "incentiveId": "GIFT-9X2K4M" }
{
  "success": true,
  "incentive": {
    "hs_object_id": "59134760886",
    "incentive_id": "K72M8DMAAA5B",
    "incentive_type": "voucher",
    "redemption_type": "balance",
    "value": 50,
    "initial_balance": 50,
    "current_balance": 32.5,
    "discount_unit": "currency",
    "discount_mode": null,
    "currency": "EUR",
    "expiry_date": null,
    "status": "active",
    "is_personal": false,
    "valid_scope": "all",
    "is_expired": false,
    "where_valid": [],
    "is_claimed": true,
    "is_claimable": true,
    "display_value": "EUR 32.5 remaining"
  },
  "message": "Incentive fetched successfully"
}

display_value is pre-formatted for a cashier-facing screen, so you need not reconstruct it from the balance and currency. is_claimed and is_claimable are independent — a claimed instrument can still be claimable — so do not treat one as the negation of the other.

Then the mutation:

POST /hs/serverless/keyring-transact
{
  "action": "redeem",
  "staffToken": "<staff token>",
  "idempotencyKey": "<a UUID your till generated and stored>",
  "code": "GIFT-9X2K4M",
  "amount": 12.5
}
{ "success": true, "transactionId": "<id>", "remainingBalance": 20 }
  • Omit amount on a balance instrument to spend the whole remaining value.
  • An empty where_valid means valid anywhere. Otherwise a redeem at the wrong venue returns 409 INVALID_LOCATION with the list of venues where it would work — show that to the cashier rather than retrying silently.
  • A repeat with the same idempotencyKey returns success: true, duplicate: true and the unchanged state. Never a double charge.
  • Pass recordId instead of code for a voucher created moments ago — code lookup goes through a search index that lags writes by seconds.

Staff at a bar counter holding a phone showing the Keyring scanner after a scan: the venue name and its capabilities across the top, then the instrument identified as a gift card with its code, an active status, the balance remaining, and an amount field beside a redeem button. A printed voucher carrying a QR code lies on the counter.

That is our own scanner, and it is a plain HTTP client of the endpoints above — the screen your till is replacing, or sitting beside. The venue name and capability chips along the top are the authority model made visible: this device may redeem here because the venue record says so, and the server re-checks that on the call rather than trusting what the screen believes.

Scan a member card, identify, award points#

POST /hs/serverless/keyring-resolve-token
{ "token": "<the t parameter from the card>", "staffToken": "<staff token>" }
{
  "success": true, "valid": true, "tokenClass": "member",
  "expiresAt": 1817818040684,
  "identified": false,
  "contact": { "id": "236761208998", "firstName": "Sarah", "lastName": "Johnson" },
  "memberships": [
    { "membershipId": "…", "programId": "…", "programName": "Rewards Club",
      "pointsName": "Points", "accentColor": "#2680c2",
      "pointsBalance": 292, "tier": null, "joinedDate": "2026-07-21" }
  ],
  "incentives": [
    { "recordId": "…", "code": "NA3J43C2AEBB", "redemptionType": "balance",
      "currentBalance": 37.5, "currency": "EUR", "status": "active",
      "engagementStatus": "viewed", "claimedAt": null, "redeemedAt": null }
  ],
  "ledger": [
    { "transactionId": "…", "label": "Earn +42 Points · Rewards Club",
      "reason": "earn", "pointsDelta": 42, "valueDelta": null,
      "occurredAt": 1786282058164, "voided": false }
  ],
  "pointsEarnedLifetime": 292
}

The response carries name only — never email or phone. That is deliberate data minimisation on a device that stands on a counter, and it is not a gap to work around.

Two things about this response cost time if you meet them by surprise:

  • identified is not "did we recognise them". It reports whether an identification was recorded against a venue, which happens only when you send a staffToken and that venue holds the identify capability. Reading a member card without one returns identified: false alongside a perfectly valid contact — check valid and contact, never identified, to decide whether you know who this is.
  • The casing changes between endpoints. This response is camelCase (currentBalance, redemptionType, pointsBalance) while keyring-fetch-incentive returns the CRM property names in snake_case (current_balance, redemption_type). The two describe the same instrument. That is a wart rather than a design, and it is not going to change under you — but map both explicitly rather than assuming one shape.
POST /hs/serverless/keyring-transact
{
  "action": "award",
  "staffToken": "<staff token>",
  "idempotencyKey": "<UUID>",
  "contactId": "<from resolve>",
  "programId": "<from staff-config>",
  "amount": 87.40
}

amount is the spend, converted by the programme's earn rate. Send points instead for a literal delta — which may be negative for a correction, and may never be zero.

Idempotency#

Every keyring-transact call requires idempotencyKey, or it is refused with IDEMPOTENCY_KEY_REQUIRED. The key is the identity of the operation:

  • Generate one UUID per business operation — per sale line, not per HTTP attempt.
  • Persist it before you send, alongside the sale, and reuse it for every retry of that operation.
  • Treat duplicate: true as success. It is: the state it returns is authoritative.

The failure this prevents is the ordinary one. A till sends a redeem, the network times out after the server committed, the till retries. Without a stable key that is two redemptions and an angry customer.

A reference client#

Every integration writes this, and the parts that get written wrong are always the same three: re-minting on a 401, generating the idempotency key in the wrong place, and retrying a network failure as though it were a failed redemption. Here it is, in about fifty lines, with those three done properly.

// keyring.js — minimal reference client. No dependencies; Node 18+ or a browser.
export class Keyring {
  // `store` persists idempotency keys across a crash — a Map is fine for a demo,
  // but a real till needs the key on disk BEFORE the request goes out.
  constructor({ baseUrl, email, locationId, pin, store = new Map() }) {
    Object.assign(this, { baseUrl, email, locationId, pin, store });
    this.token = null;
  }

  async #post(path, body) {
    const res = await fetch(`${this.baseUrl}/hs/serverless/${path}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    return { status: res.status, data: await res.json() };
  }

  async signIn() {
    const { data } = await this.#post('keyring-staff-session', {
      email: this.email, locationId: this.locationId, pin: this.pin,
    });
    if (!data.success) throw new Error(`sign-in refused: ${data.code}`);
    this.token = data.token;
  }

  // One re-mint, then give up. Re-minting on every call defeats the point of a
  // token; never re-minting turns a routine expiry into a dead till.
  async #authed(path, body) {
    if (!this.token) await this.signIn();
    let r = await this.#post(path, { ...body, staffToken: this.token });
    if (r.status === 401 && /STAFF_TOKEN_(INVALID|REQUIRED)/.test(r.data.code || '')) {
      await this.signIn();
      r = await this.#post(path, { ...body, staffToken: this.token });
    }
    return r;
  }

  fetchIncentive(code) {
    return this.#post('keyring-fetch-incentive', { incentiveId: code });
  }

  // saleId is YOUR id for the sale line — stable across retries, which is the
  // whole contract. Deriving the key from it means a retry cannot double-spend.
  async transact(saleId, action, fields) {
    let key = this.store.get(saleId);
    if (!key) { key = crypto.randomUUID(); this.store.set(saleId, key); }  // persist BEFORE sending

    const { status, data } = await this.#authed('keyring-transact', {
      action, idempotencyKey: key, ...fields,
    });

    if (data.success) return data;            // duplicate:true is success — state is authoritative
    if (status === 429) {
      await new Promise((r) => setTimeout(r, data.retryAfterMs ?? 1000));
      return this.transact(saleId, action, fields);   // same key, so this is safe
    }
    throw Object.assign(new Error(data.error || data.code), { code: data.code, data });
  }
}

Using it, with the failure branches a cashier actually meets:

const kr = new Keyring({
  baseUrl: 'https://www.example.com',
  email: 'pos-frontdesk@venue.example',
  locationId: '<location record id>',
  pin: process.env.KEYRING_PIN,
});

const { incentive } = await kr.fetchIncentive(scannedCode);   // show the cashier first
try {
  const result = await kr.transact(sale.lineId, 'redeem', { code: scannedCode, amount: 12.5 });
  applyDiscountOnTill(result);                                 // your side, after Keyring said yes
} catch (e) {
  switch (e.code) {
    case 'INSUFFICIENT_BALANCE': offerPartial(e.data.currentBalance); break;
    case 'INVALID_LOCATION':     showValidVenues(e.data.validLocations); break;
    case 'ALREADY_REDEEMED':
    case 'EXPIRED':              tellCashierTerminal(e.code); break;
    case 'CAPABILITY_MISSING':   tellCashierNotPermitted(); break;
    default:                     tellCashierGeneric(e.code);
  }
}

Warning

A thrown network error is not a failed redemption. The one branch this sketch leaves to you is a timeout or a dropped connection, because the right behaviour is not generic: retry transact with the same saleId, and read what comes back. The server may already have committed, and the idempotency key is what lets you find out safely instead of guessing.

Machine-readable contract#

The same surface as an OpenAPI 3.1 document, for generating a client or pointing an agent at:

/docs/keyring-openapi.json

Read what it does and does not promise. The endpoint inventory is generated from the app's own deployment and policy manifests, so it cannot list something that is not deployed or omit something that is. The request and response shapes are hand-written, carry this page's verified date, and exist for the endpoints documented above — every other path is marked x-shape-documented: false rather than being given an invented schema. A generated client that types those as unknown is telling you the truth.

Authentication appears in the request schemas rather than as a security scheme, because a staff token is a body field rather than a header.

Errors, and how a till should behave#

Every failure carries a code. These are the ones a till must handle by name rather than by showing a generic message:

CodeHTTPWhat the till should do
STAFF_TOKEN_REQUIRED / STAFF_TOKEN_INVALID401Re-mint the token, retry the call once
RATE_LIMITED429Wait retryAfterMs and retry. Do not tighten the loop
INSUFFICIENT_BALANCE409Show currentBalance and offer to spend that instead
ALREADY_REDEEMED / ALREADY_VOIDED / EXPIRED409Terminal. Tell the cashier; do not retry
INVALID_LOCATION409Show the returned validLocations. Do not retry elsewhere
CAPABILITY_MISSING403This till is not permitted to do this. An operator decision, not a bug
VOID_NOT_ALLOWED403Needs a manager. Surface it as such
NOT_FOUND404The code does not exist. Check for a scanner mis-read before blaming the customer
NOT_BOOTSTRAPPED503The app is not initialised on this portal. Stop and call the operator

Also returned and worth handling generically: NOT_REDEEMABLE, INVALID_AMOUNT, INVALID_POINTS, TEMPLATE_NOT_FOUND (which returns the available keys), CONTACT_NOT_FOUND, PROGRAM_NOT_FOUND, MISSING_PARAMETER, INVALID_ACTION.

Rate limit: mutations are limited per staff identity — 30 per minute by default. One token per till keeps normal counter traffic far below it; a token shared across a whole site does not.

Rules for integrators#

  • Do not cache voucher state across sales. Balances move. Re-read at scan time; the read endpoint is unauthenticated and cheap.
  • Do not try to enrich the identity response. Name-only is the contract, not an oversight.
  • Do not put the staff token anywhere it can be read — logs, receipts, query strings, crash reports.
  • Do not treat a network failure as a failed redemption. Retry with the same key and read the answer; the server may already have committed.

What does not exist yet#

Say these out loud before designing around them.

  • No per-integrator credential. There is no API key, OAuth client or scoped service account. The staff-token lane is the supported path and its authority model is real — per-venue capabilities, per-identity rate limiting, rotation — but the credential is an install-level PIN plus a contact, not a grant issued to you. Revoking one integrator means rotating the secret for every till on the portal. A designed replacement exists on the roadmap; it should be built before a real third party is onboarded, and we will say so rather than pretend otherwise.
  • No webhooks or event push. Keyring never calls you. If you want activity, poll keyring-reports or keyring-staff-home.
  • No per-token revoke. Rotation is all-or-nothing.
  • No self-service sandbox. Test against a demo portal arranged with the operator. Never develop against a live venue.

Change policy#

  • The endpoint index above is generated from the running app's own deployment manifests. If an endpoint is listed, it is deployed; if it is deployed, it is listed. That is a structural guarantee, not a promise to keep a table updated.
  • Request and response bodies are hand-written and carry a verified date (in this file's frontmatter). Check it. If it is more than a few months old, confirm the specific field you depend on before shipping.
  • Additive changes — new optional fields, new error codes, new endpoints — should be expected and must not break your client. Parse defensively and treat an unrecognised code as a generic failure rather than crashing.
  • Breaking changes are communicated, not discovered. There is no version prefix on these paths, so an integration that matters should be registered with us so it can be told. Send it to w@reus.ie.

Onboarding checklist#

  1. Operator: create the till's staff contact, confirm the venue's capabilities are exactly what that till may do, and set the PIN.
  2. You: implement the mint → 401 → re-mint loop, the scan parsing rule, idempotency-key persistence, and the error table above.
  3. Prove it on a demo portal, in this order: read a voucher · redeem it · replay the same key and confirm nothing changed twice · spend part of a balance · attempt a redeem at the wrong venue and confirm it refuses · void a transaction · rotate the secret and confirm the till re-mints and carries on.
  4. Only then point at a real install.

Step 3 is the whole test plan. Every line of it is a failure that has bitten someone, and the two most commonly skipped — the replay and the rotation — are the two that surface in production rather than in testing.