Browse the docs

Quickstart (V2)

Issue and fund a card end to end with the V2 API.

V2 models the things V1 keeps implicit: your organization, the accounts that hold balances, and cardholders as records you create and verify. A card is issued to a cardholder, funded by an account, and both must exist before you issue it.

This walkthrough goes from an empty organization to a card with a simulated purchase on it. If you only need a card keyed by your own customer id, Issuing your first card does that in three requests with V1.

Every request carries your API key in the x-api-key header. See Authentication and Environments.

1. Find your organization

Everything in V2 hangs off an organization. Yours already exists — read it rather than creating one.

cURL

curl --request GET \
     --url https://api.themooncard.com/v2/api-gateway/organizations/main \
     --header 'accept: application/json' \
     --header 'x-api-key: YOUR_API_KEY'

JSON

{
  "id": "4f0f1c02-1b1a-4f1d-9a0e-2c9b7f4a8d31",
  "name": "Acme Payments",
  "parent_organization_id": null,
  "created_at": "2026-01-14T09:22:04Z",
  "updated_at": "2026-08-16T11:03:47Z"
}

parent_organization_id is null on your top-level organization. Store the id.

If you separate customers, brands or regions, create child organizations with POST /organizations (body: name, and parent_organization_id to nest deeper than one level) and list them with GET /organizations/{id}/children. Children are ordinary organizations: accounts, cardholders and cards all belong to exactly one.

StatusWhat happened
401Missing or invalid x-api-key.
404ORGANIZATION_NOT_FOUND — the key has no top-level organization yet. Contact support.

Reference: Get main organization, Get child organizations.

2. Create an account

An account holds a balance in one currency, and cards issued against it spend from that balance. One account per currency is enough to start; use more when you want spending segregated.

cURL

curl --request POST \
     --url https://api.themooncard.com/v2/api-gateway/accounts \
     --header 'accept: application/json' \
     --header 'content-type: application/json' \
     --header 'x-api-key: YOUR_API_KEY' \
     --data '{
        "organization_id": "4f0f1c02-1b1a-4f1d-9a0e-2c9b7f4a8d31",
        "name": "Card funding — USD",
        "account_currency": "USD"
      }'

JSON

{
  "id": "6c1c9a37-9c1b-4f6c-9e79-8f8a2f0d5f31",
  "name": "Card funding — USD",
  "organization_id": "4f0f1c02-1b1a-4f1d-9a0e-2c9b7f4a8d31",
  "ledger_account_id": "a1d0f7f2-32c7-4a5d-b1e9-5c31f0a0be44",
  "balance": 0,
  "available_balance": 0,
  "currency": "USD",
  "created_at": "2026-08-16T17:44:10Z",
  "updated_at": null
}

Every field of the body is optional: omit organization_id to use your top-level organization, and account_currency to get USD (the other accepted value is MXN). A new account starts at zero — you fund it in step 5.

StatusWhat happened
400Validation failed — errors[] names the offending field.
401Missing or invalid x-api-key.
404organization_id does not exist or is not one of yours.

Reference: Create account, List accounts.

3. Create a cardholder

A cardholder is the person the card belongs to. external_id is your own identifier for them — the same value V1 calls external_id — and must be unique inside the organization.

cURL

curl --request POST \
     --url https://api.themooncard.com/v2/api-gateway/cardholders \
     --header 'accept: application/json' \
     --header 'content-type: application/json' \
     --header 'x-api-key: YOUR_API_KEY' \
     --data '{
        "external_id": "customer_123",
        "email": "ada@example.com",
        "organization_id": "4f0f1c02-1b1a-4f1d-9a0e-2c9b7f4a8d31"
      }'

JSON

{
  "id": "3f5b1c2e-8d4a-4b6f-9e2a-1c7d8e9f0a1b",
  "external_id": "customer_123",
  "organization_id": "4f0f1c02-1b1a-4f1d-9a0e-2c9b7f4a8d31",
  "email": "ada@example.com",
  "delegated_kyc_completed_at": null,
  "banned_at": null,
  "auth_enabled_at": null,
  "last_login_at": null,
  "created_at": "2026-08-16T17:46:02Z",
  "updated_at": null
}

id is the Moon cardholder id. Every V2 cardholder route takes that UUID in the path, not your external_id. Keep both: external_id is what you look the cardholder up by later with GET /cardholders?email= or GET /cards?external_id=.

