Browse the docs

Moon JIT API Integration Guide

This guide explains how to integrate with Moon's Just-In-Time (JIT) decision API for card transaction authorization. Using JIT, you can receive real-time transaction requests and provide approval/decline decisions for card transactions.

Just-in-time (JIT) authorization hands you the approve-or-decline decision on each card purchase. Instead of spending against a balance held at Moon, the card asks your systems, in real time, whether the purchase should go through — so the authoritative balance can live in your ledger.

Moon calls your registered webhook URL with a JIT_REQUEST message and waits one second for your answer. No answer, or an answer it cannot read, declines the transaction.

When Moon asks you

Two events reach your endpoint as JIT_REQUEST:

transaction_typeSent when
AUTHORIZATIONA merchant asks to hold funds for a purchase.
WITHDRAWALFunds are pulled off the card.

Moon runs its own checks before it asks you, and declines on its own if any of them fails. By the time a JIT_REQUEST reaches you, Moon has already confirmed that:

  • the transaction currency can be converted into the card's currency at a current rate, and the amounts are well formed;
  • the merchant, the merchant category and the merchant's country are permitted for this card;
  • the card exists, is activated, and is not frozen, terminated or expired;
  • the account the card belongs to is not restricted, and authorizations are not turned off for it;
  • the per-card spend limit and any velocity controls on the card or cardholder still have room.

You do not need to repeat any of these. Decide on the context in the request.

Two behaviours are worth planning for:

  • A zero-amount authorization is approved without asking you. Merchants use these to check a card is live; no JIT_REQUEST is sent and nothing is held.
  • The balance on a JIT card is yours to hold. Moon leaves it in your ledger, so INSUFFICIENT_FUNDS is a decision you make.

1. Register your endpoint

JIT reuses webhook registration. You register one URL per organization and it receives both event notifications and JIT authorization requests.

cURL

curl --request POST \
     --url https://api.themooncard.com/v1/api-gateway/webhook \
     --header 'content-type: application/json' \
     --header 'x-api-key: YOUR_API_KEY' \
     --data '{"url": "https://example.com/moon/webhooks"}'

V2 integrations register at POST /v2/api-gateway/webhooks; the registration is the same one either way. Swap the host for https://api.themooncard.com in production. Registration requirements, the signing secret returned in the response, and how to rotate it are on Webhooks.

Because one URL carries both, branch on the body's type field: JIT_REQUEST is a request for a decision, everything else is a notification. Notifications and JIT requests also differ in their headers — see below.

2. Handle the request

Headers

HeaderSentMeaning
Content-Typealwaysapplication/json
Moon-Signatureonce your endpoint has a signing secrett=<unix seconds>,v1=<hex hmac>, over the exact body bytes
Moon-Event-Id, Moon-Event-Typenever on JIT_REQUESTEvent deliveries carry them; a JIT request is not an event, so it carries neither.

JIT_REQUEST is signed exactly the way event deliveries are. Verify it. Your endpoint URL is not a secret, and an unverified JIT endpoint will approve whatever anyone who learns the URL sends it. Verify against the raw body, before parsing, and reject a timestamp outside your tolerance window. The recipe is in Verifying webhook signatures; the scheme is described under Verifying signatures.

An endpoint with no signing secret receives unsigned requests until you rotate a secret for it.

Payload

JSON

