KTAP Cards API
Webhooks

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.

The secret is shown exactly once

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.

text
<X-KTAP-Timestamp> + "." + <raw request body bytes>
Sign the raw body, not the re-serialised body

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.

json
{
  "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.

json
{
  "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"
  }
}
When the merchant settles a different amount

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.

json
{
  "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"
  }
}
Branch on event, not on the sign

Amounts 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.

json
{
  "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.

LegType`event``status`Money moved
Authorizationcard.authorizedauthorizedpendingCharged to the card, fee included
Settlementcard.transactionclearedsettledNothing more, unless the merchant settles a different amount
Refundcard.transactionrefundsettledReturned to the card
Reversalcard.transactionreversalsettledReleased back, in full or in part
Declinecard.declineddeclinedsettledNothing, 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 2xx within 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 from GET /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#

GET/api/partner/v1/webhook#

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.

Scope users:read
Response
webhookobject | null
Null until your first successful PUT /webhook
urlstring
The normalised absolute https URL events are posted to
activeboolean
Whether the delivery loop will send to this URL. True on every row the API can create
updated_atstring
ISO 8601 UTC with milliseconds. Moves on every PUT, including one that keeps your existing secret
Errors
401UNAUTHORIZED
No key was presented, or the key is unknown, revoked, or belongs to a suspended account
Do not retry. Check the key, then contact KTAP
403FORBIDDEN
The key is valid but does not carry users:read
Do not retry. Use a key that carries the scope
405BAD_REQUEST
POST, PATCH or DELETE is used on /webhook
Do not retry. Use GET to read or PUT to set
429RATE_LIMITED
The key exceeded its per-minute allowance
Wait the number of seconds in Retry-After, which is always 60, then resend
500UNAVAILABLE
An unhandled fault on the KTAP side
Retry after a short pause. The endpoint is read-only
Notes
  • webhook is null until your first successful PUT. 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, PUT the same URL with rotate set to true.
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"
    }
  }
}
PUT/api/partner/v1/webhook#

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.

Scope users:write
Headers
Content-Typestring
application/json. The body is parsed as JSON regardless of this header
Body
urlstringrequired
Absolute https URL that KTAP posts events to
Must parse as an absolute URL and use the https scheme. Maximum 2000 characters. The hostname must be a public name: IPv4 literals and bracketed IPv6 literals are rejected, as are localhost and any host ending in .localhost, .local or .internal. A missing or non-string url is stringified and fails URL parsing, which returns 400
rotateboolean
Mint a new signing secret even when the URL is unchanged
Only the JSON literal true is honoured. The string "true", 1, and any other truthy value are treated as absent·Defaults to false
Response
urlstring
The normalised URL that was stored
secretstring | null
The signing secret, formatted as whsec_ followed by 32 base64url characters. Non-null only when a secret was minted on this call
notestring
Human-readable guidance. The wording is not part of the contract, so branch on whether secret is null
Errors
400BAD_REQUEST
The body is not valid JSON, the body is not a JSON object, url is absent or does not parse as an absolute URL, the scheme is not https, the host is an IP literal, the host is localhost or ends in.localhost,.local or.internal, or the URL exceeds 2000 characters
Fix the URL and resend. The stored configuration is unchanged
400BAD_REQUEST
The save fails for a reason other than validation, for example a database error. The handler collapses any unrecognised failure into a generic invalid-URL message
Resend the identical body. If a valid URL keeps failing, the fault is on the KTAP side, so retry after a pause and then contact KTAP
401UNAUTHORIZED
No key was presented, or the key is unknown, revoked, or belongs to a suspended account
Do not retry. Check the key, then contact KTAP
403FORBIDDEN
The key is valid but does not carry users:write
Do not retry. Use a key that carries the scope
405BAD_REQUEST
POST, PATCH or DELETE is used on /webhook
Do not retry. Use PUT
429RATE_LIMITED
The key exceeded its per-minute allowance
Wait the number of seconds in Retry-After, which is always 60, then resend
Notes
  • A secret is returned in exactly three cases: you had no endpoint registered, the URL differs from the stored one, or rotate is true. Otherwise secret is null and 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."
  }
}
GET/api/partner/v1/events#

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.

Scope cards:read
Query parameters
typestring
Only events of this type, for example card.transaction
sincestring
Only events created at or after this ISO 8601 timestamp
limitnumber
Page size
1 to 100·Defaults to 50
pagenumber
1-based page number
Defaults to 1
Response
eventsarray
One entry per event, newest first
idstring
The event id. The same id appears on the webhook delivery, so dedupe works across both
typestring
The event type
payloadobject
Exactly the object the webhook delivery carries under data
deliveredboolean
Whether a delivery attempt has succeeded
delivered_atstring | null
When it succeeded, or null
attemptsnumber
Delivery attempts so far
created_atstring
When the event was generated, UTC
countnumber
Events in this page
pagenumber
This page
totalnumber
Events matching the filter
total_pagesnumber
Page until page equals total_pages
Errors
400BAD_REQUEST
limit above 100, or an unparseable since
Notes
  • Payloads are exactly what the webhook delivery carries under data, so one code path can process both the push and this pull.
  • The event id here matches the id on 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_...'
Response
{ "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 } }
POST/api/partner/v1/events/{eventId}/redeliver#

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.

Scope users:write
Path parameters
eventIdstring
From GET /events. Events belonging to another account return 404, the same as ids that do not exist
Errors
404NOT_FOUND
Unknown event, or an event that is not yours
Notes
  • 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_...'
Response
{ "data": { "event_id": "5f2c9a1e", "type": "card.transaction", "queued": true } }
Receiving events · KTAP Cards API