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
type | Sent when | Key fields |
|---|---|---|
CARD_ | A card purchase is authorized, cleared, reversed, or withdrawn against. One event per phase. | transaction_, transaction_, amount, fees |
CARD_ | A card purchase is refunded. | Same shape as CARD_ |
CARD_ | An authorization is declined. | decline_, customer_, card_, fees |
KYC_ | A cardholder's identity-verification status changes. | cardholder_, external_, status |
THREE_ | A one-time password is generated for a 3DS challenge. | otp, otp_, card_ |
THREE_ | A 3DS authentication challenge reaches its final outcome. | status, acs_, card_ |
VELOCITY_ | An authorization breached a velocity control running in WATCH mode. Nothing was declined. | velocity_, limit, observed, reason |
PURCHASE_ | A purchase on an ask-first card was sent to its approver — the cardholder, or the account that owns the card. | purchase_, status, kind, amount, expires_ |
PURCHASE_ | The approver approved it. | Same shape as PURCHASE_ |
PURCHASE_ | The approver denied it. | Same shape |
PURCHASE_ | Nobody answered in time, or an approval's window closed unused. | Same shape |
PURCHASE_ | The 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_ and cancel_ |
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_ and the five PURCHASE_ 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_, 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_, 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:
| Header | Always sent | Meaning |
|---|---|---|
Content-Type | yes | application/json |
Moon-Signature | once the endpoint has a signing secret | t=<unix seconds>,v1=<hex hmac>. See Verifying signatures. |
Moon-Event-Id | on event deliveries | The same value as webhook_ in the body. Stable across retries. |
Moon-Event-Type | on event deliveries | The 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_ 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 timeout | 10 seconds |
| Attempts | Up to 9, including the first |
| Total window | About 46 hours from the first attempt |
| After the last failure | The event is marked failed and is not delivered again automatically. It stays readable through Events. |
The delay before each retry, in order:
| Retry | Sent after |
|---|---|
| 1st | 1 minute |
| 2nd | 5 minutes |
| 3rd | 15 minutes |
| 4th | 1 hour |
| 5th | 3 hours |
| 6th | 6 hours |
| 7th | 12 hours |
| 8th | 24 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_ 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 todatetime_when it is absent.iso - Treat the newest event you have seen as the current state, rather than applying deltas in arrival order.
CARD_ and CARD_ carry three optional fields that make the order recoverable without guessing:
| Field | Meaning |
|---|---|
lifecycle_ | AUTHORIZATION, CLEARING, REVERSAL, WITHDRAWAL or REFUND — which step this event reports. |
sequence | Monotonically non-decreasing per transaction_. Sort on this to reconstruct the real order. |
occurred_ | When 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_ nor datetime_ is an ordering key.
Duplicates
Retries reuse the same webhook_, and a delivery your endpoint processed but failed to acknowledge in time will arrive again. Record webhook_ (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.
| Version | Register | Read | Delete | Rotate the signing secret |
|---|---|---|---|---|
| V1 | POST /v1/api-gateway/webhook | — | DELETE /v1/api-gateway/webhook | POST /v1/api-gateway/webhook/signing-secret/rotate |
| V2 | POST /v2/api-gateway/webhooks | GET /v2/api-gateway/webhooks | DELETE /v2/api-gateway/webhooks | POST /v2/api-gateway/webhooks/signing-secret/rotate |
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_, 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_ or ERROR_.
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_, 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| Component | Meaning |
|---|---|
t | Unix timestamp (seconds) at the moment the delivery was signed |
v1 | Lowercase 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:
- 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.
- The timestamp is covered by the signature, so it cannot be altered without invalidating it. Reject deliveries whose
tis outside a tolerance window — we recommend 300 seconds — so a captured delivery cannot be replayed indefinitely.
Verification steps
- Read the raw request body as a string or bytes.
- Parse
Moon-Signatureinto itstandv1components. - Reject the request if
tis more than 300 seconds away from your current time, in either direction. - Compute
HMAC-SHA256(secret, "<t>.<raw body>")and hex-encode it. - Only after that, parse the JSON and act on the event. Deduplicate on
webhook_, since deliveries are retried.event_ id
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 want | Call |
|---|---|
| Register a webhook and get a secret | POST /v1/api-gateway/webhook or POST /v2/api-gateway/webhooks — the 201 response includes signing_ |
| 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.
POSTto 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 thesigning_from every such call, or your handler will be validating against a superseded secret.secret - If you are not verifying yet, an unrecognised
Moon-Signatureheader is safe to ignore.
***
Payload conventions
Every webhook is delivered as an HTTP POST with a JSON body. The root object always includes:
| Field | Description |
|---|---|
type | Event type (see the index) |
webhook_ | Unique id for this delivery (UUID) |
data | Event-specific payload |
Timestamp formats
Timestamps are UTC. Each field carries the format below:
| Field | Event | Format |
|---|---|---|
datetime_ | CARD_, CARD_ | 2026-05-14T12:00:37Z |
datetime | CARD_, CARD_ | 2026-05-14 12:00:37. Listed in data.deprecated_; prefer datetime_. |
created_ | MOON_ | 2026-05-14T12:00:37.000Z |
created_ | CARD_ | 2026-05-14 12:00:37 |
updated_, otp_ | KYC_, THREE_ | 2026-05-14T12:00:37.000Z |
occurred_ | THREE_, VELOCITY_, PURCHASE_, and card events that carry a lifecycle stage | 2026-05-14T12:00:37Z |
expires_ | PURCHASE_ | 2026-05-14T12:15:37Z |
Key on datetime_ in a webhook and on datetime_ in the card transaction feed, and the two describe the same instant.
deprecated_fields
CARD_, CARD_, their fees entries and MOON_ carry a deprecated_ 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
amountandamount_are the transaction principal excluding fees, rounded for display.in_ transaction_ currency amount_andfees_ in_ ledger_ currency amount_are the total fees for this event in each currency.fees_ in_ transaction_ currency - Fields ending in
_contain the full-precision string values from the ledger (use these when you need exact arithmetic).no_ rounding
Fees
feesis a breakdown array. Each item is one fee line (typeis alwaysTRANSACTION_).FEE - Invariant:
sum(fees[].amount) === amount_fees_ in_ ledger_ currency - Each
fees[].amountis rounded half-up to the ledger currency decimal places. The fee totals are the sum of those rounded lines, not a separate calculation. feesis 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_andcurrency transaction_are ISO 4217 currency codes (example:currency USD,BRL), not numeric currency ids.
Card metadata
data.card.metadatacarries 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 nevernullor 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_isstatus SETTLEDorPENDING, 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_.
| Field | Type | Notes |
|---|---|---|
merchant_ | string | Raw merchant descriptor as received from the issuer. |
mid | string | Merchant id (a.k.a. establishment code). |
acquirer_ | string | Acquirer id/reference, when the issuer distinguishes it from the MID. |
address | object | Merchant address { postal_. Granularity varies by issuer. |
is_ | boolean | Whether the charge is a recurring/subscription transaction. |
transaction_ | string | Transaction 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_ events (all sharing one transaction_); see How the transaction lifecycle maps to events below. Refunds are delivered as a separate CARD_ event.
Event name: CARD_
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_ events over its lifetime, one per phase. They all carry the same transaction_ (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_ event (still with the original transaction_).
| Phase | Event type | Typical transaction_ |
|---|---|---|
| Authorization approved | CARD_ | PENDING (a hold is placed) |
| Authorization declined | CARD_ | — (no CARD_ is sent) |
| Reversal (partial) | CARD_ | PENDING (hold reduced) |
| Reversal (full) | CARD_ | SETTLED (hold released, transaction effectively cancelled) |
| Clearing (partial) | CARD_ | PENDING (uncleared amount still held) |
| Clearing (full) | CARD_ | SETTLED |
| Withdrawal | CARD_ | SETTLED (settles immediately) |
| Refund | CARD_ | SETTLED |
What transaction_ 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_) is the transaction principal excluding fees as reflected at that event. Because every event for a transaction shares its transaction_, the recommended pattern is:
- Key on
transaction_to group all events for one purchase.id - Deduplicate on
webhook_(deliveries can be retried).event_ id - Treat the latest event (by
datetime_) as the current state of the transaction, and diff against the previous event for the sameiso transaction_if you need per-phase deltas.id
Example webhook sequence for a $100 purchase that is partially reversed, cleared, then partially refunded (the flow you can reproduce with simulated transactions):
| Step | You do | Webhook received | transaction_ |
|---|---|---|---|
| 1 | Authorize $100 | CARD_ | PENDING |
| 2 | Reverse $20 | CARD_ | PENDING |
| 3 | Clear $80 | CARD_ | SETTLED |
| 4 | Refund $30 | CARD_ | SETTLED |
2. Card authorization refund event
Triggered when a card refund is processed.
Event name: CARD_
Uses the same data fields as CARD_. Refund principal and fee amounts are positive magnitudes. When no fee is refunded (or the proportional fee rounds to zero), expect amount_ 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_) 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_
Fields
customer_— a human-readable decline reason, specific to the decline type.friendly_ description decline_— the numeric id of the decline type, so you can branch programmatically. May betype_ id nullif 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_andcurrency ledger_— arrive as a group on most declines, carrying the same names and the same meanings as oncurrency CARD_, so one renderer handles both.TRANSACTION amountstill excludes fees: the total the cardholder attempted isamount + amount_. The invariantfees_ in_ ledger_ currency sum(fees[].amount) === amount_holds here too; each line is cut to its currency's decimal places and the matchingfees_ in_ ledger_ currency _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. Treatno_ rounding feeson 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_) may also be present.type
Decline types
Branch on decline_. customer_ is the text to show a person; its wording can change, the id does not.
| id | Name | customer_ | What it means |
|---|---|---|---|
| 1 | INSUFFICIENT_ | Insufficient Funds | The card balance does not cover the amount. |
| 2 | CARD_ | Card Expired | The card is past its expiration date. |
| 3 | INVALID_ | Invalid Transaction | The authorization could not be processed as presented. |
| 4 | INTERNAL_ | Internal Error | Processing failed on our side. |
| 5 | UNKNOWN | Unknown | No more specific reason could be determined. |
| 6 | SPENT_ | Spent limit reached | A spending control on the cardholder or card was hit. |
| 7 | MERCHANT_ | Merchant Geography Restricted | The merchant's country is not permitted for this card. |
| 8 | CARD_ | Card Limit Reached | A per-card limit was hit. |
| 9 | INVALID_ | Invalid expiration date | The expiration date presented does not match the card. |
| 10 | INVALID_ | Invalid CVV | The security code presented does not match the card. |
| 11 | EMPTY_ | Empty expiration date | No expiration date was presented. |
| 12 | TRANSACTION_ | Invalid Transaction | This kind of transaction is not permitted on this card. |
| 13 | BIN_ | BIN attack detected | Enumeration activity was detected. No webhook is sent for this decline type. |
| 14 | CARD_ | Card is Frozen | The card is frozen. |
| 15 | CARD_ | Card is No Longer Active | The card has not been activated, or is no longer active. |
| 16 | CARD_ | Card is Terminated | The card has been terminated. |
| 17 | USER_ | Account is Restricted | The account the card belongs to is restricted. |
| 18 | MERCHANT_ | Merchant is Blocked | This merchant is blocked for the card. |
| 19 | MERCHANT_ | Merchant Category is Blocked | The merchant's category is blocked for the card. |
| 20 | AUTHORIZATION_ | Authorizations are Disabled | Authorizations are turned off for this card. |
| 21 | PROCESSOR_ | Declined by Card Network | The card network declined the authorization. |
| 22 | AMOUNT_ | Amount too small for account currency | The authorized amount is real but converts to exactly zero in the account's currency. |
| 23 | AUTHORIZATION_ | Transaction Limit Reached | A 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. |
| 24 | MERCHANT_ | Merchant Not Allowed | The merchant is not on a control's only_ allowlist — for example, the card is locked to a different merchant. Distinct from MERCHANT_, which means the merchant is blocked outright. |
| 25 | INTENT_ | Approval Required | The card asks first and there is no approved, unspent, unexpired purchase intent for it. Ask the cardholder — do not retry the authorization. |
| 26 | INTENT_ | Approval Does Not Match | The 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_ — and null — as a generic decline: show customer_ and do not fail.
INTENT_ (25) and INTENT_ (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_ 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_
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_
Status values: NOT_, IN_, IN_, 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_ 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_
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_ 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_
card_, acs_, status and occurred_ are always sent. challenge_, merchant_, amount with amount_, currency_, transaction_ and transaction_ are present when the authentication service supplies them, and omitted otherwise.
amount is a JSON number in major units and amount_ its full-precision decimal-string counterpart — the same pairing the card events use. The two are always present together and absent together, alongside currency_.
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_, RBA_, BLOCKED, CANCELLED, EXPIRED, TIMED_, 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_
One event is emitted per breached control, so two WATCH controls breached by the same authorization produce two events carrying the same transaction_. Every field is sent; the ones marked nullable are null rather than absent.
| Field | Meaning |
|---|---|
velocity_ | The control that was breached. |
card_ | The card's public id, or null for a cardholder- or account-scoped control. |
cardholder_ | The cardholder, or null for a card- or account-scoped control. |
type | SPEND, COUNT or INTENT. |
velocity_ | MONTH, DAY, WEEK or TOTAL. All windows are UTC. |
limit | The control's limit, in the control's own unit. |
observed | What the window holds including this transaction, or null on an INTENT control. |
currency | ISO code that limit and observed are in; null on COUNT and INTENT. |
reason | SPEND_, COUNT_, MERCHANT_, INTENT_ or INTENT_. |
merchant | The acquirer merchant name, or null when none was sent. |
merchant_ | The four-digit merchant category code, or null. |
transaction_ | The transaction this observation belongs to, or null. |
occurred_ | UTC, 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 _ 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_ 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_, PURCHASE_, PURCHASE_, PURCHASE_, PURCHASE_
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.
| Field | Meaning |
|---|---|
purchase_ | The intent this event is about. |
card_ | The card's public id. |
cardholder_ | The cardholder who was asked, or null when the card has none. |
approver_ | Who 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. |
status | AWAITING_, APPROVED, CONSUMED, DENIED, EXPIRED or CANCELLED. |
kind | single or order. Never null; an approval created before orders existed reads single. |
merchant | The merchant name the intent names, or null when it names only an MCC. |
merchant_ | The four-digit category code, or null. |
merchant_ | The 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. |
amount | The 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_, and it is still the figure authorizations are compared against. |
amount_ | The same amount at full precision, as a decimal string. |
currency | ISO code — the card's ledger account currency. |
merchant_ | The price as the store stated it, in merchant_, when the request gave it that way. null when the request gave amount. |
merchant_ | The uppercase ISO 4217 currency of merchant_, or null. |
request_ | The 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_ | Percentage slack on amount, or null. |
tolerance_ | Absolute slack, in the same unit as amount, or null. |
reason | The sentence the agent wrote and the approver read. Human text, not a reason code. |
recurring | true for a standing approval that keeps authorizing the same merchant. |
monthly_ | What a standing approval may take per UTC calendar month, or null. |
valid_ | How 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_ | An order's budget: amount plus the tolerance band. null on any other kind. |
spent | How much of that budget has been taken. null on any other kind. |
remaining | max_ minus spent, floored at zero. null on any other kind. |
expires_ | UTC, second precision. After this the approval is worthless. Until the approver answers it is the deadline to answer (answer_ 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_ days after it for an order or a recurring approval's first charge. A denial keeps the answer deadline. |
approved_ | How 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_ | The authorization that spent the approval, or null. |
cancelled_ | On PURCHASE_: partner when you ended it, or when Moon ended a duplicate approval (see cancel_); approver when the person did. null on the other four. |
cancel_ | On PURCHASE_: superseded_ 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_ | The requesting agent key's id, when the request was signed. null otherwise. |
agent_ | That key's current label, read fresh on every event, or null. |
occurred_ | UTC, 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.
| Field | Today |
|---|---|
autonomy_ | null |
profile_ | null |
profile_ | null |
profile_ | null |
approved_ 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_ is a stable key thumbprint rather than a Moon record id, and it is the value an audit trail should be keyed on. agent_ 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_, for valid_ 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_, 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_ — it is a purchase that happened. One that was never charged expires with the event, like any other approval. PURCHASE_ 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_ 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_ and cancel_.
Identical requests. A request identical to one still awaiting the approver's answer is not created again, so it emits no second PURCHASE_; 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_. 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_ 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_; 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_. Both reach you as an ordinary CARD_.
***
Testing your receiver
Confirm along the way that your handler verifies the signature, deduplicates on webhook_, and answers 2xx inside 10 seconds.
Last updated October 22, 2026