Set up service-to-service authentication

Authenticate a backend service to the Moveworks Conversations API using a self-signed JWT bearer, letting your service make requests on behalf of any provisioned user.

View as Markdown

The Conversations API is in Controlled Availability. Endpoints, behaviors, and configuration surfaces may change during CA. Contact your Moveworks account team to participate.

This guide covers authenticating a backend service to the Moveworks Conversations API, so that service can make requests on behalf of any provisioned user in your Moveworks tenant. Your backend generates and signs the JWT locally, then exchanges it for a Moveworks bearer token.

When to use this

Use this pattern when a trusted backend system (test harness, batch worker, monitoring bot, scripted integration) needs to make Conversations API calls on behalf of an already-known user, and there is no interactive user browser flow available to obtain a token.

Use one of the interactive user guides instead when:

The trade-off:

Interactive user authService-to-service auth
Each user signs in with their IdP; only that user can obtain a token for themselvesYour backend holds a private key; it can obtain a token for any provisioned user
Attribution is guaranteed by the IdPAttribution depends on your backend correctly choosing the sub claim
Setup requires an IdP application (Okta, Entra)Setup requires only a keypair and a Moveworks credential
Best for user-facing product integrationsBest for automation, testing, monitoring, and back-office integrations

How it works

Your backend service holds an RSA private key. The matching public key is registered as a credential in your Moveworks tenant. For each API call you want to make on behalf of a user:

  1. Your backend builds a short-lived JWT that names the user in the sub claim and signs it with the private key.
  2. Your backend exchanges the signed JWT at POST /oauth/v1/token for a Moveworks bearer token.
  3. Your backend uses that bearer token on Conversations API calls. The bearer is scoped to the user in sub.

The private key is a tenant-wide credential. Any process holding it can obtain a bearer for any user in the tenant. Treat it accordingly (see Security).