StatusWhat happened
400external_id missing or empty, or email is not a valid address.
401Missing or invalid x-api-key.
404organization_id is not visible to your key.
409EXTERNAL_ID_ALREADY_EXISTS — a cardholder with this external_id or email is already in the organization. Fetch it with GET /cardholders?email=… instead of retrying.

Reference: Create cardholder, List cardholders.

4. Verify the cardholder

If your organization requires cardholder identity verification, the cardholder must be approved before a card can be issued to them. Verification is hosted: you request a link, the cardholder completes the flow in their browser, and you read the resulting status.

cURL

curl --request POST \
     --url https://api.themooncard.com/v2/api-gateway/cardholders/3f5b1c2e-8d4a-4b6f-9e2a-1c7d8e9f0a1b/kyc-link \
     --header 'accept: application/json' \
     --header 'x-api-key: YOUR_API_KEY'

JSON

{
  "url": "https://verification.example/2f1a6b3c4d5e4f608a7b9c0d1e2f3a4b",
  "status": "NOT_STARTED"
}

This route returns 200, not 201, and it takes no body. Send the cardholder to url exactly as returned — the host belongs to the hosted verification flow and is not part of your integration. Requesting a link again reuses the existing one while it is still valid, so it is safe to call on every page load of your own flow.

url comes back null when the cardholder is already APPROVED, IN_REVIEW or DECLINED, since those states have no link to open. Read status for where the cardholder stands.

Poll the status, or wait for the webhook:

cURL

curl --request GET \
     --url https://api.themooncard.com/v2/api-gateway/cardholders/3f5b1c2e-8d4a-4b6f-9e2a-1c7d8e9f0a1b/kyc \
     --header 'accept: application/json' \
     --header 'x-api-key: YOUR_API_KEY'

JSON

{
  "status": "APPROVED",
  "url": "https://verification.example/2f1a6b3c4d5e4f608a7b9c0d1e2f3a4b",
  "created_at": "2026-08-16T17:48:31Z",
  "updated_at": "2026-08-16T18:02:15Z"
}

status is one of NOT_STARTED, IN_REVIEW, APPROVED, DECLINED or RETRY. RETRY means the cardholder can start again — request a new link. Prefer the webhook to polling; see Webhooks.

StatusWhat happened
403Verification is temporarily unavailable. Retry later.
404On kyc-link: the cardholder does not exist or is not yours. On GET …/kyc: that, or verification was never started for this cardholder — request a link first.

If you collect identity documents yourself, POST /cardholders/{id}/verification-documents uploads them and POST /cardholders/{id}/check asks for a review. Send one document per request as multipart/form-data under the file field: JPEG, PNG, WebP, HEIC, HEIF or PDF, up to 10 MB. Anything else is rejected with a 400 naming the accepted types.

Reference: Create cardholder KYC link, Get cardholder KYC status by cardholder id.

5. Issue the card

POST /cards takes three ids, and all three are required — omitting cardholder_id or account_id is a 400.

FieldRequiredDescription
cardholder_idyesThe Moon cardholder id from step 3. Must belong to the same organization as account_id.
account_idyesThe account that funds the card, from step 2.
card_product_idyesThe product to issue from.
card_typenoVIRTUAL (default) or PHYSICAL. PHYSICAL requires physical cards to be enabled for your organization.

Card products are shared by both versions and listed from the V1 endpoint; the ids are the same:

cURL

curl --request GET \
     --url 'https://api.themooncard.com/v1/api-gateway/card/card-products?current_page=1&per_page=10' \
     --header 'accept: application/json' \
     --header 'x-api-key: YOUR_API_KEY'

Then issue:

cURL

curl --request POST \
     --url https://api.themooncard.com/v2/api-gateway/cards \
     --header 'accept: application/json' \
     --header 'content-type: application/json' \
     --header 'x-api-key: YOUR_API_KEY' \
     --data '{
  "cardholder_id": "3f5b1c2e-8d4a-4b6f-9e2a-1c7d8e9f0a1b",
  "account_id": "6c1c9a37-9c1b-4f6c-9e79-8f8a2f0d5f31",
  "card_product_id": "8f1c611d-098d-4f61-b106-f7b6d344b1ae",
  "card_type": "VIRTUAL"
}'

