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:
| URL | When |
|---|---|
AuthorizationURL | The user sends |
CaptureURL | The recipient claims |
HoldReleaseURL | The 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.
- Authorize fires as the payment is created. Decrypt the JWE. Under
moovyou get the payout ID,externalID,fundingSource(yourproviderSourceID), the amount, and afraudScore. Place a hold on that account. Returnapprovedand aholdReferenceyou can look up later, echoingidempotencyKey. Authorize. - Capture fires when the recipient finishes the claim. Moov sends
back your
holdReference. Debit the hold, returnapprovedand acaptureReference. Capture. - 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 #
- Overview: payments and requests, who does what.
- Sender authentication: the token you hand the SDK.
- Credit: the callback after a request is paid, if you collect that way.
- Program configuration:
set the
shortNamethat appears on the recipient’s statement. - API reference: every field.