Developer guide
Sign-in links: a one-time link to your own site
For a brand without its own sign-in: Ambition emails your patient's verified address a one-time link to a hand-off page of your site, and your server confirms it.
Open the Developer CenterNever send patient information to a sandbox key.
On this deployment: Headless clinical API (patient data): Not available · Headless Connect API: Not available in this environment
- Headless clinical API (patient data): Not available. What runs now: Nothing for a real patient: the headless patient API is written and has run only against a local synthetic stack; no brand's patient data access is approved in any environment, and the Developer Center's clinical-loop simulator is a script with synthetic ids.
- Headless Connect API: Not available in this environment. What runs now: Sandbox-key calls to /api/v1 on the nonproduction deployment with synthetic data: claims scan, formulary, rulebook, the sandbox order preview, delivery reads and replay, and the MCP endpoint, on a deployment whose database has the 20261101090000-range migrations applied (a hard deploy-order dependency the merge gate enforces; ahead of them the rulebook, claims scan, MCP and replay answer 503).
When to use it
Your app owns the patient's account, and every patient call says how the person signed in to you (Ambition-Patient-Auth). If your site has no sign-in of its own, or you want a passwordless one, Ambition can email the patient a one-time link that lands on your site; your server confirms it with Ambition and then signs the patient in on your side. Whoever clicked proved control of the patient's current Ambition-verified email with a one-time token (not the patient's identity: you set that email), so for that sign-in you send Ambition-Patient-Auth: otp with the confirmation's sessionReference as Ambition-Patient-Session. That is the only way otp is accepted: an otp claim without a current Ambition sign-in session (24 hours, this patient, the key that confirmed the link, not signed out, the email not replaced since) is refused with 401 patient_sign_in_unverified, on patient calls and on GET /patients?externalId=, and POST /patients never takes otp. If you run your own one-time codes, or use the contact code as your sign-up code, use sign-in links for otp, or send the method your sign-in actually used.
Status: written, not open. The headless patient API exists on the developer sandbox build and has run only against synthetic data; no brand's patient data access is approved in any environment yet, and there are no live PHI keys.
Before the first link
- The patient has an Ambition-verified email (POST /patients/{patientId}/contact-verifications, then its confirm). Otherwise the link is refused with 409 contact_not_verified and nothing is sent.
- Your site is a verified external site in the same environment with an in-force site attestation, and the page the link lands on is registered under it as a hand-off page: the pages that attestation keeps free of third-party pixels and scripts. Any other URL is refused with 422 redirect_url_unverified, and a URL with a query string with 422 redirect_url_query_not_allowed (keep any state in your own session).
- A phi_server key with patients.write, used from your server. Neither call takes patient headers: the patient has not signed in to you yet. Neither is refused while a deployment admits no new patients: a sign-in reaches only patients who already hold a verified email, and must keep working for access, stop and withdrawal.
The three steps
- Your server: POST /sign-in-links {patientId, redirectUrl} with an Idempotency-Key. Ambition emails <redirectUrl>#ambition_sign_in=<signInLinkId>.<token> (exactly your registered page, no query) from your brand's sender, showing only your brand's name. The answer is {signInLinkId, status: sent, destinationHint, expiresAt}, never the token or the address.
- Your hand-off page: read location.hash and clear it at once with history.replaceState. Show a Continue button and POST the value to your own server (same origin) only when the patient clicks it: an email scanner that runs scripts would otherwise spend the link. Load no third-party script on this page.
- Your server: POST /sign-in-links/{signInLinkId}/confirm {token}. signed_in names the patient (once) and answers sessionReference and sessionExpiresAt; sign them in on your side and keep the reference server-side with that session. Send it as Ambition-Patient-Session with Ambition-Patient-Auth: otp on that patient's calls, from the same key, until sessionExpiresAt (24 hours) or the patient's email is replaced, then sign the patient in again. invalid counts an attempt (attemptsRemaining); expired, revoked (your page no longer qualifies), used and locked need a new link, and none of them signs anyone in. A 503 sign_in_session_unavailable means the link is spent but its session was not recorded: retry the same request (same Idempotency-Key and token) within 10 minutes.
- When the patient signs out of your site: POST /sign-in-sessions/revoke {patientId, sessionReference}. The reference stops backing otp at once ({status: revoked}, again on a repeat). It works even with your PHI gate closed or your brand suspended: ending access is never refused.
const link = await ambition.signInLinks.create({ patientId, redirectUrl: "https://shop.example/auth/callback" });
// ... the patient clicks Continue; your hand-off page posts the fragment's value to your server ...
const parsed = parseSignInFragment(value);
const result = parsed && (await ambition.signInLinks.confirm(parsed.signInLinkId, parsed.token));
if (result?.status === "signed_in") {
// keep result.sessionReference with your session; it backs otp for 24 hours
const p = ambition.patients(result.patientId!, { session: result.sessionReference!, auth: "otp" });
}
// ... and when the patient signs out of your site:
await ambition.signInSessions.revoke(patientId, sessionReference);Limits and what is stored
| Rule | Value |
|---|---|
| Lifetime | 15 minutes |
| Uses | One: a second confirmation answers used |
| Wrong tokens | 5; the fifth locks the link |
| Links per patient | 5 in 15 minutes (429 rate_limited) |
| Stored by Ambition | The token's domain-separated SHA-256 digest, the page's origin and path, the ids |
| Sign-in session | 24 hours from the confirmation, until you sign it out or the patient's email is replaced; backs otp for that patient and key only |
| Session reference | You hold it, like a credential. Only its domain-separated SHA-256 digest reaches Ambition's database and audit trail, and the WAF log redacts the header |
The token travels only in the URL fragment, which a browser never sends to a server or in a Referer. A link of another brand or environment is 404 sign_in_link_not_found. A link stops working when the patient's verified email changes or their access is closed. When a verified email is replaced (PATCH /patients/{patientId}), Ambition sends the previous address a neutral notice, asynchronously, from Ambition's own address, never from your domain or under your name: no new address, no link, nothing about care. A brand-asserted email gets none. A link is not bound to the browser that asked for it (the patient may open it on another device); for same-browser binding, set your own first-party pending-sign-in cookie when you ask for the link and check it before you confirm. Sandbox: the email is caught by the sandbox mail catcher, never delivered to a real inbox; read the link with GET /sandbox/patients/{patientId}/messages. A deployment without a catcher answers the create 503 contact_sender_unavailable.