Quick start

This guide walks through the first payment, end to end: a user on your books, a source to pull from, a payout, then a hold on your ledger.

Payments are funded from deposits your user already holds with you. You host authorize, capture, and release, and Moov calls them as the payment moves. See flow of funds.

You will need a Moov API key (Dashboard → Developers → API keys), your provider ID (your Moov account ID), a public HTTPS URL Moov can POST to, and a test deposit account on your core. Every example below reads these from your shell:

export MOOV_API_KEY_ID="..."
export MOOV_API_KEY_SECRET="..."
export PROVIDER_ID="..."
export MOOV_VERSION="v2026.10.00"

Send X-Moov-Version on every call to Moov. Without it the server applies its default, which may be an older version with different shapes. See versioning.

Register ledger URLs and a signing secret #

Send the URLs you will host to your Moov integration contact; there is no API for this. Moov POSTs to each URL as-is:

URLWhen
AuthorizationURLThe user sends
CaptureURLThe recipient claims
HoldReleaseURLThe payment is canceled or expires

Add CreditURL later if you collect on requests. How to register the URLs, the JWE shape, and a decrypt/encrypt example: funding setup and encryption.

Then issue the secret that encrypts that traffic:

curl -X POST "https://api.moov.money/providers/$PROVIDER_ID/signing-secrets" \
  -u "$MOOV_API_KEY_ID:$MOOV_API_KEY_SECRET" \
  -H "X-Moov-Version: $MOOV_VERSION" \
  -H "X-Idempotency-Key: $(uuidgen)"
{
  "signingSecret": {
    "secretID": "sec_01HF5J9ZKQT4RXP8M2VYBN3D",
    "createdOn": "2026-08-31T12:00:00Z",
    "revokedOn": null,
    "secret": "c2VjcmV0LXZhbHVlLWZvci1leGFtcGxlLW9ubHk"
  }
}

Decode signingSecret.secret (base64url, no padding) to 32 raw bytes and store it. It is returned once; you cannot fetch it again.

Create signing secret. A first stub can always return approved with a holdReference you mint. Wire real balances after the round trip works.

Create a user #

Register each sender once, with your ID as externalID. Do this when they sign up, or the first time you offer them send:

curl -X POST "https://api.moov.money/providers/$PROVIDER_ID/participants" \
  -u "$MOOV_API_KEY_ID:$MOOV_API_KEY_SECRET" \
  -H "X-Moov-Version: $MOOV_VERSION" \
  -H "Content-Type: application/json" \
  -H "X-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"
    },
    "phones": [{ "countryCode": "1", "number": "5555550101" }]
  }'

address and at least one phone are required. Phones are structured: countryCode without the +, number as digits only. The 201 returns the assigned participantID. X-Idempotency-Key is required; reuse the same key when you retry the same create.

They start pending while a signup check runs, then active or denied. Only active can send. Poll rather than minting a token straight away. Every participant path accepts your externalID or Moov’s participantID:

curl "https://api.moov.money/providers/$PROVIDER_ID/participants/user-abc-123" \
  -u "$MOOV_API_KEY_ID:$MOOV_API_KEY_SECRET" \
  -H "X-Moov-Version: $MOOV_VERSION"
{
  "participantID": "3v2whagmwvbujhwbt7v3nl6ffq_user",
  "givenName": "Jane",
  "familyName": "Smith",
  "status": "active",
  ...
}

A repeat create with an externalID you’ve already registered returns 201 with the existing participantID; the stored profile is not changed. A 409 means an idempotency conflict: the key was reused with a different body, or the first request is still running. Create participant, get participant.

Add a funding source #

Tell Moov which account they can send from. providerSourceID is your ID for that account. You will see it again on authorize as fundingSource:

curl -X POST "https://api.moov.money/providers/$PROVIDER_ID/participants/user-abc-123/funding-sources" \
  -u "$MOOV_API_KEY_ID:$MOOV_API_KEY_SECRET" \
  -H "X-Moov-Version: $MOOV_VERSION" \
  -H "Content-Type: application/json" \
  --data '{
    "fundingType": "bank_account",
    "providerSourceID": "checking-1234",
    "displayName": "Premier Checking",
    "last4": "1234"
  }'

fundingType is bank_account or card. displayName and last4 are what the user sees as “sent from”. The 201 returns a fundingSourceID. Creation is idempotent on (participant, fundingType, providerSourceID), so re-sending an existing source returns the same ID, and reactivates it if you had removed it. Create funding source.

You can skip this call entirely. The create-participant body takes an optional fundingSources array (up to 10), so the previous step and this one can be one request. The 201 doesn’t echo them back; list the participant’s funding sources to confirm.

Send the first payout #

The payment happens in your app, through the SDK. There is no provider-authenticated payout endpoint. Payouts are created against the sender’s own session. Mint a sender token (clientType: device) for the signed-in user and present the iOS or Android SDK. The SDK resolves the funding sources you registered above from the token, so there is nothing further to pass in. They pick a person and an amount; the SDK creates the payout. Moov then hits your authorize URL.

The token is the only part you build. Mint it with your API key:

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

Create sender token. Hand access_token to the SDK. The SDK creates the payout against that sender’s session. You never post the payout yourself.

Your signal that it worked is the authorize call landing on your ledger, in the next step. The recipient gets a claim link good for 10 days. A payment your ledger refuses, or that the fraud check denies, ends as a denied payout with a reason the SDK surfaces to your app. See trust and safety.

Watch the ledger #

The payout is what the SDK created. The ledger is what Moov calls you.

  1. Authorize fires as the payment is created. Decrypt the JWE. Under moov you get the payout ID, externalID, fundingSource (your providerSourceID), the amount, and a fraudScore. Place a hold on that account. Return approved and a holdReference you can look up later, echoing idempotencyKey. Authorize.
  2. Capture fires when the recipient finishes the claim. Moov sends back your holdReference. Debit the hold, return approved and a captureReference. Capture.
  3. Release fires if they cancel or the 10-day window lapses. The hold comes off; nothing is captured. Release.

Inner JWT: iat and exp at the top, Moov fields under moov, and typ naming the call. A decrypted authorize, and the payload you encrypt back:

{
  "iat": 1788177600,
  "exp": 1788177900,
  "typ": "authorize",
  "moov": {
    "idempotencyKey": "...",
    "payoutID": "...",
    "providerID": "...",
    "externalID": "user-abc-123",
    "fundingSource": "checking-1234",
    "amount": {
      "value": "25.00",
      "currency": { "code": "USD", "decimalPlaces": 2, "numericCode": "840", "name": "US Dollar" }
    },
    "fraudScore": 0.12
  }
}
{
  "iat": 1788177601,
  "exp": 1788177901,
  "moov": {
    "idempotencyKey": "<echoed>",
    "outcome": "approved",
    "holdReference": "hold_7a1b2c3d"
  }
}

If your core is unreachable, return a 5xx and Moov retries. Don’t answer 200 with anything but a real decision. Encryption has the unwrap.

Outcomes, retries, and what to persist: ledger endpoints and idempotency.

A hold ends in exactly one of capture or release. If authorize never arrived, the payout did not land on your core.

Next #