KTAP Cards API
Webhooks

Event reference

Every type we send today. Treat the list as open: ignore a type you do not recognise and return 2xx.

A purchase produces at least two events: card.authorized when it is authorised, then a card.transaction for each later leg, whether it clears, reverses or refunds. They all carry the same transaction_id. A decline stands alone. See Receiving events for signature verification and retries.

card.issued#

A virtual card was created for you and recorded against your account

Sent when POST /api/partner/v1/cards succeeds. Emitted after the card exists at the issuer, after the cards row is inserted, and after the first month is billed. It is emitted outside any transaction, so it is queued only once the card genuinely exists.
Payload
card_idstring
The card id. Use it in every /cards/{id} path
partner_refstring | null
The partner_ref you sent on the issue request, null when you sent none
last4string | null
Last four digits of the PAN, null when the issuer did not return them on create
card_holder_namestring
The name on the card as the issuer stored it: uppercased, at most 26 characters, accents already converted to their ASCII form
Notes
  • Arrives once per card. A retry that replays an existing card under the same idempotency key emits nothing.
Payload
{
  "id": "0f4c2c1e-9a3d-4a67-8b21-5b8e3d1c7a90",
  "type": "card.issued",
  "created_at": "2026-08-22T00:32:35.412Z",
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "partner_ref": "user_8812",
    "last4": "4417",
    "card_holder_name": "MARIA GONZALEZ"
  }
}
card.topped_up#

Money moved from your float onto one of your cards

Sent when POST /api/partner/v1/cards/{id}/topup succeeds. Emitted, after the float ledger has committed and after the issuer confirmed the credit.
Payload
card_idstring
The card that was credited
partner_refstring | null
Your reference stored on the card
amount_requested_usdnumber
The amount_usd you asked for, at most 2 decimal places
fee_usdnumber
Always 0 on the float model: the load fee is charged when you fund the float, not when you move money to a card
credited_usdnumber
What landed on the card. Equal to amount_requested_usd while fee_usd is 0
partner_balance_usdnumber
Your float BALANCE after the debit, not your remaining spend-limit headroom
Notes
  • A top-up whose processor call fails ambiguously emits nothing, so the absence of this event does not prove the card was not funded.
Payload
{
  "id": "6b1a08d3-2f4c-4d19-9f77-c0a1e2b4d556",
  "type": "card.topped_up",
  "created_at": "2026-08-22T09:14:02.087Z",
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "partner_ref": "user_8812",
    "amount_requested_usd": 250,
    "fee_usd": 0,
    "credited_usd": 250,
    "partner_balance_usd": 18750.25
  }
}
card.authorized#

A purchase was authorized and its amount, fee included, was charged to the card. The merchant can still settle a different amount

Sent when The issuer sends a card event with event_type 'authorized' for a card that belongs to you.
Payload
card_idstring
The card the purchase happened on
partner_refstring | null
Your reference stored on the card
transaction_idstring | null
The purchase id. Join on it to pair this authorization with its later settlement, and with the transaction_id field (not id) on GET /cards/{id}/transactions rows
eventstring
Always "authorized" on this event type
statusstring
Always "pending" on this event type
is_pendingboolean
Always true on this event type
delivery_statusstring
The processor's own status on the source event, lowercased. One of pending, success, failed, canceled, time_canceled, manual_check. Kept for support, do not branch on it
merchantstring | null
Merchant name, taken from merchant_name and falling back to merchant_data.name
mcc_codestring | null
Merchant category code, passed through from merchant_data.mcc_code
mcc_categorystring | null
Merchant category label, passed through from merchant_data.mcc_category
merchant_countrystring | null
Merchant country, passed through from merchant_data.country
amount_usdnumber | null
The total charged to the card at authorization, in the billing currency, fee included. The merchant can still settle a different amount
original_amountnumber | null
The amount in the currency the cardholder actually paid in
original_currencystring | null
Currency of original_amount, read from transaction_currency_code and falling back to transaction_currency
bill_currencystring | null
The currency the card was debited in, read from bill_currency_code and falling back to bill_currency. This is what amount_usd is denominated in
exchange_ratenumber | null
Close to amount_usd divided by original_amount, but not an exact identity, so do not assert it. amount_usd includes the fee, so this rate includes it too and is not a market rate. It can carry more than 6 decimal places
fee_usdnumber
The fee inside amount_usd, at full precision. This is the purchase's fee and the settlement event does not repeat it, so keep it. Six decimals appear here in practice
fee_breakdownobject
{ "fx_usd": number, "atm_usd": number }, the two components of fee_usd
decline_reasonobject | null
Always null on this event type
occurred_atstring | null
When the purchase happened, UTC with a Z suffix, from the source event's created_at and falling back to datetime. Distinct from the envelope created_at, which is when the event entered our outbox
Notes
  • This is the first event for one purchase. Every later leg arrives as card.transaction, and they all carry the same transaction_id.
  • Settlement is slow: the median gap to the settled event is around 22 hours. Show the purchase as pending with this event's amount_usd and fee_usd straight away. When a settlement arrives with the same original_amount, keep those figures as the charge. If a settlement arrives first, do not book its figures: wait for this event.
