Browse the docs

Webhooks

Moon offers webhook notifications to keep you informed of important events related to your card transactions, declined transactions, and credit fund updates. Each webhook payload provides detailed information about the event, allowing you to take specific actions or update your records.

Webhooks tell your systems what happened on your cards and balances without you polling for it. You register one endpoint per organization; Moon POSTs a signed JSON event to it every time something happens.

Event index

typeSent whenKey fields
CARD_TRANSACTIONA card purchase is authorized, cleared, reversed, or withdrawn against. One event per phase.transaction_id, transaction_status, amount, fees
CARD_AUTHORIZATION_REFUNDA card purchase is refunded.Same shape as CARD_TRANSACTION
CARD_DECLINEAn authorization is declined.decline_type_id, customer_friendly_description, card_public_id, fees
KYC_UPDATEA cardholder's identity-verification status changes.cardholder_id, external_id, status
THREE_DS_OTPA one-time password is generated for a 3DS challenge.otp, otp_expiration_date, card_public_id
THREE_DS_AUTHENTICATION_RESULTA 3DS authentication challenge reaches its final outcome.status, acs_transaction_id, card_public_id
VELOCITY_CONTROL_WATCHEDAn authorization breached a velocity control running in WATCH mode. Nothing was declined.velocity_control_id, limit, observed, reason
PURCHASE_INTENT_REQUESTEDA purchase on an ask-first card was sent to its approver — the cardholder, or the account that owns the card.purchase_intent_id, status, kind, amount, expires_at
PURCHASE_INTENT_APPROVEDThe approver approved it.Same shape as PURCHASE_INTENT_REQUESTED
PURCHASE_INTENT_DENIEDThe approver denied it.Same shape
PURCHASE_INTENT_EXPIREDNobody answered in time, or an approval's window closed unused.Same shape
PURCHASE_INTENT_CANCELLEDThe intent was ended: you withdrew or closed it, the approver stopped it, or Moon ended a duplicate approval after an identical one was spent.Same shape, with cancelled_by and cancel_reason

All of these arrive on both API versions and are registered the same way in each. There is no per-event subscription — one URL receives every type — so branch on type and ignore what you do not recognise. New types are added without a version change; see Versioning.

VELOCITY_CONTROL_WATCHED and the five PURCHASE_INTENT_* events are self-gating: only a velocity control you put in WATCH mode, or a card you put in ask-first mode can produce one. If you use none of these features, you will never receive them.

A separate message, JIT_REQUEST, is delivered to the same URL when just-in-time authorization is enabled for your organization. It is a request for a decision rather than a notification: it is signed the same way, but carries no webhook_event_id, is not retried, and expects your approval or decline within one second. Branch on type so your handler routes it separately. See the Moon JIT API integration guide.

Delivery contract

The request your endpoint receives

An HTTP POST with a JSON body, and these headers:

HeaderAlways sentMeaning
Content-Typeyesapplication/json
Moon-Signatureonce the endpoint has a signing secrett=<unix seconds>,v1=<hex hmac>. See Verifying signatures.
Moon-Event-Idon event deliveriesThe same value as webhook_event_id in the body. Stable across retries.
Moon-Event-Typeon event deliveriesThe same value as type in the body.

Moon-Event-Id and Moon-Event-Type exist so you can deduplicate and route before parsing or verifying anything. They are not covered by the signature — they duplicate what is in the body, and the body is the only trustworthy source. Use the headers to decide whether to bother reading a delivery; use the verified body to decide what to do about it. JIT_REQUEST calls carry neither.

Do not depend on headers other than these, and do not authenticate deliveries by source address — verify the signature.

What counts as success

Any 2xx status. Anything else — 3xx, 4xx, 5xx, a connection failure, or no response within the timeout — counts as a failed attempt.

Timeout and retries

Per-attempt timeout10 seconds
AttemptsUp to 9, including the first
Total windowAbout 46 hours from the first attempt
After the last failureThe event is marked failed and is not delivered again automatically. It stays readable through Events.

The delay before each retry, in order:

RetrySent after
1st1 minute
2nd5 minutes
3rd15 minutes
4th1 hour
5th3 hours
6th6 hours
7th12 hours
8th24 hours

The early steps are short so an ordinary redeploy on your side costs nothing, and the tail is long so an outage found the next morning is still inside the window. You can read the schedule per event rather than tracking it yourself: delivery.next_attempt_at on Events is the moment the next attempt is due, and it is null once the event is settled.

Answer with 2xx as soon as you have durably recorded the delivery, and do the work afterwards. An endpoint that finishes its own processing before responding will start timing out under load, and a timeout produces a retry, not a lost event.

Ordering

There is no ordering guarantee. Deliveries are processed concurrently and retried independently, so a later event can arrive before an earlier one for the same card or transaction. Do not infer sequence from arrival order:

  • Group card events by transaction_id.
  • Order them by sequence (see below), falling back to datetime_iso when it is absent.
  • Treat the newest event you have seen as the current state, rather than applying deltas in arrival order.

CARD_TRANSACTION and CARD_AUTHORIZATION_REFUND carry three optional fields that make the order recoverable without guessing:

