Identifying the guest
A signed token identifies the guest to the widget. It is minted by the host’s backend and verified by Service, so its claims cannot be forged.
The values on the passing context page are hints and are not trusted. A token carries two things a hint cannot: identity and locks.
The shape
Section titled “The shape”token = base64url(payload_json) "." base64url(HMAC_SHA256(secret, base64url(payload_json)))The MAC covers the base64url segment as transmitted, not the JSON bytes. That means verification never re-serialises your hash, so we can never disagree with you about key ordering or unicode escaping.
{ "iss": "ivk_…", "sub": "member-8891", "iat": 1754297400, "exp": 1754298300, "guest": { "first_name": "Marie", "last_name": "Lefèvre", "email": "…", "phone": "…", "locale": "fr" }, "consent": { "marketing_email": true }, "reservation": { "date": "2026-09-03", "shift_id": 1, "metadata": { "voucher": "GOLF-3P-8891" } }, "locked": ["date", "shift_id"], "return_url": "https://groupe.example/sejour/8891"}iss is your verification key’s public id — it selects the secret, so there is
no separate key id to send. Rotation is “create a second key, switch your site
over, then revoke the first”: a data operation, not a credentials edit.
Hand it to the widget and nothing else:
widget.setGuestToken(await myBackend.mintServiceToken(currentUser));Mint it on your server. The secret signs assertions about who someone is; a copy in a page is a copy anyone can read and use to assert anything.
Getting the key
Section titled “Getting the key”The restaurant creates it in the back-office, under Settings › Developers › Identifying the guest. It is a Premium feature, and only a user with the Owner role can create one.
The secret stays readable. There is no one-time reveal to write down: it can be shown again at any time from the same screen, and every viewing is recorded — who looked, and when. That is the opposite of how the rest of the product treats secrets, and it is deliberate: losing your copy is not an incident, and reading it is not free.
A key covers one restaurant — unless that restaurant belongs to a group. Most do not, and if yours does not, there is nothing more to know here: the key you create covers your restaurant, and the two paragraphs below do not apply to you.
For restaurants that are part of a group, one key covers all of them. There is nothing to create per site and nothing to narrow — a key created from any restaurant in the group verifies against every restaurant in that group, and only those.
Also groups only: if the restaurants in a group do not share the same owner, no one there can create a key on their own — the back-office refuses, and the group should ask Service to issue one. Revoking is unaffected either way: any owner can revoke.
Fifteen minutes, per widget open
Section titled “Fifteen minutes, per widget open”exp - iat may not exceed 15 minutes, and a token claiming more is rejected
outright rather than quietly shortened — an over-long token is a contract
violation worth failing loudly in integration, not a session we silently trim.
We allow 60 seconds of clock skew on iat only; being lenient on exp would
extend the effective lifetime past the ceiling.
Mint one per widget open. When it expires the guest sees an explicit “session expired” and the ordinary anonymous funnel — never a silent re-mint, which would make the TTL decorative.
Size limits
Section titled “Size limits”Three limits apply. The third requires a calculation.
| Cap | Limit | Applies to |
|---|---|---|
| Any string claim | 255 characters | names, e-mail, phone, locale, section key |
return_url | 2 048 characters | URLs legitimately need the room |
reservation.metadata | 4 096 bytes | the decoded JSON object |
| The whole token | 32 768 bytes | base64url, as transmitted |
metadata is a member of reservation, not a top-level claim. Placed at the top
level it is not read, and nothing says so — the booking simply carries none.
The token limit measures the encoded form as transmitted, not the JSON object. Two multipliers apply:
- base64 is 4/3. The
metadatacap of 4 096 measures the decoded object; this one measures what goes on the wire. ensure_asciiis up to 3×. Python’sjson.dumpsand PHP’sjson_encodeescape non-ASCII by default, soétravels asé— two bytes become six — and an emoji’s surrogate pair turns four bytes into twelve.
A payload within every per-field limit can therefore exceed the token limit. A
2 611-byte metadata object once produced a 10 622-byte token against an 8 KB
limit, and the booking lost its locks without reporting an error. The limit is
now 32 KiB, sized against the largest token a compliant host can produce, and
exceeding it is reported rather than ignored.
Measure the base64 length of the finished token rather than the size of the object it encodes.
Locking what the guest may not change
Section titled “Locking what the guest may not change”"reservation": { "date": "2026-09-03", "shift_id": 1 },"locked": ["date", "shift_id"]A locked field renders without an edit control and with your reason beside it, and the server refuses a booking that contradicts it. That second half is why locks are signed: presentation alone would be a suggestion, and a crafted request would walk straight past it.
Lockable: date, time, party_size, section_key, shift_id. The wire
spelling is snake_case.
Lock only what your voucher genuinely constrains. A funnel where the date, the service and the party size are all pinned, on a day with nothing available, is a dead end — the guest cannot change anything and the widget can only say so.
Returning the guest to the host
Section titled “Returning the guest to the host”return_url adds a button at the end of the booking that returns the guest to
the host site. On the dead end described above, it is the only available exit.
The URL is filtered server-side against the allowlist on your verification key. Registering the origin is an integration step, not a code change. An integration that ships the code without registering its origins renders no button, and no error is logged.
The URL is supplied on two surfaces, with different trust:
- On the booking confirmation it is signed, carrying
service_reservation,service_timestampandservice_signature— so you can verify the booking you are being told about instead of trusting a query string. - While the funnel is still open it is unsigned, because there is no reservation yet to sign for. It exists so a guest who cannot book anything still has somewhere to go.
Verifying the signed return
Section titled “Verifying the signed return”Recompute the HMAC over the timestamp and the reservation id, joined by a dot, using your verification key’s secret:
service_signature == hex( HMAC_SHA256( secret, "{service_timestamp}.{service_reservation}" ) )const expected = crypto .createHmac("sha256", secret) .update(`${params.get("service_timestamp")}.${params.get("service_reservation")}`) .digest("hex");
const ok = crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(params.get("service_signature")),);This is the same construction as the Service-Signature header on our webhooks,
so an integrator who has implemented one has implemented the other.
Reject a stale timestamp. The timestamp is inside the signed material and in the query string precisely so you can: the signature covers a stable reservation id and never expires on its own, so a link captured once would otherwise work forever. Compare against your own clock and pick a window — minutes, not hours.
Compare in constant time, as above. A byte-by-byte early return leaks the expected signature to anyone willing to make enough requests.
A reference implementation
Section titled “A reference implementation”This is the exact file our own test suite runs, and the token it produces is verified against the live verifier by a backend spec. Copy it rather than working from the prose above.
import { createHmac, timingSafeEqual } from "node:crypto";
/** * Reference implementation of the Service identity assertion. * * ⚠️ **This file is the published example.** It is embedded verbatim in * `identifying-the-guest.md` and is covered by tests on both sides: a frontend * test asserts it reproduces a fixed token byte for byte, and a backend spec * asserts that same token verifies as `:valid`. If you change the algorithm * here, both fail — which is the point. The docs used to describe this in prose * and an adversarial reader who implemented from it got four independent * decisions wrong, every one of which lands in the deliberately-undiagnosable * `invalid` bucket. * * Those four, stated outright because prose kept losing them: * * 1. **The MAC covers the base64url SEGMENT as transmitted**, not the JSON * that produced it. Sign the string you are about to send. * 2. **The signature is base64url of the RAW digest**, not hex. (The * return-URL signature IS hex — they differ, and that is not a mistake. * See `verifyReturnUrl` below.) * 3. **base64url, not base64**: `-` and `_`, and padding stripped. We accept * padded input too, but unpadded is what to send. * 4. **`iss` is the verification key's public id including its * `ivk_` prefix** — it is not a bare id and not a key name. */
/** base64url with padding stripped (RFC 4648 §5). */const b64url = (buf) => Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
/** * Mint an identity assertion. * * @param {object} claims The payload. `iss` is required; `iat`/`exp` are added * when absent, with the 15-minute maximum lifetime. * @param {string} secret The verification key's secret, as issued — used * verbatim, not decoded. * @returns {string} `<payload>.<signature>` */export function mintIdentityAssertion(claims, secret) { const now = Math.floor(Date.now() / 1000); const payload = { iat: now, exp: now + 15 * 60, ...claims };
// Sign the SEGMENT, not the JSON — see note 1 above. Whatever your JSON // encoder does with key order or unicode escaping is therefore irrelevant to // whether the signature verifies. const segment = b64url(JSON.stringify(payload)); const signature = b64url(createHmac("sha256", secret).update(segment).digest());
return `${segment}.${signature}`;}
/** * Verify the signed return we append to your `return_url`. * * **Different construction from the token above, deliberately:** this one is * HMAC over `"{timestamp}.{reservation}"` and is **hex**, matching the * `Service-Signature` header on our webhooks — so an integrator who has * implemented webhook verification has already implemented this. * * @param {URLSearchParams} params The query string we sent you. * @param {string} secret The same verification-key secret. * @param {number} maxAgeSeconds Reject anything older. The signature covers a * stable reservation id and so never expires by * itself; a link captured once would otherwise * work forever. */export function verifyReturnUrl(params, secret, maxAgeSeconds = 300) { const timestamp = params.get("service_timestamp"); const reservation = params.get("service_reservation"); const signature = params.get("service_signature"); if (!timestamp || !reservation || !signature) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${reservation}`).digest("hex");
const given = Buffer.from(signature); const want = Buffer.from(expected); // Length-check first: timingSafeEqual THROWS on a length mismatch, which // would turn a forged signature into a 500 rather than a rejection. if (given.length !== want.length) return false; if (!timingSafeEqual(given, want)) return false;
return Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) <= maxAgeSeconds;}Verification outcomes
Section titled “Verification outcomes”A token resolves to one of three outcomes:
| Outcome | When | What the guest sees |
|---|---|---|
| valid | Signature good, inside its window | Their details, already filled in |
| expired | Signature good, exp passed | An explicit “session expired” |
| invalid | Everything else | Nothing — the ordinary funnel |
invalid is a catch-all. A bad signature, an unknown issuer, an issuer
belonging to another group, a malformed segment and an over-long lifetime all
resolve to it, so that a failed verification discloses nothing about which check
failed. Only a token whose signature verifies produces the expiry message.
A token is always scoped to one restaurant. A verification key covers the
restaurant it was created from — and, when that restaurant belongs to a group,
every restaurant in that group. Presenting its token against a restaurant the
key does not cover is invalid — the same answer as an unknown issuer.
Per-field trust
Section titled “Per-field trust”An asserted field that the guest edits is no longer treated as asserted. The comparison is per field, against the value submitted, so correcting one field does not revoke the others. A guest booking on behalf of someone else changes the name, and the booking is attributed accordingly.