Payload
{
  "id": "c74e1b6a-3d55-4b8f-9a2e-1c6d0f5a8b41",
  "type": "card.authorized",
  "created_at": "2026-08-22T14:07:54.118Z",
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "partner_ref": "user_8812",
    "transaction_id": "tid_01a09a51-2b3c-7d4e-8f5a-6b7c8d9e0f1a",
    "event": "authorized",
    "status": "pending",
    "is_pending": true,
    "delivery_status": "success",
    "merchant": "CORNER CAFE",
    "mcc_code": "5814",
    "mcc_category": "Fast Food Restaurants",
    "merchant_country": "USA",
    "amount_usd": 25.5,
    "original_amount": 25,
    "original_currency": "USD",
    "bill_currency": "USD",
    "exchange_rate": 1.02,
    "fee_usd": 0.5,
    "fee_breakdown": { "fx_usd": 0.5, "atm_usd": 0 },
    "decline_reason": null,
    "occurred_at": "2026-08-22T14:07:51.906Z"
  }
}
card.transaction#

A purchase settled, or a refund or reversal posted. A settlement does not repeat the fee taken at authorization, so its amount is not the total charged

Sent when The issuer sends a card event with event_type 'cleared', 'refund' or 'reversal' for one of your cards.
Payload
card_idstring
The card the movement happened on
partner_refstring | null
Your reference stored on the card
transaction_idstring | null
The purchase id: the same value as on its card.authorized, and as the transaction_id field (not id) on GET /cards/{id}/transactions rows. Resolved by matching the event timestamp against purchases from the previous 45 days, so a settlement that clears days after the hold still pairs. Null only when that lookup fails or more than one purchase matched: we send the event without an id rather than guess and merge two purchases
eventstring
Which movement this is: "cleared", "refund" or "reversal". Branch on this, not on the event type
statusstring
Always "settled" on this event type
is_pendingboolean
Always false on this event type
delivery_statusstring
The processor's own status on the source event, lowercased. One of pending, success, failed, canceled, time_canceled, manual_check
merchantstring | null
Merchant name, from merchant_name falling back to merchant_data.name
mcc_codestring | null
Merchant category code from merchant_data.mcc_code
mcc_categorystring | null
Merchant category label from merchant_data.mcc_category
merchant_countrystring | null
Merchant country from merchant_data.country
amount_usdnumber | null
On a settlement (event cleared): the amount the merchant settled, in the billing currency, without the fee taken at authorization, so on a purchase that settled what it authorized it sits below the authorization's amount_usd. On a refund or reversal: the amount released back. Branch on event for the direction
original_amountnumber | null
The amount in the currency the cardholder paid in
original_currencystring | null
Currency of original_amount
bill_currencystring | null
The currency the card was debited in
exchange_ratenumber | null
Rate between original_amount and amount_usd on this event. It usually differs from the authorization's rate, because the authorization's amount_usd includes the fee and this one does not
fee_usdnumber
Not the purchase's fee. The fee is taken at authorization and reported on card.authorized, so on a settlement this is normally 0. Keep the authorization's fee_usd rather than replacing it with this
fee_breakdownobject
{ "fx_usd": number, "atm_usd": number }
decline_reasonobject | null
Always null on this event type
occurred_atstring | null
When the movement happened, UTC with a Z suffix
Notes
  • This is the second event for a purchase that already arrived as card.authorized. Pair them on transaction_id and tell them apart by status.
  • A refund or reversal is its own card.transaction sharing the original transaction_id, so one transaction can produce three or more events. Key your ledger on the event id for deduplication and on transaction_id for grouping.
  • A settlement does not repeat the fee taken at authorization: its fee_usd is normally 0. Keep the authorization's amount_usd and fee_usd as the charge while original_amount is unchanged. If it changed, treat the charge as open and reconcile it with us by transaction_id.