FieldMeaning
lifecycle_stageAUTHORIZATION, CLEARING, REVERSAL, WITHDRAWAL or REFUND — which step this event reports.
sequenceMonotonically non-decreasing per transaction_id. Sort on this to reconstruct the real order.
occurred_atWhen the step landed in the ledger, 2026-05-14T12:00:37Z.

All three are optional and are omitted rather than sent as null, so a consumer that ignores them sees exactly the payload it saw before. sequence is not gap-free and does not start at 1 — it is an ordering key, not a counter, and equal values mean nothing changed in the ledger between the two events, so either order is correct. Neither occurred_at nor datetime_iso is an ordering key.

Duplicates

Retries reuse the same webhook_event_id, and a delivery your endpoint processed but failed to acknowledge in time will arrive again. Record webhook_event_id (or the Moon-Event-Id header) and make your handler idempotent.

***

Registering a webhook

You may have one active webhook URL per organization at a time. Registering again replaces the previous one and issues a new signing secret.

All of these authenticate with your x-api-key.

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"}'

JSON

HTTP/1.1 201 Created

{
  "message": "Webhook created successfully",
  "signing_secret": "whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
}

Requirements for the URL. It must be a publicly reachable http(s) endpoint — use HTTPS in production, since the payloads carry transaction detail and, on THREE_DS_OTP, a one-time password. Private, loopback, link-local and reserved addresses are rejected, as are URLs that embed credentials (https://user:pass@host/). At registration the URL is probed with a POST; it must not answer with a 5xx. A URL that fails these checks returns 400 with WEBHOOK_URL_NOT_ALLOWED or ERROR_SENDING_POST_REQUEST.

Reading it back. V2 only: GET /v2/api-gateway/webhooks returns { "webhook": { "url": "https://example.com/moon/webhooks" } }, or { "webhook": { "url": null } } when none is registered — 200 either way, never 404. The signing secret is never returned here.

Deleting. DELETE returns 204 with an empty body, and is idempotent: deleting when nothing is registered also returns 204.

Register a separate URL in each environment; a Sandbox registration is invisible to production and vice versa. See Environments.

***

Verifying signatures

Webhook deliveries are signed with an HMAC so your endpoint can prove a request came from Moon. Your endpoint URL is not a secret — it can leak through logs, proxies and error trackers — so without a signature check, anyone who learns the URL can post a convincing payload to it. If you act on events automatically (crediting a ledger on MOON_CREDIT_FUNDS_CREDITED, for example), verify the signature before acting.

The header

Signed deliveries carry a Moon-Signature header:

Moon-Signature: t=1786838985,v1=8f2a1c...64 hex characters...9e
Content-Type: application/json
ComponentMeaning
tUnix timestamp (seconds) at the moment the delivery was signed
v1Lowercase hex HMAC-SHA256 digest, 64 characters

v1 is a scheme version. If we introduce a v2= digest later, it will be sent alongside v1= so you can migrate without a hard cutover — parse the header by key rather than by position.

What is signed

The HMAC input is not the request body alone. It is the timestamp, a literal ., then the exact body bytes:

1786838985.{"type":"CARD_TRANSACTION","webhook_event_id":"...","data":{...}}

Two consequences worth knowing before you implement:

  1. Verify against the raw request body, before any JSON parsing. Parsing and re-serializing can change key order and number formatting, which changes the bytes and the digest with them.
  2. The timestamp is covered by the signature, so it cannot be altered without invalidating it. Reject deliveries whose t is outside a tolerance window — we recommend 300 seconds — so a captured delivery cannot be replayed indefinitely.

Verification steps

  1. Read the raw request body as a string or bytes.
  2. Parse Moon-Signature into its t and v1 components.
  3. Reject the request if t is more than 300 seconds away from your current time, in either direction.
  4. Compute HMAC-SHA256(secret, "<t>.<raw body>") and hex-encode it.
  5. Only after that, parse the JSON and act on the event. Deduplicate on webhook_event_id, since deliveries are retried.

Full copy-paste examples in Node.js, Python and shell are in the Verifying webhook signatures guide.

Getting your signing secret

The secret is returned once, by the call that creates it. Store it when you receive it, and rotate for a new one if you lose it.

What you wantCall
Register a webhook and get a secretPOST /v1/api-gateway/webhook or POST /v2/api-gateway/webhooks — the 201 response includes signing_secret
Replace a secret (leak, or an endpoint registered before signing existed)POST /v1/api-gateway/webhook/signing-secret/rotate or POST /v2/api-gateway/webhooks/signing-secret/rotate

Secrets look like whsec_ followed by 43 URL-safe characters. Store the value somewhere your webhook handler can read it, the same way you would an API key, and never commit it.

Rotation takes effect on the next delivery and there is no grace period during which the previous secret still validates. Deploy the new secret to your handler first, then rotate. Rotating without a webhook registered returns 404.

What changes for existing integrations

  • An endpoint that has no signing secret receives unsigned deliveries, byte for byte identical to what it receives today. Nothing breaks and no action is required.
  • To start verifying on such an endpoint, call the rotate endpoint to issue its first secret.
  • Re-registering issues a new secret. POST to the registration endpoint replaces your webhook: the previous registration is retired and the new one gets a fresh secret, returned in that response. If you re-register as part of a deploy or a scheduled job, capture the signing_secret from every such call, or your handler will be validating against a superseded secret.
  • If you are not verifying yet, an unrecognised Moon-Signature header is safe to ignore.

***

Payload conventions

Every webhook is delivered as an HTTP POST with a JSON body. The root object always includes:

FieldDescription
typeEvent type (see the index)
webhook_event_idUnique id for this delivery (UUID)
dataEvent-specific payload

Timestamp formats

Timestamps are UTC. Each field carries the format below:

FieldEventFormat
datetime_isoCARD_TRANSACTION, CARD_AUTHORIZATION_REFUND2026-05-14T12:00:37Z
datetimeCARD_TRANSACTION, CARD_AUTHORIZATION_REFUND2026-05-14 12:00:37. Listed in data.deprecated_fields; prefer datetime_iso.
created_atMOON_CREDIT_FUNDS_CREDITED2026-05-14T12:00:37.000Z
created_atCARD_DECLINE2026-05-14 12:00:37
updated_at, otp_expiration_dateKYC_UPDATE, THREE_DS_OTP2026-05-14T12:00:37.000Z
occurred_atTHREE_DS_AUTHENTICATION_RESULT, VELOCITY_CONTROL_WATCHED, PURCHASE_INTENT_*, and card events that carry a lifecycle stage2026-05-14T12:00:37Z
expires_atPURCHASE_INTENT_*2026-05-14T12:15:37Z

Key on datetime_iso in a webhook and on datetime_iso in the card transaction feed, and the two describe the same instant.

deprecated_fields

CARD_TRANSACTION, CARD_AUTHORIZATION_REFUND, their fees entries and MOON_CREDIT_FUNDS_CREDITED carry a deprecated_fields array. It names the fields in that same object that have a preferred replacement — on card events it is ["datetime"], and elsewhere it is empty. The listed fields are still sent and still correct; the array simply tells your integration what to migrate to next. It appears on those four objects.

Card transaction and refund events (CARD_TRANSACTION, CARD_AUTHORIZATION_REFUND)

These events share the same data shape. They are sent after authorization, clearing, reversal, refund, withdrawal, or withdrawal reversal.

Amounts

  • amount and amount_in_transaction_currency are the transaction principal excluding fees, rounded for display.
  • amount_fees_in_ledger_currency and amount_fees_in_transaction_currency are the total fees for this event in each currency.
  • Fields ending in _no_rounding contain the full-precision string values from the ledger (use these when you need exact arithmetic).

Fees

  • fees is a breakdown array. Each item is one fee line (type is always TRANSACTION_FEE).
  • Invariant: sum(fees[].amount) === amount_fees_in_ledger_currency
  • Each fees[].amount is rounded half-up to the ledger currency decimal places. The fee totals are the sum of those rounded lines, not a separate calculation.
  • fees is empty when the rounded fee total is zero (for example, very small refunds where proportional fees are below one cent).
  • On refunds, fee amounts are positive magnitudes and reflect fee credits posted on this refund, not fees from the original purchase.

Currencies

  • ledger_currency and transaction_currency are ISO 4217 currency codes (example: USD, BRL), not numeric currency ids.

Card metadata

  • data.card.metadata carries your own key-value data for the card, so a transaction can be matched to your order without a second API call. See Metadata.
  • The field is always present and always an object. It is {} when the card has none, and it is never null or absent — including for cards issued before the field existed. On the rare degraded payload it is also {}, because the card row was not read.

Status

  • transaction_status is SETTLED or PENDING, based on the remaining pending ledger balance for the transaction.

Merchant and transaction detail (optional)

These fields are additive and optional: each appears only when the issuer provides the underlying data, and is omitted otherwise, so existing consumers are unaffected. They are also included on CARD_DECLINE.

FieldTypeNotes
merchant_descriptorstringRaw merchant descriptor as received from the issuer.
midstringMerchant id (a.k.a. establishment code).
acquirer_idstringAcquirer id/reference, when the issuer distinguishes it from the MID.
addressobjectMerchant address { postal_code?, city?, country? }. Granularity varies by issuer.
is_recurringbooleanWhether the charge is a recurring/subscription transaction.
transaction_typestringTransaction type, e.g. POS (card present) or CNP (card not present).

***

Event reference

1. Card transaction event

Sent at each phase of a card transaction's lifecycle — authorization, clearing, reversal, and withdrawal. The same transaction emits multiple CARD_TRANSACTION events (all sharing one transaction_id); see How the transaction lifecycle maps to events below. Refunds are delivered as a separate CARD_AUTHORIZATION_REFUND event.

Event name: CARD_TRANSACTION

Example payload

JSON

{
  "type": "CARD_TRANSACTION",
  "webhook_event_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "data": {
    "id": "500539aa-d323-4fe7-9162-8d6123515ec5",
    "card": {
      "public_id": "3f0c8a12-5b6d-4e7f-9a01-2b3c4d5e6f70",
      "name": "Marketing spend",
      "type": "Moon 1X Card"
    },
    "transaction_id": "500539aa-d323-4fe7-9162-8d6123515ec5",
    "transaction_status": "SETTLED",
    "datetime_iso": "2026-05-14T12:00:37Z",
    "datetime": "2026-05-14 12:00:37",
    "merchant": "Starbucks",
    "merchant_mcc": "5814",
    "merchant_mid": "mid_123",
    "merchant_descriptor": "STARBUCKS STORE #1234",
    "mid": "mid_123",
    "acquirer_id": "acq_789",
    "address": { "postal_code": "98101", "city": "Seattle", "country": "US" },
    "is_recurring": false,
    "transaction_type": "CNP",
    "amount": 14.85,
    "amount_no_rounding": "14.85",
    "amount_fees_in_ledger_currency": 0.15,
    "amount_fees_in_ledger_currency_no_rounding": "0.1485",
    "amount_in_transaction_currency": 85,
    "amount_in_transaction_currency_no_rounding": "85",
    "amount_fees_in_transaction_currency": 0.85,
    "amount_fees_in_transaction_currency_no_rounding": "0.85",
    "fees": [
      {
        "type": "TRANSACTION_FEE",
        "amount": 0.15,
        "amount_no_rounding": "0.1485",
        "fee_amount_in_transaction_currency": 0.85,
        "fee_description": "A 1.00% fee (minimum of $0.00) is charged on all transactions",
        "deprecated_fields": []
      }
    ],
    "transaction_currency": "BRL",
    "ledger_currency": "USD",
    "deprecated_fields": ["datetime"]
  }
}

