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_ 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_:
transaction_ | Sent when |
|---|---|
AUTHORIZATION | A merchant asks to hold funds for a purchase. |
WITHDRAWAL | Funds 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_ 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_is sent and nothing is held.REQUEST - The balance on a JIT card is yours to hold. Moon leaves it in your ledger, so
INSUFFICIENT_is a decision you make.FUNDS
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_ 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
| Header | Sent | Meaning |
|---|---|---|
Content-Type | always | application/json |
Moon-Signature | once your endpoint has a signing secret | t=<unix seconds>,v1=<hex hmac>, over the exact body bytes |
Moon-Event-Id, Moon-Event-Type | never on JIT_ | Event deliveries carry them; a JIT request is not an event, so it carries neither. |
JIT_ 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"
}
}
}| Field | Type | Description |
|---|---|---|
id | string (UUID) | Identifies this JIT request. Log it; it is the id Moon records the exchange under. |
type | string | Always JIT_. |
transaction_ | string | AUTHORIZATION or WITHDRAWAL. |
data.id | string (UUID) | The transaction this decision is about. It is the transaction_ you will see on the resulting webhook and in the card's transaction feed, so use it to reconcile. |
data.merchant.name | string | Merchant name as presented. |
data.merchant.mcc | string | Merchant category code. Empty string when the merchant does not supply one. |
data.merchant.mid | string | Merchant id. Empty string when not supplied. |
data.currency | string | ISO 4217 code of the card's currency — the currency amount is stated in, not necessarily the currency the merchant charged. |
data.amount | number | The total to hold, in data.currency, in major units. See below. |
data.accepts_ | boolean | Whether the merchant will accept less than the full amount. Always false on WITHDRAWAL. |
data.datetime | string | Timestamp of the transaction, ISO 8601 in UTC. |
data.card.id | string (UUID) | The card. The same id you use on the card endpoints and receive as card_ 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_ 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"
}| Field | Required | Notes |
|---|---|---|
response | yes | APPROVED, PARTIAL_ or DECLINED. Any other value declines the transaction. |
partially_ | with PARTIAL_ | A positive number in data.currency, in major units. |
decline_ | with DECLINED | One of the four values below. A DECLINED response with no decline_ is not understood, and declines the transaction with no reason recorded. |
Decline reasons
These four are the reasons you may return:
decline_ | Use it when |
|---|---|
INSUFFICIENT_ | The balance you hold for this card does not cover amount. |
SPENT_ | A spending limit of your own has been hit. |
MERCHANT_ | You do not allow this card to spend where this merchant is. |
TRANSACTION_ | Anything 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_ on the resulting CARD_ webhook — 1 for INSUFFICIENT_, 6 for SPENT_, 7 for MERCHANT_, 12 for TRANSACTION_, 5 for UNKNOWN. The full table is on Webhooks.
Partial approval
Return PARTIAL_ only when the request carried accepts_. If it did not, a partial answer declines the transaction.
partially_must be a positive number. Zero, a missing value or a non-number is not understood, and declines the transaction.approved_ amount - An amount greater than
data.amountis 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_. Read the resultingapproved_ amount CARD_webhook for the amounts that were actually posted.TRANSACTION - 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.
| Situation | Outcome | Reason recorded |
|---|---|---|
You answer APPROVED | Approved | — |
You answer PARTIAL_ within the rules above | Approved for that amount | — |
You answer DECLINED with one of the four reasons | Declined | Your reason |
You answer DECLINED with any other reason, or none | Declined | UNKNOWN |
| No response within one second | Declined | UNKNOWN |
A status other than 200, or a body that is not one of the three shapes | Declined | UNKNOWN |
| Connection refused, TLS failure, DNS failure | Declined | UNKNOWN |
| No webhook URL registered for your organization | Declined, without any call being made | UNKNOWN |
| The registered URL is not a publicly reachable address | Declined, without any call being made | UNKNOWN |
Every declined authorization — including the ones you decline — produces a CARD_ webhook to the same endpoint, and every approved one produces a CARD_. 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_events that follow. Key both onTRANSACTION data.id, which is the transaction id throughout. - Fail deliberately. Decide what your endpoint does when your own database is unreachable.
TRANSACTION_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.NOT_ PERMITTED - 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
| Symptom | Where to look |
|---|---|
| No requests arrive | Confirm 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 called | No registered URL, or one that no longer resolves to a public address. Re-register it. |
| Signature never verifies | You are hashing a re-serialized body. Hash the raw bytes, prefixed with <t>.. |
| Approvals are treated as declines | Check the status code is exactly 200 and that the body is {"response": "APPROVED"} with that spelling. |
| Intermittent declines under load | Response time. Measure at your edge, not in your application. |
For anything else, contact our team.
Last updated September 4, 2026