Payload
{
  "id": "e1907c22-4a6b-49d8-b0f3-5d2c8e7a1140",
  "type": "card.transaction",
  "created_at": "2026-08-23T12:41:19.006Z",
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "partner_ref": "user_8812",
    "transaction_id": "tid_01a09a51-2b3c-7d4e-8f5a-6b7c8d9e0f1a",
    "event": "cleared",
    "status": "settled",
    "is_pending": false,
    "delivery_status": "success",
    "merchant": "CORNER CAFE",
    "mcc_code": "5814",
    "mcc_category": "Fast Food Restaurants",
    "merchant_country": "USA",
    "amount_usd": 25,
    "original_amount": 25,
    "original_currency": "USD",
    "bill_currency": "USD",
    "exchange_rate": 1,
    "fee_usd": 0,
    "fee_breakdown": { "fx_usd": 0, "atm_usd": 0 },
    "decline_reason": null,
    "occurred_at": "2026-08-23T12:41:17.882Z"
  }
}
card.declined#

A purchase was declined at the point of sale

Sent when The issuer sends a card event with event_type 'declined' for one of your cards.
Payload
card_idstring
The card the attempt was made on
partner_refstring | null
Your reference stored on the card
transaction_idstring | null
The declined attempt's purchase id, resolved the same way as a settlement and null only when that lookup fails. A decline never pairs with another event, so use it to find the attempt on GET /cards/{id}/transactions (its transaction_id field)
eventstring
Always "declined" on this event type
statusstring
Always "settled". A decline is final but moved no money, so do not read "settled" here as "money moved"
is_pendingboolean
Always false on this event type
delivery_statusstring
The processor's own status on the source event, lowercased
merchantstring | null
Merchant name
mcc_codestring | null
Merchant category code. Use it with mcc_category to recognise a blocked or high-risk category
mcc_categorystring | null
Merchant category label
merchant_countrystring | null
Merchant country
amount_usdnumber | null
The amount that was attempted, in the billing currency. Nothing was charged
original_amountnumber | null
The attempted amount in the cardholder's currency
original_currencystring | null
Currency of original_amount
bill_currencystring | null
The currency the card would have been debited in
exchange_ratenumber | null
Rate that would have applied
fee_usdnumber
Always 0 on a decline, forced to zero so this event and GET /cards/{id}/transactions cannot disagree
fee_breakdownobject
{ "fx_usd": 0, "atm_usd": 0 } on a decline
decline_reasonobject | null
The reason object: { "code": string, "message": string, "retryable": boolean, "card_blocked": boolean }. code is one of a fixed set we control: insufficient_card_balance, issuer_funds_unavailable, daily_limit_exceeded, weekly_limit_exceeded, monthly_limit_exceeded, single_purchase_limit_exceeded, velocity_limit_exceeded, card_blocked, fraud_risk_declined, security_check_failed, merchant_category_blocked, processing_error, authorization_timeout, unknown. An unrecognised upstream code maps to unknown. Branch on code; message is display text we author
occurred_atstring | null
When the attempt happened, UTC with a Z suffix
Notes
  • A decline stands alone. It never pairs with a card.authorized or a card.transaction.
  • decline_reason.card_blocked means the card is unusable until we unblock it. Stop retrying and contact us.