How the transaction lifecycle maps to events

A single purchase produces several CARD_TRANSACTION events over its lifetime, one per phase. They all carry the same transaction_id (equal to data.id) — think of each event as a fresh snapshot of that transaction, not a separate transaction. Refunds are the exception: they arrive as a CARD_AUTHORIZATION_REFUND event (still with the original transaction_id).

PhaseEvent typeTypical transaction_status
Authorization approvedCARD_TRANSACTIONPENDING (a hold is placed)
Authorization declinedCARD_DECLINE— (no CARD_TRANSACTION is sent)
Reversal (partial)CARD_TRANSACTIONPENDING (hold reduced)
Reversal (full)CARD_TRANSACTIONSETTLED (hold released, transaction effectively cancelled)
Clearing (partial)CARD_TRANSACTIONPENDING (uncleared amount still held)
Clearing (full)CARD_TRANSACTIONSETTLED
WithdrawalCARD_TRANSACTIONSETTLED (settles immediately)
RefundCARD_AUTHORIZATION_REFUNDSETTLED

What transaction_status means. It is derived from the transaction's remaining pending ledger balance, not from the phase name:

  • PENDING — the transaction still has an outstanding authorization hold (fully or partially authorized but not yet cleared or reversed).
  • SETTLED — no pending balance remains (fully cleared, fully reversed, withdrawn, or refunded).

