Skip to content

Testing your integration

There is no sandbox and no test mode. A booking made through the widget is a real booking in a real restaurant’s calendar, and the restaurant is notified of it like any other.

Test against a restaurant you control, or with one that knows to expect it. The confirmation screen carries a Modify or cancel link — use it on every booking you create, before the restaurant has to.

Most integration mistakes surface as a widget that works perfectly and quietly does less than you intended. This is the list worth walking before going live.

What you seeWhat it means
No return button at the endThe return_url origin is not on your allowlist.
Details not prefilled, no messageThe token was rejected.
”Session expired”The token verified, and its exp had passed.
A field the guest can still editIt is in reservation but not in locked.
?t= on your own page does nothingWorking as intended — see below.

Registering return origins is a separate step

Section titled “Registering return origins is a separate step”

return_url is filtered server-side against a per-integration allowlist. An integration that ships the code without registering its origins renders no button and logs no error — the guest simply reaches the end of the booking with nowhere to go back to.

Register the origins, then test the confirmation screen, not the code path.

Telling a rejected token apart from a bad one

Section titled “Telling a rejected token apart from a bad one”

A rejected token produces the ordinary anonymous funnel and no message. That is deliberate: a bad signature, an unknown issuer, an issuer belonging to another account, a malformed segment and a lifetime longer than 15 minutes all resolve to the same outcome, so a failed verification discloses nothing about which check failed.

Which is exactly the problem when the failing party is you. Use the one outcome that is distinguishable:

Mint a correctly signed token whose exp has already passed. If the widget says the session expired, your signing, your issuer and the restaurant are all correct, and whatever is wrong with your real token is in its claims or its lifetime. If it says nothing at all, the failure is before that: signature, issuer, or the account the restaurant belongs to.

Then work through the rest:

  • exp - iat must not exceed 15 minutes. A token with a 24-hour lifetime is rejected outright, not shortened — and it is rejected as invalid, which looks exactly like a bad signature.
  • The MAC covers the base64url segment as transmitted, not the JSON bytes. If you are re-serialising the payload before hashing, key order or unicode escaping will eventually disagree.
  • The whole token must be under 32 768 bytes as encoded. Measure the final string, not the object: base64 adds a third, and ensure_ascii in Python’s json.dumps or PHP’s json_encode can triple non-ASCII text before that.
  • Locked fields use the snake_case wire spellingparty_size, section_key, shift_id.
  • ?t= in the address bar of your own page is ignored, by design. Only the standalone booking page reads it; on a host page, use setGuestToken(). See deep links.

create() throws synchronously when mode: "inline" and its container cannot be resolved. A mistyped selector surfaces at the call site rather than as a form that never appears — so check the console before assuming the widget is broken.

Subscribe to both booking events and log them once before wiring anything to them:

widget.on("reservation:created", (e) => console.log("created", e.reservation));
widget.on("reservation:confirmed", (e) => console.log("confirmed", e.reservation));

On a restaurant that approves bookings manually, or one that takes a card guarantee, only the first of these fires at the end of the funnel. That is the behaviour to test for, because it is the one that sends a “your table is confirmed” message to a guest who has neither.

step:changed reports transitions and never the step the funnel opens on. If your funnel’s first stage is empty, that is the cause — take the entry from opened or ready, per events.

The widget runs under the host page’s policy, not ours. A page with a strict CSP needs:

DirectiveValueFor
script-srchttps://app.useservice.appThe widget bundle.
connect-srchttps://app.useservice.appAvailability and booking calls.
img-srcThe origin serving the restaurant’s logo, if one is set.The header.
script-srchttps://js.stripe.comCard guarantees only.
frame-srchttps://js.stripe.com https://hooks.stripe.comCard guarantees and 3-D Secure.

The widget calls back to the origin that served its bundle, which is why connect-src names the same host as script-src.

No style-src entry is needed: the widget’s stylesheet is adopted through the CSSOM rather than injected, and CSP does not govern that. One caveat — a policy that sets style-src without style-src-attr 'unsafe-inline' blocks the inline attribute carrying the restaurant’s brand colour, and the widget renders in its default colour instead. See appearance.

Test with the policy enforced, not in report-only: a blocked subresource is a widget that does not appear, and the only evidence is in the console.

  • Bookings created during testing are cancelled.
  • Return origins registered, and the button verified on the confirmation screen.
  • Tokens minted on your server, per widget open, never in page source.
  • The guest can still complete the booking with the token rejected — that is the path every expired session takes.
  • Locked fields match what your voucher actually constrains. A funnel with the date, service and party size all pinned, on a day with nothing available, is a dead end.
  • Your confirmation messaging is wired to reservation:confirmed, or to a webhook if the record matters.