Skip to content

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.

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.

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.

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.

Three limits apply. The third requires a calculation.

CapLimitApplies to
Any string claim255 charactersnames, e-mail, phone, locale, section key
return_url2 048 charactersURLs legitimately need the room
reservation.metadata4 096 bytesthe decoded JSON object
The whole token32 768 bytesbase64url, 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 metadata cap of 4 096 measures the decoded object; this one measures what goes on the wire.
  • ensure_ascii is up to 3×. Python’s json.dumps and PHP’s json_encode escape 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.

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

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_timestamp and service_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.

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.

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.

src/snippets/mint-identity-assertion.mjs
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;
}

A token resolves to one of three outcomes:

OutcomeWhenWhat the guest sees
validSignature good, inside its windowTheir details, already filled in
expiredSignature good, exp passedAn explicit “session expired”
invalidEverything elseNothing — 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.

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.