So the status you receive depends on the cumulative state of the transaction at that moment — a $100 authorization (PENDING) followed by an $80 clearing and a $20 reversal ends SETTLED.

Reading amount across events. amount (and amount_in_transaction_currency) is the transaction principal excluding fees as reflected at that event. Because every event for a transaction shares its transaction_id, the recommended pattern is:

  1. Key on transaction_id to group all events for one purchase.
  2. Deduplicate on webhook_event_id (deliveries can be retried).
  3. Treat the latest event (by datetime_iso) as the current state of the transaction, and diff against the previous event for the same transaction_id if you need per-phase deltas.

Example webhook sequence for a $100 purchase that is partially reversed, cleared, then partially refunded (the flow you can reproduce with simulated transactions):

StepYou doWebhook receivedtransaction_status
1Authorize $100CARD_TRANSACTIONPENDING
2Reverse $20CARD_TRANSACTIONPENDING
3Clear $80CARD_TRANSACTIONSETTLED
4Refund $30CARD_AUTHORIZATION_REFUNDSETTLED

2. Card authorization refund event

Triggered when a card refund is processed.

Event name: CARD_AUTHORIZATION_REFUND

Uses the same data fields as CARD_TRANSACTION. Refund principal and fee amounts are positive magnitudes. When no fee is refunded (or the proportional fee rounds to zero), expect amount_fees_in_ledger_currency of 0 and fees as an empty array.

Example payload

JSON

{
  "type": "CARD_AUTHORIZATION_REFUND",
  "webhook_event_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
  "data": {
    "id": "500539aa-d323-4fe7-9162-8d6123515ec5",
    "card": {
      "public_id": "3f0c8a12-5b6d-4e7f-9a01-2b3c4d5e6f70",
      "name": "Marketing spend",
      "type": "Moon 1X Card"
    },
    "transaction_id": "500539aa-d323-4fe7-9162-8d6123515ec5",
    "transaction_status": "SETTLED",
    "datetime_iso": "2026-05-14T12:45:02Z",
    "datetime": "2026-05-14 12:45:02",
    "merchant": "Starbucks",
    "merchant_mcc": "5814",
    "merchant_mid": "mid_123",
    "amount": 0.01,
    "amount_no_rounding": "0.01",
    "amount_fees_in_ledger_currency": 0,
    "amount_fees_in_ledger_currency_no_rounding": "0.0003",
    "amount_in_transaction_currency": 0.05,
    "amount_in_transaction_currency_no_rounding": "0.05",
    "amount_fees_in_transaction_currency": 0,
    "amount_fees_in_transaction_currency_no_rounding": "0.0015",
    "fees": [],
    "transaction_currency": "BRL",
    "ledger_currency": "USD",
    "deprecated_fields": ["datetime"]
  }
}