Payload
{
  "id": "3c8d5f10-b7a2-4e69-8c11-9f0e2d4a6b73",
  "type": "card.declined",
  "created_at": "2026-08-22T18:22:41.503Z",
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "partner_ref": "user_8812",
    "transaction_id": "tid_01a0b3e1-77c2-7d04-8e5f-6a1b2c3d4e5f",
    "event": "declined",
    "status": "settled",
    "is_pending": false,
    "delivery_status": "success",
    "merchant": "STEAMGAMES.COM",
    "mcc_code": "5816",
    "mcc_category": "Digital Goods: Games",
    "merchant_country": "LU",
    "amount_usd": 59.99,
    "original_amount": 59.99,
    "original_currency": "USD",
    "bill_currency": "USD",
    "exchange_rate": 1,
    "fee_usd": 0,
    "fee_breakdown": { "fx_usd": 0, "atm_usd": 0 },
    "decline_reason": {
      "code": "insufficient_card_balance",
      "message": "The card balance did not cover the purchase.",
      "retryable": false,
      "card_blocked": false
    },
    "occurred_at": "2026-08-22T18:22:40.115Z"
  }
}
card.3ds_challenge#

A 3DS one-time code for one of your cards, for you to deliver to the cardholder

Sent when The issuer sends a card event with event_type 'notification_3ds_authorization' for one of your cards. Every card delivers its 3DS codes this way: none are sent by text message.
Payload
card_idstring
The card being challenged
partner_refstring | null
Your reference stored on the card, so you can route the code to the right end user without a lookup
codestring | null
The challenge's identifier. It is not a code for the cardholder to type: send it back with POST /cards/{cardId}/3ds/respond and approve true or false. Null when the source event carried no code
auto_approvedboolean
True when we already approved this challenge because auto-approve is on for your account. Nothing more to do. False means the purchase waits for your answer
merchantstring | null
Merchant requesting the challenge, from merchant_name
merchant_countrystring | null
Merchant country, from merchant_country_code
amountnumber | null
The transaction amount being challenged, from transaction_amount. Not converted to USD
currencystring | null
Currency of amount, from transaction_currency
occurred_atstring | null
When the challenge was raised, from transaction_timestamp
Notes
  • Time critical. Acknowledge it immediately and deliver the code to your cardholder: a failed first attempt costs at least a minute of backoff, by which point the challenge has usually expired.
  • Sent for every card: 3DS codes are delivered by webhook only, never by text message. Keep your webhook registered and fast.
Payload
{
  "id": "7d0b3a99-1e42-4c07-a5f8-6b3d9c2e0417",
  "type": "card.3ds_challenge",
  "created_at": "2026-08-22T15:03:11.271Z",
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "partner_ref": "user_8812",
    "code": "481902",
    "merchant": "BOOKING.COM",
    "merchant_country": "NL",
    "amount": 412.5,
    "currency": "EUR",
    "occurred_at": "2026-08-22T15:03:10.804Z"
  }
}
card.closed#

A card was closed and any balance left on it returned to your float

Sent when POST /api/partner/v1/cards/{id}/close succeeds. Emitted, after the sweep, after the issuer deleted the card, and after the local cards row is marked DELETED.
Payload
card_idstring
The card that was closed
partner_refstring | null
Your reference stored on the card
returned_usdnumber
The balance swept off the card back to your float. 0 when the card was empty. On a retry of a partly completed close this reports the amount recorded by the original sweep, not the card's current zero balance
Notes
  • Closing does not return card allowance. Your lifetime issuance count still includes the closed card.
Payload
{
  "id": "a2d9f7b1-55c8-4e02-8a3b-71f4c9e0d233",
  "type": "card.closed",
  "created_at": "2026-08-22T11:40:55.900Z",
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "partner_ref": "user_8812",
    "returned_usd": 12.34
  }
}
deposit.credited#

A crypto payment matched one of your deposit quotes and the card was credited (quote-matched variant)