The response is the card in detail form — the same shape GET /cards/{card_id} returns. It carries the full pan and cvv: treat them as sensitive. card_type is the V2 addition to the card object and reads back lowercase ("virtual", "physical"), or null when the issuer reports no form factor.

A V2 card spends from account_id, so you fund the account and the card draws on it. balance on the card object reflects what has been allocated to the card, and starts at zero.

StatuscodeWhen
400— (errors[])cardholder_id or account_id missing, or an id that is not a UUID.
400CARD_PRODUCT_NOT_SUPPORTEDThe product is not enabled for your organization.
400USER_NOT_ALLOWED_PHYSICAL_CARDcard_type: "PHYSICAL" and physical cards are not enabled for your organization.
400CARDHOLDER_KYC_NOT_APPROVEDYour organization requires verification and this cardholder is not approved. Back to step 4.
401UNAUTHORIZEDMissing or invalid x-api-key.
403—A card-issuance limit, or the lifetime card cap for this product, would be exceeded.
404ACCOUNT_NOT_FOUND, CARDHOLDER_NOT_FOUND, CARD_NOT_FOUNDAn id is unknown or belongs to another organization.

Reference: Create card, Get card.

6. Fund the account

Omit account_id to credit your organization's prepaid balance instead of a specific account.

StatusWhat happened
401Missing or invalid x-api-key.

An empty body pays the full amount owed. In production this route returns 403 DEV_ONLY_ENDPOINT.

Confirm with GET /accounts/{id} — balance and available_balance should now be 100.

7. Simulate a transaction

With the account funded, run a purchase through the card. The simulation follows the same processing path as a real transaction, including webhooks.

cURL

curl --request POST \
     --url https://api.themooncard.com/v2/api-gateway/simulate/cards/8d2cd518-5ef2-4280-91e8-63b1a0e672e7/transactions \
     --header 'accept: application/json' \
     --header 'content-type: application/json' \
     --header 'x-api-key: YOUR_API_KEY' \
     --data '{
        "transaction_amount": 12.34,
        "transaction_currency": "USD",
        "transaction_type": "AUTHORIZATION",
        "merchant_name": "Acme Coffee",
        "merchant_country_code": "US"
      }'

JSON

{
  "success": true,
  "message": "Transaction simulated successfully",
  "transaction_id": "2f1a6b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b"
}

transaction_type is AUTHORIZATION (place a hold), CLEARING (settle one), REVERSAL (release one), REFUND (credit back) or WITHDRAWAL. CLEARING, REVERSAL and REFUND need original_transaction_id set to the transaction_id of the earlier event.

A declined simulation is still 200, with success: false and the reason in message. Branch on success, not on the status code.

StatusWhat happened
403DEV_ONLY_ENDPOINT — you called production. Simulation is Sandbox-only.
404CARD_NOT_FOUND — the card is unknown or belongs to another organization.
503EXCHANGE_RATE_UNAVAILABLE — no rate for transaction_currency right now.

Read the result back with GET /cards/{card_id}/transactions, which pages with current_page and per_page.

Reference: Simulate card transaction, Simulating card transactions, Fetching card transactions.

The same flow in V1

If you already run V1, this is where each step lands. Paths are relative to each version's base URL.

StepV2V1 equivalent
OrganizationGET /organizations/main, POST /organizations, GET /organizations/{id}/childrenImplicit — your API key resolves to one organization.
AccountPOST /accounts, GET /accountsImplicit — a single prepaid balance, read with GET /moon-reserve.
CardholderPOST /cardholders with external_idImplicit — pass external_id on create card and the cardholder is created if it does not exist.
VerificationPOST /cardholders/{id}/kyc-link, GET /cardholders/{id}/kycPOST /cardholder/kyc, POST /cardholder/{end_customer_id}/whitelabel-kyc, GET /cardholder/{end_customer_id}/kyc
Issue a cardPOST /cards with cardholder_id + account_id + card_product_idPOST /card/{card_product_id} with external_id, and optionally amount to load it at issuance
Card products— (use the V1 route)GET /card/card-products
Simulate a transactionPOST /simulate/cards/{card_id}/transactionsPOST /card/{card_id}/transactions

The two versions share one set of records: a card issued through V1 is readable through GET /v2/api-gateway/cards, and the cardholder V1 created implicitly is the one V2 returns, with your id in external_id. See API versions for the full map, and Issuing your first card for the V1 walkthrough.

Last updated August 17, 2026