Receiving events
Register one HTTPS endpoint and we post every event to it. This is the only way to learn about a purchase, a decline or a 3-D Secure challenge as it happens.
Register an endpoint#
One endpoint per account, set with PUT /webhook. The response carries a secret when the registration is new, when the URL changed, or when you asked to rotate.
It is returned in the PUT response and never again. There is no endpoint that reads it back. Store it before you do anything else; if you lose it, PUT the same URL with rotate set to true and store the new one.
What a delivery looks like#
Every delivery is an HTTPS POST with a JSON body and four headers. X-KTAP-Delivery equals the body's id.
POST /your/webhook HTTP/1.1
Content-Type: application/json
X-KTAP-Event: card.authorized
X-KTAP-Delivery: 4f1c9d2a-1f2e-4a55-9a5d-6b6b0a1d9c33
X-KTAP-Timestamp: 1787391672
X-KTAP-Signature: 9f8a1c...Verify the signature#
The signed string is the timestamp header, one ASCII full stop, then the raw request body. No newline, no length prefix, no version token. The HMAC key is your secret as a UTF-8 string, verbatim including the whsec_ prefix, and the output is lowercase hex.
<X-KTAP-Timestamp> + "." + <raw request body bytes>Read the raw bytes before any JSON middleware consumes them. Parsing and re-serialising can reorder keys or change number formatting, and the comparison then fails for reasons that look like a bug in the signature rather than in the plumbing.
import { createHmac, timingSafeEqual } from 'node:crypto';
/** Five minutes. Long enough for a legitimate retry, short enough to bound replay. */
const TOLERANCE_SECONDS = 300;
export function verify(rawBody: string, headers: Headers, secret: string): boolean {
const timestamp = headers.get('x-ktap-timestamp');
const signature = headers.get('x-ktap-signature');
if (!timestamp || !signature) return false;
// We do not enforce a freshness window, so you should. Without this a
// captured body stays replayable forever.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// timingSafeEqual throws on a length mismatch, so guard first.
const a = Buffer.from(expected);
const b = Buffer.from(signature.toLowerCase());
return a.length === b.length && timingSafeEqual(a, b);
}We do not enforce a freshness window, so enforce your own: without one a captured body stays replayable forever. The timestamp is generated per attempt, so a retry carries the same id and body but a different signature.
One purchase, end to end#
A purchase arrives as more than one event. card.authorized is the authorization, card.transaction is every later leg, and the three legs are told apart by event and status, not by the event type. They all carry the SAME transaction_id, which is how you group them: key your ledger on the envelope id to deduplicate, and on transaction_id to group. Pulled history joins the same way: each row of GET /cards/{id}/transactions carries the same transaction_id. That row's id is a different identifier and never matches a webhook.
1. The authorization. The card is charged now. amount_usd is the total charged to the card, fee included, and fee_usd is the part of it that is fee. Record both: the settlement does not repeat the fee. The purchase is not settled yet, and the merchant can still settle a different amount.
{
"id": "3f8a2c61-7b4e-4d09-a5c3-9e1b6f20d847",
"type": "card.authorized",
"created_at": "2026-09-16T14:16:23.518Z",
"data": {
"card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
"partner_ref": "user_8812",
"transaction_id": "tid_01a0b2c4-5d6e-7f80-9a1b-2c3d4e5f6a7b",
"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-09-16T14:16:21.204Z"
}
}2. The settlement. Same transaction_id, status flips to settled and is_pending to false. The settlement does not repeat the fee taken at authorization, so its fee_usd is normally 0 and its amount_usd normally sits below the authorization's. Never overwrite the authorization's figures with these. Compare original_amount with the authorization's instead: when they match, the merchant settled what it authorized, and the authorization's amount_usd and fee_usd stand as the charge. Delivery is unordered, so if a settlement arrives before its authorization, do not book its figures as the charge: wait for card.authorized.
{
"id": "b6d1e4f7-2a58-4c3b-8e90-5f7a1d3c6b24",
"type": "card.transaction",
"created_at": "2026-09-17T09:02:11.006Z",
"data": {
"card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
"partner_ref": "user_8812",
"transaction_id": "tid_01a0b2c4-5d6e-7f80-9a1b-2c3d4e5f6a7b",
"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-09-17T09:02:09.713Z"
}
}A tip added afterwards, a fuel pump or a hotel can settle more than was authorized, and a merchant can settle less. Our card processor then recalculates the fee on the settled amount and adjusts the card once, when the purchase closes. You can spot this because the settlement's original_amount differs from the authorization's. Do not assume either event carries the adjusted total: treat that purchase's charge as open and reconcile it with us by transaction_id.
3a. A refund. Money goes back to the card. It is its own card.transaction with event set to refund, sharing the original transaction_id, so one transaction can carry three or more events over its life.
{
"id": "d9c27a15-6e3f-4b81-9a4d-2c8e5f1b7a63",
"type": "card.transaction",
"created_at": "2026-09-19T16:44:02.118Z",
"data": {
"card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
"partner_ref": "user_8812",
"transaction_id": "tid_01a0b2c4-5d6e-7f80-9a1b-2c3d4e5f6a7b",
"event": "refund",
"status": "settled",
"is_pending": false,
"delivery_status": "success",
"merchant": "CORNER CAFE",
"mcc_code": "5814",
"mcc_category": "Fast Food Restaurants",
"merchant_country": "USA",
"amount_usd": 10,
"original_amount": 10,
"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-09-19T16:44:00.371Z"
}
}event, not on the signAmounts are passed through exactly as the card processor reports them, sign included, so do not assume a refund is negative. Branch on event to decide the direction, never on the sign of amount_usd. Treating a refund as a debit is the single most common way a partner ledger drifts from ours.
3b. A reversal. The merchant releases all or part of what it authorized. Same shape, event set to reversal, and it can carry a fee_usd of its own. A reversal can be partial and be followed by a settlement of the rest, so one purchase can carry an authorization, a reversal and a settlement. This sample is a different purchase.
{
"id": "e4a83b29-1d6c-4f57-b2e8-7c9a0d5f3e16",
"type": "card.transaction",
"created_at": "2026-09-16T18:30:43.402Z",
"data": {
"card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
"partner_ref": "user_8812",
"transaction_id": "tid_01a0b3f2-8a9b-7c0d-9e1f-2a3b4c5d6e7f",
"event": "reversal",
"status": "settled",
"is_pending": false,
"delivery_status": "success",
"merchant": "CITY PARKING",
"mcc_code": "7523",
"mcc_category": "Automobile Parking Lots and Garages",
"merchant_country": "USA",
"amount_usd": 5,
"original_amount": 5,
"original_currency": "USD",
"bill_currency": "USD",
"exchange_rate": 1,
"fee_usd": 0.1,
"fee_breakdown": { "fx_usd": 0.1, "atm_usd": 0 },
"decline_reason": null,
"occurred_at": "2026-09-16T18:30:41.086Z"
}
}A decline is the other ending: it arrives once as card.declined, never pairs with an authorization or a settlement, and carries every fee at zero because nothing moved. decline_reason is populated there and null everywhere else. Its status reads settled because the attempt is final, so recognise a decline by its type, never by status.
| Leg | Type | `event` | `status` | Money moved |
|---|---|---|---|---|
| Authorization | card.authorized | authorized | pending | Charged to the card, fee included |
| Settlement | card.transaction | cleared | settled | Nothing more, unless the merchant settles a different amount |
| Refund | card.transaction | refund | settled | Returned to the card |
| Reversal | card.transaction | reversal | settled | Released back, in full or in part |
| Decline | card.declined | declined | settled | Nothing, ever |
Respond in time#
- Deliveries originate from a single static IP,
204.168.213.162. Allowlist it if your endpoint filters inbound traffic. The signature, not the source address, is the authentication: verify it on every delivery regardless, and we will notify you ahead of any change to the address. - Return any
2xxwithin 8 seconds. Anything else counts as a failed attempt and the request is aborted at 8 seconds. - Acknowledge first, process afterwards. Doing the work before you reply is the usual cause of a timeout.
- Retries run to 8 attempts with doubling backoff from 1 minute to a 64 minute cap, about 127 minutes end to end. After the eighth failure automatic retries stop, but the event is not lost: requeue it with
POST /events/{eventId}/redeliver, or backfill fromGET /events. - Deduplicate on the body's
id. Delivery is at-least-once and unordered, so a settled event can arrive before the authorisation it belongs to. - Ignore any event type you do not recognise and still return
2xx. New types can appear without a version bump. - Redirects are followed and the status after the last hop is what counts.
Endpoints#
Returns the callback URL currently registered for your account. The signing secret is never returned here; it is shown once, in the response to the PUT that minted it. Before your first successful PUT, webhook is null.
webhookobject | nullurlstringactivebooleanupdated_atstringUNAUTHORIZEDFORBIDDENBAD_REQUESTRATE_LIMITEDUNAVAILABLEwebhookisnulluntil your first successfulPUT. There is no error for the unconfigured case.- The secret is never returned here and there is no endpoint that reads it back. If you lose it,
PUTthe same URL withrotateset totrue.
curl -sS -X GET 'https://virtual.ktap.io/api/partner/v1/webhook' \
-H 'X-API-Key: ktap_live_...'{
"data": {
"webhook": {
"url": "https://api.yoursite.com/hooks/ktap",
"active": true,
"updated_at": "2026-08-12T10:37:58.160Z"
}
}
}Sets the https endpoint where KTAP posts your events, and returns a signing secret when one is minted. Re-sending the same URL is idempotent and keeps your existing secret, so a deploy script can assert the configuration on every release without breaking signature verification. Send rotate true to force a new secret.
Content-Typestringurlstringrequiredrotatebooleanurlstringsecretstring | nullnotestringBAD_REQUESTBAD_REQUESTUNAUTHORIZEDFORBIDDENBAD_REQUESTRATE_LIMITED- A secret is returned in exactly three cases: you had no endpoint registered, the URL differs from the stored one, or
rotateistrue. Otherwisesecretisnulland the stored secret is unchanged. - Events are signed with the secret in force at the moment of the delivery attempt. A rotation takes effect immediately on the undelivered backlog as well as on new events, so the previous secret stops verifying anything.
- Registering an endpoint flushes your undelivered backlog. Events queued before you had a URL are delivered on the next drain.
curl -sS -X PUT 'https://virtual.ktap.io/api/partner/v1/webhook' \
-H 'X-API-Key: ktap_live_...' \
-H 'Content-Type: application/json' \
-d '{"url":"https://api.yoursite.com/hooks/ktap"}'{
"data": {
"webhook": {
"url": "https://api.yoursite.com/hooks/ktap"
},
"secret": "whsec_Kq7Zt2mV9xR4bN1sYh6vP0LcE3fJa8uD",
"note": "Store this secret now. It is not shown again. Setting a new URL rotates it."
}
}Newest first, delivered or not. This is the backfill: a consumer that was down pages through what it missed instead of losing it. Payloads are exactly what the webhook delivery carried, so one code path can process both the push and this pull.
typestringsincestringlimitnumberpagenumbereventsarrayidstringtypestringpayloadobjectdeliveredbooleandelivered_atstring | nullattemptsnumbercreated_atstringcountnumberpagenumbertotalnumbertotal_pagesnumberBAD_REQUEST- Payloads are exactly what the webhook delivery carries under
data, so one code path can process both the push and this pull. - The event
idhere matches theidon the webhook delivery. Deduplicate on it across both sources.
curl -sS -X GET 'https://virtual.ktap.io/api/partner/v1/events?type=value' \
-H 'X-API-Key: ktap_live_...'{ "data": { "events": [ { "id": "5f2c9a1e", "type": "card.transaction", "payload": {}, "delivered": true, "delivered_at": "2026-08-22T10:00:05Z", "attempts": 1, "created_at": "2026-08-22T10:00:00Z" } ], "count": 1, "page": 1, "total": 84, "total_pages": 2 } }Queues the event for delivery with a fresh retry schedule. It arrives with the same event id as the original, so a consumer that deduplicates on id, as the webhook guide instructs, can request redelivery at any time without double-processing.
eventIdstringNOT_FOUND- The redelivery carries the SAME event id as the original, so deduplicating on id makes it safe to request at any time.
- Attempts reset to zero, so redelivery is not throttled by an earlier backoff that may already be hours long.
curl -sS -X POST 'https://virtual.ktap.io/api/partner/v1/events/value/redeliver' \
-H 'X-API-Key: ktap_live_...'{ "data": { "event_id": "5f2c9a1e", "type": "card.transaction", "queued": true } }