Quickstart

Get from zero to a successful deposit in 5 minutes.

What you'll need

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) seconds

The 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/withdraw

Step 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 hash

If 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 60

Note the --max-time 60 on deposit/withdraw — the default curl timeout (typically 30s) is too short for on-chain transactions.

Next steps