Read this first#
Three facts decide most of your design, and all three are easier to accept now than to discover in week three.
- 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.
- 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.
- 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
401withSTAFF_TOKEN_INVALIDorSTAFF_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 thing | Payload | What it means |
|---|---|---|
| Member loyalty card | A wallet URL — https://<domain>/wallet?t=<token> | Identity. Take the t parameter; that is the input to keyring-resolve-token |
| Voucher or gift card | The code itself, e.g. WELCOME-A1B2C3 | Value. 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.
| Endpoint | Surface | Auth required |
|---|---|---|
GET/POST keyring-fetch-locations | Public | None |
GET/POST keyring-qr-sheet | Public | None |
GET/POST keyring-reports | Public | None (no personal data) |
GET/POST keyring-workflow-options | Public | None (no personal data) |
POST keyring-claim-incentive | Public | None |
POST keyring-fetch-incentive | Public | None |
POST keyring-fetch-product | Public | None |
POST keyring-staff-session | Public | None (the PIN is enforced inside when one is configured) |
POST keyring-enrollment-create | Staff | Staff token + the location capability for the action |
POST keyring-enrollment-search | Staff | Staff token + the location capability for the action |
POST keyring-generate-incentives | Staff | Staff token + the location capability for the action |
POST keyring-print-member-card | Staff | Staff token + the location capability for the action |
POST keyring-staff-config | Staff | Staff token + the location capability for the action |
POST keyring-staff-home | Staff | Staff token + the location capability for the action |
POST keyring-transact | Staff | Staff token + the location capability for the action |
POST keyring-fetch-wallet | Customer | Customer token — identity comes from the token |
POST keyring-offer-decision | Customer | Customer token — identity comes from the token |
POST keyring-request-link | Customer | Customer token — identity comes from the token |
POST keyring-resolve-token | Customer | Customer token — identity comes from the token |
POST keyring-workflow-award-points | Workflow | HubSpot signature (v3) |
POST keyring-workflow-enroll | Workflow | HubSpot signature (v3) |
POST keyring-workflow-issue-voucher | Workflow | HubSpot signature (v3) |
POST keyring-workflow-mint-magic-link | Workflow | HubSpot signature (v3) |
GET keyring-status | Install | None |
POST keyring-bootstrap | Install | None (deliberate — it runs before any token can exist) |
POST keyring-user-create | Retired | Retired — returns 410 ENDPOINT_RETIRED |
POST keyring-user-lookup | Retired | Retired — returns 410 ENDPOINT_RETIRED |
Use case → endpoint#
| What the till needs to do | Call | Requires |
|---|---|---|
| Read a voucher or gift card — balance, type, validity, whether it is claimed | keyring-fetch-incentive { incentiveId: <code> } | nothing |
| Identify a customer from a scanned member card | keyring-resolve-token { token, staffToken } | the scanned token proves identity; adding a staff token also records the identification |
| List venues and their capabilities | keyring-fetch-locations | nothing |
| Read what can be issued or awarded here | keyring-staff-config { staffToken } | staff token |
| Read today's activity at this venue | keyring-staff-home { staffToken } | staff token |
| Redeem a voucher in full | keyring-transact action: "redeem" { code } | redeem capability |
| Spend part of a gift-card balance | keyring-transact action: "redeem" { code, amount } | redeem capability |
| Top up a gift-card balance | keyring-transact action: "reload" { recordId, amount } | issue capability |
| Issue a voucher from the catalogue | keyring-transact action: "issue" { templateKey, contactId | email } | issue capability |
| Award points, from a spend amount or a literal delta | keyring-transact action: "award" { contactId | email, programId, points | amount } | earn capability |
| Reverse a transaction | keyring-transact action: "void" { transactionId } | staff token |
| Enrol a customer at the counter | keyring-enrollment-search then keyring-enrollment-create | staff token |
| Create vouchers in bulk for a campaign | keyring-generate-incentives | staff token |
| Read aggregate numbers, with no personal data | keyring-reports | nothing |
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
amounton a balance instrument to spend the whole remaining value. - An empty
where_validmeans valid anywhere. Otherwise a redeem at the wrong venue returns409 INVALID_LOCATIONwith the list of venues where it would work — show that to the cashier rather than retrying silently. - A repeat with the same
idempotencyKeyreturnssuccess: true, duplicate: trueand the unchanged state. Never a double charge. - Pass
recordIdinstead ofcodefor a voucher created moments ago — code lookup goes through a search index that lags writes by seconds.

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:
identifiedis not "did we recognise them". It reports whether an identification was recorded against a venue, which happens only when you send astaffTokenand that venue holds theidentifycapability. Reading a member card without one returnsidentified: falsealongside a perfectly valid contact — checkvalidandcontact, neveridentified, to decide whether you know who this is.- The casing changes between endpoints. This response is camelCase (
currentBalance,redemptionType,pointsBalance) whilekeyring-fetch-incentivereturns 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: trueas 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:
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:
| Code | HTTP | What the till should do |
|---|---|---|
STAFF_TOKEN_REQUIRED / STAFF_TOKEN_INVALID | 401 | Re-mint the token, retry the call once |
RATE_LIMITED | 429 | Wait retryAfterMs and retry. Do not tighten the loop |
INSUFFICIENT_BALANCE | 409 | Show currentBalance and offer to spend that instead |
ALREADY_REDEEMED / ALREADY_VOIDED / EXPIRED | 409 | Terminal. Tell the cashier; do not retry |
INVALID_LOCATION | 409 | Show the returned validLocations. Do not retry elsewhere |
CAPABILITY_MISSING | 403 | This till is not permitted to do this. An operator decision, not a bug |
VOID_NOT_ALLOWED | 403 | Needs a manager. Surface it as such |
NOT_FOUND | 404 | The code does not exist. Check for a scanner mis-read before blaming the customer |
NOT_BOOTSTRAPPED | 503 | The 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-reportsorkeyring-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
verifieddate (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
codeas 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#
- Operator: create the till's staff contact, confirm the venue's capabilities are exactly what that till may do, and set the PIN.
- You: implement the mint → 401 → re-mint loop, the scan parsing rule, idempotency-key persistence, and the error table above.
- 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.
- 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.