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.
The failures that say nothing
Section titled “The failures that say nothing”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 see | What it means |
|---|---|
| No return button at the end | The return_url origin is not on your allowlist. |
| Details not prefilled, no message | The token was rejected. |
| ”Session expired” | The token verified, and its exp had passed. |
| A field the guest can still edit | It is in reservation but not in locked. |
?t= on your own page does nothing | Working 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 - iatmust 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_asciiin Python’sjson.dumpsor PHP’sjson_encodecan triple non-ASCII text before that. - Locked fields use the snake_case wire spelling —
party_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, usesetGuestToken(). See deep links.
The failures that are loud
Section titled “The failures that are loud”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.
Events
Section titled “Events”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.
Content-Security-Policy
Section titled “Content-Security-Policy”The widget runs under the host page’s policy, not ours. A page with a strict CSP needs:
| Directive | Value | For |
|---|---|---|
script-src | https://app.useservice.app | The widget bundle. |
connect-src | https://app.useservice.app | Availability and booking calls. |
img-src | The origin serving the restaurant’s logo, if one is set. | The header. |
script-src | https://js.stripe.com | Card guarantees only. |
frame-src | https://js.stripe.com https://hooks.stripe.com | Card 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.
Before going live
Section titled “Before going live”- 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.