{
  "id": "9b1a7c04-6f2e-4d8b-a35c-1e7f0d94b2a6",
  "type": "JIT_REQUEST",
  "transaction_type": "AUTHORIZATION",
  "data": {
    "id": "500539aa-d323-4fe7-9162-8d6123515ec5",
    "merchant": {
      "name": "ONLINE STORE",
      "mcc": "5942",
      "mid": "987654321"
    },
    "currency": "USD",
    "amount": 101.5,
    "accepts_partial_approval": true,
    "datetime": "2026-05-14T12:00:37.000Z",
    "card": {
      "id": "3f0c8a12-5b6d-4e7f-9a01-2b3c4d5e6f70"
    }
  }
}
FieldTypeDescription
idstring (UUID)Identifies this JIT request. Log it; it is the id Moon records the exchange under.
typestringAlways JIT_REQUEST.
transaction_typestringAUTHORIZATION or WITHDRAWAL.
data.idstring (UUID)The transaction this decision is about. It is the transaction_id you will see on the resulting webhook and in the card's transaction feed, so use it to reconcile.
data.merchant.namestringMerchant name as presented.
data.merchant.mccstringMerchant category code. Empty string when the merchant does not supply one.
data.merchant.midstringMerchant id. Empty string when not supplied.
data.currencystringISO 4217 code of the card's currency — the currency amount is stated in, not necessarily the currency the merchant charged.
data.amountnumberThe total to hold, in data.currency, in major units. See below.
data.accepts_partial_approvalbooleanWhether the merchant will accept less than the full amount. Always false on WITHDRAWAL.
data.datetimestringTimestamp of the transaction, ISO 8601 in UTC.
data.card.idstring (UUID)The card. The same id you use on the card endpoints and receive as card_public_id on webhooks.

What amount includes. On AUTHORIZATION it is not the figure the merchant presented. It is that figure converted into the card's currency, plus Moon's fees for the transaction, plus a gratuity allowance on merchant categories where a tip is commonly added after the fact (restaurants, bars, hotels, car rental, taxis, personal services). Hold and check the full amount — that is what will be debited. On WITHDRAWAL, amount is the withdrawal amount converted into the card's currency, with nothing added.

All ids in the payload are UUIDs. Amounts are JSON numbers in major units and timestamps are ISO 8601 in UTC.

3. Answer

Answer with HTTP 200 and a JSON body, within one second. A JIT_REQUEST answered with 201, 204, a redirect or an error status declines the transaction — event deliveries accept any 2xx, a JIT answer takes 200.

Approve in full

JSON

HTTP/1.1 200 OK

{
  "response": "APPROVED"
}

Approve part of it

JSON

HTTP/1.1 200 OK

{
  "response": "PARTIAL_APPROVED",
  "partially_approved_amount": 8.5
}

Decline

JSON

HTTP/1.1 200 OK

{
  "response": "DECLINED",
  "decline_reason": "INSUFFICIENT_FUNDS"
}
FieldRequiredNotes
responseyesAPPROVED, PARTIAL_APPROVED or DECLINED. Any other value declines the transaction.
partially_approved_amountwith PARTIAL_APPROVEDA positive number in data.currency, in major units.
decline_reasonwith DECLINEDOne of the four values below. A DECLINED response with no decline_reason is not understood, and declines the transaction with no reason recorded.

Decline reasons

These four are the reasons you may return:

decline_reasonUse it when
INSUFFICIENT_FUNDSThe balance you hold for this card does not cover amount.
SPENT_LIMIT_REACHEDA spending limit of your own has been hit.
MERCHANT_GEOGRAPHY_RESTRICTEDYou do not allow this card to spend where this merchant is.
TRANSACTION_NOT_PERMITTEDAnything else you disallow by policy.

Any other value still declines the transaction, but the reason is recorded as UNKNOWN and your cardholder sees a generic message. Keep to the four.

These are distinct from the decline reasons Moon itself produces. Your reason becomes the decline_type_id on the resulting CARD_DECLINE webhook — 1 for INSUFFICIENT_FUNDS, 6 for SPENT_LIMIT_REACHED, 7 for MERCHANT_GEOGRAPHY_RESTRICTED, 12 for TRANSACTION_NOT_PERMITTED, 5 for UNKNOWN. The full table is on Webhooks.

Partial approval

Return PARTIAL_APPROVED only when the request carried accepts_partial_approval: true. If it did not, a partial answer declines the transaction.

  • partially_approved_amount must be a positive number. Zero, a missing value or a non-number is not understood, and declines the transaction.
  • An amount greater than data.amount is treated as approval in full.
  • Fees and the gratuity allowance are recalculated against the amount you approved, so the final debit is not exactly partially_approved_amount. Read the resulting CARD_TRANSACTION webhook for the amounts that were actually posted.
  • Partial approval is available only on card products configured for it, and never on WITHDRAWAL.

