Quickstart
Get from zero to a successful deposit in 5 minutes.
What you'll need
- Entra App Registration credentials — tenant ID, client ID, and client secret for your Azure AD application. See Entra authentication for how the M2M client credentials grant works and how to configure the application.
- An RSA-2048 keypair for partner JWT signing. The demo generates and persists one automatically on first run (see
src/lib/keys.ts). See User authentication for key requirements. - JWKS endpoint URL — a publicly reachable URL serving your public key in JWK Set format, registered against your tenant in the Yieldforce backend. In the demo this is served by the app itself at
/api/jwks. - Yieldforce backend URL — defaults to
http://localhost:3000/api(the consumer-frontend proxy). Override with theYIELDFORCE_API_BASE_URLenvironment variable.
Step 1: Get an Entra token
Every B2B endpoint requires a valid Entra token in the Authorization header. Use the OAuth 2.0 Client Credentials grant:
const tenantId = process.env.ENTRA_TENANT_ID;
const clientId = process.env.ENTRA_CLIENT_ID;
const clientSecret = process.env.ENTRA_CLIENT_SECRET;
const scope = 'api://yieldforce/.default';
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const res = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
scope,
}).toString(),
});
const { access_token, expires_in } = await res.json();
// Cache access_token for (expires_in - 60) secondsThe demo implements this with a module-level in-memory cache in src/lib/entra.ts: tokens are served from cache until 60 seconds before expiry, then a fresh request is made automatically.
Step 2: Mint a partner JWT for a user
User-scoped endpoints (create/get portfolio, deposit, withdraw, etc.) require a second token identifying the end-user within your tenant. You mint this yourself using your private key:
import { SignJWT, importPKCS8 } from 'jose';
const privatePem = process.env.PARTNER_PRIVATE_KEY_PEM;
const privateKey = await importPKCS8(privatePem, 'RS256');
const partnerJwt = await new SignJWT({})
.setProtectedHeader({ alg: 'RS256', kid: 'partner-demo-key-1', typ: 'JWT' })
.setSubject(externalUserId) // your stable user identifier
.setIssuer(process.env.PARTNER_ISSUER) // must match tenant's expectedIssuer
.setAudience('api://yieldforce')
.setIssuedAt()
.setExpirationTime('1h')
.sign(privateKey);This is exactly what src/lib/jwt.ts does. The iss claim must match the issuer URL registered for your tenant. The sub claim must match the external_id in the URL of user-scoped requests.
Step 3: List available pools
Listing pools requires only the Entra token — no user token needed:
const baseUrl = process.env.YIELDFORCE_API_BASE_URL ?? 'http://localhost:3000/api';
const res = await fetch(`${baseUrl}/v1/partner/pools`, {
headers: {
Authorization: `Bearer ${entraToken}`,
},
});
const { pools } = await res.json();
// pools: Array<{ id, name, protocol, token_address, market_address, chain_id, apy, ... }>Note the market_address field — for Morpho vaults this is distinct from token_address and is required when creating a portfolio.
Step 4: Create a portfolio
Creating a portfolio requires both the Entra token (Authorization) and a partner JWT for the user (X-User-Token):
const res = await fetch(`${baseUrl}/v1/partner/end_users/${externalUserId}/portfolios`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${entraToken}`,
'X-User-Token': partnerJwt,
},
body: JSON.stringify({
name: 'My USDC portfolio',
chain_id: 84532, // Base Sepolia
token_address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
market_address: '0x6A0935DEF442D92c3456FBb38B888375F022C646', // Morpho vault
protocol: 'morpho',
}),
});
const { portfolio } = await res.json();
// portfolio.id is used for deposit/withdrawStep 5: Deposit
Deposit requires both auth headers and an Idempotency-Key. The connection stays open for 5–30 seconds while the on-chain transaction completes:
import { randomUUID } from 'node:crypto';
const res = await fetch(`${baseUrl}/v1/partner/end_users/${externalUserId}/deposit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${entraToken}`,
'X-User-Token': partnerJwt,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
portfolio_id: portfolio.id,
amount_minor: '10000000', // 10 USDC (6 decimals)
}),
// Set your HTTP client timeout to at least 60s for this endpoint
});
const result = await res.json();
// result.tx_hash — on-chain transaction hashIf your connection drops before the response arrives, the transaction is still being processed on-chain. Poll GET .../transactions to recover the result.
Step 6: Withdraw
Withdraw has the same shape as deposit:
const res = await fetch(`${baseUrl}/v1/partner/end_users/${externalUserId}/withdraw`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${entraToken}`,
'X-User-Token': partnerJwt,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
portfolio_id: portfolio.id,
amount_minor: '10000000',
}),
});Testing with curl
Each step as a one-liner. Replace $ENTRA, $JWT, $USER_ID, $PORTFOLIO_ID with real values.
# Step 1 — get Entra token
curl -s -X POST "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \
-d "grant_type=client_credentials&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&scope=api://yieldforce/.default" \
| jq -r .access_token
# Step 3 — list pools
curl -s "$BASE_URL/v1/partner/pools" -H "Authorization: Bearer $ENTRA"
# Step 4 — create portfolio
curl -s -X POST "$BASE_URL/v1/partner/end_users/$USER_ID/portfolios" \
-H "Authorization: Bearer $ENTRA" -H "X-User-Token: $JWT" \
-H "Content-Type: application/json" \
-d '{"name":"test","chain_id":84532,"token_address":"0x036CbD53842c5426634e7929541eC2318f3dCF7e","market_address":"0x6A0935DEF442D92c3456FBb38B888375F022C646","protocol":"morpho"}'
# Step 5 — deposit
curl -s -X POST "$BASE_URL/v1/partner/end_users/$USER_ID/deposit" \
-H "Authorization: Bearer $ENTRA" -H "X-User-Token: $JWT" \
-H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
-d "{\"portfolio_id\":\"$PORTFOLIO_ID\",\"amount_minor\":\"10000000\"}" \
--max-time 60
# Step 6 — withdraw (same shape as deposit)
curl -s -X POST "$BASE_URL/v1/partner/end_users/$USER_ID/withdraw" \
-H "Authorization: Bearer $ENTRA" -H "X-User-Token: $JWT" \
-H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
-d "{\"portfolio_id\":\"$PORTFOLIO_ID\",\"amount_minor\":\"10000000\"}" \
--max-time 60Note the --max-time 60 on deposit/withdraw — the default curl timeout (typically 30s) is too short for on-chain transactions.
Next steps
- Architecture — understand how Entra + user token compose, how tenant identity resolves, and why synchronous HTTP replaced webhooks.
- List pools reference — full parameter and response documentation for every endpoint.