Sent when The watcher confirms an on-chain transfer that matches a pending partner_deposits row on asset, exact amount and destination address. Emitted, after the issuer confirmed the card credit. Available only to partners with deposits_enabled true. It is also sent when the watcher sees a confirmed transfer to an address derived for a specific card, on EVM chains, BTC or TRON. Emitted, after the issuer confirmed the credit. Available only to partners with deposits_enabled true; a transfer to a float-only partner's card is held for manual handling and emits nothing.
Payload
deposit_idstring
The deposit quote this payment settled. Present only on this variant
card_idstring
The card that was credited
credited_usdnumber
USD placed on the card, net of fees, rounded to cents
tx_hashstring
The on-chain transaction hash that funded it
partner_refstring | null
Card-address variant only. Your id for the cardholder, as set at issuance
assetstring
Card-address variant only. The asset code the transfer arrived in
amount_receivednumber
Card-address variant only. The on-chain amount received, in crypto units of asset, not USD
fee_usdnumber
Card-address variant only. Everything deducted before the credit. It can exceed ktap_fee_usd plus partner_fee_usd because it absorbs the sub-cent truncation remainder
ktap_fee_usdnumber
Card-address variant only. Our share of the fee
partner_fee_usdnumber
Card-address variant only. Your markup. Accrues to your commission balance
Notes
  • Two payload shapes ship under this type. Tell them apart by presence: deposit_id marks one, partner_ref with asset marks the other. Read every field defensively.
Payload
{
  "id": "b58c1d7e-90a3-4f21-b6c4-2e7f8a3d0159",
  "type": "deposit.credited",
  "created_at": "2026-08-22T20:11:44.630Z",
  "data": {
    "deposit_id": "41f6d2c8-7b09-4a35-9e18-c3d5b0a7f264",
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "credited_usd": 98.4,
    "tx_hash": "0x9c1f3a7d4e2b6058ab77c0d9e3f1425a6b8c0d2e4f6a8b1c3d5e7f9012345678"
  }
}
float.low#

Your spendable float fell below the low-balance threshold

Sent when The watcher's partner float check runs on every tick. It emits this when available_usd falls below the threshold, once per drop.
Payload
available_usdnumber
What you can actually spend: the lower of your balance and your remaining spend limit. The same figure GET /me reports as float.available_usd. This is the number that gates a top-up
balance_usdnumber
Your raw float balance, ignoring any spend limit. It can be far higher than available_usd when a limit binds
threshold_usdnumber
The threshold that fired, $500 by default
messagestring
Fixed display text: "Your balance is running low. Send funds to avoid card top-ups being declined."
Notes
  • About your account, not a card. It carries no card_id and no partner_ref.
  • Sent once when your available balance (available_usd) falls below the threshold, not repeated while it stays there. It comes again, with a new event id, only after your available balance has recovered to a fifth above the threshold and fallen below it again.
Payload
{
  "id": "5a1e8c73-2b90-4f16-8d47-e0c9b3f5a218",
  "type": "float.low",
  "created_at": "2026-08-22T07:00:19.442Z",
  "data": {
    "available_usd": 412.8,
    "balance_usd": 50412.8,
    "threshold_usd": 500,
    "message": "Your balance is running low. Send funds to avoid card top-ups being declined."
  }
}
float.credited#

A transaction hash you submitted with POST /funding was credited to your balance

