Partner integration protocol
Server-side partner SSO and exact raw-body webhook verification.
Use the HTTPS origin of your Asoju installation and its /api base. asoju.example.test, the identity values and uppercase placeholders below are synthetic examples, not working credentials. This guide describes the current partner API. Product keys, signed-in browser sessions and partner app keys have different purposes.
Partner access and launch
A platform administrator configures the partner app. A workspace owner or administrator manages its integration; launching requires current membership, an active app and integration, and access to the app's tier. A partner key does not grant access to browser administration routes. Existing selectors, membership, tier and organization policies remain enforced.
The signed-in launch request is GET /api/partner/sso/{integrationId}. It returns success, ssoUrl and expiresAt. The launch URL points to the partner website's /sso/callback with a token query parameter. Keep that token out of logs, analytics, screenshots and persistent storage; redeem it once on the partner server, then redirect to a clean URL.
Redeem the launch token
Send the partner app key in X-API-Key, not as a Bearer API key. The integration and organization headers bind this example to the expected configured context. Never take those expected values from an unverified token or arbitrary caller input.
POST /api/partner/sso/validate
Content-Type: application/json
X-API-Key: YOUR_PARTNER_API_KEY
X-Integration-ID: YOUR_INTEGRATION_ID
X-Organization-ID: YOUR_ORGANIZATION_ID
{"token":"SINGLE_USE_LAUNCH_TOKEN"}A successful response has identity inside token, with verified expiry alongside it. The nested value is identity data, not another credential. No organization object, tier or permission list is promised by this response. The example expiry is illustrative; use the actual response value.
{
"valid": true,
"token": {
"userId": "syn-user",
"organizationId": "syn-org",
"appId": "syn-app",
"integrationId": "syn-integration",
"email": "person@example.test",
"name": "Syn 李 & café"
},
"expiresAt": "2026-10-03T22:05:00.000Z"
}Successful validation consumes the launch token. A repeated, expired or invalid token is refused. Access and policy checks happen before consumption; store failures fail closed. If a timeout leaves the result unknown, request a fresh launch link rather than assuming the same token remains usable. Do not validate that consumed token on every protected request.
Use the shipped server-only Next.js SDK entry point for response checks, bounded requests and safe errors. Validate required server configuration before handling requests; the example assumes those environment values and launchToken have been supplied. No secret belongs in a client bundle.
import { validatePartnerSsoToken } from '@efounders/nextjs/partner-server';
const session = await validatePartnerSsoToken(launchToken, {
apiBaseUrl: 'https://asoju.example.test/api',
apiKey: process.env.ASOJU_PARTNER_API_KEY,
appId: process.env.ASOJU_PARTNER_APP_ID,
integrationId: process.env.ASOJU_INTEGRATION_ID,
organizationId: process.env.ASOJU_ORGANIZATION_ID,
});
if (!session) throw new Error('Partner launch was refused');
const { identity, expiresAt } = session;The SDK returns identity plus expiresAt after checking the nested response and expected IDs. Create your own server session only after that success, with a lifetime no longer than the returned expiry (launch tokens last five minutes). Identity does not define your application's permissions. An existing consumer session is not continuously re-authorized by token redemption; a new launch requires fresh validation. Never ask a partner operator to copy Asoju's platform signing key.
The PHP SDK's validateSsoToken likewise redeems through the platform and checks the nested result. Use the current server SDK, not legacy OAuth helpers or a locally decoded JWT. This guide does not claim an OAuth authorization/token server.
Verify partner webhooks before parsing
Partner webhooks use X-EFounders-Signature: sha256=<64 lowercase hex characters>. Compute HMAC-SHA256 over the exact raw request bytes using the configured partner webhook secret. Do not parse and reserialize JSON first: whitespace, ordering and Unicode affect the signature. Malformed signatures must return false without a length exception.
const { createHmac, timingSafeEqual } = require('node:crypto');
function verifyWebhookSignature(rawBody, signature, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' ||
secret.length < 32 || typeof signature !== 'string' ||
!/^sha256=[a-f0-9]{64}$/.test(signature)) return false;
const expected = Buffer.from('sha256=' + createHmac('sha256', secret)
.update(rawBody).digest('hex'));
const actual = Buffer.from(signature);
return actual.length === expected.length && timingSafeEqual(actual, expected);
}For an Express consumer, register the raw-body route before any express.json() middleware. express, app and the verifier above must be available in your application. processEventOnce below is your own durable handler: validate the expected workspace/app context and supported event, deduplicate by event ID, and finish or durably enqueue processing before responding successfully. It is not an Asoju SDK method.
app.post('/webhooks/asoju', express.raw({ type: 'application/json', limit: '1mb' }),
async (req, res) => {
if (!verifyWebhookSignature(req.body, req.get('X-EFounders-Signature'),
process.env.ASOJU_PARTNER_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
let event;
try { event = JSON.parse(req.body.toString('utf8')); }
catch { return res.status(400).send('Invalid JSON'); }
if (!event || typeof event.id !== 'string' || !event.id ||
typeof event.event !== 'string' || !event.event ||
typeof event.createdAt !== 'string' ||
!Number.isFinite(Date.parse(event.createdAt)) ||
!event.data || typeof event.data !== 'object' || Array.isArray(event.data)) {
return res.status(400).send('Invalid event');
}
try {
await processEventOnce(event);
return res.status(204).end();
} catch {
return res.status(500).send('Processing unavailable');
}
});The signed envelope uses id, event, createdAt and data; dispatch on event, not type. Only subscribed, scoped events are delivered. The emitted-event vocabulary includes integration connection, disconnection, activation, deactivation and permission changes; a name in the source does not prove that a particular workflow emitted or delivered it. Retries may repeat the same event, so deduplication belongs in the consumer. Never log the raw payload or secret when verification fails.
Developer-organization webhooks use the separate X-Asoju-Signature header. Do not interchange their configuration or secret with partner-app webhooks.
Refusals and recovery
Check the HTTP status before reading success fields. Validation can return 400 for malformed, invalid, expired or consumed tokens and mismatched organization selectors; 401 for invalid partner credentials or inactive bound integration; 403 for a conflicting authorized integration; 429 for current organization policy limits; and 503 when required validation services are unavailable. Responses outside a successful identity envelope must not create a session. Honor actual Retry-After headers where supplied; do not assume a dedicated quota or guaranteed delivery.
A refusal does not authorize changing membership, app availability, subscription tier or policy. Use the existing Asoju administration flow for configuration and request a fresh launch after the problem is resolved. Integration and key-management browser requests still require their native signed-in permissions. Usage records are not payment or external-delivery proof.