Developers
Partner SSO integration
Send your hierarchy managers straight into the CRM, already signed in and scoped to their own team. Adding your platform is configuration on our side, not a release.
How it works
Your platform authenticates the user. We mint a single-use code that locks the lane (MANAGER or POSP). The browser carries only that code back to us.
- A user signs in on your platform and clicks through to the CRM.
- Your backend asks us for a handoff with nameId (EMP ID) + subjectType (MANAGER or POSP).
- We return a redirectUri containing a single-use code that expires in about a minute.
- You redirect the browser there. Our callback exchanges the code and the user lands in the CRM on the correct lane.
Both modes converge on the same single-use code, so steps 3 and 4 are identical either way. Your connection is configured for one of them; GET /metadata tells you which.
Who this is for
Two lanes: MANAGER for hierarchy users, POSP for field agents. Choose subjectType on handoff — never in the browser URL.
1a. Handoff (platform-signed)
Your backend calls us with a per-connection API key, nameId (EMP ID), and subjectType. Use this if you cannot mint signed JWTs.
/api/v1/sso/{slug}/handoffX-Sso-Api-KeyPOST /api/v1/sso/{slug}/handoff HTTP/1.1
Host: localhost:3000
Content-Type: application/json
X-Sso-Api-Key: sso_9f3c2b7a4d1e...
{
"nameId": "EMP-10428",
"subjectType": "MANAGER",
"profile": {
"fullName": "Priya Raghunathan",
"email": "priya@acme.com",
"mobile": "9876543210",
"roleHint": "DM",
"update": true,
"metadata": {
"branchCode": "PUN-01",
"channel": "partner-app",
"tags": ["priority"]
}
}
}POST /api/v1/sso/{slug}/handoff HTTP/1.1
Host: localhost:3000
Content-Type: application/json
X-Sso-Api-Key: sso_9f3c2b7a4d1e...
{
"nameId": "CSP023057",
"subjectType": "POSP",
"profile": {
"fullName": "John Doe",
"email": "john@partner.com",
"mobile": "9876543210",
"update": true
}
}
Already registered in Xpresso (KYC is fetched once). Omit isAlreadyPosp, or send false, for a new POSP.
{
"nameId": "CSP242922",
"subjectType": "POSP",
"isAlreadyPosp": true,
"profile": {
"fullName": "AJAY SRIVASTAV",
"email": "a@roinet.in",
"mobile": "8808462727"
}
}HTTP/1.1 200 OK
Content-Type: application/json
{
"redirectUri": "http://localhost:3000/sso/acme/callback?code=F3xq2m9K1s...",
"expires_in": 60
}curl -X POST http://localhost:3000/api/v1/sso/acme/handoff \
-H "Content-Type: application/json" \
-H "X-Sso-Api-Key: $SSO_API_KEY" \
-d '{"nameId":"EMP-10428","subjectType":"MANAGER"}'curl -X POST http://localhost:3000/api/v1/sso/acme/handoff \
-H "Content-Type: application/json" \
-H "X-Sso-Api-Key: $SSO_API_KEY" \
-d '{"nameId":"CSP023057","subjectType":"POSP"}'1b. Assert (partner-signed)
You sign a short-lived assertion with your own key. Optional claim subjectType (or isPosp) selects the lane.
/api/v1/sso/{slug}/assertSigned assertion{
"iss": "https://idp.acme.com",
"aud": "http://localhost:3000",
"sub": "EMP-10428",
"jti": "0f9c1c4e-6f0e-4d5e-9a3f-2c1b8e6a77d1",
"iat": 1767225600,
"exp": 1767225720,
"subjectType": "MANAGER",
"name": "Priya Raghunathan",
"email": "priya@acme.com"
}POST /api/v1/sso/{slug}/assert HTTP/1.1
Host: localhost:3000
Content-Type: application/json
{
"assertion": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImsxIn0..."
}HTTP/1.1 200 OK
Content-Type: application/json
{
"redirectUri": "http://localhost:3000/sso/acme/callback?code=F3xq2m9K1s...",
"expires_in": 60
}2. Redirect the browser
Send the user's browser to the redirectUri exactly as returned. Do not rewrite it or add isPosp to the URL.
HTTP/1.1 302 Found
Location: http://localhost:3000/sso/acme/callback?code=F3xq2m9K1s...3. Exchange the code
Our callback page does this for you. The lane comes from the code, not from the request body.
/api/v1/sso/{slug}/exchangeThe code is the credentialPOST /api/v1/sso/{slug}/exchange HTTP/1.1
Host: localhost:3000
Content-Type: application/json
{
"code": "F3xq2m9K1s..."
}HTTP/1.1 200 OK
Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Lax; Path=/
Content-Type: application/json
{
"id": "cku9x...",
"email": "priya@acme.com",
"role": "DM",
"status": "ACTIVE",
"partnerId": "cpartner...",
"isPlatformAdmin": false
}Metadata
Everything you need to configure your side, readable without credentials.
/api/v1/sso/{slug}/metadataPublicHTTP/1.1 200 OK
Content-Type: application/json
{
"slug": "acme",
"label": "Acme Insurance — production",
"mode": "PLATFORM_SIGNED_HANDOFF",
"audience": "http://localhost:3000",
"callbackUrl": "http://localhost:3000/sso/acme/callback",
"handoffUrl": "http://localhost:3000/api/v1/sso/acme/handoff",
"assertUrl": "http://localhost:3000/api/v1/sso/acme/assert",
"exchangeUrl": "http://localhost:3000/api/v1/sso/acme/exchange",
"acceptedAlgorithms": ["RS256", "ES256"],
"subjectClaim": "sub",
"codeTtlSecs": 60,
"supportedSubjectTypes": ["MANAGER", "POSP"],
"journeyEnabled": true,
"journeyProductKeys": ["marine-git"],
"journeyReturnUrlAllowlist": ["https://trucksuvidha.com"],
"audience_note": "Pass subjectType MANAGER for hierarchy users or POSP for field agents. The lane is locked into the handoff code; the browser cannot change it. Buy journeys send a journey block; subject is forced to the house POSP."
}Errors
Every refusal carries a reason code.
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"statusCode": 401,
"message": "Invalid or expired handoff code"
}| Reason code | What it means |
|---|---|
| WRONG_SUBJECT_TYPE | The account does not match the subjectType lane (e.g. a manager on POSP, or a POSP on MANAGER). |
| ONBOARDING_REQUIRED | POSP first login needs profile.mobile (and usually name/email) so we can start onboarding, or the account cannot complete login yet. Mid-onboarding PENDING users with a User are signed in and sent to /onboarding. Fully ACTIVE POSPs go to /dashboard. |
| UNKNOWN_SUBJECT | No CRM profile matches that nameId and no usable partner profile was sent on handoff. Managers: send profile or map externalSubject. POSPs: send profile for first-time create/prefill. |
| AUTO_PROVISION_DISABLED | The manager has no CRM account and this connection does not create accounts automatically. A partner admin must add them first. |
| ROLE_NOT_GRANTABLE | The resolved role is outside the roles this connection may create automatically. |
| RANK_CAP | The resolved role sits above the automatic provisioning ceiling. Senior roles are created by a partner admin. |
| PARTNER_SUSPENDED | The tenant is suspended. No door signs anyone in while that holds. |
| USER_INACTIVE | The CRM account exists but is not active. |
| INVALID_CODE | The handoff code is unknown, expired, or already used. Codes are single-use. |
| BAD_API_KEY | The X-Sso-Api-Key header is missing or wrong. |
| BAD_SIGNATURE | The assertion failed verification: signature, algorithm, issuer, audience, or timing. |
| REPLAY | This assertion's jti has already been used. Mint a fresh one per sign-in. |
| CONNECTION_DISABLED | This SSO connection is switched off. Contact your integration owner. |
| RATE_LIMITED | Too many attempts in a short window. Back off and retry. |
| JOURNEY_DISABLED | This connection does not accept a journey block on handoff. |
| JOURNEY_RETURN_URL_DENIED | journey.returnUrl is not on the connection allowlist. |
| JOURNEY_PRODUCT_MISMATCH | journey.productKey is unknown or does not match the connection. |
| JOURNEY_HOUSE_POSP_MISSING | journeyHousePospCode is not configured on the connection. |
Buy journey handoff
Optional journey block on platform-signed handoff for partner-embedded insurance purchase (e.g. marine GIT). Reuses Marketplace sell flows after a scoped house-POSP session.
Send a typed journey block on handoff (not profile.metadata). We force the house POSP subject, mint a scoped session, and land the browser on /journey/{partner}/marine-git. After policy issue we POST a signed callback to your journeyCallbackUrl (X-Roinet-Signature: sha256=… over timestamp.body). Pull fallback: GET /api/v1/partner/journeys/:id with the same API key.
POST /api/v1/sso/{slug}/handoff HTTP/1.1
Host: localhost:3000
Content-Type: application/json
X-Sso-Api-Key: sso_9f3c2b7a4d1e...
{
"nameId": "HOUSE-POSP-CODE",
"subjectType": "POSP",
"journey": {
"productKey": "marine-git",
"returnUrl": "https://trucksuvidha.com/booking/TS-2608-77412",
"partnerRefs": { "bookingId": "TS-2608-77412" },
"ewb": {
"ewbNo": "692163844058",
"totInvValue": 45340,
"fromGstin": "24AAGFD4779D1ZH",
"toGstin": "37AACCD9205D1ZX",
"hsnCode": "39269059",
"productDesc": "Articles of plastics",
"vehicleNo": "GJ07YZ8615"
}
}
}Security notes
What the design guarantees, and what it expects from you.
- The browser never carries an assertion, a role, a tenant id, or a subjectType — only an opaque single-use code that is worthless once exchanged.
- subjectType is locked at handoff time. Changing anything on the callback URL cannot escalate a POSP into a manager session (or the reverse).
- For MANAGER, roles and territory come from the CRM's own records — we do not honour a role claim from you.
- We redirect only to the exact origin registered on your connection. There is no caller-supplied redirect parameter.
- Send nameId as a stable EMP ID / Name ID. Email is a poor choice; an HR / IdP / POSP code is a good one.
- Optional profile.metadata (JSON object, max ~8KB) is stored on User.metadata for partner-specific extras — not used for RBAC.
- Endpoints are rate limited per connection and per nameId. Handle 401 and 429 responses rather than retrying in a tight loop.