
# Keyring — integration guide

> Load this file when: an external system — a point-of-sale, a kiosk, a property-management module, any third-party client — needs to call **Keyring** over HTTP: scan its QR codes, read voucher and member state, redeem, top up a balance, award points, issue or void. This is the **inbound** direction. What Keyring is and what it stores: [keyring-reference.md](keyring-reference.md). Installing and operating it: [keyring-setup.md](keyring-setup.md).

> 🔒 **Works by Design system — not a HubSpot platform feature.** These endpoints exist only on portals where Keyring is installed. Portable regardless: the till-as-service-identity model, per-location authority checked server-side, and the idempotency-key contract. What we share vs. what we can only demo: [projects-catalog.md](projects-catalog.md)

## 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.

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

{ "email": "pos-frontdesk@venue.example", "locationId": "<location record id>", "pin": "<install PIN>" }
```

```json
{ "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 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.

<!-- routes:keyring — GENERATED from keyring's src/app/functions/ — the *-hsmeta.json deployment manifests cross-checked against policy-manifest.js by sync-routes.mjs. Do not edit by hand. -->

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` |

<!-- /routes:keyring -->

## 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:

```http
POST /hs/serverless/keyring-fetch-incentive
{ "incentiveId": "GIFT-9X2K4M" }
```

```json
{
  "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:

```http
POST /hs/serverless/keyring-transact
{
  "action": "redeem",
  "staffToken": "<staff token>",
  "idempotencyKey": "<a UUID your till generated and stored>",
  "code": "GIFT-9X2K4M",
  "amount": 12.5
}
```

```json
{ "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.](../media/keyring/scanner-result.webp)

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

```http
POST /hs/serverless/keyring-resolve-token
{ "token": "<the t parameter from the card>", "staffToken": "<staff token>" }
```

```json
{
  "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.

```http
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.

```js
// 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:

```js
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`](/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:

| 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-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](mailto: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.