Sent when A submission to POST /api/partner/v1/funding becomes credited: the transaction was verified on-chain, the funds arrived (on BNB Chain and Base, once the transaction is final on its network), and the funding and its load fee were written to your ledger. The ledger rows, the credited status and this event are recorded together, so none exists without the others. Also when we credit a submission after review. Sent once per submission. Not sent for a submission credited separately, nor for a reversal.
Payload
funding_idstring
The id POST /funding returned for this submission
networkstring
The network the transaction was made on
POLYGON · BNB · BASE
tx_hashstring
The transaction hash, lowercased
tokenstring
The stablecoin sent
USDC · USDT
amount_usdnumber
The amount credited before the load fee: normally the amount sent, what the transaction paid into your funding address; after a review, if less reached us than was sent, no more than reached us. It can therefore be lower than amount_usd on GET /funding/{fundingId}, which is always the amount sent
fee_usdnumber
The load fee on your agreement, charged on amount_usd
credited_usdnumber
amount_usd minus fee_usd: what reached your balance
balance_usdnumber
Your balance straight after this credit
Notes
  • Sent once per submission, when it becomes credited. funding_id is the id that POST /funding returned. A submission we close as credited with reason credited_separately after a review sends none: those funds were already credited to your balance by other means. A reversal sends none either. Its amount_usd is the amount credited before the load fee: normally the amount sent, but after a review no more than reached us, so it can be below amount_usd on GET /funding/{fundingId}.
  • About your account, not a card. It carries no card_id and no partner_ref.
  • balance_usd is your balance straight after the credit. A top-up is checked against float.available_usd on GET /me, which a limit can hold below it.
Payload
{
  "id": "6f3b8d2a-9c14-4e70-b5a8-1d7e3c9f2b46",
  "type": "float.credited",
  "created_at": "2026-09-23T10:17:40.618Z",
  "data": {
    "funding_id": "3b9e6f1a-7c42-4d85-a1e0-5f2c8d9b4e63",
    "network": "POLYGON",
    "tx_hash": "0x4a7e2c9d1b8f3e6a5c0d7b2e9f4a1c8d3e6b0f5a2d9c7e4b1a8f6d3c0e5b2a91",
    "token": "USDC",
    "amount_usd": 2500.25,
    "fee_usd": 25.0025,
    "credited_usd": 2475.2475,
    "balance_usd": 12475.2475
  }
}
card.unloaded#

Money moved off one of your cards back into your balance

Sent when POST /api/partner/v1/cards/{id}/unload succeeds. Emitted (unloadPartnerCard), after the processor subtracted from the card and the balance credit committed.
Payload
card_idstring
The card that was unloaded
partner_refstring | null
Your reference stored on the card
amount_usdnumber
What came off the card and returned to your balance
partner_balance_usdnumber
Your balance after the credit
Notes
  • Money left the card and returned to your balance. Pairs with a POST /cards/{id}/unload you made; there is no processor-initiated unload.
Payload
{
  "id": "8c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
  "type": "card.unloaded",
  "created_at": "2026-08-24T10:15:00.000Z",
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "partner_ref": "your-user-8842",
    "amount_usd": 50,
    "partner_balance_usd": 9995
  }
}
card.status_changed#

A card's status changed, whether you changed it or we did

Sent when The card processor blocks a card on velocity rules, or you freeze or unfreeze one via POST /cards/{id}/freeze. All status changes arrive on this one event so a consumer maintains card state from a single stream.
Payload
card_idstring
The card whose status changed
partner_refstring | null
Your reference stored on the card
statusstring
The status the card is now in
ACTIVE · FROZEN · BLOCKED
reasonstring
Why it changed. velocity means the processor blocked it after repeated limit breaches; partner_freeze and partner_unfreeze are your own freeze calls, mirrored here so your webhook consumer sees them without coordinating with the caller
velocity · partner_freeze · partner_unfreeze
action_requiredstring | null
contact_support when only we can restore the card, as with a velocity block. null when no action is needed
occurred_atstring | null
When the change happened, UTC
Notes
  • One stream for every status change, whether you made it (freeze, unfreeze) or we did (velocity block). reason tells you which.
  • A BLOCKED card cannot be restored through the API. action_required: "contact_support" means exactly that.
Payload
{
  "id": "3e2a1b0c-9d8e-7f6a-5b4c-3d2e1f0a9b8c",
  "type": "card.status_changed",
  "created_at": "2026-08-24T10:20:00.000Z",
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "partner_ref": "your-user-8842",
    "status": "BLOCKED",
    "reason": "velocity",
    "action_required": "contact_support",
    "occurred_at": "2026-08-24T10:19:58.412Z"
  }
}

Something arriving that is not described here, or an event you expected and did not get: email dev@ktap.io with the delivery id and the time in UTC.

Event reference · KTAP Cards API