The example above shows a small refund where fees were credited in the ledger (amount_fees_in_ledger_currency_no_rounding) but round to zero in the display fields and fees array.

3. Decline transaction event

Triggered when a card authorization is declined, except for declines attributed to BIN-attack detection.

Event name: CARD_DECLINE

Fields

  • customer_friendly_description — a human-readable decline reason, specific to the decline type.
  • decline_type_id — the numeric id of the decline type, so you can branch programmatically. May be null if the decline type could not be resolved. See the table below.
  • The fee breakdown fields — amount_no_rounding, amount_fees_in_ledger_currency(_no_rounding), amount_in_transaction_currency(_no_rounding), amount_fees_in_transaction_currency(_no_rounding), fees, transaction_currency and ledger_currency — arrive as a group on most declines, carrying the same names and the same meanings as on CARD_TRANSACTION, so one renderer handles both. amount still excludes fees: the total the cardholder attempted is amount + amount_fees_in_ledger_currency. The invariant sum(fees[].amount) === amount_fees_in_ledger_currency holds here too; each line is cut to its currency's decimal places and the matching _no_rounding string carries the exact value. The group is omitted in full — never sent as zeros when the authorization was declined because the currency or the converted amount could not be determined, since there is then nothing to price. Treat fees on a decline as a quote, not a charge: a decline posts nothing to the ledger.
  • The optional merchant and transaction detail fields described above (merchant_descriptor, mid, acquirer_id, address, is_recurring, transaction_type) may also be present.

Decline types

Branch on decline_type_id. customer_friendly_description is the text to show a person; its wording can change, the id does not.

idNamecustomer_friendly_descriptionWhat it means
1INSUFFICIENT_FUNDSInsufficient FundsThe card balance does not cover the amount.
2CARD_EXPIREDCard ExpiredThe card is past its expiration date.
3INVALID_TRANSACTIONInvalid TransactionThe authorization could not be processed as presented.
4INTERNAL_ERRORInternal ErrorProcessing failed on our side.
5UNKNOWNUnknownNo more specific reason could be determined.
6SPENT_LIMIT_REACHEDSpent limit reachedA spending control on the cardholder or card was hit.
7MERCHANT_GEOGRAPHY_RESTRICTEDMerchant Geography RestrictedThe merchant's country is not permitted for this card.
8CARD_LIMIT_REACHEDCard Limit ReachedA per-card limit was hit.
9INVALID_EXP_DATEInvalid expiration dateThe expiration date presented does not match the card.
10INVALID_CVVInvalid CVVThe security code presented does not match the card.
11EMPTY_EXP_DATEEmpty expiration dateNo expiration date was presented.
12TRANSACTION_NOT_PERMITTEDInvalid TransactionThis kind of transaction is not permitted on this card.
13BIN_ATTACK_DETECTEDBIN attack detectedEnumeration activity was detected. No webhook is sent for this decline type.
14CARD_FROZENCard is FrozenThe card is frozen.
15CARD_INACTIVECard is No Longer ActiveThe card has not been activated, or is no longer active.
16CARD_TERMINATEDCard is TerminatedThe card has been terminated.
17USER_BANNEDAccount is RestrictedThe account the card belongs to is restricted.
18MERCHANT_BLOCKEDMerchant is BlockedThis merchant is blocked for the card.
19MERCHANT_CATEGORY_BLOCKEDMerchant Category is BlockedThe merchant's category is blocked for the card.
20AUTHORIZATION_DISABLEDAuthorizations are DisabledAuthorizations are turned off for this card.
21PROCESSOR_DECLINEDDeclined by Card NetworkThe card network declined the authorization.
22AMOUNT_NOT_REPRESENTABLE_IN_LEDGER_CURRENCYAmount too small for account currencyThe authorized amount is real but converts to exactly zero in the account's currency.
23AUTHORIZATION_COUNT_LIMIT_REACHEDTransaction Limit ReachedA COUNT velocity control is full: the card or cardholder has already had as many approved authorizations in the window as the control allows. A single-use card declines its second authorization with this.
24MERCHANT_NOT_ALLOWEDMerchant Not AllowedThe merchant is not on a control's only_merchants allowlist — for example, the card is locked to a different merchant. Distinct from MERCHANT_BLOCKED, which means the merchant is blocked outright.
25INTENT_REQUIREDApproval RequiredThe card asks first and there is no approved, unspent, unexpired purchase intent for it. Ask the cardholder — do not retry the authorization.
26INTENT_MISMATCHApproval Does Not MatchThe card asks first and an approval exists, but it is for a different merchant or for an amount outside the tolerance the person approved.

Treat an unrecognised decline_type_id — and null — as a generic decline: show customer_friendly_description and do not fail.

INTENT_REQUIRED (25) and INTENT_MISMATCH (26) mean different things and are answered differently: the first means ask the person, the second means the person approved something else. The decline carries the merchant and amount actually seen, so you can ask again for the real one. See Ask-first cards.

Example payload

JSON