What happens when the answer does not arrive

The JIT call fails closed: anything Moon cannot turn into a clear approval declines the transaction. A JIT request is asked once and is not retried.

SituationOutcomeReason recorded
You answer APPROVEDApproved—
You answer PARTIAL_APPROVED within the rules aboveApproved for that amount—
You answer DECLINED with one of the four reasonsDeclinedYour reason
You answer DECLINED with any other reason, or noneDeclinedUNKNOWN
No response within one secondDeclinedUNKNOWN
A status other than 200, or a body that is not one of the three shapesDeclinedUNKNOWN
Connection refused, TLS failure, DNS failureDeclinedUNKNOWN
No webhook URL registered for your organizationDeclined, without any call being madeUNKNOWN
The registered URL is not a publicly reachable addressDeclined, without any call being madeUNKNOWN

Every declined authorization — including the ones you decline — produces a CARD_DECLINE webhook to the same endpoint, and every approved one produces a CARD_TRANSACTION. Those arrive on the normal delivery path, with retries; the JIT request itself does not.

4. Test it

In Sandbox, a simulated authorization against a JIT-enabled card runs the whole path, including the call to your endpoint:

cURL

curl --request POST \
     --url https://api.themooncard.com/v1/api-gateway/card/3f0c8a12-5b6d-4e7f-9a01-2b3c4d5e6f70/transactions \
     --header 'content-type: application/json' \
     --header 'x-api-key: YOUR_API_KEY' \
     --data '{
  "transaction_type": "AUTHORIZATION",
  "transaction_amount": 100,
  "transaction_currency": "USD",
  "merchant_name": "ONLINE STORE"
}'

The call returns 200 with success: true when the authorization was approved and success: false with the reason in message when it was declined — so you can assert on your own decision from the simulating side. Reversals, clearings and refunds do not call your endpoint. See Simulating card transactions.

Worth exercising before you go live: an approval, a decline for each of the four reasons, a partial approval, a deliberate one-second timeout, and a request with a bad signature.

Building a receiver that holds up

  • Budget well under a second. The whole exchange — reaching your endpoint, your decision, and your response arriving — has to fit inside it. Keep the decision path free of anything slow: no synchronous calls to third parties, no queue round-trip, no cold start. Do your logging and bookkeeping after you have answered.
  • Reserve, then reconcile. Place a hold in your own ledger when you approve, and settle it against the CARD_TRANSACTION events that follow. Key both on data.id, which is the transaction id throughout.
  • Fail deliberately. Decide what your endpoint does when your own database is unreachable. TRANSACTION_NOT_PERMITTED is the accurate answer for "I cannot decide right now" — it records a reason, where a timeout declines with none, and it does not report a balance you have not checked.
  • Serve HTTPS, and verify the signature on every request before acting on it.
  • Expect concurrency. Two authorizations on the same card can arrive at once. Hold funds atomically in your ledger; Moon's per-card and velocity limits are checked before you are asked, but the balance is yours to serialize.
  • Watch approval rate and response time. A rising decline rate with no change on your side usually means latency, not policy.

Troubleshooting

SymptomWhere to look
No requests arriveConfirm a webhook URL is registered, that it is publicly reachable over HTTPS, and that your firewall is not filtering. A URL that fails its checks is rejected at registration with a 400.
Everything is declined and your endpoint is never calledNo registered URL, or one that no longer resolves to a public address. Re-register it.
Signature never verifiesYou are hashing a re-serialized body. Hash the raw bytes, prefixed with <t>..
Approvals are treated as declinesCheck the status code is exactly 200 and that the body is {"response": "APPROVED"} with that spelling.
Intermittent declines under loadResponse time. Measure at your edge, not in your application.

For anything else, contact our team.

Last updated September 4, 2026