Prerequisites

  • Your Moveworks tenant is provisioned for the Conversations API and your account team has provided the Bot Name (used in the Assistant-Name header on every API call).
  • Admin access to your Moveworks Admin Portal (https://{org}.moveworks.com).
  • A Unix-like environment (or WSL / OpenSSL for Windows) to generate keys.
  • Ability to run a backend runtime that can sign JWTs (any language with an RS256 JWT library works; the samples in this guide are Python).
  • The set of users your backend will act on behalf of are already resolvable against your Moveworks user roster.

Step 1: Generate an RSA keypair

On the machine that will run your backend service, generate a 2048-bit RSA keypair and lock down the private key permissions.

$openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
$openssl rsa -in private.pem -pubout -out public.pem
$chmod 600 private.pem

You will register public.pem with Moveworks in step 2 and keep private.pem on your backend.

Step 2: Create the JWT OAuth credential in Moveworks

In the Moveworks Admin Portal, go to https://{org}.moveworks.com/http-connectors/connector_studio/homeCredentials → create a new credential.

FieldValueNotes
Credential NameA descriptive label, e.g. Backend-CAPI-ServiceFree text.
Credential TypeJWT OAuthReveals the fields below.
Public Key valueThe contents of your public.pem file (PEM-encoded, including the -----BEGIN PUBLIC KEY----- header and footer)Paste the raw public key. Unlike the IdP-based flows, there is no discovery URL here because your backend, not an IdP, signs the tokens.
AudienceA unique string of your choice, e.g. capi-backend.acme.comYou control the audience because your backend controls the JWT. Pick a value unique to this credential and use the same value in your JWTs.
IssuerA unique string of your choice, e.g. capi-backend.acme.comSame reasoning as Audience. Together, Issuer + Audience identify this credential to Moveworks.
Identifier TypeIdPTells Moveworks to resolve the identifier claim through an integration rather than as a raw email or record ID.
Integration IDconversations_rest_apiMoveworks matches the sub claim against the user’s Conversations API channel entry on their profile.
Identifier Claimsub (default)The claim your backend puts the user’s identifier in.

Save the credential. Moveworks displays a Key ID (KID) value; copy it. You will include it in every JWT header.

Because your backend signs the JWT itself, Issuer and Audience are values you choose. They must match exactly between the credential configuration and the JWT you sign. If either value differs, the token exchange fails with invalid_audience or invalid_signature.

Step 3: Sign the JWT and exchange for a bearer

The examples below use https://api.moveworks.ai, which is only correct for orgs on the US Production data center. Replace it with the base URL for your data center from the Base URLs table (for example, https://api.am-eu-central.moveworks.ai for EU). Requests to the wrong host return 404 or invalid_audience.

Your backend performs two operations per request (or per short-lived cache window):

  1. Build a JWT with the user’s identifier in sub, sign with the private key.
  2. Exchange the signed JWT at Moveworks’ token endpoint for a bearer.

Python example

Install dependencies:

$pip install pyjwt cryptography httpx

Sign the assertion and exchange it for a bearer:

1import time
2import uuid
3import jwt # PyJWT
4import httpx
5
6PRIVATE_KEY = open('private.pem').read()
7ISS = 'capi-backend.acme.com' # matches the credential's Issuer field
8AUD = 'capi-backend.acme.com' # matches the credential's Audience field
9KID = '<KEY_ID_FROM_MOVEWORKS>' # from the credential creation response
10TOKEN_URL = 'https://api.moveworks.ai/oauth/v1/token'
11
12
13def sign_assertion(user_email: str, ttl_seconds: int = 300) -> str:
14 now = int(time.time())
15 claims = {
16 'iss': ISS,
17 'sub': user_email,
18 'aud': AUD,
19 'iat': now,
20 'exp': now + ttl_seconds,
21 'jti': str(uuid.uuid4()),
22 }
23 return jwt.encode(
24 claims,
25 PRIVATE_KEY,
26 algorithm='RS256',
27 headers={'kid': KID},
28 )
29
30
31def mint_bearer(user_email: str) -> str:
32 assertion = sign_assertion(user_email)
33 response = httpx.post(
34 TOKEN_URL,
35 data={
36 'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
37 'assertion': assertion,
38 },
39 timeout=15.0,
40 )
41 response.raise_for_status()
42 return response.json()['access_token']
43
44
45bearer = mint_bearer('alice@acme.com')

What each claim does

ClaimValuePurpose
issThe Issuer string configured on the Moveworks credentialIdentifies the credential this assertion belongs to.
audThe Audience string configured on the Moveworks credentialIdentifies the credential this assertion belongs to, paired with iss.
subThe user’s identifierThe user your backend is acting on behalf of. Moveworks resolves this against the user roster.
iatCurrent Unix timestampIssued-at time.
expiat + ttl_secondsExpiration. Keep short (5 minutes or less); this JWT is not the API bearer, it is exchanged for one.
jtiA random UUIDUnique assertion ID. Prevents replay.

The kid header identifies which registered public key Moveworks should use to verify the signature.

Caching the bearer

The exchange response includes expires_in (typically ~1 hour). You can cache the returned bearer per user for its lifetime and only re-mint when it expires or nears expiration. Refresh proactively (for example, at 90% of expires_in) so long-running operations do not fail mid-flight.

Step 4: Call the Conversations API

Use the bearer on any Conversations API endpoint. Every request must include the Assistant-Name header (the Bot Name provided by your Moveworks account team) and the Authorization header.

$curl -X POST "https://api.moveworks.ai/assistant/v1/conversations" \
> -H "Content-Type: application/json" \
> -H "Assistant-Name: <YOUR_BOT_NAME>" \
> -H "Authorization: Bearer <MW_ACCESS_TOKEN>" \
> -d '{"title": "Backend-initiated conversation"}'

A 201 response with a conversation_id confirms the integration works end to end. To switch to a different user on a subsequent request, sign a new assertion with a different sub and exchange it for a new bearer.

Step 5: Validate

Confirm end to end before wiring it into production:

  1. Sign an assertion for a known-good test user in your roster and decode it (for example, at jwt.io) to confirm iss, aud, sub, iat, exp, jti, and the kid header are all populated as expected.
  2. Exchange the assertion at /oauth/v1/token. A 200 response with an access_token means the signature verified and the user resolved. Common failure modes:
    • invalid_audience: your JWT’s aud does not match the credential’s Audience field.
    • invalid_signature: the kid doesn’t match a registered key, or the private key doesn’t match the registered public key.
    • Generic post-verification failure: the sub value doesn’t resolve to a user in the roster. Confirm the identifier your backend is sending in sub is what your Moveworks tenant expects for that user.
  3. Create a conversation with the returned bearer as above. A 201 with a conversation_id closes the loop.

Security

The private key is a tenant-wide credential. Any process that holds it can obtain a bearer token for any provisioned user in your Moveworks tenant, and every Conversations API call made with that bearer is recorded as an action performed by the impersonated user. Treat the private key with the same rigor as a production API key or a service-account credential.

Recommended handling:

  • Store the private key in a secret manager (AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, etc.). Do not commit it to source control, embed it in application bundles, or store it on developer laptops beyond initial setup.
  • Restrict which services can read the private key. Only the specific backend process that needs to mint bearers should have access.
  • Log every action taken on behalf of each user in your own audit trail. From Moveworks’ perspective, actions taken by your backend on behalf of a user are indistinguishable from actions that user performed themselves. Your own audit trail is the only source of truth for which system initiated the call.
  • Rotate the credential if the private key is plausibly exposed. Generate a fresh keypair, register the new public key as a new credential, cut your backend over, then delete the old credential.
  • Do not use this credential type for user-facing product integrations where end users obtain tokens directly. Use the interactive user auth guides for those flows.

Summary

Credential fieldValue
Public Key valueThe full contents of your public.pem file
AudienceA unique string you choose, matching the aud claim your backend signs
IssuerA unique string you choose, matching the iss claim your backend signs
Identifier TypeIdP
Integration IDconversations_rest_api
Identifier Claimsub
Your backendHolds the private key. Signs an RS256 JWT per user with iss, aud, sub, iat, exp, jti, and the kid header, then exchanges it for a bearer at /oauth/v1/token.

For the full API contract and interaction patterns (polling and streaming), see the Conversations API reference.