{
  "type": "CARD_DECLINE",
  "webhook_event_id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
  "data": {
    "id": "8c2f1b45-6a7d-4e83-9012-3f4a5b6c7d8e",
    "created_at": "2026-05-14 12:15:47",
    "amount": 29.99,
    "customer_friendly_description": "Card is Frozen",
    "decline_type_id": 14,
    "card_public_id": "3f0c8a12-5b6d-4e7f-9a01-2b3c4d5e6f70",
    "merchant": "ONLINE STORE",
    "merchant_mcc": "5411",
    "merchant_mid": "mid_456",
    "merchant_descriptor": "AMZN Mktp US",
    "mid": "mid_456",
    "acquirer_id": "acq_789",
    "address": { "city": "Seattle", "country": "US" },
    "is_recurring": true,
    "transaction_type": "CNP",
    "amount_no_rounding": "29.99",
    "amount_fees_in_ledger_currency": 0.29,
    "amount_fees_in_ledger_currency_no_rounding": "0.2999",
    "amount_in_transaction_currency": 29.99,
    "amount_in_transaction_currency_no_rounding": "29.99",
    "amount_fees_in_transaction_currency": 0.29,
    "amount_fees_in_transaction_currency_no_rounding": "0.2999",
    "fees": [
      {
        "type": "TRANSACTION_FEE",
        "amount": 0.29,
        "amount_no_rounding": "0.2999",
        "fee_amount_in_transaction_currency": 0.29,
        "fee_description": "A 1.00% fee (minimum of $0.00) is charged on all transactions",
        "deprecated_fields": []
      }
    ],
    "transaction_currency": "USD",
    "ledger_currency": "USD"
  }
}

A 1% fee on a $29.99 authorization is $0.2999, so fees[0].amount reads 0.29 while amount_no_rounding keeps 0.2999. The cardholder attempted 29.99 + 0.29 = $30.28; amount stays at the principal, 29.99.

The same decline appears in the card's transaction feed as an entry of type: "DECLINE".

4. Prepaid balance funds credited event

Triggered when funds are credited to your prepaid balance — for example, when a bank transfer to your funding details settles. See Funding your balance.

Event name: MOON_CREDIT_FUNDS_CREDITED

Example payload

JSON

{
  "type": "MOON_CREDIT_FUNDS_CREDITED",
  "webhook_event_id": "d4e5f6a7-b8c9-0123-def0-234567890123",
  "data": {
    "amount": 100.0,
    "id": "6f1d0a94-3b52-4c8e-9a17-5d2e8c4b0f31",
    "currency": "USD",
    "reference": "MC-4F7K2Q",
    "created_at": "2026-05-14T12:05:12.000Z",
    "deprecated_fields": []
  }
}

5. Cardholder KYC status update

Triggered when a cardholder's identity-verification status changes.

Event name: KYC_UPDATE

Status values: NOT_STARTED, IN_PROGRESS, IN_REVIEW, RETRY, APPROVED, DECLINED, EXPIRED, ABANDONED. Only APPROVED allows the cardholder to be issued a card; treat any value you do not recognise as not approved.

Example payload

JSON

{
  "type": "KYC_UPDATE",
  "webhook_event_id": "f6a7b8c9-d0e1-2345-f012-456789012345",
  "data": {
    "cardholder_id": "9d4a1f70-2c8b-4f31-a6de-7b5c0e93af12",
    "external_id": "customer_123",
    "status": "APPROVED",
    "updated_at": "2026-02-12T20:30:15.000Z"
  }
}

external_id is your own identifier for the cardholder, so you can route the update without storing our id.

6. Cardholder 3DS OTP

Triggered when a one-time password is generated for a 3DS authentication challenge.

Event name: THREE_DS_OTP

Example payload

JSON

{
  "type": "THREE_DS_OTP",
  "webhook_event_id": "a7b8c9d0-e1f2-3456-0123-567890123456",
  "data": {
    "otp": "123456",
    "otp_expiration_date": "2026-02-12T20:35:00.000Z",
    "otp_reason": "PURCHASE",
    "card_public_id": "3f0c8a12-5b6d-4e7f-9a01-2b3c4d5e6f70",
    "pan_suffix": "4242",
    "merchant_name": "Merchant",
    "amount": "10.00",
    "currency_code": "USD",
    "acs_transaction_id": "acs-tx-id"
  }
}

7. 3DS authentication result

Triggered when a 3DS authentication challenge reaches its final outcome. THREE_DS_OTP says a challenge was raised; this event says how it ended. They are separate because the first tells you nothing about the second — a challenge can be passed, declined, abandoned, expired, or accepted by risk-based analysis, and only this event carries that outcome.

Event name: THREE_DS_AUTHENTICATION_RESULT

card_public_id, acs_transaction_id, status and occurred_at are always sent. challenge_method, merchant_name, amount with amount_no_rounding, currency_code, transaction_category and transaction_channel are present when the authentication service supplies them, and omitted otherwise.

amount is a JSON number in major units and amount_no_rounding its full-precision decimal-string counterpart — the same pairing the card events use. The two are always present together and absent together, alongside currency_code.

status is the value the authentication recorded, forwarded verbatim rather than normalised into a Moon enum, so it reconciles against what the network holds. You may see AUTHENTICATED, DECLINED, RBA_ACCEPTED, RBA_CHALLENGED, BLOCKED, CANCELLED, EXPIRED, TIMED_OUT, ABORTED, FAILED or ERROR. Treat the set as open and handle a value you do not recognise as "finished, outcome unknown".

Example payload

JSON

