Sender authentication

Your users never create a Moov Money login. They’re already signed into your app. Your backend mints a short-lived token for that person and hands it to the SDK.

Authenticate those calls with an API key from the Moov Dashboard (Developers → API keys), using HTTP Basic auth. That is not the same credential as the signing secret on your ledger endpoints.

CredentialWhere you get itUsed for
API keyDashboard → Developers → API keysParticipants and sender tokens
Signing secretFunding setupEncrypting callbacks to your ledger

The key needs the /accounts/{your account}/transfers.write scope. Without it, minting a token fails even if the username and password look right.

Authenticate with Basic auth #

Do not exchange the API key for a Bearer token first. Send the public/secret pair straight to api.moov.money:

Authorization: Basic base64(MOOV_API_KEY_ID:MOOV_API_KEY_SECRET)

curl -u "$MOOV_API_KEY_ID:$MOOV_API_KEY_SECRET" does the encoding. Only Basic auth works on /provider-participant-token. The usual Moov OAuth2 exchange at api.moov.io/oauth2/token does not.

Register the sender as a participant #

Register each user once, with your own id as externalID. Do this when they sign up, or when you first offer them payments and requests:

curl -X POST "https://api.moov.money/providers/$PROVIDER_ID/participants" \
  -u "$MOOV_API_KEY_ID:$MOOV_API_KEY_SECRET" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data '{
    "externalID": "user-abc-123",
    "givenName": "Jane",
    "familyName": "Smith",
    "address": {
      "addressLine1": "123 Main St",
      "city": "San Francisco",
      "stateOrProvince": "CA",
      "postalCode": "94105",
      "country": "US"
    },
    "phoneNumbers": ["+15555550101"]
  }'

address is required, and every address field except addressLine2 must be present. The 201 returns the assigned participantID.

Creation is asynchronous: the participant starts pending while a signup fraud check runs, then moves to active or denied. Only active participants can obtain a sender token, so poll GET /providers/{providerID}/participants/{participantID} (same Basic auth) rather than minting a token immediately after registering. A token request against a still-pending participant is rejected with 403.

A repeat call with an externalID you’ve already registered returns 409. Treat that as “already registered” and move on. Field-by-field: create participant.

Mint a sender token for the signed-in user #

Same Basic auth. Identify the user with the externalID you registered:

curl -X POST "https://api.moov.money/provider-participant-token" \
  -u "$MOOV_API_KEY_ID:$MOOV_API_KEY_SECRET" \
  -H "Content-Type: application/json" \
  --data '{
    "externalID": "user-abc-123",
    "clientType": "device"
  }'
{
  "access_token": "eyJhbGciOiJFZERTQSIs...",
  "token_type": "bearer",
  "expires_in": 3600
}

Hand access_token to the iOS or Android SDK. It is scoped to this one user and lasts about an hour (expires_in). Field-by-field: create sender token.

Re-mint when it expires #

Mint another token the same way. There is no refresh flow to build around: the response carries no refresh_token.

Client types #

clientType defaults to web when omitted. Set it to match the surface the token is for:

clientTypeUse for
web (default)Browser-based flow built on the API
deviceThe iOS or Android SDK
serviceServer-initiated payments or requests with no SDK presented

Errors #

StatusMeaning
400App-key request missing externalID
401Missing or invalid Basic auth credentials
403Key lacks transfers.write, or the participant isn’t active (still pending, denied, or disabled)
404externalID doesn’t match a registered participant; register it first

Responses that carry a body use RFC 9457 problem details, served as application/problem+json. Branch on type, a stable URI such as https://api.moov.money/errors#invalid-request. Treat it as an opaque constant; title and detail are prose for humans and may be reworded. Validation failures list each rejected field under errors, with a JSON Pointer in pointer:

{
  "type": "https://api.moov.money/errors#invalid-request",
  "title": "Bad Request",
  "status": 400,
  "errors": [
    { "pointer": "/externalID", "detail": "must not be empty" }
  ]
}

401, 403, and 404 have no body. Errors are intentionally generic: a bad credential and a participant that belongs to a different provider both come back as 403, so a caller probing with a stolen credential learns nothing from the status code.

Next steps #