{
  "type": "THREE_DS_AUTHENTICATION_RESULT",
  "webhook_event_id": "b8c9d0e1-f2a3-4567-1234-678901234567",
  "data": {
    "card_public_id": "3f0c8a12-5b6d-4e7f-9a01-2b3c4d5e6f70",
    "acs_transaction_id": "acs-tx-id",
    "status": "AUTHENTICATED",
    "challenge_method": "OTP",
    "merchant_name": "Merchant",
    "amount": 10,
    "amount_no_rounding": "10.00",
    "currency_code": "USD",
    "transaction_category": "PURCHASE",
    "transaction_channel": "CNP",
    "occurred_at": "2026-05-14T12:20:11Z"
  }
}

8. Velocity control watched

Triggered when an authorization would have breached a velocity control running in mode: WATCH. A WATCH control never declines: the authorization is decided as if the control were not there, and this event is sent instead. It is how you size a limit before enforcing it — turn the control on in WATCH, collect these events for a while, then update the mode to STRICT.

Event name: VELOCITY_CONTROL_WATCHED

One event is emitted per breached control, so two WATCH controls breached by the same authorization produce two events carrying the same transaction_id. Every field is sent; the ones marked nullable are null rather than absent.

FieldMeaning
velocity_control_idThe control that was breached.
card_idThe card's public id, or null for a cardholder- or account-scoped control.
cardholder_idThe cardholder, or null for a card- or account-scoped control.
typeSPEND, COUNT or INTENT.
velocity_windowMONTH, DAY, WEEK or TOTAL. All windows are UTC.
limitThe control's limit, in the control's own unit.
observedWhat the window holds including this transaction, or null on an INTENT control.
currencyISO code that limit and observed are in; null on COUNT and INTENT.
reasonSPEND_LIMIT, COUNT_LIMIT, MERCHANT_NOT_ALLOWED, INTENT_REQUIRED or INTENT_MISMATCH.
merchantThe acquirer merchant name, or null when none was sent.
merchant_mccThe four-digit merchant category code, or null.
transaction_idThe transaction this observation belongs to, or null.
occurred_atUTC, second precision.

limit and observed are a matched pair in the control's unit: an amount in currency on a SPEND control, a whole count of approved authorizations on a COUNT one. currency is the card's ledger account currency, which is what the authorization engine actually compares — a limit of 100 on an MXN account is 100 MXN. They are plain JSON numbers with no _no_rounding twin, because this is a threshold and a measurement against it rather than money that moved.

observed includes the transaction that triggered the event for every reason, the merchant miss included — a MERCHANT_NOT_ALLOWED observation still reports where the window stood, so you can see the size of what the lock turned away. A zero-amount verification adds nothing to either unit, because it posts no ledger entry.

Example payload

JSON

{
  "type": "VELOCITY_CONTROL_WATCHED",
  "webhook_event_id": "c9d0e1f2-a3b4-5678-2345-789012345678",
  "data": {
    "velocity_control_id": "2f6b1c04-8d3a-4e57-b19c-0a7e5d3f2c81",
    "card_id": "3f0c8a12-5b6d-4e7f-9a01-2b3c4d5e6f70",
    "cardholder_id": null,
    "type": "SPEND",
    "velocity_window": "DAY",
    "limit": 50,
    "observed": 63.4,
    "currency": "USD",
    "reason": "SPEND_LIMIT",
    "merchant": "ONLINE STORE",
    "merchant_mcc": "5411",
    "transaction_id": "500539aa-d323-4fe7-9162-8d6123515ec5",
    "occurred_at": "2026-05-14T12:30:02Z"
  }
}

9. Purchase intent events

An ask-first card spends nothing until the person who approves it has said yes to the purchase. The agent asks before it pays; the approver — the card's cardholder, or the account that owns the card when it has none — answers from a one-time link in their email, in the Moon app, or through an assistant of their own; and the authorization engine then lets through only the charges that fit what they approved. That can be one charge (kind: single), several charges inside a budget over a few days (kind: order), or every charge from one merchant under a monthly limit (recurring: true). These five events are that conversation on the wire. Read Ask-first cards and Purchase intents for the flow itself.

Event names: PURCHASE_INTENT_REQUESTED, PURCHASE_INTENT_APPROVED, PURCHASE_INTENT_DENIED, PURCHASE_INTENT_EXPIRED, PURCHASE_INTENT_CANCELLED

One data shape serves all five. They are five moments in the life of the same record, and status inside the payload always agrees with type on the wire, so you can switch on either. Every field below is always sent; the ones that do not apply are null, never absent.

FieldMeaning
purchase_intent_idThe intent this event is about.
card_idThe card's public id.
cardholder_idThe cardholder who was asked, or null when the card has none.
approver_typeWho was asked: cardholder, or account when the card has no cardholder and the request went to the account that owns it, or when the card is on your organization's own cardholder (the account holder). Never null.
statusAWAITING_APPROVAL, APPROVED, CONSUMED, DENIED, EXPIRED or CANCELLED.
kindsingle or order. Never null; an approval created before orders existed reads single.
merchantThe merchant name the intent names, or null when it names only an MCC.
merchant_mccThe four-digit category code, or null.
merchant_domainThe site the purchase is made on, as a bare hostname, or null. Advisory: the card networks carry no domain, so nothing is declined on it.
amountThe approved amount, a JSON number in major units, in the card's currency. When the request gave the price in the store's currency, this is Moon's conversion of merchant_amount, and it is still the figure authorizations are compared against.
amount_no_roundingThe same amount at full precision, as a decimal string.
currencyISO code — the card's ledger account currency.
merchant_amountThe price as the store stated it, in merchant_currency, when the request gave it that way. null when the request gave amount.
merchant_currencyThe uppercase ISO 4217 currency of merchant_amount, or null.
request_fx_rateThe market rate, with no spread, that amount was converted with when the request was made, or null. The network converts the real charge at its own rate, which the tolerance absorbs.
tolerance_percentPercentage slack on amount, or null.
tolerance_absoluteAbsolute slack, in the same unit as amount, or null.
reasonThe sentence the agent wrote and the approver read. Human text, not a reason code.
recurringtrue for a standing approval that keeps authorizing the same merchant.
monthly_limitWhat a standing approval may take per UTC calendar month, or null.
valid_for_daysHow many days the approval stays spendable once approved — an order's window (1–90, default 14), or how long a recurring approval waits for its first charge. null on a plain single approval, which may be spent for 15 minutes once approved.
max_totalAn order's budget: amount plus the tolerance band. null on any other kind.
spentHow much of that budget has been taken. null on any other kind.
remainingmax_total minus spent, floored at zero. null on any other kind.
expires_atUTC, second precision. After this the approval is worthless. Until the approver answers it is the deadline to answer (answer_within_minutes after the request, 60 minutes by default). Once they approve it is the deadline to spend: 15 minutes after the approval for a single approval, valid_for_days days after it for an order or a recurring approval's first charge. A denial keeps the answer deadline.
approved_viaHow the approver answered: email (the link, or the page it opens), mcp (an assistant connection recorded the answer they gave it) or web (they answered in the Moon app, signed in). null until answered.
consumed_transaction_idThe authorization that spent the approval, or null.
cancelled_byOn PURCHASE_INTENT_CANCELLED: partner when you ended it, or when Moon ended a duplicate approval (see cancel_reason); approver when the person did. null on the other four.
cancel_reasonOn PURCHASE_INTENT_CANCELLED: superseded_by_consumed_sibling when Moon cancelled this approval because an identical one-off approval on the same card — same merchant, amount and currency — was just spent. null when you or the approver ended it, and on the other four events. Treat it as an open set.
agent_key_idThe requesting agent key's id, when the request was signed. null otherwise.
agent_labelThat key's current label, read fresh on every event, or null.
occurred_atUTC, second precision.

Four more fields are sent on every event and are reserved for features that are not generally available yet. Today they are always null; ignore them rather than validating them away.

FieldToday
autonomy_grant_idnull
profile_fieldsnull
profile_shipping_addressnull
profile_releases_remainingnull

approved_via is an open set. web was added after the field first shipped, and more channels may follow: branch on the values you know and fall through on the rest.

agent_key_id is a stable key thumbprint rather than a Moon record id, and it is the value an audit trail should be keyed on. agent_label is your own name for the key, so renaming a key changes what later events say about intents that already exist. Both are null when the request was not signed, which is allowed. See MCP Server.

What each kind lets through. A single approval pays for exactly one authorization inside its tolerance band. An order accepts any number of authorizations at that merchant while spent stays inside max_total, for valid_for_days days from the moment it was approved, and closes — moving to CONSUMED — when the budget is gone. A recurring approval reads CONSUMED after its first charge and keeps authorizing the same merchant, bounded by monthly_limit, until it is stopped. Refunds and reversals do not give budget back on either.

How an approval ends. At the end of its window, an order that was charged moves to CONSUMED and emits no PURCHASE_INTENT_EXPIRED — it is a purchase that happened. One that was never charged expires with the event, like any other approval. PURCHASE_INTENT_CANCELLED is emitted when you withdraw a request nobody answered, close an open order early, or stop a standing approval, and when the approver stops one from their link — cancelled_by says which, so a subscription that stops being authorized does not have to be discovered by polling. Moon also emits it when the person approved the same one-off purchase more than once and one of those approvals has just been spent: every other APPROVED, unexpired one-off approval on the card for the same merchant, amount and currency is cancelled, with cancelled_by: partner and cancel_reason: superseded_by_consumed_sibling.

Identical requests. A request identical to one still awaiting the approver's answer is not created again, so it emits no second PURCHASE_INTENT_REQUESTED; see Purchase intents.

The tolerance is symmetric, and the wider of the two values wins: an authorization matches a single approval when |authorized − amount| ≤ max(amount × tolerance_percent / 100, tolerance_absolute). A charge below the approved amount is outside the band just as one above it is.

All five are emitted after the write they describe has committed. PURCHASE_INTENT_APPROVED is the one an agent acts on by going and spending money, so it is never emitted from inside a transaction that could still roll back.

Example payload

An authorization on an ask-first card with no matching approval is declined with INTENT_REQUIRED; one whose merchant or amount does not match the approval on file — or that would take an order past its budget or a standing approval past its monthly limit — is declined with INTENT_MISMATCH. Both reach you as an ordinary CARD_DECLINE.

***

Testing your receiver

Confirm along the way that your handler verifies the signature, deduplicates on webhook_event_id, and answers 2xx inside 10 seconds.

Last updated October 22, 2026